@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.
- package/__tests__/integration/nats-adapter.real-nats.test.ts +116 -0
- package/__tests__/integration/pgboss-adapter.real-pg.test.ts +164 -0
- package/__tests__/integration/rabbitmq-adapter.real-rabbitmq.test.ts +179 -0
- package/__tests__/integration/sqs-adapter.real-sqs.test.ts +228 -0
- package/dist/WorkerTrigger.d.ts +40 -4
- package/dist/WorkerTrigger.js +272 -40
- 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 +13 -12
- package/dist/adapters/KafkaAdapter.d.ts +62 -0
- package/dist/adapters/KafkaAdapter.js +236 -0
- package/dist/adapters/NATSAdapter.d.ts +110 -0
- package/dist/adapters/NATSAdapter.js +394 -0
- package/dist/adapters/PgBossAdapter.d.ts +56 -0
- package/dist/adapters/PgBossAdapter.js +251 -0
- package/dist/adapters/RabbitMQAdapter.d.ts +51 -0
- package/dist/adapters/RabbitMQAdapter.js +241 -0
- package/dist/adapters/RedisStreamsAdapter.d.ts +64 -0
- package/dist/adapters/RedisStreamsAdapter.js +240 -0
- package/dist/adapters/SQSAdapter.d.ts +61 -0
- package/dist/adapters/SQSAdapter.js +269 -0
- package/dist/adapters/factory.d.ts +34 -0
- package/dist/adapters/factory.js +103 -0
- package/dist/index.d.ts +25 -7
- package/dist/index.js +31 -16
- package/package.json +27 -5
- package/src/WorkerTrigger.test.ts +44 -14
- package/src/WorkerTrigger.ts +299 -27
- package/src/adapters/InMemoryAdapter.ts +9 -5
- package/src/adapters/KafkaAdapter.ts +277 -0
- package/src/adapters/NATSAdapter.ts +454 -0
- package/src/adapters/PgBossAdapter.ts +293 -0
- package/src/adapters/RabbitMQAdapter.ts +285 -0
- package/src/adapters/RedisStreamsAdapter.ts +286 -0
- package/src/adapters/SQSAdapter.ts +306 -0
- package/src/adapters/factory.test.ts +89 -0
- package/src/adapters/factory.ts +111 -0
- package/src/adapters/new-adapters.test.ts +130 -0
- package/src/index.ts +31 -4
- 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 +47 -0
- package/template/tsconfig.json +31 -0
- 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.
|
|
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.
|
|
18
|
-
"@blokjs/runner": "^0.
|
|
19
|
-
"@blokjs/shared": "^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
|
-
|
|
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,23 @@
|
|
|
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,
|
|
27
|
+
DebounceCoordinator,
|
|
24
28
|
DefaultLogger,
|
|
29
|
+
DeferredDispatchSignal,
|
|
25
30
|
type GlobalOptions,
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 *
|
|
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
|
-
|
|
126
|
-
|
|
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 *
|
|
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 {
|