@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.
- package/dist/WorkerTrigger.d.ts +3 -3
- package/dist/WorkerTrigger.js +132 -21
- package/dist/adapters/BullMQAdapter.d.ts +1 -1
- package/dist/adapters/BullMQAdapter.js +5 -42
- package/dist/adapters/InMemoryAdapter.d.ts +1 -1
- package/dist/adapters/InMemoryAdapter.js +4 -8
- package/dist/adapters/NATSAdapter.d.ts +110 -0
- package/dist/adapters/NATSAdapter.js +394 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +8 -13
- package/package.json +9 -5
- package/src/WorkerTrigger.test.ts +44 -14
- package/src/WorkerTrigger.ts +147 -6
- package/src/adapters/NATSAdapter.ts +452 -0
- package/src/index.ts +1 -0
- package/template/.env.example +13 -0
- package/template/package.json +45 -0
- package/template/src/Nodes.ts +10 -0
- package/template/src/Workflows.ts +8 -0
- package/template/src/index.ts +41 -0
- package/template/src/runner/WorkerServer.ts +34 -0
- package/template/src/runner/types/Workflows.ts +7 -0
- package/template/src/workflows/jobs/process-job.ts +45 -0
- package/template/tsconfig.json +31 -0
- package/template/vitest.config.ts +39 -0
|
@@ -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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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:
|
|
418
|
-
port: Number.parseInt(
|
|
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 *
|
|
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 *
|
|
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 *
|
|
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
|
+
});
|
package/src/WorkerTrigger.ts
CHANGED
|
@@ -19,14 +19,21 @@
|
|
|
19
19
|
* - Ack on success, retry or DLQ on failure
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import type
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 *
|
|
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
|
}
|