@blokjs/trigger-worker 0.4.0 → 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.
Files changed (37) hide show
  1. package/__tests__/integration/nats-adapter.real-nats.test.ts +116 -0
  2. package/__tests__/integration/pgboss-adapter.real-pg.test.ts +164 -0
  3. package/__tests__/integration/rabbitmq-adapter.real-rabbitmq.test.ts +179 -0
  4. package/__tests__/integration/sqs-adapter.real-sqs.test.ts +228 -0
  5. package/dist/WorkerTrigger.d.ts +37 -1
  6. package/dist/WorkerTrigger.js +142 -21
  7. package/dist/adapters/InMemoryAdapter.js +10 -5
  8. package/dist/adapters/KafkaAdapter.d.ts +62 -0
  9. package/dist/adapters/KafkaAdapter.js +236 -0
  10. package/dist/adapters/NATSAdapter.js +3 -3
  11. package/dist/adapters/PgBossAdapter.d.ts +56 -0
  12. package/dist/adapters/PgBossAdapter.js +251 -0
  13. package/dist/adapters/RabbitMQAdapter.d.ts +51 -0
  14. package/dist/adapters/RabbitMQAdapter.js +241 -0
  15. package/dist/adapters/RedisStreamsAdapter.d.ts +64 -0
  16. package/dist/adapters/RedisStreamsAdapter.js +240 -0
  17. package/dist/adapters/SQSAdapter.d.ts +61 -0
  18. package/dist/adapters/SQSAdapter.js +269 -0
  19. package/dist/adapters/factory.d.ts +34 -0
  20. package/dist/adapters/factory.js +103 -0
  21. package/dist/index.d.ts +21 -4
  22. package/dist/index.js +24 -4
  23. package/package.json +23 -5
  24. package/src/WorkerTrigger.ts +153 -22
  25. package/src/adapters/InMemoryAdapter.ts +9 -5
  26. package/src/adapters/KafkaAdapter.ts +277 -0
  27. package/src/adapters/NATSAdapter.ts +4 -2
  28. package/src/adapters/PgBossAdapter.ts +293 -0
  29. package/src/adapters/RabbitMQAdapter.ts +285 -0
  30. package/src/adapters/RedisStreamsAdapter.ts +286 -0
  31. package/src/adapters/SQSAdapter.ts +306 -0
  32. package/src/adapters/factory.test.ts +89 -0
  33. package/src/adapters/factory.ts +111 -0
  34. package/src/adapters/new-adapters.test.ts +130 -0
  35. package/src/index.ts +30 -4
  36. package/template/package.json +6 -6
  37. package/template/src/workflows/jobs/process-job.ts +37 -35
@@ -0,0 +1,293 @@
1
+ /**
2
+ * PgBossAdapter — v0.7 PR 5 — Worker adapter backed by `pg-boss`,
3
+ * which stores jobs in PostgreSQL. Operationally attractive when
4
+ * Postgres is already in the stack — no extra broker infra needed.
5
+ *
6
+ * Semantics:
7
+ * - `pg-boss` handles concurrency, retries, dead-letter, priority,
8
+ * delays, and exactly-once scheduling natively — the adapter
9
+ * forwards `config.concurrency`, `config.retries`, etc. to the
10
+ * `boss.work(queue, opts, handler)` and `boss.send(queue, data, opts)`
11
+ * calls directly.
12
+ * - **Single connection per process** — `pg-boss` manages its own
13
+ * pool; we instantiate one `PgBoss` instance per `PgBossAdapter`.
14
+ * - **Schema**: `pg-boss` creates its own schema (`pgboss` by
15
+ * default) on first start. Tables are migrated automatically.
16
+ *
17
+ * Requires `pg-boss` as a peer dependency:
18
+ *
19
+ * bun add pg-boss
20
+ *
21
+ * Environment variables:
22
+ * - `DATABASE_URL` — Postgres connection string (default
23
+ * `postgres://localhost:5432/blok`).
24
+ * - `PG_BOSS_SCHEMA` — schema name (default `"pgboss"`).
25
+ */
26
+
27
+ import type { WorkerTriggerOpts } from "@blokjs/helper";
28
+ import { v4 as uuid } from "uuid";
29
+ import type { WorkerAdapter, WorkerJob, WorkerQueueStats } from "../WorkerTrigger";
30
+
31
+ export interface PgBossConfig {
32
+ connectionString: string;
33
+ schema?: string;
34
+ }
35
+
36
+ interface PgBossInstance {
37
+ start(): Promise<unknown>;
38
+ stop(opts?: { graceful?: boolean }): Promise<void>;
39
+ createQueue(queue: string, opts?: Record<string, unknown>): Promise<unknown>;
40
+ send(queue: string, data: unknown, opts?: Record<string, unknown>): Promise<string | null>;
41
+ work(
42
+ queue: string,
43
+ opts: Record<string, unknown>,
44
+ handler: (job: PgBossJob | PgBossJob[]) => Promise<unknown>,
45
+ ): Promise<string>;
46
+ offWork(queue: string): Promise<void>;
47
+ getQueueSize(queue: string): Promise<number>;
48
+ }
49
+
50
+ interface PgBossJob {
51
+ id: string;
52
+ data: unknown;
53
+ name: string;
54
+ }
55
+
56
+ interface QueueStatsCounters {
57
+ completed: number;
58
+ failed: number;
59
+ active: number;
60
+ }
61
+
62
+ export class PgBossAdapter implements WorkerAdapter {
63
+ readonly provider = "pg-boss" as const;
64
+ private readonly config: PgBossConfig;
65
+ private boss: PgBossInstance | null = null;
66
+ private workIds: Map<string, string> = new Map();
67
+ private connected = false;
68
+ private stats: Map<string, QueueStatsCounters> = new Map();
69
+
70
+ constructor(config?: Partial<PgBossConfig>) {
71
+ this.config = {
72
+ connectionString: config?.connectionString ?? process.env.DATABASE_URL ?? "postgres://localhost:5432/blok",
73
+ schema: config?.schema ?? process.env.PG_BOSS_SCHEMA ?? "pgboss",
74
+ };
75
+ }
76
+
77
+ async connect(): Promise<void> {
78
+ if (this.connected) return;
79
+ try {
80
+ // Indirect specifier so tsc doesn't try to resolve types for
81
+ // the optional peer dependency at build time.
82
+ const moduleName = "pg-boss";
83
+ // biome-ignore lint/suspicious/noExplicitAny: pg-boss is a runtime peer dep.
84
+ const mod: any = await import(moduleName);
85
+ // biome-ignore lint/suspicious/noExplicitAny: pg-boss is a runtime peer dep.
86
+ const PgBoss: any = mod.default ?? mod;
87
+ this.boss = new PgBoss({
88
+ connectionString: this.config.connectionString,
89
+ schema: this.config.schema,
90
+ }) as PgBossInstance;
91
+ await this.boss.start();
92
+ this.connected = true;
93
+ } catch (err) {
94
+ throw new Error(
95
+ `[blok][pg-boss] connect failed: ${(err as Error).message}. Install pg-boss as a peer dependency: bun add pg-boss`,
96
+ );
97
+ }
98
+ }
99
+
100
+ async disconnect(): Promise<void> {
101
+ if (!this.connected) return;
102
+ try {
103
+ await this.boss?.stop({ graceful: true });
104
+ } catch {
105
+ /* ignore */
106
+ }
107
+ this.workIds.clear();
108
+ this.boss = null;
109
+ this.connected = false;
110
+ }
111
+
112
+ async process(config: WorkerTriggerOpts, handler: (job: WorkerJob) => Promise<void>): Promise<void> {
113
+ if (!this.connected || !this.boss) throw new Error("[blok][pg-boss] not connected. Call connect() first.");
114
+ this.stats.set(config.queue, { completed: 0, failed: 0, active: 0 });
115
+ const stats = this.stats.get(config.queue) as QueueStatsCounters;
116
+
117
+ // pg-boss v10 requires explicit queue creation before send/work.
118
+ // The call is idempotent — succeeds whether the queue exists or
119
+ // not — so this is safe to repeat across re-entries.
120
+ await this.ensureQueue(config.queue);
121
+
122
+ // pg-boss v10's handler receives an ARRAY of jobs (batch); the
123
+ // `batchSize` option controls the max. We keep `batchSize: 1` so
124
+ // the iteration is a single-job loop (matches pre-v10 semantics
125
+ // the adapter was written against). `pollingIntervalSeconds`
126
+ // defaults to 2s which adds ~2s latency on the happy path —
127
+ // tighten it for low-latency workloads via the worker config.
128
+ const id = await this.boss.work(
129
+ config.queue,
130
+ {
131
+ batchSize: 1,
132
+ includeMetadata: true,
133
+ },
134
+ async (jobOrBatch) => {
135
+ const jobs = Array.isArray(jobOrBatch) ? jobOrBatch : [jobOrBatch];
136
+ for (const job of jobs) {
137
+ await this.runOneJob(config, handler, stats, job);
138
+ }
139
+ },
140
+ );
141
+ this.workIds.set(config.queue, id);
142
+ }
143
+
144
+ private async runOneJob(
145
+ config: WorkerTriggerOpts,
146
+ handler: (job: WorkerJob) => Promise<void>,
147
+ stats: QueueStatsCounters,
148
+ job: PgBossJob,
149
+ ): Promise<void> {
150
+ stats.active += 1;
151
+ // Tracks whether stats have already been counted by the
152
+ // WorkerJob API (`complete` / `fail`). Without this, both
153
+ // the callback and the wrapper's try/catch credit the same
154
+ // outcome, double-counting `stats.completed` /
155
+ // `stats.failed`. pg-boss itself handles ack/retry/DLQ via
156
+ // the handler's return-vs-throw — these callbacks exist
157
+ // purely for stats parity with the other worker adapters.
158
+ let settled = false;
159
+ const workerJob: WorkerJob = {
160
+ id: job.id,
161
+ data: job.data,
162
+ headers: {},
163
+ queue: config.queue,
164
+ priority: config.priority ?? 0,
165
+ attempts: 0,
166
+ maxRetries: config.retries ?? 0,
167
+ createdAt: new Date(),
168
+ timeout: config.timeout,
169
+ raw: job,
170
+ complete: async () => {
171
+ if (settled) return;
172
+ stats.completed += 1;
173
+ settled = true;
174
+ },
175
+ fail: async (err: Error) => {
176
+ if (settled) return;
177
+ stats.failed += 1;
178
+ settled = true;
179
+ // Re-throw so pg-boss's `boss.work` sees the handler
180
+ // failure and schedules a retry / drops to DLQ per
181
+ // the queue config — the contract is "handler throws
182
+ // => job failed".
183
+ throw err;
184
+ },
185
+ };
186
+ try {
187
+ await handler(workerJob);
188
+ if (!settled) {
189
+ stats.completed += 1;
190
+ settled = true;
191
+ }
192
+ } catch (err) {
193
+ if (!settled) {
194
+ stats.failed += 1;
195
+ settled = true;
196
+ }
197
+ throw err;
198
+ } finally {
199
+ stats.active = Math.max(0, stats.active - 1);
200
+ }
201
+ }
202
+
203
+ private async ensureQueue(queue: string): Promise<void> {
204
+ if (!this.boss) return;
205
+ try {
206
+ await this.boss.createQueue(queue);
207
+ } catch (err) {
208
+ // pg-boss v10's createQueue is idempotent but may throw on
209
+ // race conditions when multiple processes call it concurrently
210
+ // against a fresh schema — those errors are harmless once the
211
+ // queue exists. Swallow + let downstream send/work surface a
212
+ // real problem if the queue isn't usable.
213
+ const message = (err as Error).message ?? "";
214
+ if (!message.includes("already exists")) {
215
+ // Log the unexpected case but don't propagate — operators
216
+ // shouldn't see a queue-creation race blow up their
217
+ // trigger boot path.
218
+ console.warn(`[blok][pg-boss] ensureQueue(${queue}) warning:`, message);
219
+ }
220
+ }
221
+ }
222
+
223
+ async addJob(
224
+ queue: string,
225
+ data: unknown,
226
+ opts?: { priority?: number; delay?: number; retries?: number; timeout?: number; jobId?: string },
227
+ ): Promise<string> {
228
+ if (!this.connected || !this.boss) throw new Error("[blok][pg-boss] not connected. Call connect() first.");
229
+ // pg-boss v10 requires the queue to exist before send. The
230
+ // call is idempotent — cheap to repeat per add.
231
+ await this.ensureQueue(queue);
232
+ // pg-boss v10's `attorney.checkSendArgs` rejects any explicitly
233
+ // undefined `priority` / `retryLimit` / `startAfter` /
234
+ // `expireInSeconds` / `singletonKey` with "X must be an integer".
235
+ // Build the options object with only the keys we actually want
236
+ // set — caught by the real-broker integration test in
237
+ // `__tests__/integration/pgboss-adapter.real-pg.test.ts`.
238
+ const sendOpts: Record<string, unknown> = {};
239
+ if (typeof opts?.priority === "number") sendOpts.priority = opts.priority;
240
+ if (typeof opts?.delay === "number" && opts.delay > 0) {
241
+ sendOpts.startAfter = Math.ceil(opts.delay / 1000);
242
+ }
243
+ if (typeof opts?.retries === "number") sendOpts.retryLimit = opts.retries;
244
+ if (typeof opts?.timeout === "number") sendOpts.expireInSeconds = Math.ceil(opts.timeout / 1000);
245
+ if (typeof opts?.jobId === "string" && opts.jobId.length > 0) sendOpts.singletonKey = opts.jobId;
246
+
247
+ const jobId = await this.boss.send(queue, data, sendOpts);
248
+ return jobId ?? opts?.jobId ?? uuid();
249
+ }
250
+
251
+ async stopProcessing(queue: string): Promise<void> {
252
+ if (!this.connected || !this.boss) return;
253
+ try {
254
+ await this.boss.offWork(queue);
255
+ } catch {
256
+ /* ignore */
257
+ }
258
+ this.workIds.delete(queue);
259
+ }
260
+
261
+ isConnected(): boolean {
262
+ return this.connected;
263
+ }
264
+
265
+ async healthCheck(): Promise<boolean> {
266
+ if (!this.connected || !this.boss) return false;
267
+ try {
268
+ await this.boss.getQueueSize("__health_check__");
269
+ return true;
270
+ } catch {
271
+ // getQueueSize on a non-existent queue may throw — fall back to
272
+ // "connection is live" by checking the boss instance attribute.
273
+ return this.connected;
274
+ }
275
+ }
276
+
277
+ async getQueueStats(queue: string): Promise<WorkerQueueStats> {
278
+ const counters = this.stats.get(queue) ?? { completed: 0, failed: 0, active: 0 };
279
+ let waiting = 0;
280
+ try {
281
+ waiting = (await this.boss?.getQueueSize(queue)) ?? 0;
282
+ } catch {
283
+ /* ignore */
284
+ }
285
+ return {
286
+ waiting,
287
+ active: counters.active,
288
+ completed: counters.completed,
289
+ failed: counters.failed,
290
+ delayed: 0,
291
+ };
292
+ }
293
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * RabbitMQAdapter — v0.7 PR 5 — Worker adapter backed by RabbitMQ via
3
+ * the `amqplib` driver. Direct-exchange / queue model — the `queue`
4
+ * field maps to an AMQP queue name; messages are consumed with
5
+ * manual ACK. The default exchange (`""`) is used for publishing.
6
+ *
7
+ * Features:
8
+ * - Concurrency via `prefetch(N)` per channel.
9
+ * - Retries via `nack` with requeue=true until `retries` is hit, then
10
+ * drop or DLQ-route based on `deadLetterQueue` config.
11
+ * - Priorities via the `x-max-priority` queue arg (AMQP standard).
12
+ * - Delayed delivery via the `x-delayed-message` plugin when
13
+ * available; falls back to immediate dispatch otherwise.
14
+ *
15
+ * Requires `amqplib` as a peer dependency:
16
+ *
17
+ * bun add amqplib
18
+ *
19
+ * Environment variables:
20
+ * - `AMQP_URL` — full AMQP connection string (default `amqp://localhost`).
21
+ * - `AMQP_VHOST` — virtual host (default `/`).
22
+ */
23
+
24
+ import type { WorkerTriggerOpts } from "@blokjs/helper";
25
+ import { v4 as uuid } from "uuid";
26
+ import type { WorkerAdapter, WorkerJob, WorkerQueueStats } from "../WorkerTrigger";
27
+
28
+ export interface RabbitMQConfig {
29
+ url: string;
30
+ vhost?: string;
31
+ }
32
+
33
+ interface RabbitChannel {
34
+ prefetch(n: number): Promise<void>;
35
+ assertQueue(
36
+ name: string,
37
+ opts?: Record<string, unknown>,
38
+ ): Promise<{ queue: string; messageCount: number; consumerCount: number }>;
39
+ checkQueue(name: string): Promise<{ queue: string; messageCount: number; consumerCount: number }>;
40
+ consume(
41
+ queue: string,
42
+ cb: (
43
+ msg: {
44
+ content: Buffer;
45
+ fields: { deliveryTag: number; redelivered: boolean };
46
+ properties: { messageId?: string; priority?: number; timestamp?: number; headers?: Record<string, unknown> };
47
+ } | null,
48
+ ) => void,
49
+ opts?: { noAck?: boolean; consumerTag?: string },
50
+ ): Promise<{ consumerTag: string }>;
51
+ cancel(consumerTag: string): Promise<void>;
52
+ ack(msg: { fields: { deliveryTag: number } }): void;
53
+ nack(msg: { fields: { deliveryTag: number } }, allUpTo?: boolean, requeue?: boolean): void;
54
+ sendToQueue(queue: string, content: Buffer, opts?: Record<string, unknown>): boolean;
55
+ close(): Promise<void>;
56
+ }
57
+
58
+ interface RabbitConnection {
59
+ createChannel(): Promise<RabbitChannel>;
60
+ close(): Promise<void>;
61
+ }
62
+
63
+ interface QueueStatsCounters {
64
+ completed: number;
65
+ failed: number;
66
+ active: number;
67
+ }
68
+
69
+ export class RabbitMQAdapter implements WorkerAdapter {
70
+ readonly provider = "rabbitmq" as const;
71
+ private readonly config: RabbitMQConfig;
72
+ private conn: RabbitConnection | null = null;
73
+ private channels: Map<string, { channel: RabbitChannel; consumerTag?: string }> = new Map();
74
+ private connected = false;
75
+ private stats: Map<string, QueueStatsCounters> = new Map();
76
+
77
+ constructor(config?: Partial<RabbitMQConfig>) {
78
+ this.config = {
79
+ url: config?.url ?? process.env.AMQP_URL ?? "amqp://localhost",
80
+ vhost: config?.vhost ?? process.env.AMQP_VHOST,
81
+ };
82
+ }
83
+
84
+ async connect(): Promise<void> {
85
+ if (this.connected) return;
86
+ try {
87
+ // biome-ignore lint/suspicious/noExplicitAny: amqplib's connect returns a loose ConnectionLike.
88
+ const amqp: any = await import("amqplib");
89
+ this.conn = (await amqp.connect(this.config.url, { vhost: this.config.vhost })) as RabbitConnection;
90
+ this.connected = true;
91
+ } catch (err) {
92
+ throw new Error(
93
+ `[blok][rabbitmq] connect failed: ${(err as Error).message}. Install amqplib as a peer dependency: bun add amqplib`,
94
+ );
95
+ }
96
+ }
97
+
98
+ async disconnect(): Promise<void> {
99
+ if (!this.connected) return;
100
+ for (const [, entry] of this.channels) {
101
+ try {
102
+ if (entry.consumerTag) await entry.channel.cancel(entry.consumerTag);
103
+ await entry.channel.close();
104
+ } catch {
105
+ /* ignore */
106
+ }
107
+ }
108
+ this.channels.clear();
109
+ try {
110
+ await this.conn?.close();
111
+ } catch {
112
+ /* ignore */
113
+ }
114
+ this.conn = null;
115
+ this.connected = false;
116
+ }
117
+
118
+ async process(config: WorkerTriggerOpts, handler: (job: WorkerJob) => Promise<void>): Promise<void> {
119
+ if (!this.connected || !this.conn) throw new Error("[blok][rabbitmq] not connected. Call connect() first.");
120
+ const channel = await this.conn.createChannel();
121
+ await channel.prefetch(config.concurrency ?? 1);
122
+
123
+ const queueArgs: Record<string, unknown> = {};
124
+ if (config.deadLetterQueue) {
125
+ queueArgs["x-dead-letter-exchange"] = "";
126
+ queueArgs["x-dead-letter-routing-key"] = config.deadLetterQueue;
127
+ await channel.assertQueue(config.deadLetterQueue, { durable: true });
128
+ }
129
+ await channel.assertQueue(config.queue, { durable: true, arguments: queueArgs });
130
+
131
+ this.stats.set(config.queue, { completed: 0, failed: 0, active: 0 });
132
+ const stats = this.stats.get(config.queue) as QueueStatsCounters;
133
+ const maxAttempts = (config.retries ?? 3) + 1;
134
+
135
+ const { consumerTag } = await channel.consume(
136
+ config.queue,
137
+ (msg) => {
138
+ if (!msg) return;
139
+ void (async () => {
140
+ const payloadString = msg.content.toString("utf8");
141
+ let data: unknown;
142
+ try {
143
+ data = payloadString.length > 0 ? JSON.parse(payloadString) : null;
144
+ } catch {
145
+ data = payloadString;
146
+ }
147
+ const headers: Record<string, string> = {};
148
+ for (const [k, v] of Object.entries(msg.properties.headers ?? {})) headers[k] = String(v);
149
+ const attempts = Number.parseInt(String(msg.properties.headers?.["x-blok-attempt"] ?? 0), 10);
150
+ // Tracks whether the message has already been ack/nack'd via
151
+ // the WorkerJob API (`complete` / `fail`). The wrapping
152
+ // try/catch below also wants to ack on handler success and
153
+ // nack on handler throw — without this flag we'd
154
+ // double-ack the same delivery tag and Rabbit closes the
155
+ // channel with PRECONDITION_FAILED (caught by the
156
+ // real-broker integration test in
157
+ // `__tests__/integration/rabbitmq-adapter.real-rabbitmq.test.ts`).
158
+ let settled = false;
159
+ const job: WorkerJob = {
160
+ id: msg.properties.messageId ?? `${config.queue}:${msg.fields.deliveryTag}`,
161
+ data,
162
+ headers,
163
+ queue: config.queue,
164
+ priority: msg.properties.priority ?? config.priority ?? 0,
165
+ attempts,
166
+ maxRetries: config.retries ?? 3,
167
+ createdAt: msg.properties.timestamp ? new Date(msg.properties.timestamp) : new Date(),
168
+ timeout: config.timeout,
169
+ raw: msg,
170
+ complete: async () => {
171
+ if (settled) return;
172
+ channel.ack(msg);
173
+ stats.completed += 1;
174
+ settled = true;
175
+ },
176
+ fail: async (err: Error, requeue?: boolean) => {
177
+ if (settled) return;
178
+ stats.failed += 1;
179
+ const exceeded = attempts + 1 >= maxAttempts;
180
+ channel.nack(msg, false, !exceeded && requeue !== false);
181
+ settled = true;
182
+ },
183
+ };
184
+ stats.active += 1;
185
+ try {
186
+ await handler(job);
187
+ if (!settled && config.ack !== false) {
188
+ channel.ack(msg);
189
+ stats.completed += 1;
190
+ settled = true;
191
+ }
192
+ } catch {
193
+ if (!settled) {
194
+ stats.failed += 1;
195
+ const exceeded = attempts + 1 >= maxAttempts;
196
+ channel.nack(msg, false, !exceeded);
197
+ settled = true;
198
+ }
199
+ } finally {
200
+ stats.active = Math.max(0, stats.active - 1);
201
+ }
202
+ })();
203
+ },
204
+ { noAck: config.ack === false },
205
+ );
206
+ this.channels.set(config.queue, { channel, consumerTag });
207
+ }
208
+
209
+ async addJob(
210
+ queue: string,
211
+ data: unknown,
212
+ opts?: { priority?: number; delay?: number; retries?: number; timeout?: number; jobId?: string },
213
+ ): Promise<string> {
214
+ if (!this.connected || !this.conn) throw new Error("[blok][rabbitmq] not connected. Call connect() first.");
215
+ let channel = this.channels.get(queue)?.channel;
216
+ if (!channel) {
217
+ channel = await this.conn.createChannel();
218
+ await channel.assertQueue(queue, { durable: true });
219
+ }
220
+ const messageId = opts?.jobId ?? uuid();
221
+ const headers: Record<string, unknown> = {};
222
+ if (typeof opts?.delay === "number") headers["x-delay"] = opts.delay;
223
+ const ok = channel.sendToQueue(queue, Buffer.from(typeof data === "string" ? data : JSON.stringify(data)), {
224
+ persistent: true,
225
+ messageId,
226
+ priority: opts?.priority,
227
+ timestamp: Date.now(),
228
+ headers,
229
+ });
230
+ if (!ok) {
231
+ // Channel is in flow-controlled state. The send is still
232
+ // accepted; the channel will emit a 'drain' event when ready.
233
+ // We don't currently surface backpressure to callers.
234
+ }
235
+ return messageId;
236
+ }
237
+
238
+ async stopProcessing(queue: string): Promise<void> {
239
+ const entry = this.channels.get(queue);
240
+ if (!entry) return;
241
+ try {
242
+ if (entry.consumerTag) await entry.channel.cancel(entry.consumerTag);
243
+ await entry.channel.close();
244
+ } catch {
245
+ /* ignore */
246
+ }
247
+ this.channels.delete(queue);
248
+ }
249
+
250
+ isConnected(): boolean {
251
+ return this.connected;
252
+ }
253
+
254
+ async healthCheck(): Promise<boolean> {
255
+ if (!this.connected || !this.conn) return false;
256
+ try {
257
+ const channel = await this.conn.createChannel();
258
+ await channel.close();
259
+ return true;
260
+ } catch {
261
+ return false;
262
+ }
263
+ }
264
+
265
+ async getQueueStats(queue: string): Promise<WorkerQueueStats> {
266
+ const counters = this.stats.get(queue) ?? { completed: 0, failed: 0, active: 0 };
267
+ let waiting = 0;
268
+ const entry = this.channels.get(queue);
269
+ if (entry) {
270
+ try {
271
+ const info = await entry.channel.checkQueue(queue);
272
+ waiting = info.messageCount;
273
+ } catch {
274
+ /* ignore */
275
+ }
276
+ }
277
+ return {
278
+ waiting,
279
+ active: counters.active,
280
+ completed: counters.completed,
281
+ failed: counters.failed,
282
+ delayed: 0,
283
+ };
284
+ }
285
+ }