@blokjs/trigger-worker 0.2.1 → 0.4.0

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.
@@ -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,21 @@
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,
24
27
  DefaultLogger,
28
+ DeferredDispatchSignal,
25
29
  type GlobalOptions,
26
- type BlokService,
30
+ Janitor,
27
31
  NodeMap,
32
+ QueueExpiredError,
33
+ RunTracker,
28
34
  TriggerBase,
29
35
  type TriggerResponse,
36
+ createConcurrencyBackend,
30
37
  } from "@blokjs/runner";
31
38
  import type { Context, RequestContext } from "@blokjs/shared";
32
39
  import { type Span, SpanStatusCode, metrics, trace } from "@opentelemetry/api";
@@ -205,6 +212,63 @@ export abstract class WorkerTrigger extends TriggerBase {
205
212
  const startTime = this.startCounter();
206
213
 
207
214
  try {
215
+ // Tier 2 #6 follow-up · install the cross-process concurrency
216
+ // backend (NATS KV) when the operator opted in via
217
+ // `BLOK_CONCURRENCY_BACKEND=nats-kv`. Default null preserves the
218
+ // existing in-process behavior.
219
+ //
220
+ // PR 3 D1 — record install attempts via OTel counter.
221
+ try {
222
+ const backend = createConcurrencyBackend();
223
+ if (backend) {
224
+ await backend.connect();
225
+ RunTracker.getInstance().setConcurrencyBackend(backend);
226
+ ConcurrencyMetrics.getInstance().recordBackendInstall({
227
+ backend: backend.name,
228
+ status: "success",
229
+ });
230
+ this.logger.log(`[concurrency] backend installed: ${backend.name}`);
231
+ }
232
+ } catch (err) {
233
+ ConcurrencyMetrics.getInstance().recordBackendInstall({
234
+ backend: "unknown",
235
+ status: "failure",
236
+ });
237
+ this.logger.error(
238
+ `[concurrency] backend install failed: ${err instanceof Error ? err.message : String(err)}; falling back to in-process behavior`,
239
+ );
240
+ }
241
+
242
+ // Tier 2 quick-wins follow-up · install crash handlers + recover
243
+ // orphaned runs from a previous (dead) process. Idempotent + opt-out
244
+ // via `BLOK_CRASH_AUTOFLIP_DISABLED=1`.
245
+ try {
246
+ WorkerTrigger.installCrashHandlers(this.logger);
247
+ const orphaned = WorkerTrigger.recoverOrphanedRuns(undefined, this.logger);
248
+ if (orphaned > 0) {
249
+ this.logger.log(`[crash-autoflip] flipped ${orphaned} orphaned run(s) to crashed on boot`);
250
+ }
251
+ } catch (err) {
252
+ this.logger.error(`[crash-autoflip] setup failed: ${err instanceof Error ? err.message : String(err)}`);
253
+ }
254
+
255
+ // Tier 2 follow-up · start the periodic storage janitor.
256
+ // Idempotent (singleton); opt-out via `BLOK_JANITOR_DISABLED=1`.
257
+ try {
258
+ Janitor.getInstance(RunTracker.getInstance().getStore(), this.logger).start();
259
+ } catch (err) {
260
+ this.logger.error(`[janitor] setup failed: ${err instanceof Error ? err.message : String(err)}`);
261
+ }
262
+
263
+ // Tier 2 follow-up · install graceful shutdown handlers
264
+ // (SIGTERM / SIGINT). Idempotent; opt-out via
265
+ // `BLOK_GRACEFUL_SHUTDOWN_DISABLED=1`.
266
+ try {
267
+ WorkerTrigger.installShutdownHandlers(this, this.logger);
268
+ } catch (err) {
269
+ this.logger.error(`[shutdown] setup failed: ${err instanceof Error ? err.message : String(err)}`);
270
+ }
271
+
208
272
  // Connect to job backend
209
273
  await this.adapter.connect();
210
274
  this.logger.log(`Connected to ${this.adapter.provider} worker system`);
@@ -373,7 +437,7 @@ export abstract class WorkerTrigger extends TriggerBase {
373
437
 
374
438
  // Store worker metadata in context
375
439
  if (!ctx.vars) ctx.vars = {};
376
- ctx.vars["_worker_job"] = {
440
+ ctx.vars._worker_job = {
377
441
  id: job.id,
378
442
  queue: job.queue,
379
443
  attempts: String(job.attempts),
@@ -420,6 +484,74 @@ export abstract class WorkerTrigger extends TriggerBase {
420
484
  await job.complete();
421
485
  } catch (error) {
422
486
  const errorMessage = (error as Error).message;
487
+
488
+ // Tier 2 #5 + #7 — deferred dispatch (delay/TTL/debounce).
489
+ // The run was deferred to a future timer; ACK without retry.
490
+ // The in-process scheduler owns the eventual dispatch, NOT
491
+ // the broker — re-queueing here would create a duplicate.
492
+ if (error instanceof DeferredDispatchSignal) {
493
+ span.setAttribute("success", false);
494
+ span.setAttribute("deferred", true);
495
+ span.setAttribute("deferred_status", error.info.status);
496
+ span.setStatus({ code: SpanStatusCode.OK, message: `deferred:${error.info.status}` });
497
+
498
+ this.logger.log(
499
+ `[scheduling] job ${jobId} runId=${error.info.runId} status=${error.info.status} ` +
500
+ `scheduledAt=${error.info.scheduledAt} pingCount=${error.info.pingCount} → ACK (no requeue)`,
501
+ );
502
+
503
+ await job.complete();
504
+ return;
505
+ }
506
+
507
+ // PR 1-5 polish — queue-mode TTL elapsed. The run is already
508
+ // flipped to `expired` (see TriggerBase queue branch); ACK
509
+ // without retry so the broker doesn't redeliver — the run
510
+ // will never succeed (timer won't re-fire). Distinct from
511
+ // the throttled NACK below.
512
+ if (error instanceof QueueExpiredError) {
513
+ span.setAttribute("success", false);
514
+ span.setAttribute("queue_expired", true);
515
+ span.setStatus({ code: SpanStatusCode.OK, message: "queue_expired" });
516
+
517
+ this.logger.log(
518
+ `[concurrency] job ${jobId} runId=${error.info.runId} key='${error.info.concurrencyKey}' ` +
519
+ `queueExpiredAt=${error.info.queueExpiredAt} → ACK (no requeue, run expired)`,
520
+ );
521
+
522
+ await job.complete();
523
+ return;
524
+ }
525
+
526
+ // Tier 2 #6 — concurrency gate denial. Distinct from a normal
527
+ // failure: NACK with redelivery so the broker re-queues the
528
+ // job with its existing back-off semantics. Doesn't count
529
+ // against the workflow's retry budget (different invariant).
530
+ // We always pass `willRetry: true` regardless of `job.attempts`
531
+ // because throttling is a transient resource state, not a
532
+ // permanent failure.
533
+ if (error instanceof ConcurrencyLimitError) {
534
+ span.setAttribute("success", false);
535
+ span.setAttribute("will_retry", true);
536
+ span.setAttribute("throttled", true);
537
+ span.setStatus({ code: SpanStatusCode.OK, message: "concurrency_limit_reached" });
538
+
539
+ this.logger.log(
540
+ `[concurrency] job ${jobId} key='${error.info.concurrencyKey}' ` +
541
+ `limit=${error.info.concurrencyLimit} inFlight=${error.info.currentInFlight} → NACK + redelivery`,
542
+ );
543
+
544
+ workerRetries.add(1, {
545
+ env: process.env.NODE_ENV,
546
+ queue: config.queue,
547
+ workflow_name: this.configuration?.name || "unknown",
548
+ reason: "throttled",
549
+ });
550
+
551
+ await job.fail(error as Error, true);
552
+ return;
553
+ }
554
+
423
555
  const shouldRetry = job.attempts < job.maxRetries;
424
556
 
425
557
  // Set span error
@@ -429,8 +561,17 @@ export abstract class WorkerTrigger extends TriggerBase {
429
561
  span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage });
430
562
 
431
563
  if (shouldRetry) {
432
- // Retry with exponential backoff
433
- const backoffMs = this.calculateBackoff(job.attempts, config.delay);
564
+ // Retry with exponential backoff. `config.delay` widened to
565
+ // `string | number | undefined` in Tier 2 #5 (duration strings).
566
+ // `calculateBackoff` only handles numbers; normalize via
567
+ // tryParseDuration. Fail-open to undefined → default backoff.
568
+ const delayMs =
569
+ typeof config.delay === "number"
570
+ ? config.delay
571
+ : typeof config.delay === "string"
572
+ ? (tryParseDuration(config.delay) ?? undefined)
573
+ : undefined;
574
+ const backoffMs = this.calculateBackoff(job.attempts, delayMs);
434
575
  workerRetries.add(1, {
435
576
  env: process.env.NODE_ENV,
436
577
  queue: config.queue,
@@ -492,7 +633,7 @@ export abstract class WorkerTrigger extends TriggerBase {
492
633
  protected calculateBackoff(attempt: number, baseDelay?: number): number {
493
634
  const base = baseDelay ?? 1000;
494
635
  const maxDelay = 30000; // 30 seconds max
495
- const exponential = Math.min(base * Math.pow(2, attempt), maxDelay);
636
+ const exponential = Math.min(base * 2 ** attempt, maxDelay);
496
637
  const jitter = Math.random() * exponential * 0.1; // 10% jitter
497
638
  return Math.floor(exponential + jitter);
498
639
  }