@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,271 @@
1
+ import {
2
+ Inject,
3
+ Injectable,
4
+ Logger,
5
+ OnApplicationBootstrap,
6
+ OnApplicationShutdown,
7
+ } from '@nestjs/common';
8
+ import { Queue, type JobsOptions } from 'bullmq';
9
+
10
+ import { BATCH_SCHEDULE_REGISTRY, BatchScheduleRegistry, type BatchScheduleEntry } from '@nest-batch/core';
11
+
12
+ import { BULLMQ_MODULE_OPTIONS, type ResolvedBullMqModuleOptions } from './module-options';
13
+
14
+ /**
15
+ * The single BullMQ queue name used by the schedule service. We
16
+ * intentionally use a DIFFERENT queue from the runtime service's
17
+ * `BULLMQ_QUEUE_NAME` so cron-triggered jobs and ad-hoc
18
+ * `launch()`-triggered jobs are inspectable in isolation (and so
19
+ * the schedule-removal path on shutdown can tear them down
20
+ * without touching the runtime work queue).
21
+ *
22
+ * BullMQ 5 rejects queue names that contain a colon (`:`) because
23
+ * it is the path separator in the key layout. We use a hyphen
24
+ * (`-`) instead, matching the existing `BULLMQ_QUEUE_NAME`
25
+ * convention (`'nest-batch-work'`).
26
+ */
27
+ export const BULLMQ_SCHEDULE_QUEUE_NAME = 'nest-batch-schedule';
28
+
29
+ /**
30
+ * `BullmqScheduleService` — the runtime scheduler for
31
+ * `@BatchScheduled` entries.
32
+ *
33
+ * Lifecycle:
34
+ * 1. `OnApplicationBootstrap` walks the `BatchScheduleRegistry`
35
+ * and, for every entry with `inert: false`, registers a
36
+ * BullMQ repeating job via `queue.upsertJobScheduler(...)`.
37
+ * Entries with `inert: true` are logged and skipped — that
38
+ * is the only place the inert flag is consulted.
39
+ * 2. BullMQ's `upsertJobScheduler` internally fires the
40
+ * schedule at the configured cron time. Each fire enqueues a
41
+ * job into the schedule queue (named after the schedule
42
+ * entry's method). A separate `Worker` (the one owned by
43
+ * `BullmqRuntimeService` if `autoStartWorker` is `true`)
44
+ * processes the jobs.
45
+ * 3. `OnApplicationShutdown` removes every installed scheduler
46
+ * (via `queue.removeJobScheduler`) and closes the queue.
47
+ * Removal is best-effort: a partial failure logs a warning
48
+ * but does not block the rest of the shutdown.
49
+ *
50
+ * Why a dedicated service (not a method on `BullmqRuntimeService`)?
51
+ * - The runtime service is `IExecutionStrategy`-facing; it
52
+ * knows about `JobExecution`, the in-process launch contract,
53
+ * and the worker bridge. Mixing scheduler concerns in would
54
+ * bloat its surface and couple two lifecycles that happen to
55
+ * share a Redis client but are otherwise independent.
56
+ * - The scheduler does NOT need a `Worker`; the runtime service
57
+ * does. A separate service can run with `autoStartWorker:
58
+ * false` cleanly (a launcher-only deployment that still wants
59
+ * cron schedules to fire).
60
+ * - The schedule service owns its own `Queue` (the schedule
61
+ * queue) so cron jobs are not interleaved with manually-launched
62
+ * jobs. They share the same `keyPrefix` so the host's Redis
63
+ * namespace policy still applies.
64
+ */
65
+ @Injectable()
66
+ export class BullmqScheduleService implements OnApplicationBootstrap, OnApplicationShutdown {
67
+ private readonly logger = new Logger(BullmqScheduleService.name);
68
+
69
+ /** BullMQ queue for the scheduler (producer side only). */
70
+ private scheduleQueue: Queue | null = null;
71
+
72
+ /**
73
+ * Every schedule key installed during `onApplicationBootstrap`.
74
+ * Tracked so the shutdown path can `removeJobScheduler` for
75
+ * each one deterministically. A `Set` keeps the test assertions
76
+ * order-independent.
77
+ */
78
+ private readonly installedKeys = new Set<string>();
79
+
80
+ /** Promise-chain lock for the close path. Mirrors the runtime service. */
81
+ private closePromise: Promise<void> | null = null;
82
+
83
+ constructor(
84
+ private readonly scheduleRegistry: BatchScheduleRegistry,
85
+ @Inject(BULLMQ_MODULE_OPTIONS)
86
+ private readonly options: ResolvedBullMqModuleOptions,
87
+ ) {}
88
+
89
+ /**
90
+ * Walk the registry and install every non-inert entry as a
91
+ * BullMQ repeating job. Runs AFTER the `BatchBootstrapper` has
92
+ * populated the registry (both hooks are on
93
+ * `OnApplicationBootstrap`, but Nest calls them in
94
+ * provider-registration order; the bootstrapper is registered
95
+ * before this service by `BullmqBatchModule.forRoot()`).
96
+ *
97
+ * Each entry is wrapped in a per-entry `try` so a single bad
98
+ * schedule does not abort the rest of the installation. Bad
99
+ * schedules are logged and skipped — the runtime keeps running
100
+ * for the valid ones.
101
+ */
102
+ onApplicationBootstrap(): void {
103
+ this.scheduleQueue = this.buildScheduleQueue();
104
+ const entries = this.scheduleRegistry.getAll();
105
+ for (const entry of entries) {
106
+ try {
107
+ this.installSchedule(entry);
108
+ } catch (err) {
109
+ this.logger.warn(
110
+ `Failed to install schedule for "${entry.jobId}::${entry.methodName}": ` +
111
+ `${err instanceof Error ? err.message : String(err)}`,
112
+ );
113
+ }
114
+ }
115
+ this.logger.log(
116
+ `BullmqScheduleService started: queue="${BULLMQ_SCHEDULE_QUEUE_NAME}" ` +
117
+ `schedules=${this.installedKeys.size}/${entries.length} ` +
118
+ `(skipped=${entries.length - this.installedKeys.size} inert)`,
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Tear down every installed scheduler and close the schedule
124
+ * queue. Idempotent: a second `onApplicationShutdown` short-
125
+ * circuits to the first close's promise.
126
+ */
127
+ async onApplicationShutdown(): Promise<void> {
128
+ if (this.closePromise !== null) {
129
+ return this.closePromise;
130
+ }
131
+ this.closePromise = this.close();
132
+ return this.closePromise;
133
+ }
134
+
135
+ /**
136
+ * Installed scheduler keys, in insertion order. Exposed for
137
+ * tests and diagnostics. Read-only: callers MUST NOT mutate
138
+ * the returned array.
139
+ */
140
+ installedSchedulerKeys(): readonly string[] {
141
+ return Array.from(this.installedKeys);
142
+ }
143
+
144
+ // -------------------------------------------------------------------------
145
+ // Installation
146
+ // -------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Install a single entry as a BullMQ repeating job. Skips
150
+ * inert entries (the runtime honours the inert flag by NOT
151
+ * calling `upsertJobScheduler` for them). Throws on
152
+ * installation failure so the caller can log + continue.
153
+ */
154
+ private installSchedule(entry: BatchScheduleEntry): void {
155
+ if (entry.inert) {
156
+ this.logger.log(
157
+ `Skipping inert schedule: ${entry.jobId}::${entry.methodName} ` +
158
+ `(cron="${entry.cron}", tz="${entry.timezone}")`,
159
+ );
160
+ return;
161
+ }
162
+ if (this.scheduleQueue === null) {
163
+ // Defensive: should never happen because `onApplicationBootstrap`
164
+ // builds the queue before iterating entries, but a future
165
+ // refactor that calls `installSchedule` from elsewhere
166
+ // should fail loudly.
167
+ throw new Error('[BullmqScheduleService] scheduleQueue is null');
168
+ }
169
+ const schedulerKey = `${entry.jobId}::${entry.methodName}`;
170
+ const template: {
171
+ name: string;
172
+ data: Record<string, unknown>;
173
+ opts: JobsOptions;
174
+ } = {
175
+ name: entry.methodName,
176
+ data: { jobId: entry.jobId, methodName: entry.methodName },
177
+ opts: {
178
+ attempts: 3,
179
+ backoff: { type: 'exponential', delay: 100, jitter: 0.5 },
180
+ removeOnComplete: { count: 100, age: 3600 },
181
+ removeOnFail: { count: 1000 },
182
+ },
183
+ };
184
+ void this.scheduleQueue.upsertJobScheduler(
185
+ schedulerKey,
186
+ { pattern: entry.cron, tz: entry.timezone },
187
+ template,
188
+ );
189
+ this.installedKeys.add(schedulerKey);
190
+ this.logger.log(
191
+ `Installed schedule: ${schedulerKey} (cron="${entry.cron}", tz="${entry.timezone}")`,
192
+ );
193
+ }
194
+
195
+ // -------------------------------------------------------------------------
196
+ // Queue construction
197
+ // -------------------------------------------------------------------------
198
+
199
+ /**
200
+ * Build the producer-side BullMQ queue for the scheduler. The
201
+ * connection tuning mirrors the runtime service's producer
202
+ * options: fail-fast on Redis-down (`enableOfflineQueue:
203
+ * false`) and a tight per-request retry budget
204
+ * (`maxRetriesPerRequest: 1`).
205
+ */
206
+ private buildScheduleQueue(): Queue {
207
+ return new Queue(BULLMQ_SCHEDULE_QUEUE_NAME, {
208
+ connection: this.producerConnectionOptions(),
209
+ defaultJobOptions: {
210
+ attempts: 3,
211
+ backoff: { type: 'exponential', delay: 100, jitter: 0.5 },
212
+ removeOnComplete: { count: 100, age: 3600 },
213
+ removeOnFail: { count: 1000 },
214
+ },
215
+ prefix: this.options.connection.keyPrefix,
216
+ skipWaitingForReady: true,
217
+ // Mirrors the runtime service: skip the constructor-time
218
+ // version probe so the queue does not throw on a Redis
219
+ // client that is not yet ready.
220
+ skipVersionCheck: true,
221
+ });
222
+ }
223
+
224
+ private producerConnectionOptions(): Record<string, unknown> {
225
+ return {
226
+ host: this.options.connection.host,
227
+ port: this.options.connection.port,
228
+ password: this.options.connection.password,
229
+ username: this.options.connection.username,
230
+ db: this.options.connection.db,
231
+ ...(this.options.connection.tls ? { tls: true } : {}),
232
+ enableOfflineQueue: false,
233
+ maxRetriesPerRequest: 1,
234
+ };
235
+ }
236
+
237
+ // -------------------------------------------------------------------------
238
+ // Close
239
+ // -------------------------------------------------------------------------
240
+
241
+ /**
242
+ * Close the schedule queue. `removeJobScheduler` is called
243
+ * first for every installed key so the next run of the host
244
+ * app does not inherit leftover schedulers. Each removal is
245
+ * best-effort: a failure on one key does not prevent the
246
+ * others from being removed.
247
+ */
248
+ private async close(): Promise<void> {
249
+ if (this.scheduleQueue !== null) {
250
+ for (const key of this.installedKeys) {
251
+ try {
252
+ await this.scheduleQueue.removeJobScheduler(key);
253
+ } catch (err) {
254
+ this.logger.warn(
255
+ `removeJobScheduler("${key}") failed: ${
256
+ err instanceof Error ? err.message : String(err)
257
+ }`,
258
+ );
259
+ }
260
+ }
261
+ try {
262
+ await this.scheduleQueue.close();
263
+ } catch (err) {
264
+ this.logger.warn(
265
+ `Schedule queue close failed: ${err instanceof Error ? err.message : String(err)}`,
266
+ );
267
+ }
268
+ this.scheduleQueue = null;
269
+ }
270
+ }
271
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * BullMQ Redis connection options accepted by `BullmqBatchModule`.
3
+ *
4
+ * `BullMQ` is opinionated about Redis client behavior: workers and
5
+ * producers must opt into different connection options so that a
6
+ * Redis outage is observed correctly in each role:
7
+ *
8
+ * - Workers MUST set `maxRetriesPerRequest: null` and
9
+ * `enableReadyCheck: false`. BullMQ's internal blocking commands
10
+ * (`BLPOP`, `BRPOPLPUSH`, `XREADGROUP`) MUST NOT retry
11
+ * per-request — a stalled worker will not surface as a connection
12
+ * error. The Redis client is expected to keep retrying
13
+ * `reconnectOnError` until the operator intervenes.
14
+ *
15
+ * - Producers (the `Queue` used to enqueue work) MUST set
16
+ * `enableOfflineQueue: false` so that a Redis-down condition
17
+ * raises an error *synchronously* on the enqueue call rather than
18
+ * buffering the command and returning success. The `JobLauncher`
19
+ * propagates the failure to the caller, so the call site can
20
+ * mark the `JobExecution` as `FAILED` and surface the error
21
+ * to its caller (HTTP, RPC, cron trigger, ...).
22
+ *
23
+ * Both roles share `host` / `port` / `password` / `username` /
24
+ * `db` / `keyPrefix` / `tls` for connection-target configuration. The
25
+ * role-specific tuning lives on `BullMqConnectionOptions` so callers
26
+ * declare the split explicitly. The default keyPrefix is
27
+ * `nest-batch:` — every key the package writes is namespaced under
28
+ * it, and a key-collision in a shared Redis is impossible.
29
+ *
30
+ * The interface is intentionally `Partial<>`-friendly: a host that
31
+ * only needs a local single-node Redis can pass `{ host: '127.0.0.1' }`
32
+ * and accept all defaults.
33
+ */
34
+ export interface BullMqConnectionOptions {
35
+ /** Redis host (default: `'127.0.0.1'`). */
36
+ host?: string;
37
+ /** Redis port (default: `6379`). */
38
+ port?: number;
39
+ /** AUTH password, if any. */
40
+ password?: string;
41
+ /** ACL username, if any (Redis 6+ ACL). */
42
+ username?: string;
43
+ /** Logical database index (default: `0`). */
44
+ db?: number;
45
+ /**
46
+ * Key prefix. Every BullMQ key the package writes is prefixed with
47
+ * this string (BullMQ appends its own `bull:` after the prefix).
48
+ * Default: `'nest-batch:'`.
49
+ */
50
+ keyPrefix?: string;
51
+ /** Enable TLS for the connection. */
52
+ tls?: boolean;
53
+ }
54
+
55
+ /**
56
+ * Resolved Redis connection settings, with all defaults filled in.
57
+ *
58
+ * `BullmqBatchModule.forRoot()` returns a frozen copy of this object
59
+ * under its module-options token; `BullMqExecutionStrategy` reads it
60
+ * to build the `ConnectionOptions` passed into BullMQ's `Queue` /
61
+ * `Worker` / `QueueEvents` constructors.
62
+ */
63
+ export interface BullMqResolvedConnection {
64
+ readonly host: string;
65
+ readonly port: number;
66
+ readonly password: string | undefined;
67
+ readonly username: string | undefined;
68
+ readonly db: number;
69
+ readonly keyPrefix: string;
70
+ readonly tls: boolean;
71
+ }
72
+
73
+ export const BULLMQ_DEFAULT_HOST = '127.0.0.1';
74
+ export const BULLMQ_DEFAULT_PORT = 6379;
75
+ export const BULLMQ_DEFAULT_KEY_PREFIX = 'nest-batch:';
76
+
77
+ /**
78
+ * Fill in defaults for a `BullMqConnectionOptions` bag and return a
79
+ * frozen, fully-resolved connection descriptor.
80
+ *
81
+ * Splitting this out from the module factory keeps the module file
82
+ * focused on DI plumbing and lets the strategy (and tests) construct
83
+ * a resolved connection without re-implementing the defaults.
84
+ */
85
+ export function resolveBullMqConnection(
86
+ options: BullMqConnectionOptions | undefined,
87
+ ): BullMqResolvedConnection {
88
+ return Object.freeze({
89
+ host: options?.host ?? BULLMQ_DEFAULT_HOST,
90
+ port: options?.port ?? BULLMQ_DEFAULT_PORT,
91
+ password: options?.password,
92
+ username: options?.username,
93
+ db: options?.db ?? 0,
94
+ keyPrefix: options?.keyPrefix ?? BULLMQ_DEFAULT_KEY_PREFIX,
95
+ tls: options?.tls ?? false,
96
+ });
97
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Public API barrel for `@nest-batch/bullmq`.
3
+ *
4
+ * The host application should depend exclusively on this barrel:
5
+ * - `BullmqAdapter` is the new factory-pattern transport
6
+ * adapter (use with `NestBatchModule.forRoot({ adapters:
7
+ * { transport: BullmqAdapter.forRoot(...) } })`).
8
+ * - `BullMqExecutionStrategy` is the strategy class (also
9
+ * exported individually so callers can inject it directly for
10
+ * inspection / health checks).
11
+ * - `BULLMQ_MODULE_OPTIONS` is the DI token for the resolved
12
+ * module options bag.
13
+ * - the connection helpers are re-exported so callers can build
14
+ * a fully-resolved `BullMqResolvedConnection` from a partial
15
+ * `BullMqConnectionOptions` without importing the internal
16
+ * `connection.ts` file.
17
+ *
18
+ * The legacy `BullmqBatchModule` (with `forRoot` / `forRootAsync`
19
+ * static methods) has been replaced by `BullmqAdapter`. Internal
20
+ * modules (`./bullmq-execution-strategy`, `./module-options`,
21
+ * `./connection`, `./adapters/bullmq.module`) are implementation
22
+ * details and may move between releases.
23
+ */
24
+ export * from './connection';
25
+ export * from './module-options';
26
+ export * from './bullmq-execution-strategy';
27
+ export * from './bullmq-schedule.service';
28
+ export * from './adapters';
@@ -0,0 +1,74 @@
1
+ import type { BullMqConnectionOptions, BullMqResolvedConnection } from './connection';
2
+
3
+ /**
4
+ * Public options bag for `BullmqBatchModule.forRoot()` and
5
+ * `forRootAsync()`.
6
+ *
7
+ * The fields cover the connections the package needs to wire up:
8
+ * - `connection` — the BullMQ `Queue` / `Worker` / `QueueEvents`
9
+ * share. T17 stores it under `BULLMQ_MODULE_OPTIONS`; T18 splits
10
+ * the role-specific tuning (worker `maxRetriesPerRequest: null`
11
+ * + `enableReadyCheck: false`; producer `enableOfflineQueue:
12
+ * false`) onto this same connection record and derives the
13
+ * per-role client from it.
14
+ * - `autoStartWorker` — whether the module should also start a
15
+ * BullMQ `Worker` on `onApplicationBootstrap`. Defaults to
16
+ * `false` so a launcher-only deployment does not accidentally
17
+ * consume Redis. T18 wires the actual worker construction.
18
+ *
19
+ * The interface extends `BullMqConnectionOptions` via composition
20
+ * (not `extends`) so the field can be `undefined` at the top level
21
+ * (the module applies its own defaults via `resolveBullMqConnection`)
22
+ * and the resolved form (with defaults filled in) is what gets
23
+ * handed to the strategy.
24
+ */
25
+ export interface BullMqModuleOptions {
26
+ /**
27
+ * Redis connection settings shared by the BullMQ `Queue`,
28
+ * `Worker`, and `QueueEvents` clients this package builds.
29
+ * Optional — defaults are filled in by
30
+ * `resolveBullMqConnection()`.
31
+ */
32
+ connection?: BullMqConnectionOptions;
33
+
34
+ /**
35
+ * Whether the module should also spin up a BullMQ `Worker` on
36
+ * `OnApplicationBootstrap`. Default: `false` (launcher-only).
37
+ * Reserved for T18 — the skeleton in T17 does not implement
38
+ * worker lifecycle.
39
+ */
40
+ autoStartWorker?: boolean;
41
+
42
+ /**
43
+ * Reserved for future per-adapter extension. Adapter packages
44
+ * (e.g. a future `@nest-batch/mikro-orm` companion) can read
45
+ * the full options bag through this field for cross-cutting
46
+ * config.
47
+ */
48
+ readonly [key: string]: unknown;
49
+ }
50
+
51
+ /**
52
+ * Token under which the resolved module options are registered.
53
+ *
54
+ * The strategy injects the options via this token so it can build
55
+ * the per-role BullMQ connection clients. The token is a
56
+ * package-scoped `Symbol.for` key (mirroring
57
+ * `@nest-batch/core/MODULE_OPTIONS_TOKEN`) so it is unique across
58
+ * the host process even if multiple `@nest-batch/bullmq` versions
59
+ * are loaded.
60
+ */
61
+ export const BULLMQ_MODULE_OPTIONS: symbol = Symbol.for(
62
+ '@nest-batch/bullmq/MODULE_OPTIONS',
63
+ );
64
+
65
+ /**
66
+ * Type alias for the fully-resolved options bag. Used by
67
+ * `BullmqBatchModule.forRoot()` to freeze the resolved value under
68
+ * `BULLMQ_MODULE_OPTIONS` and by the strategy to type its injected
69
+ * dependency.
70
+ */
71
+ export interface ResolvedBullMqModuleOptions {
72
+ readonly connection: BullMqResolvedConnection;
73
+ readonly autoStartWorker: boolean;
74
+ }