@nest-batch/bullmq 0.2.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +333 -0
  3. package/dist/src/adapters/bullmq.adapter.d.ts +157 -0
  4. package/dist/src/adapters/bullmq.adapter.d.ts.map +1 -0
  5. package/dist/src/adapters/bullmq.adapter.js +252 -0
  6. package/dist/src/adapters/bullmq.adapter.js.map +1 -0
  7. package/dist/src/adapters/index.d.ts +12 -0
  8. package/dist/src/adapters/index.d.ts.map +1 -0
  9. package/dist/src/adapters/index.js +29 -0
  10. package/dist/src/adapters/index.js.map +1 -0
  11. package/dist/src/bullmq-execution-strategy.d.ts +59 -0
  12. package/dist/src/bullmq-execution-strategy.d.ts.map +1 -0
  13. package/dist/src/bullmq-execution-strategy.js +60 -0
  14. package/dist/src/bullmq-execution-strategy.js.map +1 -0
  15. package/dist/src/bullmq-runtime.service.d.ts +237 -0
  16. package/dist/src/bullmq-runtime.service.d.ts.map +1 -0
  17. package/dist/src/bullmq-runtime.service.js +441 -0
  18. package/dist/src/bullmq-runtime.service.js.map +1 -0
  19. package/dist/src/bullmq-schedule.service.d.ts +121 -0
  20. package/dist/src/bullmq-schedule.service.d.ts.map +1 -0
  21. package/dist/src/bullmq-schedule.service.js +232 -0
  22. package/dist/src/bullmq-schedule.service.js.map +1 -0
  23. package/dist/src/connection.d.ts +83 -0
  24. package/dist/src/connection.d.ts.map +1 -0
  25. package/dist/src/connection.js +72 -0
  26. package/dist/src/connection.js.map +1 -0
  27. package/dist/src/index.d.ts +29 -0
  28. package/dist/src/index.d.ts.map +1 -0
  29. package/dist/src/index.js +46 -0
  30. package/dist/src/index.js.map +1 -0
  31. package/dist/src/module-options.d.ts +68 -0
  32. package/dist/src/module-options.d.ts.map +1 -0
  33. package/dist/src/module-options.js +13 -0
  34. package/dist/src/module-options.js.map +1 -0
  35. package/package.json +71 -0
  36. package/src/adapters/bullmq.adapter.ts +346 -0
  37. package/src/adapters/index.ts +11 -0
  38. package/src/bullmq-execution-strategy.ts +81 -0
  39. package/src/bullmq-runtime.service.ts +540 -0
  40. package/src/bullmq-schedule.service.ts +271 -0
  41. package/src/connection.ts +97 -0
  42. package/src/index.ts +28 -0
  43. package/src/module-options.ts +74 -0
@@ -0,0 +1,237 @@
1
+ import { OnApplicationBootstrap, OnApplicationShutdown } from '@nestjs/common';
2
+ import { type IExecutionStrategy, type JobDefinition, type BatchObserver, type JobRepository } from '@nest-batch/core';
3
+ import { JobExecutor, JobRegistry } from '@nest-batch/core';
4
+ import { type ResolvedBullMqModuleOptions } from './module-options';
5
+ /**
6
+ * Payload shape stored in a BullMQ job's `data` field.
7
+ *
8
+ * The strategy enqueues one BullMQ job per step (or per partition,
9
+ * in a future enhancement). The worker reconstructs the
10
+ * `JobExecution` from the repository via `executionId` and the
11
+ * `JobDefinition` from the registry via `jobId`.
12
+ *
13
+ * Why not store the full `JobDefinition` in the payload?
14
+ * - IR is mutable across the host process (decorators / builders
15
+ * may swap providers in tests, hot-reload, etc.). The
16
+ * repository + registry are the canonical sources; the
17
+ * payload carries only the keys needed to look them up.
18
+ * - Storage size — IRs can be large (listeners, resolvers).
19
+ * Redis is transport, not cache; small payloads are cheaper.
20
+ */
21
+ export interface BullmqJobPayload {
22
+ /** JobExecution id, used to load the canonical execution row. */
23
+ readonly executionId: string;
24
+ /** Mirrors `executionId` today; kept distinct for forward compat. */
25
+ readonly jobExecutionId: string;
26
+ /** JobDefinition id, used to look up the IR from the registry. */
27
+ readonly jobId: string;
28
+ /** Step id (the `name` field of the BullMQ job). */
29
+ readonly stepId: string;
30
+ /**
31
+ * Partition index. Reserved for a future enhancement where a
32
+ * chunk step is split into N partitions and enqueued as N
33
+ * BullMQ jobs. Today the strategy always enqueues one job
34
+ * per step (regardless of chunk size), so the field is
35
+ * `undefined`. Kept in the payload shape so the worker
36
+ * can distinguish "this is a step" from "this is a partition"
37
+ * without a separate discriminator.
38
+ */
39
+ readonly partitionIndex?: number;
40
+ }
41
+ /**
42
+ * The single BullMQ queue name used by the strategy + worker +
43
+ * queue-events. We deliberately do not fan out into per-step
44
+ * queues — that would force the host to pre-declare every step
45
+ * name at compile time, which is at odds with the decorator /
46
+ * builder APIs that discover steps at runtime. A single queue
47
+ * keyed by the step's `name` field is the standard BullMQ pattern
48
+ * (the `name` field discriminates the work).
49
+ *
50
+ * BullMQ 5 rejects queue names that contain a colon (`:`) because
51
+ * it is the path separator in the Redis key layout. We use a
52
+ * hyphen-separated name accordingly.
53
+ */
54
+ export declare const BULLMQ_QUEUE_NAME = "nest-batch-work";
55
+ /**
56
+ * Name of the BullMQ strategy. Logged by the bridge for diagnostic
57
+ * purposes and asserted by tests that need to distinguish the
58
+ * real implementation from the T17 stub.
59
+ */
60
+ export declare const BULLMQ_STRATEGY_NAME = "bullmq";
61
+ /**
62
+ * Bridge between the BullMQ `Queue` / `Worker` / `QueueEvents` and
63
+ * the `@nest-batch/core` execution pipeline.
64
+ *
65
+ * Responsibilities (T18 contract):
66
+ * 1. Own the producer / worker connection clients with the
67
+ * role-specific tuning (fail-fast producer, blocking worker).
68
+ * 2. Implement the `IExecutionStrategy` contract: `launch()`
69
+ * enqueues a single BullMQ job per step, returns
70
+ * `{ kind: 'enqueued', queueJobId }`. The launch is
71
+ * fire-and-forget — the strategy does NOT block on the
72
+ * worker.
73
+ * 3. Drive the worker lifecycle (`OnApplicationBootstrap` /
74
+ * `OnApplicationShutdown`).
75
+ * 4. Bridge `QueueEvents` `completed` / `failed` / `stalled`
76
+ * into the `BatchObserver` (defaulting to `NoopBatchObserver`).
77
+ * 5. Hand off to `JobExecutor.execute(execution, jobDef)` from
78
+ * inside the worker — Batch Core remains the source of truth
79
+ * for state transitions, skip/retry, checkpoint, restart.
80
+ *
81
+ * Why a single class (not separate `Queue` / `Worker` providers)?
82
+ * - The producer and worker share a `connection` record but
83
+ * carry *different* `ConnectionOptions` (different
84
+ * `maxRetriesPerRequest`, `enableReadyCheck`, ...). Splitting
85
+ * them across providers would force the connection-tuning
86
+ * logic into two places and risk the worker accidentally
87
+ * inheriting the producer's fail-fast config (or vice versa).
88
+ * - Lifecycle is a unit: open producer + worker + events
89
+ * together, close them together in the documented order
90
+ * (workers first, then events, then queues). Centralising
91
+ * this in one class makes the close-order a single source
92
+ * of truth and a single method (`close()`).
93
+ */
94
+ export declare class BullmqRuntimeService implements IExecutionStrategy, OnApplicationBootstrap, OnApplicationShutdown {
95
+ private readonly options;
96
+ private readonly repository;
97
+ private readonly registry;
98
+ private readonly jobExecutor;
99
+ private readonly observer;
100
+ /**
101
+ * Strategy name. Distinct from the T17 stub's `'bullmq-stub'`
102
+ * so log lines and boundary reports can tell them apart.
103
+ */
104
+ readonly name = "bullmq";
105
+ private readonly logger;
106
+ /** BullMQ queue (producer side). */
107
+ private queue;
108
+ /** BullMQ worker (consumer side). */
109
+ private worker;
110
+ /** BullMQ QueueEvents stream listener. */
111
+ private queueEvents;
112
+ /**
113
+ * Promise-chain lock for the close path. We capture the first
114
+ * `close()` invocation and short-circuit subsequent ones so a
115
+ * stray double-shutdown (Nest calls `OnApplicationShutdown`
116
+ * once, but tests sometimes do their own) does not race the
117
+ * in-flight close.
118
+ */
119
+ private closePromise;
120
+ constructor(options: ResolvedBullMqModuleOptions, repository: JobRepository, registry: JobRegistry, jobExecutor: JobExecutor, observer?: BatchObserver);
121
+ /**
122
+ * Nest lifecycle: spin up the queue, worker, and queue-events
123
+ * after the DI container is fully wired. We do this in
124
+ * `onApplicationBootstrap` (not `onModuleInit`) so every other
125
+ * provider — including user-supplied `JobRepository` overrides —
126
+ * is already instantiated and injectable.
127
+ *
128
+ * Worker startup is gated on `options.autoStartWorker`. The
129
+ * flag exists for launcher-only deployments (e.g. an API
130
+ * service that only enqueues) and for tests that want to
131
+ * exercise the producer side in isolation. When the flag is
132
+ * `false` the queue is still created (so `launch()` can
133
+ * enqueue), but the worker is not started (no consumer means
134
+ * the jobs sit in the queue indefinitely).
135
+ */
136
+ onApplicationBootstrap(): void;
137
+ /**
138
+ * Nest lifecycle: close every BullMQ resource in the documented
139
+ * order — workers first (let in-flight jobs finish or be
140
+ * returned to the queue), then events (no new events can
141
+ * arrive once the worker is closed), then queues (the producer
142
+ * is closed last so any pending `add()` calls had a chance to
143
+ * land).
144
+ *
145
+ * Idempotent: a second call to `onApplicationShutdown` (which
146
+ * can happen in tests) short-circuits to the first close's
147
+ * promise rather than racing.
148
+ */
149
+ onApplicationShutdown(): Promise<void>;
150
+ /**
151
+ * Enqueue a single BullMQ job per step. Returns
152
+ * `{ kind: 'enqueued', queueJobId }` after the producer has
153
+ * acknowledged the enqueue. The execution is fire-and-forget:
154
+ * the launcher resolves the latest persisted `JobExecution`
155
+ * (which is still in `STARTING`/`STARTED` because the executor
156
+ * has not run yet).
157
+ *
158
+ * The canonical `JobExecution` row is created by the launcher
159
+ * via `repository.createExecutionAtomic` BEFORE this method is
160
+ * called (the `executionId` in `ctx` is the result). This
161
+ * strategy does NOT re-create it; doing so would race the
162
+ * launcher's atomic create and break the `SELECT ... FOR
163
+ * UPDATE SKIP LOCKED` invariant.
164
+ *
165
+ * Throws if the producer cannot enqueue (Redis down, key
166
+ * collision, etc.). The launcher re-throws the error to its
167
+ * caller; the `JobExecution` row remains in `STARTING` —
168
+ * the host's recovery path (or a manual cleanup) is
169
+ * responsible for transitioning it.
170
+ */
171
+ launch(job: JobDefinition, _params: Record<string, unknown>, ctx: {
172
+ executionId: string;
173
+ jobExecutionId: string;
174
+ }): Promise<{
175
+ kind: 'enqueued';
176
+ queueJobId: string;
177
+ }>;
178
+ private buildQueue;
179
+ private buildWorker;
180
+ private buildQueueEvents;
181
+ /**
182
+ * Wire the `QueueEvents` listeners to the configured
183
+ * `BatchObserver`. Each listener swallows observer errors so
184
+ * a slow / failing observer cannot poison the BullMQ event
185
+ * stream.
186
+ */
187
+ private attachQueueEventsBridge;
188
+ private bridgeEvent;
189
+ /**
190
+ * Worker entry point. Loads the canonical `JobExecution` from
191
+ * the repository and the `JobDefinition` from the registry, then
192
+ * hands the work to `JobExecutor.execute`. All batch semantics
193
+ * (step dispatch, chunk loop, skip/retry, checkpoint) live in
194
+ * the executor — this method is a thin bridge.
195
+ */
196
+ private processJob;
197
+ /**
198
+ * Producer-side connection tuning. The two flags below are
199
+ * the contract the T18 "Redis-down" test depends on:
200
+ *
201
+ * - `enableOfflineQueue: false` — a `Queue.add()` against a
202
+ * dead Redis MUST throw synchronously rather than buffer
203
+ * the command. Without this, BullMQ keeps the command in
204
+ * memory and `add()` returns success, breaking the
205
+ * "fail fast" guarantee.
206
+ * - `maxRetriesPerRequest: 1` — keep the first `add`
207
+ * fast; subsequent reconnects are handled by ioredis
208
+ * itself (we do not want BullMQ to block on retries
209
+ * during the launcher call).
210
+ *
211
+ * BullMQ specifically warns against `maxRetriesPerRequest: null`
212
+ * on the producer, because the producer does not use blocking
213
+ * commands. We use `1` for the same reason.
214
+ */
215
+ private producerConnectionOptions;
216
+ /**
217
+ * Worker-side connection tuning. Two flags that BullMQ
218
+ * *requires* for blocking workers (per the BullMQ docs):
219
+ *
220
+ * - `maxRetriesPerRequest: null` — the worker's
221
+ * `BLPOP` / `BRPOPLPUSH` / `XREADGROUP` commands MUST NOT
222
+ * retry per request. A stalled worker surfaces as a
223
+ * stall, not a connection error.
224
+ * - `enableReadyCheck: false` — the worker should not
225
+ * refuse to start when Redis is in the middle of a
226
+ * failover; ioredis reconnects on its own.
227
+ */
228
+ private workerConnectionOptions;
229
+ /**
230
+ * Close all BullMQ resources in the documented order:
231
+ * worker → events → queue. Each step is best-effort: a close
232
+ * error on one resource does not prevent the others from
233
+ * being closed.
234
+ */
235
+ private close;
236
+ }
237
+ //# sourceMappingURL=bullmq-runtime.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bullmq-runtime.service.d.ts","sourceRoot":"","sources":["../../src/bullmq-runtime.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,sBAAsB,EACtB,qBAAqB,EAEtB,MAAM,gBAAgB,CAAC;AAGxB,OAAO,EACL,KAAK,kBAAkB,EACvB,KAAK,aAAa,EAClB,KAAK,aAAa,EAElB,KAAK,aAAa,EAInB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,WAAW,EAAE,WAAW,EAAkC,MAAM,kBAAkB,CAAC;AAE5F,OAAO,EAEL,KAAK,2BAA2B,EACjC,MAAM,kBAAkB,CAAC;AAE1B;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,gBAAgB;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,qEAAqE;IACrE,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,kEAAkE;IAClE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,oDAAoD;IACpD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;;;;;OAQG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;CAClC;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iBAAiB,oBAAoB,CAAC;AAEnD;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,WAAW,CAAC;AAE7C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,qBACa,oBACX,YAAW,kBAAkB,EAAE,sBAAsB,EAAE,qBAAqB;IA2B1E,OAAO,CAAC,QAAQ,CAAC,OAAO;IAExB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAE5B,OAAO,CAAC,QAAQ,CAAC,QAAQ;IA/B3B;;;OAGG;IACH,QAAQ,CAAC,IAAI,YAAwB;IAErC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAyC;IAEhE,oCAAoC;IACpC,OAAO,CAAC,KAAK,CAAsB;IACnC,qCAAqC;IACrC,OAAO,CAAC,MAAM,CAAyC;IACvD,0CAA0C;IAC1C,OAAO,CAAC,WAAW,CAA4B;IAC/C;;;;;;OAMG;IACH,OAAO,CAAC,YAAY,CAA8B;gBAI/B,OAAO,EAAE,2BAA2B,EAEpC,UAAU,EAAE,aAAa,EACzB,QAAQ,EAAE,WAAW,EACrB,WAAW,EAAE,WAAW,EAExB,QAAQ,GAAE,aAAwD;IAGrF;;;;;;;;;;;;;;OAcG;IACH,sBAAsB,IAAI,IAAI;IAmB9B;;;;;;;;;;;OAWG;IACG,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IAY5C;;;;;;;;;;;;;;;;;;;;OAoBG;IACG,MAAM,CACV,GAAG,EAAE,aAAa,EAClB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,GAAG,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,MAAM,CAAA;KAAE,GACnD,OAAO,CAAC;QAAE,IAAI,EAAE,UAAU,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IAwEpD,OAAO,CAAC,UAAU;IAiClB,OAAO,CAAC,WAAW;IAYnB,OAAO,CAAC,gBAAgB;IAOxB;;;;;OAKG;IACH,OAAO,CAAC,uBAAuB;YAiBjB,WAAW;IAsBzB;;;;;;OAMG;YACW,UAAU;IAyBxB;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,yBAAyB;IAajC;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,uBAAuB;IAiB/B;;;;;OAKG;YACW,KAAK;CAgCpB"}
@@ -0,0 +1,441 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ function _export(target, all) {
6
+ for(var name in all)Object.defineProperty(target, name, {
7
+ enumerable: true,
8
+ get: Object.getOwnPropertyDescriptor(all, name).get
9
+ });
10
+ }
11
+ _export(exports, {
12
+ get BULLMQ_QUEUE_NAME () {
13
+ return BULLMQ_QUEUE_NAME;
14
+ },
15
+ get BULLMQ_STRATEGY_NAME () {
16
+ return BULLMQ_STRATEGY_NAME;
17
+ },
18
+ get BullmqRuntimeService () {
19
+ return BullmqRuntimeService;
20
+ }
21
+ });
22
+ const _common = require("@nestjs/common");
23
+ const _bullmq = require("bullmq");
24
+ const _core = require("@nest-batch/core");
25
+ const _moduleoptions = require("./module-options");
26
+ function _ts_decorate(decorators, target, key, desc) {
27
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
28
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
29
+ else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
30
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
31
+ }
32
+ function _ts_metadata(k, v) {
33
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
34
+ }
35
+ function _ts_param(paramIndex, decorator) {
36
+ return function(target, key) {
37
+ decorator(target, key, paramIndex);
38
+ };
39
+ }
40
+ const BULLMQ_QUEUE_NAME = 'nest-batch-work';
41
+ const BULLMQ_STRATEGY_NAME = 'bullmq';
42
+ let BullmqRuntimeService = class BullmqRuntimeService {
43
+ options;
44
+ repository;
45
+ registry;
46
+ jobExecutor;
47
+ observer;
48
+ /**
49
+ * Strategy name. Distinct from the T17 stub's `'bullmq-stub'`
50
+ * so log lines and boundary reports can tell them apart.
51
+ */ name = BULLMQ_STRATEGY_NAME;
52
+ logger = new _common.Logger(BullmqRuntimeService.name);
53
+ /** BullMQ queue (producer side). */ queue = null;
54
+ /** BullMQ worker (consumer side). */ worker = null;
55
+ /** BullMQ QueueEvents stream listener. */ queueEvents = null;
56
+ /**
57
+ * Promise-chain lock for the close path. We capture the first
58
+ * `close()` invocation and short-circuit subsequent ones so a
59
+ * stray double-shutdown (Nest calls `OnApplicationShutdown`
60
+ * once, but tests sometimes do their own) does not race the
61
+ * in-flight close.
62
+ */ closePromise = null;
63
+ constructor(options, repository, registry, jobExecutor, observer = new _core.NoopBatchObserver()){
64
+ this.options = options;
65
+ this.repository = repository;
66
+ this.registry = registry;
67
+ this.jobExecutor = jobExecutor;
68
+ this.observer = observer;
69
+ }
70
+ /**
71
+ * Nest lifecycle: spin up the queue, worker, and queue-events
72
+ * after the DI container is fully wired. We do this in
73
+ * `onApplicationBootstrap` (not `onModuleInit`) so every other
74
+ * provider — including user-supplied `JobRepository` overrides —
75
+ * is already instantiated and injectable.
76
+ *
77
+ * Worker startup is gated on `options.autoStartWorker`. The
78
+ * flag exists for launcher-only deployments (e.g. an API
79
+ * service that only enqueues) and for tests that want to
80
+ * exercise the producer side in isolation. When the flag is
81
+ * `false` the queue is still created (so `launch()` can
82
+ * enqueue), but the worker is not started (no consumer means
83
+ * the jobs sit in the queue indefinitely).
84
+ */ onApplicationBootstrap() {
85
+ this.queue = this.buildQueue();
86
+ this.queueEvents = this.buildQueueEvents();
87
+ this.attachQueueEventsBridge();
88
+ if (this.options.autoStartWorker) {
89
+ this.worker = this.buildWorker();
90
+ this.logger.log(`BullmqRuntimeService started: queue="${BULLMQ_QUEUE_NAME}" ` + `worker=auto, keyPrefix="${this.options.connection.keyPrefix}"`);
91
+ } else {
92
+ this.logger.log(`BullmqRuntimeService started: queue="${BULLMQ_QUEUE_NAME}" ` + `worker=manual (autoStartWorker=false)`);
93
+ }
94
+ }
95
+ /**
96
+ * Nest lifecycle: close every BullMQ resource in the documented
97
+ * order — workers first (let in-flight jobs finish or be
98
+ * returned to the queue), then events (no new events can
99
+ * arrive once the worker is closed), then queues (the producer
100
+ * is closed last so any pending `add()` calls had a chance to
101
+ * land).
102
+ *
103
+ * Idempotent: a second call to `onApplicationShutdown` (which
104
+ * can happen in tests) short-circuits to the first close's
105
+ * promise rather than racing.
106
+ */ async onApplicationShutdown() {
107
+ if (this.closePromise !== null) {
108
+ return this.closePromise;
109
+ }
110
+ this.closePromise = this.close();
111
+ return this.closePromise;
112
+ }
113
+ // -----------------------------------------------------------------------
114
+ // IExecutionStrategy
115
+ // -----------------------------------------------------------------------
116
+ /**
117
+ * Enqueue a single BullMQ job per step. Returns
118
+ * `{ kind: 'enqueued', queueJobId }` after the producer has
119
+ * acknowledged the enqueue. The execution is fire-and-forget:
120
+ * the launcher resolves the latest persisted `JobExecution`
121
+ * (which is still in `STARTING`/`STARTED` because the executor
122
+ * has not run yet).
123
+ *
124
+ * The canonical `JobExecution` row is created by the launcher
125
+ * via `repository.createExecutionAtomic` BEFORE this method is
126
+ * called (the `executionId` in `ctx` is the result). This
127
+ * strategy does NOT re-create it; doing so would race the
128
+ * launcher's atomic create and break the `SELECT ... FOR
129
+ * UPDATE SKIP LOCKED` invariant.
130
+ *
131
+ * Throws if the producer cannot enqueue (Redis down, key
132
+ * collision, etc.). The launcher re-throws the error to its
133
+ * caller; the `JobExecution` row remains in `STARTING` —
134
+ * the host's recovery path (or a manual cleanup) is
135
+ * responsible for transitioning it.
136
+ */ async launch(job, _params, ctx) {
137
+ if (this.queue === null) {
138
+ throw new Error(`[BullmqRuntimeService] launch() called before onApplicationBootstrap — ` + 'module is not initialized. Did you forget to import BullmqBatchModule?');
139
+ }
140
+ // T8 (partition orchestration): when the start step declares
141
+ // `partitions.count >= 2`, the strategy enqueues one BullMQ job
142
+ // per partition (each carrying a distinct `partitionIndex`).
143
+ // Otherwise (default, `count === 1`, or absent) it preserves
144
+ // the 0.1.0 "one job per step" behaviour. The validate call
145
+ // surfaces a misconfiguration (e.g. `count <= 0`) at the
146
+ // launcher's boundary so the host's caller sees the failure
147
+ // before the worker is ever asked to process the job.
148
+ const stepId = job.startStepId;
149
+ const startStep = job.steps[stepId];
150
+ const partitions = startStep?.kind === 'chunk' ? startStep.partitions : undefined;
151
+ (0, _core.validatePartitions)(partitions);
152
+ const partitionCount = partitions?.count ?? 1;
153
+ const partitionOrdinals = partitionCount >= 2 ? Array.from({
154
+ length: partitionCount
155
+ }, (_, i)=>i) : [
156
+ undefined
157
+ ];
158
+ const jobOpts = {
159
+ attempts: 3,
160
+ backoff: {
161
+ type: 'exponential',
162
+ delay: 100,
163
+ jitter: 0.5
164
+ },
165
+ removeOnComplete: {
166
+ count: 100,
167
+ age: 3600
168
+ },
169
+ removeOnFail: {
170
+ count: 1000
171
+ }
172
+ };
173
+ let lastQueueJobId = null;
174
+ for (const partitionIndex of partitionOrdinals){
175
+ const payload = {
176
+ executionId: ctx.executionId,
177
+ jobExecutionId: ctx.jobExecutionId,
178
+ jobId: job.id,
179
+ stepId,
180
+ ...partitionIndex !== undefined ? {
181
+ partitionIndex
182
+ } : {}
183
+ };
184
+ const enqueued = await this.queue.add(stepId, payload, jobOpts);
185
+ if (enqueued.id === undefined) {
186
+ // BullMQ returns a job with `id` undefined only when the
187
+ // producer cannot reach Redis and the in-memory buffer
188
+ // (which is disabled by `enableOfflineQueue: false`) is
189
+ // not available. Surface this as a hard error so the
190
+ // launcher propagates the failure.
191
+ throw new Error(`[BullmqRuntimeService] enqueue returned undefined job id (Redis down?)`);
192
+ }
193
+ const qid = String(enqueued.id);
194
+ lastQueueJobId = qid;
195
+ this.logger.debug(`Enqueued step "${stepId}" for execution ${ctx.executionId}` + (partitionIndex !== undefined ? ` (partition ${partitionIndex}/${partitionCount})` : '') + ` as BullMQ job ${qid}`);
196
+ }
197
+ if (lastQueueJobId === null) {
198
+ // Defensive: the loop above always runs at least once
199
+ // (partitionOrdinals has length >= 1), so this branch is
200
+ // unreachable in practice. Keep the explicit throw so a
201
+ // future refactor cannot quietly enqueue zero jobs.
202
+ throw new Error(`[BullmqRuntimeService] enqueued zero jobs for execution ${ctx.executionId}`);
203
+ }
204
+ return {
205
+ kind: 'enqueued',
206
+ queueJobId: lastQueueJobId
207
+ };
208
+ }
209
+ // -----------------------------------------------------------------------
210
+ // Construction
211
+ // -----------------------------------------------------------------------
212
+ buildQueue() {
213
+ return new _bullmq.Queue(BULLMQ_QUEUE_NAME, {
214
+ connection: this.producerConnectionOptions(),
215
+ // `defaultJobOptions` is a defence-in-depth measure. The
216
+ // strategy already passes per-call `JobsOptions` (with
217
+ // the T18 retry / remove policy) so this is the fallback
218
+ // for any code path that calls `queue.add` without
219
+ // explicit options. Today the only caller is the strategy.
220
+ defaultJobOptions: {
221
+ attempts: 3,
222
+ backoff: {
223
+ type: 'exponential',
224
+ delay: 100,
225
+ jitter: 0.5
226
+ },
227
+ removeOnComplete: {
228
+ count: 100,
229
+ age: 3600
230
+ },
231
+ removeOnFail: {
232
+ count: 1000
233
+ }
234
+ },
235
+ prefix: this.options.connection.keyPrefix,
236
+ // Skip waiting for the producer connection to become ready
237
+ // before returning from `add`. The fail-fast producer
238
+ // options (see `producerConnectionOptions`) make a dead
239
+ // Redis surface as a synchronous error on the first `add`,
240
+ // which is exactly what the "Redis-down" test asserts.
241
+ skipWaitingForReady: true,
242
+ // BullMQ 5 calls `client.info()` to discover the server
243
+ // version + database type at `Queue` construction time. With
244
+ // `enableOfflineQueue: false` and the ioredis client not
245
+ // yet ready, the call throws `Stream isn't writeable`.
246
+ // `skipVersionCheck: true` short-circuits that probe — the
247
+ // strategy never depends on the version, and a dead Redis
248
+ // still surfaces synchronously on the first `add()` (per
249
+ // the fail-fast contract above).
250
+ skipVersionCheck: true
251
+ });
252
+ }
253
+ buildWorker() {
254
+ return new _bullmq.Worker(BULLMQ_QUEUE_NAME, async (job)=>this.processJob(job.data), {
255
+ connection: this.workerConnectionOptions(),
256
+ prefix: this.options.connection.keyPrefix,
257
+ concurrency: 1
258
+ });
259
+ }
260
+ buildQueueEvents() {
261
+ return new _bullmq.QueueEvents(BULLMQ_QUEUE_NAME, {
262
+ connection: this.workerConnectionOptions(),
263
+ prefix: this.options.connection.keyPrefix
264
+ });
265
+ }
266
+ /**
267
+ * Wire the `QueueEvents` listeners to the configured
268
+ * `BatchObserver`. Each listener swallows observer errors so
269
+ * a slow / failing observer cannot poison the BullMQ event
270
+ * stream.
271
+ */ attachQueueEventsBridge() {
272
+ if (this.queueEvents === null) return;
273
+ this.queueEvents.on('completed', ({ jobId })=>{
274
+ void this.bridgeEvent(_core.BATCH_EVENT.JOB_COMPLETED, {
275
+ queueJobId: jobId,
276
+ kind: 'completed'
277
+ });
278
+ });
279
+ this.queueEvents.on('failed', ({ jobId, failedReason })=>{
280
+ void this.bridgeEvent(_core.BATCH_EVENT.JOB_FAILED, {
281
+ queueJobId: jobId,
282
+ kind: 'failed',
283
+ reason: failedReason
284
+ });
285
+ });
286
+ this.queueEvents.on('stalled', ({ jobId })=>{
287
+ void this.bridgeEvent(_core.BATCH_EVENT.JOB_FAILED, {
288
+ queueJobId: jobId,
289
+ kind: 'stalled'
290
+ });
291
+ });
292
+ }
293
+ async bridgeEvent(type, data) {
294
+ try {
295
+ await this.observer.onEvent({
296
+ type,
297
+ timestamp: new Date(),
298
+ jobExecutionId: String(data['queueJobId'] ?? '<unknown>'),
299
+ data: data
300
+ });
301
+ } catch (err) {
302
+ this.logger.warn(`BatchObserver threw on event ${type}: ${err instanceof Error ? err.message : String(err)}`);
303
+ }
304
+ }
305
+ // -----------------------------------------------------------------------
306
+ // Worker processor — delegated to JobExecutor
307
+ // -----------------------------------------------------------------------
308
+ /**
309
+ * Worker entry point. Loads the canonical `JobExecution` from
310
+ * the repository and the `JobDefinition` from the registry, then
311
+ * hands the work to `JobExecutor.execute`. All batch semantics
312
+ * (step dispatch, chunk loop, skip/retry, checkpoint) live in
313
+ * the executor — this method is a thin bridge.
314
+ */ async processJob(payload) {
315
+ const execution = await this.repository.getJobExecution(payload.executionId);
316
+ if (execution === null) {
317
+ // The DB row is gone. The launcher pre-created it via
318
+ // `createExecutionAtomic`; if it's missing now, the host
319
+ // either deleted it or restored a DB without the row.
320
+ // Surface as a BullMQ-level failure so the technical
321
+ // retry / dead-letter path handles it.
322
+ throw new Error(`[BullmqRuntimeService] JobExecution ${payload.executionId} not found in repository`);
323
+ }
324
+ const jobDef = this.registry.get(payload.jobId);
325
+ // `JobRegistry.get` throws `JobNotFoundError` if the
326
+ // definition is missing. We let it propagate so BullMQ
327
+ // records the failure and the dead-letter queue catches
328
+ // it (a missing job definition is a misconfiguration that
329
+ // should be loud, not silent).
330
+ await this.jobExecutor.execute(execution, jobDef);
331
+ }
332
+ // -----------------------------------------------------------------------
333
+ // Connection options
334
+ // -----------------------------------------------------------------------
335
+ /**
336
+ * Producer-side connection tuning. The two flags below are
337
+ * the contract the T18 "Redis-down" test depends on:
338
+ *
339
+ * - `enableOfflineQueue: false` — a `Queue.add()` against a
340
+ * dead Redis MUST throw synchronously rather than buffer
341
+ * the command. Without this, BullMQ keeps the command in
342
+ * memory and `add()` returns success, breaking the
343
+ * "fail fast" guarantee.
344
+ * - `maxRetriesPerRequest: 1` — keep the first `add`
345
+ * fast; subsequent reconnects are handled by ioredis
346
+ * itself (we do not want BullMQ to block on retries
347
+ * during the launcher call).
348
+ *
349
+ * BullMQ specifically warns against `maxRetriesPerRequest: null`
350
+ * on the producer, because the producer does not use blocking
351
+ * commands. We use `1` for the same reason.
352
+ */ producerConnectionOptions() {
353
+ return {
354
+ host: this.options.connection.host,
355
+ port: this.options.connection.port,
356
+ password: this.options.connection.password,
357
+ username: this.options.connection.username,
358
+ db: this.options.connection.db,
359
+ ...this.options.connection.tls ? {
360
+ tls: true
361
+ } : {},
362
+ enableOfflineQueue: false,
363
+ maxRetriesPerRequest: 1
364
+ };
365
+ }
366
+ /**
367
+ * Worker-side connection tuning. Two flags that BullMQ
368
+ * *requires* for blocking workers (per the BullMQ docs):
369
+ *
370
+ * - `maxRetriesPerRequest: null` — the worker's
371
+ * `BLPOP` / `BRPOPLPUSH` / `XREADGROUP` commands MUST NOT
372
+ * retry per request. A stalled worker surfaces as a
373
+ * stall, not a connection error.
374
+ * - `enableReadyCheck: false` — the worker should not
375
+ * refuse to start when Redis is in the middle of a
376
+ * failover; ioredis reconnects on its own.
377
+ */ workerConnectionOptions() {
378
+ return {
379
+ host: this.options.connection.host,
380
+ port: this.options.connection.port,
381
+ password: this.options.connection.password,
382
+ username: this.options.connection.username,
383
+ db: this.options.connection.db,
384
+ ...this.options.connection.tls ? {
385
+ tls: true
386
+ } : {},
387
+ maxRetriesPerRequest: null,
388
+ enableReadyCheck: false
389
+ };
390
+ }
391
+ // -----------------------------------------------------------------------
392
+ // Close
393
+ // -----------------------------------------------------------------------
394
+ /**
395
+ * Close all BullMQ resources in the documented order:
396
+ * worker → events → queue. Each step is best-effort: a close
397
+ * error on one resource does not prevent the others from
398
+ * being closed.
399
+ */ async close() {
400
+ if (this.worker !== null) {
401
+ try {
402
+ await this.worker.close();
403
+ } catch (err) {
404
+ this.logger.warn(`Worker close failed: ${err instanceof Error ? err.message : String(err)}`);
405
+ }
406
+ this.worker = null;
407
+ }
408
+ if (this.queueEvents !== null) {
409
+ try {
410
+ await this.queueEvents.close();
411
+ } catch (err) {
412
+ this.logger.warn(`QueueEvents close failed: ${err instanceof Error ? err.message : String(err)}`);
413
+ }
414
+ this.queueEvents = null;
415
+ }
416
+ if (this.queue !== null) {
417
+ try {
418
+ await this.queue.close();
419
+ } catch (err) {
420
+ this.logger.warn(`Queue close failed: ${err instanceof Error ? err.message : String(err)}`);
421
+ }
422
+ this.queue = null;
423
+ }
424
+ }
425
+ };
426
+ BullmqRuntimeService = _ts_decorate([
427
+ (0, _common.Injectable)(),
428
+ _ts_param(0, (0, _common.Inject)(_moduleoptions.BULLMQ_MODULE_OPTIONS)),
429
+ _ts_param(1, (0, _common.Inject)(_core.JOB_REPOSITORY_TOKEN)),
430
+ _ts_param(4, (0, _common.Optional)()),
431
+ _ts_metadata("design:type", Function),
432
+ _ts_metadata("design:paramtypes", [
433
+ typeof ResolvedBullMqModuleOptions === "undefined" ? Object : ResolvedBullMqModuleOptions,
434
+ typeof JobRepository === "undefined" ? Object : JobRepository,
435
+ typeof _core.JobRegistry === "undefined" ? Object : _core.JobRegistry,
436
+ typeof _core.JobExecutor === "undefined" ? Object : _core.JobExecutor,
437
+ typeof BatchObserver === "undefined" ? Object : BatchObserver
438
+ ])
439
+ ], BullmqRuntimeService);
440
+
441
+ //# sourceMappingURL=bullmq-runtime.service.js.map