@blokjs/trigger-worker 0.2.1 → 0.6.1

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 (50) hide show
  1. package/__tests__/integration/nats-adapter.real-nats.test.ts +116 -0
  2. package/__tests__/integration/pgboss-adapter.real-pg.test.ts +164 -0
  3. package/__tests__/integration/rabbitmq-adapter.real-rabbitmq.test.ts +179 -0
  4. package/__tests__/integration/sqs-adapter.real-sqs.test.ts +228 -0
  5. package/dist/WorkerTrigger.d.ts +40 -4
  6. package/dist/WorkerTrigger.js +272 -40
  7. package/dist/adapters/BullMQAdapter.d.ts +1 -1
  8. package/dist/adapters/BullMQAdapter.js +5 -42
  9. package/dist/adapters/InMemoryAdapter.d.ts +1 -1
  10. package/dist/adapters/InMemoryAdapter.js +13 -12
  11. package/dist/adapters/KafkaAdapter.d.ts +62 -0
  12. package/dist/adapters/KafkaAdapter.js +236 -0
  13. package/dist/adapters/NATSAdapter.d.ts +110 -0
  14. package/dist/adapters/NATSAdapter.js +394 -0
  15. package/dist/adapters/PgBossAdapter.d.ts +56 -0
  16. package/dist/adapters/PgBossAdapter.js +251 -0
  17. package/dist/adapters/RabbitMQAdapter.d.ts +51 -0
  18. package/dist/adapters/RabbitMQAdapter.js +241 -0
  19. package/dist/adapters/RedisStreamsAdapter.d.ts +64 -0
  20. package/dist/adapters/RedisStreamsAdapter.js +240 -0
  21. package/dist/adapters/SQSAdapter.d.ts +61 -0
  22. package/dist/adapters/SQSAdapter.js +269 -0
  23. package/dist/adapters/factory.d.ts +34 -0
  24. package/dist/adapters/factory.js +103 -0
  25. package/dist/index.d.ts +25 -7
  26. package/dist/index.js +31 -16
  27. package/package.json +27 -5
  28. package/src/WorkerTrigger.test.ts +44 -14
  29. package/src/WorkerTrigger.ts +299 -27
  30. package/src/adapters/InMemoryAdapter.ts +9 -5
  31. package/src/adapters/KafkaAdapter.ts +277 -0
  32. package/src/adapters/NATSAdapter.ts +454 -0
  33. package/src/adapters/PgBossAdapter.ts +293 -0
  34. package/src/adapters/RabbitMQAdapter.ts +285 -0
  35. package/src/adapters/RedisStreamsAdapter.ts +286 -0
  36. package/src/adapters/SQSAdapter.ts +306 -0
  37. package/src/adapters/factory.test.ts +89 -0
  38. package/src/adapters/factory.ts +111 -0
  39. package/src/adapters/new-adapters.test.ts +130 -0
  40. package/src/index.ts +31 -4
  41. package/template/.env.example +13 -0
  42. package/template/package.json +45 -0
  43. package/template/src/Nodes.ts +10 -0
  44. package/template/src/Workflows.ts +8 -0
  45. package/template/src/index.ts +41 -0
  46. package/template/src/runner/WorkerServer.ts +34 -0
  47. package/template/src/runner/types/Workflows.ts +7 -0
  48. package/template/src/workflows/jobs/process-job.ts +47 -0
  49. package/template/tsconfig.json +31 -0
  50. package/template/vitest.config.ts +39 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blokjs/trigger-worker",
3
- "version": "0.2.1",
3
+ "version": "0.6.1",
4
4
  "description": "Worker-based trigger for Blok workflows - supports background job processing with concurrency, retries, and scheduling",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,28 +14,50 @@
14
14
  "author": "Deskree Technologies Inc.",
15
15
  "license": "Apache-2.0",
16
16
  "dependencies": {
17
- "@blokjs/helper": "^0.2.0",
18
- "@blokjs/runner": "^0.2.0",
19
- "@blokjs/shared": "^0.2.0",
17
+ "@blokjs/helper": "^0.6.1",
18
+ "@blokjs/runner": "^0.6.1",
19
+ "@blokjs/shared": "^0.6.1",
20
20
  "@opentelemetry/api": "^1.9.0",
21
21
  "uuid": "^11.1.0"
22
22
  },
23
23
  "devDependencies": {
24
+ "@types/amqplib": "^0.10.8",
24
25
  "@types/node": "^22.15.21",
25
26
  "@types/uuid": "^11.0.0",
27
+ "pg-boss": "^10.0.0",
26
28
  "typescript": "^5.8.3",
27
29
  "vitest": "^4.0.18"
28
30
  },
29
31
  "peerDependencies": {
32
+ "@aws-sdk/client-sqs": "^3.0.0",
33
+ "amqplib": "^0.10.0",
30
34
  "bullmq": "^5.67.2",
31
- "ioredis": "^5.9.2"
35
+ "ioredis": "^5.9.2",
36
+ "kafkajs": "^2.2.0",
37
+ "nats": "",
38
+ "pg-boss": ""
32
39
  },
33
40
  "peerDependenciesMeta": {
41
+ "@aws-sdk/client-sqs": {
42
+ "optional": true
43
+ },
44
+ "amqplib": {
45
+ "optional": true
46
+ },
34
47
  "bullmq": {
35
48
  "optional": true
36
49
  },
37
50
  "ioredis": {
38
51
  "optional": true
52
+ },
53
+ "kafkajs": {
54
+ "optional": true
55
+ },
56
+ "nats": {
57
+ "optional": true
58
+ },
59
+ "pg-boss": {
60
+ "optional": true
39
61
  }
40
62
  },
41
63
  "private": false,
@@ -8,6 +8,7 @@
8
8
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
9
  import type { WorkerAdapter, WorkerJob, WorkerQueueStats } from "./WorkerTrigger";
10
10
  import { InMemoryAdapter } from "./adapters/InMemoryAdapter";
11
+ import { computeXDelayHoldMs } from "./adapters/NATSAdapter";
11
12
 
12
13
  // ============================================================================
13
14
  // WorkerJob Interface Tests
@@ -407,23 +408,22 @@ describe("BullMQAdapter", () => {
407
408
  });
408
409
 
409
410
  it("should use default values when env vars not set", () => {
410
- const originalHost = process.env.REDIS_HOST;
411
- const originalPort = process.env.REDIS_PORT;
412
-
413
- delete process.env.REDIS_HOST;
414
- delete process.env.REDIS_PORT;
411
+ // Pure: don't mutate process.env (races with other parallel workers
412
+ // when running via `nx run-many -t test` and pollutes other tests).
413
+ // Simulate "env unset" by reading from an explicit snapshot rather
414
+ // than the live process.env.
415
+ const fakeEnv: Record<string, string | undefined> = {
416
+ REDIS_HOST: undefined,
417
+ REDIS_PORT: undefined,
418
+ };
415
419
 
416
420
  const config = {
417
- host: process.env.REDIS_HOST || "localhost",
418
- port: Number.parseInt(process.env.REDIS_PORT || "6379", 10),
421
+ host: fakeEnv.REDIS_HOST || "localhost",
422
+ port: Number.parseInt(fakeEnv.REDIS_PORT || "6379", 10),
419
423
  };
420
424
 
421
425
  expect(config.host).toBe("localhost");
422
426
  expect(config.port).toBe(6379);
423
-
424
- // Restore
425
- process.env.REDIS_HOST = originalHost;
426
- process.env.REDIS_PORT = originalPort;
427
427
  });
428
428
  });
429
429
 
@@ -482,7 +482,7 @@ describe("Exponential Backoff", () => {
482
482
  const maxDelay = 30000;
483
483
 
484
484
  const delays = [0, 1, 2, 3, 4, 5].map((attempt) => {
485
- const exponential = Math.min(base * Math.pow(2, attempt), maxDelay);
485
+ const exponential = Math.min(base * 2 ** attempt, maxDelay);
486
486
  return exponential;
487
487
  });
488
488
 
@@ -498,13 +498,43 @@ describe("Exponential Backoff", () => {
498
498
  const base = 1000;
499
499
  const maxDelay = 30000;
500
500
 
501
- const delay = Math.min(base * Math.pow(2, 10), maxDelay);
501
+ const delay = Math.min(base * 2 ** 10, maxDelay);
502
502
  expect(delay).toBe(30000);
503
503
  });
504
504
 
505
505
  it("should support custom base delay", () => {
506
506
  const base = 500;
507
- const exponential = base * Math.pow(2, 2);
507
+ const exponential = base * 2 ** 2;
508
508
  expect(exponential).toBe(2000);
509
509
  });
510
510
  });
511
+
512
+ // ============================================================================
513
+ // NATSAdapter — computeXDelayHoldMs (Tier 2 polish: x-delay enforcement)
514
+ // ============================================================================
515
+
516
+ describe("NATSAdapter — computeXDelayHoldMs", () => {
517
+ it("returns 0 when no delay was set", () => {
518
+ expect(computeXDelayHoldMs(0, 1_000_000, 1_000_000)).toBe(0);
519
+ expect(computeXDelayHoldMs(-50, 1_000_000, 1_000_000)).toBe(0);
520
+ });
521
+
522
+ it("returns the full delay when the message just arrived", () => {
523
+ // createdMs == nowMs (just published), delay 5s → wait 5s.
524
+ expect(computeXDelayHoldMs(5000, 2_000_000, 2_000_000)).toBe(5000);
525
+ });
526
+
527
+ it("returns the remaining delay when partially elapsed", () => {
528
+ // Published 2s ago, delay 5s → wait 3s remaining.
529
+ expect(computeXDelayHoldMs(5000, 1_000_000, 1_002_000)).toBe(3000);
530
+ });
531
+
532
+ it("returns 0 when the delay has already elapsed", () => {
533
+ // Published 10s ago, delay 5s → fire immediately.
534
+ expect(computeXDelayHoldMs(5000, 1_000_000, 1_010_000)).toBe(0);
535
+ });
536
+
537
+ it("clamps to 0 when nowMs is far in the future", () => {
538
+ expect(computeXDelayHoldMs(5000, 1_000_000, 9_999_999)).toBe(0);
539
+ });
540
+ });
@@ -19,14 +19,23 @@
19
19
  * - Ack on success, retry or DLQ on failure
20
20
  */
21
21
 
22
- import type { HelperResponse, WorkerTriggerOpts } from "@blokjs/helper";
22
+ import { type HelperResponse, type WorkerTriggerOpts, tryParseDuration } from "@blokjs/helper";
23
23
  import {
24
+ type BlokService,
25
+ ConcurrencyLimitError,
26
+ ConcurrencyMetrics,
27
+ DebounceCoordinator,
24
28
  DefaultLogger,
29
+ DeferredDispatchSignal,
25
30
  type GlobalOptions,
26
- type BlokService,
31
+ Janitor,
27
32
  NodeMap,
33
+ QueueExpiredError,
34
+ RunTracker,
28
35
  TriggerBase,
29
36
  type TriggerResponse,
37
+ createConcurrencyBackend,
38
+ createDebounceBackend,
30
39
  } from "@blokjs/runner";
31
40
  import type { Context, RequestContext } from "@blokjs/shared";
32
41
  import { type Span, SpanStatusCode, metrics, trace } from "@opentelemetry/api";
@@ -165,11 +174,31 @@ export abstract class WorkerTrigger extends TriggerBase {
165
174
  process.env.PROJECT_VERSION || "0.0.1",
166
175
  );
167
176
  protected readonly logger = new DefaultLogger();
168
- protected abstract adapter: WorkerAdapter;
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;
169
190
 
170
191
  /** Active queues being processed */
171
192
  protected activeQueues: Set<string> = new Set();
172
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
+
173
202
  // Subclasses provide these
174
203
  protected abstract nodes: Record<string, BlokService<unknown>>;
175
204
  protected abstract workflows: Record<string, HelperResponse>;
@@ -205,19 +234,84 @@ export abstract class WorkerTrigger extends TriggerBase {
205
234
  const startTime = this.startCounter();
206
235
 
207
236
  try {
208
- // Connect to job backend
209
- await this.adapter.connect();
210
- this.logger.log(`Connected to ${this.adapter.provider} worker system`);
211
-
212
- // Register health dependency
213
- this.registerHealthDependency(`worker-${this.adapter.provider}`, async () => {
214
- const healthy = await this.adapter.healthCheck();
215
- return {
216
- status: healthy ? ("healthy" as const) : ("unhealthy" as const),
217
- lastChecked: Date.now(),
218
- message: healthy ? "Connected" : "Connection lost",
219
- };
220
- });
237
+ // Tier 2 #6 follow-up · install the cross-process concurrency
238
+ // backend (NATS KV) when the operator opted in via
239
+ // `BLOK_CONCURRENCY_BACKEND=nats-kv`. Default null preserves the
240
+ // existing in-process behavior.
241
+ //
242
+ // PR 3 D1 — record install attempts via OTel counter.
243
+ try {
244
+ const backend = createConcurrencyBackend();
245
+ if (backend) {
246
+ await backend.connect();
247
+ RunTracker.getInstance().setConcurrencyBackend(backend);
248
+ ConcurrencyMetrics.getInstance().recordBackendInstall({
249
+ backend: backend.name,
250
+ status: "success",
251
+ });
252
+ this.logger.log(`[concurrency] backend installed: ${backend.name}`);
253
+ }
254
+ } catch (err) {
255
+ ConcurrencyMetrics.getInstance().recordBackendInstall({
256
+ backend: "unknown",
257
+ status: "failure",
258
+ });
259
+ this.logger.error(
260
+ `[concurrency] backend install failed: ${err instanceof Error ? err.message : String(err)}; falling back to in-process behavior`,
261
+ );
262
+ }
263
+
264
+ // Tier C #1 · install the cross-process debounce backend
265
+ // (NATS KV / Redis) when the operator opted in via
266
+ // `BLOK_DEBOUNCE_BACKEND`. Default unset = the existing
267
+ // in-process behavior is preserved. On connect failure, log +
268
+ // fall back to in-memory coordination.
269
+ try {
270
+ const debounceBackend = createDebounceBackend();
271
+ if (debounceBackend) {
272
+ const leaseRaw = process.env.BLOK_DEBOUNCE_OWNER_LEASE_MS;
273
+ if (leaseRaw && /^\d+$/.test(leaseRaw)) {
274
+ DebounceCoordinator.getInstance().setOwnerLeaseMs(Number(leaseRaw));
275
+ }
276
+ await debounceBackend.connect();
277
+ DebounceCoordinator.getInstance().setBackend(debounceBackend);
278
+ this.logger.log(`[debounce] backend installed: ${debounceBackend.name}`);
279
+ }
280
+ } catch (err) {
281
+ this.logger.error(
282
+ `[debounce] backend install failed: ${err instanceof Error ? err.message : String(err)}; falling back to in-memory coordination`,
283
+ );
284
+ }
285
+
286
+ // Tier 2 quick-wins follow-up · install crash handlers + recover
287
+ // orphaned runs from a previous (dead) process. Idempotent + opt-out
288
+ // via `BLOK_CRASH_AUTOFLIP_DISABLED=1`.
289
+ try {
290
+ WorkerTrigger.installCrashHandlers(this.logger);
291
+ const orphaned = WorkerTrigger.recoverOrphanedRuns(undefined, this.logger);
292
+ if (orphaned > 0) {
293
+ this.logger.log(`[crash-autoflip] flipped ${orphaned} orphaned run(s) to crashed on boot`);
294
+ }
295
+ } catch (err) {
296
+ this.logger.error(`[crash-autoflip] setup failed: ${err instanceof Error ? err.message : String(err)}`);
297
+ }
298
+
299
+ // Tier 2 follow-up · start the periodic storage janitor.
300
+ // Idempotent (singleton); opt-out via `BLOK_JANITOR_DISABLED=1`.
301
+ try {
302
+ Janitor.getInstance(RunTracker.getInstance().getStore(), this.logger).start();
303
+ } catch (err) {
304
+ this.logger.error(`[janitor] setup failed: ${err instanceof Error ? err.message : String(err)}`);
305
+ }
306
+
307
+ // Tier 2 follow-up · install graceful shutdown handlers
308
+ // (SIGTERM / SIGINT). Idempotent; opt-out via
309
+ // `BLOK_GRACEFUL_SHUTDOWN_DISABLED=1`.
310
+ try {
311
+ WorkerTrigger.installShutdownHandlers(this, this.logger);
312
+ } catch (err) {
313
+ this.logger.error(`[shutdown] setup failed: ${err instanceof Error ? err.message : String(err)}`);
314
+ }
221
315
 
222
316
  // Find all workflows with worker triggers
223
317
  const workerWorkflows = this.getWorkerWorkflows();
@@ -227,16 +321,19 @@ export abstract class WorkerTrigger extends TriggerBase {
227
321
  return this.endCounter(startTime);
228
322
  }
229
323
 
230
- // Start processing each queue
324
+ // Start processing each queue, dispatching to the right adapter
325
+ // based on the workflow's `provider` field (with back-compat
326
+ // fallback to `this.adapter` when subclasses still set it).
231
327
  for (const workflow of workerWorkflows) {
232
328
  const config = workflow.config.trigger?.worker as WorkerTriggerOpts;
329
+ const adapter = await this.resolveAdapterForWorkflow(config);
233
330
  this.logger.log(
234
- `Starting worker for queue: ${config.queue} (concurrency=${config.concurrency}, retries=${config.retries})`,
331
+ `Starting worker for queue: ${config.queue} via ${adapter.provider} (concurrency=${config.concurrency}, retries=${config.retries})`,
235
332
  );
236
333
 
237
334
  this.activeQueues.add(config.queue);
238
335
 
239
- await this.adapter.process(config, async (job) => {
336
+ await adapter.process(config, async (job) => {
240
337
  await this.handleJob(job, workflow, config);
241
338
  });
242
339
  }
@@ -259,12 +356,28 @@ export abstract class WorkerTrigger extends TriggerBase {
259
356
  * Stop all workers and disconnect
260
357
  */
261
358
  async stop(): Promise<void> {
359
+ // Stop each queue on its owning adapter — adapters are tracked
360
+ // in the pool so multi-provider workers all drain cleanly.
262
361
  for (const queue of this.activeQueues) {
263
- await this.adapter.stopProcessing(queue);
362
+ for (const adapter of this.adapterPool.values()) {
363
+ try {
364
+ await adapter.stopProcessing(queue);
365
+ } catch {
366
+ /* swallow — adapter may not own this queue */
367
+ }
368
+ }
264
369
  this.logger.log(`Stopped processing queue: ${queue}`);
265
370
  }
266
371
  this.activeQueues.clear();
267
- await this.adapter.disconnect();
372
+ // Disconnect every adapter we ever connected.
373
+ for (const adapter of this.adapterPool.values()) {
374
+ try {
375
+ await adapter.disconnect();
376
+ } catch (err) {
377
+ this.logger.error(`[blok][worker] disconnect failed: ${(err as Error).message}`);
378
+ }
379
+ }
380
+ this.adapterPool.clear();
268
381
  this.destroyMonitoring();
269
382
  this.logger.log("Worker trigger stopped");
270
383
  }
@@ -291,14 +404,33 @@ export abstract class WorkerTrigger extends TriggerBase {
291
404
  jobId?: string;
292
405
  },
293
406
  ): Promise<string> {
294
- return this.adapter.addJob(queue, data, opts);
407
+ // Back-compat: when a subclass set `this.adapter`, use it.
408
+ // Otherwise dispatch via the first pool adapter — typically the
409
+ // only one when a process owns one trigger workflow.
410
+ const adapter =
411
+ this.adapter ??
412
+ (this.adapterPool.size > 0 ? (this.adapterPool.values().next().value as WorkerAdapter) : undefined);
413
+ if (!adapter) {
414
+ throw new Error(
415
+ "[blok][worker] dispatch() called before any adapter is connected. Call listen() first, or set this.adapter on the subclass.",
416
+ );
417
+ }
418
+ return adapter.addJob(queue, data, opts);
295
419
  }
296
420
 
297
421
  /**
298
422
  * Get statistics for a queue
299
423
  */
300
424
  async getQueueStats(queue: string): Promise<WorkerQueueStats> {
301
- return this.adapter.getQueueStats(queue);
425
+ const adapter =
426
+ this.adapter ??
427
+ (this.adapterPool.size > 0 ? (this.adapterPool.values().next().value as WorkerAdapter) : undefined);
428
+ if (!adapter) {
429
+ throw new Error(
430
+ "[blok][worker] getQueueStats() called before any adapter is connected. Call listen() first, or set this.adapter on the subclass.",
431
+ );
432
+ }
433
+ return adapter.getQueueStats(queue);
302
434
  }
303
435
 
304
436
  /**
@@ -308,6 +440,60 @@ export abstract class WorkerTrigger extends TriggerBase {
308
440
  return Array.from(this.activeQueues);
309
441
  }
310
442
 
443
+ /**
444
+ * v0.7 PR 5 — pick the adapter for a workflow's `provider` field.
445
+ *
446
+ * Resolution order:
447
+ * 1. Subclass-set `this.adapter` (back-compat: pre-v0.7 pattern
448
+ * where one process binds to one adapter at construction time).
449
+ * 2. Per-workflow `provider` field, looked up via the factory.
450
+ * 3. `BLOK_WORKER_ADAPTER` env var.
451
+ * 4. `in-memory` fallback.
452
+ *
453
+ * Adapters are connected on first use and pooled per provider so
454
+ * multiple workflows sharing a provider share one broker
455
+ * connection. Health-dependency registration also happens here so
456
+ * each provider is tracked individually in `/health`.
457
+ */
458
+ protected async resolveAdapterForWorkflow(config: WorkerTriggerOpts): Promise<WorkerAdapter> {
459
+ // Subclass override wins for back-compat.
460
+ if (this.adapter) {
461
+ if (!this.adapter.isConnected()) {
462
+ await this.adapter.connect();
463
+ this.logger.log(`Connected to ${this.adapter.provider} worker system (subclass adapter)`);
464
+ this.registerAdapterHealth(this.adapter);
465
+ }
466
+ // Pool-track so stop() can drain it.
467
+ this.adapterPool.set(this.adapter.provider, this.adapter);
468
+ return this.adapter;
469
+ }
470
+
471
+ // Lazy-import the factory so the worker package doesn't pull in
472
+ // every adapter on import — only the ones actually exercised.
473
+ const { resolveProvider, createWorkerAdapter } = await import("./adapters/factory");
474
+ const provider = resolveProvider(config.provider);
475
+ let adapter = this.adapterPool.get(provider);
476
+ if (!adapter) {
477
+ adapter = createWorkerAdapter(provider);
478
+ await adapter.connect();
479
+ this.logger.log(`Connected to ${adapter.provider} worker system`);
480
+ this.registerAdapterHealth(adapter);
481
+ this.adapterPool.set(provider, adapter);
482
+ }
483
+ return adapter;
484
+ }
485
+
486
+ private registerAdapterHealth(adapter: WorkerAdapter): void {
487
+ this.registerHealthDependency(`worker-${adapter.provider}`, async () => {
488
+ const healthy = await adapter.healthCheck();
489
+ return {
490
+ status: healthy ? ("healthy" as const) : ("unhealthy" as const),
491
+ lastChecked: Date.now(),
492
+ message: healthy ? "Connected" : "Connection lost",
493
+ };
494
+ });
495
+ }
496
+
311
497
  /**
312
498
  * Get all workflows that have worker triggers
313
499
  */
@@ -373,7 +559,7 @@ export abstract class WorkerTrigger extends TriggerBase {
373
559
 
374
560
  // Store worker metadata in context
375
561
  if (!ctx.vars) ctx.vars = {};
376
- ctx.vars["_worker_job"] = {
562
+ ctx.vars._worker_job = {
377
563
  id: job.id,
378
564
  queue: job.queue,
379
565
  attempts: String(job.attempts),
@@ -388,6 +574,15 @@ export abstract class WorkerTrigger extends TriggerBase {
388
574
  `Processing job ${jobId} from ${config.queue} (attempt ${job.attempts + 1}/${job.maxRetries + 1})`,
389
575
  );
390
576
 
577
+ // v0.6 · apply the merged middleware chain (process-global →
578
+ // workflow-level → trigger-level) on the same ctx the main
579
+ // workflow will see. State mutations from middleware
580
+ // (e.g. ctx.state.identity) carry forward. Middleware that
581
+ // throws (via `@blokjs/throw`) propagates to the outer
582
+ // catch and is routed through the worker's retry / DLQ
583
+ // logic exactly like a main-workflow error.
584
+ await this.applyMiddlewareChain(ctx, this.nodeMap);
585
+
391
586
  // Execute workflow with timeout if configured
392
587
  let response: TriggerResponse;
393
588
  if (config.timeout && config.timeout > 0) {
@@ -420,6 +615,74 @@ export abstract class WorkerTrigger extends TriggerBase {
420
615
  await job.complete();
421
616
  } catch (error) {
422
617
  const errorMessage = (error as Error).message;
618
+
619
+ // Tier 2 #5 + #7 — deferred dispatch (delay/TTL/debounce).
620
+ // The run was deferred to a future timer; ACK without retry.
621
+ // The in-process scheduler owns the eventual dispatch, NOT
622
+ // the broker — re-queueing here would create a duplicate.
623
+ if (error instanceof DeferredDispatchSignal) {
624
+ span.setAttribute("success", false);
625
+ span.setAttribute("deferred", true);
626
+ span.setAttribute("deferred_status", error.info.status);
627
+ span.setStatus({ code: SpanStatusCode.OK, message: `deferred:${error.info.status}` });
628
+
629
+ this.logger.log(
630
+ `[scheduling] job ${jobId} runId=${error.info.runId} status=${error.info.status} ` +
631
+ `scheduledAt=${error.info.scheduledAt} pingCount=${error.info.pingCount} → ACK (no requeue)`,
632
+ );
633
+
634
+ await job.complete();
635
+ return;
636
+ }
637
+
638
+ // PR 1-5 polish — queue-mode TTL elapsed. The run is already
639
+ // flipped to `expired` (see TriggerBase queue branch); ACK
640
+ // without retry so the broker doesn't redeliver — the run
641
+ // will never succeed (timer won't re-fire). Distinct from
642
+ // the throttled NACK below.
643
+ if (error instanceof QueueExpiredError) {
644
+ span.setAttribute("success", false);
645
+ span.setAttribute("queue_expired", true);
646
+ span.setStatus({ code: SpanStatusCode.OK, message: "queue_expired" });
647
+
648
+ this.logger.log(
649
+ `[concurrency] job ${jobId} runId=${error.info.runId} key='${error.info.concurrencyKey}' ` +
650
+ `queueExpiredAt=${error.info.queueExpiredAt} → ACK (no requeue, run expired)`,
651
+ );
652
+
653
+ await job.complete();
654
+ return;
655
+ }
656
+
657
+ // Tier 2 #6 — concurrency gate denial. Distinct from a normal
658
+ // failure: NACK with redelivery so the broker re-queues the
659
+ // job with its existing back-off semantics. Doesn't count
660
+ // against the workflow's retry budget (different invariant).
661
+ // We always pass `willRetry: true` regardless of `job.attempts`
662
+ // because throttling is a transient resource state, not a
663
+ // permanent failure.
664
+ if (error instanceof ConcurrencyLimitError) {
665
+ span.setAttribute("success", false);
666
+ span.setAttribute("will_retry", true);
667
+ span.setAttribute("throttled", true);
668
+ span.setStatus({ code: SpanStatusCode.OK, message: "concurrency_limit_reached" });
669
+
670
+ this.logger.log(
671
+ `[concurrency] job ${jobId} key='${error.info.concurrencyKey}' ` +
672
+ `limit=${error.info.concurrencyLimit} inFlight=${error.info.currentInFlight} → NACK + redelivery`,
673
+ );
674
+
675
+ workerRetries.add(1, {
676
+ env: process.env.NODE_ENV,
677
+ queue: config.queue,
678
+ workflow_name: this.configuration?.name || "unknown",
679
+ reason: "throttled",
680
+ });
681
+
682
+ await job.fail(error as Error, true);
683
+ return;
684
+ }
685
+
423
686
  const shouldRetry = job.attempts < job.maxRetries;
424
687
 
425
688
  // Set span error
@@ -429,8 +692,17 @@ export abstract class WorkerTrigger extends TriggerBase {
429
692
  span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage });
430
693
 
431
694
  if (shouldRetry) {
432
- // Retry with exponential backoff
433
- const backoffMs = this.calculateBackoff(job.attempts, config.delay);
695
+ // Retry with exponential backoff. `config.delay` widened to
696
+ // `string | number | undefined` in Tier 2 #5 (duration strings).
697
+ // `calculateBackoff` only handles numbers; normalize via
698
+ // tryParseDuration. Fail-open to undefined → default backoff.
699
+ const delayMs =
700
+ typeof config.delay === "number"
701
+ ? config.delay
702
+ : typeof config.delay === "string"
703
+ ? (tryParseDuration(config.delay) ?? undefined)
704
+ : undefined;
705
+ const backoffMs = this.calculateBackoff(job.attempts, delayMs);
434
706
  workerRetries.add(1, {
435
707
  env: process.env.NODE_ENV,
436
708
  queue: config.queue,
@@ -492,7 +764,7 @@ export abstract class WorkerTrigger extends TriggerBase {
492
764
  protected calculateBackoff(attempt: number, baseDelay?: number): number {
493
765
  const base = baseDelay ?? 1000;
494
766
  const maxDelay = 30000; // 30 seconds max
495
- const exponential = Math.min(base * Math.pow(2, attempt), maxDelay);
767
+ const exponential = Math.min(base * 2 ** attempt, maxDelay);
496
768
  const jitter = Math.random() * exponential * 0.1; // 10% jitter
497
769
  return Math.floor(exponential + jitter);
498
770
  }
@@ -122,8 +122,14 @@ export class InMemoryAdapter implements WorkerAdapter {
122
122
  throw new Error("Not connected. Call connect() first.");
123
123
  }
124
124
 
125
- if (!this.jobs.has(queue)) {
126
- this.jobs.set(queue, []);
125
+ // Get-or-init the per-queue job list. Same pattern as `stats`
126
+ // below — the previous code used `set-if-absent` then `.get()!`,
127
+ // but that non-null assertion is what biome flags. Pulling the
128
+ // reference once and seeding when missing keeps the type exact.
129
+ let jobs = this.jobs.get(queue);
130
+ if (!jobs) {
131
+ jobs = [];
132
+ this.jobs.set(queue, jobs);
127
133
  }
128
134
  if (!this.stats.has(queue)) {
129
135
  this.stats.set(queue, { completed: 0, failed: 0 });
@@ -146,8 +152,6 @@ export class InMemoryAdapter implements WorkerAdapter {
146
152
  job.scheduledAt = new Date(Date.now() + job.delay);
147
153
  }
148
154
 
149
- const jobs = this.jobs.get(queue)!;
150
-
151
155
  // Insert sorted by priority (higher first)
152
156
  const insertIdx = jobs.findIndex((j) => j.status === "waiting" && j.priority < job.priority);
153
157
  if (insertIdx >= 0) {
@@ -245,7 +249,7 @@ export class InMemoryAdapter implements WorkerAdapter {
245
249
 
246
250
  if (requeue && internalJob.attempts < internalJob.maxRetries) {
247
251
  // Requeue with backoff
248
- const backoff = Math.min(1000 * Math.pow(2, internalJob.attempts), 30000);
252
+ const backoff = Math.min(1000 * 2 ** internalJob.attempts, 30000);
249
253
  internalJob.status = "delayed";
250
254
  internalJob.scheduledAt = new Date(Date.now() + backoff);
251
255
  } else {