@blokjs/trigger-worker 0.6.17 → 0.6.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/WorkerTrigger.d.ts +27 -3
- package/dist/WorkerTrigger.js +168 -26
- package/dist/adapters/KafkaAdapter.d.ts +5 -0
- package/dist/adapters/KafkaAdapter.js +12 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/package.json +5 -4
- package/CHANGELOG.md +0 -22
- package/__tests__/integration/nats-adapter.real-nats.test.ts +0 -116
- package/__tests__/integration/pgboss-adapter.real-pg.test.ts +0 -164
- package/__tests__/integration/rabbitmq-adapter.real-rabbitmq.test.ts +0 -179
- package/__tests__/integration/sqs-adapter.real-sqs.test.ts +0 -228
- package/src/WorkerTrigger.test.ts +0 -540
- package/src/WorkerTrigger.ts +0 -784
- package/src/adapters/BullMQAdapter.ts +0 -296
- package/src/adapters/InMemoryAdapter.ts +0 -280
- package/src/adapters/KafkaAdapter.ts +0 -277
- package/src/adapters/NATSAdapter.ts +0 -454
- package/src/adapters/PgBossAdapter.ts +0 -293
- package/src/adapters/RabbitMQAdapter.ts +0 -285
- package/src/adapters/RedisStreamsAdapter.ts +0 -286
- package/src/adapters/SQSAdapter.ts +0 -306
- package/src/adapters/factory.test.ts +0 -89
- package/src/adapters/factory.ts +0 -111
- package/src/adapters/new-adapters.test.ts +0 -130
- package/src/index.ts +0 -94
- package/template/.env.example +0 -13
- package/template/package.json +0 -45
- package/template/src/Nodes.ts +0 -10
- package/template/src/Workflows.ts +0 -8
- package/template/src/index.ts +0 -41
- package/template/src/runner/WorkerServer.ts +0 -34
- package/template/src/runner/types/Workflows.ts +0 -7
- package/template/src/workflows/jobs/process-job.ts +0 -47
- package/template/tsconfig.json +0 -31
- package/template/vitest.config.ts +0 -39
- package/tsconfig.json +0 -32
|
@@ -1,293 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,285 +0,0 @@
|
|
|
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
|
-
}
|