@grupodiariodaregiao/bunstone 0.3.8 → 0.3.9
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/index.d.ts +1 -0
- package/dist/index.js +165 -11
- package/dist/lib/rabbitmq/dead-letter.service.d.ts +103 -0
- package/dist/lib/rabbitmq/interfaces/rabbitmq-message.interface.d.ts +72 -0
- package/dist/lib/rabbitmq/interfaces/rabbitmq-options.interface.d.ts +15 -0
- package/lib/rabbitmq/dead-letter.service.ts +324 -0
- package/lib/rabbitmq/interfaces/rabbitmq-message.interface.ts +83 -0
- package/lib/rabbitmq/interfaces/rabbitmq-options.interface.ts +15 -0
- package/lib/rabbitmq/rabbitmq-connection.ts +32 -0
- package/lib/rabbitmq/rabbitmq-module.ts +3 -2
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -39,6 +39,7 @@ export * from "./lib/bullmq/decorators/processor.decorator";
|
|
|
39
39
|
export * from "./lib/bullmq/decorators/process.decorator";
|
|
40
40
|
export { RabbitMQModule } from "./lib/rabbitmq/rabbitmq-module";
|
|
41
41
|
export { RabbitMQService } from "./lib/rabbitmq/rabbitmq.service";
|
|
42
|
+
export { RabbitMQDeadLetterService } from "./lib/rabbitmq/dead-letter.service";
|
|
42
43
|
export { RabbitMQConnection } from "./lib/rabbitmq/rabbitmq-connection";
|
|
43
44
|
export * from "./lib/rabbitmq/decorators/consumer.decorator";
|
|
44
45
|
export * from "./lib/rabbitmq/decorators/subscribe.decorator";
|
package/dist/index.js
CHANGED
|
@@ -32209,7 +32209,7 @@ var require_node_cron = __commonJS((exports) => {
|
|
|
32209
32209
|
});
|
|
32210
32210
|
|
|
32211
32211
|
// index.ts
|
|
32212
|
-
var
|
|
32212
|
+
var import_reflect_metadata29 = __toESM(require_Reflect(), 1);
|
|
32213
32213
|
|
|
32214
32214
|
// lib/adapters/cache-adapter.ts
|
|
32215
32215
|
var {RedisClient, redis } = globalThis.Bun;
|
|
@@ -116845,6 +116845,23 @@ class RabbitMQConnection {
|
|
|
116845
116845
|
await channel.bindQueue(q4.name, binding.exchange, binding.routingKey ?? "", binding.arguments);
|
|
116846
116846
|
logger2.log(`Queue "${q4.name}" bound to exchange "${binding.exchange}" with key "${binding.routingKey ?? ""}"`);
|
|
116847
116847
|
}
|
|
116848
|
+
if (q4.deadLetterQueue && q4.deadLetterExchange) {
|
|
116849
|
+
const dlxType = q4.deadLetterExchangeType ?? "direct";
|
|
116850
|
+
await channel.assertExchange(q4.deadLetterExchange, dlxType, {
|
|
116851
|
+
durable: true,
|
|
116852
|
+
autoDelete: false
|
|
116853
|
+
});
|
|
116854
|
+
logger2.log(`Dead letter exchange asserted: [${dlxType}] ${q4.deadLetterExchange}`);
|
|
116855
|
+
await channel.assertQueue(q4.deadLetterQueue, {
|
|
116856
|
+
durable: true,
|
|
116857
|
+
exclusive: false,
|
|
116858
|
+
autoDelete: false
|
|
116859
|
+
});
|
|
116860
|
+
logger2.log(`Dead letter queue asserted: ${q4.deadLetterQueue}`);
|
|
116861
|
+
const dlxBindingKey = q4.deadLetterRoutingKey ?? "";
|
|
116862
|
+
await channel.bindQueue(q4.deadLetterQueue, q4.deadLetterExchange, dlxBindingKey);
|
|
116863
|
+
logger2.log(`DLQ "${q4.deadLetterQueue}" bound to DLX "${q4.deadLetterExchange}" with key "${dlxBindingKey}"`);
|
|
116864
|
+
}
|
|
116848
116865
|
}
|
|
116849
116866
|
} finally {
|
|
116850
116867
|
await channel.close().catch(() => {});
|
|
@@ -117880,8 +117897,144 @@ function Process(name3) {
|
|
|
117880
117897
|
Reflect.defineMetadata(BULLMQ_PROCESS_METADATA, { name: name3 }, target2, propertyKey);
|
|
117881
117898
|
};
|
|
117882
117899
|
}
|
|
117883
|
-
// lib/rabbitmq/
|
|
117900
|
+
// lib/rabbitmq/dead-letter.service.ts
|
|
117884
117901
|
var import_reflect_metadata20 = __toESM(require_Reflect(), 1);
|
|
117902
|
+
class RabbitMQDeadLetterService {
|
|
117903
|
+
logger = new Logger(RabbitMQDeadLetterService.name);
|
|
117904
|
+
async inspect(queue2, count = 10) {
|
|
117905
|
+
const pubCh = await RabbitMQConnection.getPublisherChannel();
|
|
117906
|
+
const ch = await RabbitMQConnection.getConsumerChannel(`__dlq_inspect_${queue2}`);
|
|
117907
|
+
const results = [];
|
|
117908
|
+
for (let i = 0;i < count; i++) {
|
|
117909
|
+
const raw = await ch.get(queue2, { noAck: false });
|
|
117910
|
+
if (!raw)
|
|
117911
|
+
break;
|
|
117912
|
+
const data = this.parsePayload(raw);
|
|
117913
|
+
const deathInfo = this.extractDeathInfo(raw);
|
|
117914
|
+
results.push(this.buildWrapper(ch, raw, data, deathInfo, pubCh));
|
|
117915
|
+
ch.nack(raw, false, true);
|
|
117916
|
+
}
|
|
117917
|
+
await ch.close().catch(() => {});
|
|
117918
|
+
return results;
|
|
117919
|
+
}
|
|
117920
|
+
async requeueMessages(options) {
|
|
117921
|
+
const { fromQueue, toExchange, routingKey, count, publishOptions } = options;
|
|
117922
|
+
const ch = await RabbitMQConnection.getConsumerChannel(`__dlq_requeue_${fromQueue}`);
|
|
117923
|
+
const pubCh = await RabbitMQConnection.getPublisherChannel();
|
|
117924
|
+
let requeued = 0;
|
|
117925
|
+
const max = count ?? Number.POSITIVE_INFINITY;
|
|
117926
|
+
while (requeued < max) {
|
|
117927
|
+
const raw = await ch.get(fromQueue, { noAck: false });
|
|
117928
|
+
if (!raw)
|
|
117929
|
+
break;
|
|
117930
|
+
try {
|
|
117931
|
+
const pubOpts = this.buildPublishOpts(raw, publishOptions);
|
|
117932
|
+
await new Promise((resolve2, reject) => {
|
|
117933
|
+
pubCh.publish(toExchange, routingKey, raw.content, pubOpts, (err) => {
|
|
117934
|
+
if (err)
|
|
117935
|
+
reject(err);
|
|
117936
|
+
else
|
|
117937
|
+
resolve2();
|
|
117938
|
+
});
|
|
117939
|
+
});
|
|
117940
|
+
ch.ack(raw);
|
|
117941
|
+
requeued++;
|
|
117942
|
+
} catch (err) {
|
|
117943
|
+
ch.nack(raw, false, true);
|
|
117944
|
+
this.logger.error(`Failed to republish message from "${fromQueue}" to "${toExchange}/${routingKey}": ${err.message}`);
|
|
117945
|
+
break;
|
|
117946
|
+
}
|
|
117947
|
+
}
|
|
117948
|
+
await ch.close().catch(() => {});
|
|
117949
|
+
this.logger.log(`Requeued ${requeued} message(s) from "${fromQueue}" to exchange "${toExchange}" (key: "${routingKey}")`);
|
|
117950
|
+
return requeued;
|
|
117951
|
+
}
|
|
117952
|
+
async discardMessages(queue2, count) {
|
|
117953
|
+
const ch = await RabbitMQConnection.getConsumerChannel(`__dlq_discard_${queue2}`);
|
|
117954
|
+
let discarded = 0;
|
|
117955
|
+
const max = count ?? Number.POSITIVE_INFINITY;
|
|
117956
|
+
while (discarded < max) {
|
|
117957
|
+
const raw = await ch.get(queue2, { noAck: false });
|
|
117958
|
+
if (!raw)
|
|
117959
|
+
break;
|
|
117960
|
+
ch.ack(raw);
|
|
117961
|
+
discarded++;
|
|
117962
|
+
}
|
|
117963
|
+
await ch.close().catch(() => {});
|
|
117964
|
+
this.logger.log(`Discarded ${discarded} message(s) from queue "${queue2}"`);
|
|
117965
|
+
return discarded;
|
|
117966
|
+
}
|
|
117967
|
+
async messageCount(queue2) {
|
|
117968
|
+
const ch = await RabbitMQConnection.getConsumerChannel(`__dlq_count_${queue2}`);
|
|
117969
|
+
const info = await ch.checkQueue(queue2);
|
|
117970
|
+
await ch.close().catch(() => {});
|
|
117971
|
+
return info.messageCount;
|
|
117972
|
+
}
|
|
117973
|
+
parsePayload(raw) {
|
|
117974
|
+
try {
|
|
117975
|
+
return JSON.parse(raw.content.toString("utf-8"));
|
|
117976
|
+
} catch {
|
|
117977
|
+
return raw.content.toString("utf-8");
|
|
117978
|
+
}
|
|
117979
|
+
}
|
|
117980
|
+
extractDeathInfo(raw) {
|
|
117981
|
+
const xDeath = raw.properties.headers?.["x-death"];
|
|
117982
|
+
if (!Array.isArray(xDeath) || xDeath.length === 0)
|
|
117983
|
+
return null;
|
|
117984
|
+
const record2 = xDeath[0];
|
|
117985
|
+
return {
|
|
117986
|
+
queue: String(record2["queue"] ?? ""),
|
|
117987
|
+
exchange: String(record2["exchange"] ?? ""),
|
|
117988
|
+
routingKeys: Array.isArray(record2["routing-keys"]) ? record2["routing-keys"].map(String) : [],
|
|
117989
|
+
count: Number(record2["count"] ?? 1),
|
|
117990
|
+
reason: String(record2["reason"] ?? "rejected"),
|
|
117991
|
+
time: record2["time"] instanceof Date ? record2["time"] : new Date(String(record2["time"] ?? Date.now()))
|
|
117992
|
+
};
|
|
117993
|
+
}
|
|
117994
|
+
buildPublishOpts(raw, overrides) {
|
|
117995
|
+
return {
|
|
117996
|
+
persistent: overrides?.persistent ?? raw.properties.deliveryMode === 2,
|
|
117997
|
+
contentType: overrides?.contentType ?? raw.properties.contentType ?? "application/json",
|
|
117998
|
+
contentEncoding: overrides?.contentEncoding ?? raw.properties.contentEncoding ?? "utf-8",
|
|
117999
|
+
headers: {
|
|
118000
|
+
...raw.properties.headers,
|
|
118001
|
+
...overrides?.headers,
|
|
118002
|
+
"x-dlq-requeued": Number(raw.properties.headers?.["x-dlq-requeued"] ?? 0) + 1
|
|
118003
|
+
},
|
|
118004
|
+
correlationId: overrides?.correlationId ?? raw.properties.correlationId,
|
|
118005
|
+
messageId: overrides?.messageId ?? raw.properties.messageId,
|
|
118006
|
+
priority: overrides?.priority ?? raw.properties.priority,
|
|
118007
|
+
expiration: overrides?.expiration !== undefined ? String(overrides.expiration) : undefined
|
|
118008
|
+
};
|
|
118009
|
+
}
|
|
118010
|
+
buildWrapper(ch, raw, data, deathInfo, pubCh) {
|
|
118011
|
+
const asMsg = raw;
|
|
118012
|
+
return {
|
|
118013
|
+
data,
|
|
118014
|
+
raw: asMsg,
|
|
118015
|
+
deathInfo,
|
|
118016
|
+
ack: () => ch.ack(raw),
|
|
118017
|
+
nack: (requeue = false) => ch.nack(raw, false, requeue),
|
|
118018
|
+
republish: async (exchange, routingKey, options) => {
|
|
118019
|
+
const pubOpts = this.buildPublishOpts(raw, options);
|
|
118020
|
+
await new Promise((resolve2, reject) => {
|
|
118021
|
+
pubCh.publish(exchange, routingKey, raw.content, pubOpts, (err) => {
|
|
118022
|
+
if (err)
|
|
118023
|
+
reject(err);
|
|
118024
|
+
else
|
|
118025
|
+
resolve2();
|
|
118026
|
+
});
|
|
118027
|
+
});
|
|
118028
|
+
}
|
|
118029
|
+
};
|
|
118030
|
+
}
|
|
118031
|
+
}
|
|
118032
|
+
RabbitMQDeadLetterService = __legacyDecorateClassTS([
|
|
118033
|
+
Injectable()
|
|
118034
|
+
], RabbitMQDeadLetterService);
|
|
118035
|
+
|
|
118036
|
+
// lib/rabbitmq/rabbitmq.service.ts
|
|
118037
|
+
var import_reflect_metadata21 = __toESM(require_Reflect(), 1);
|
|
117885
118038
|
class RabbitMQService {
|
|
117886
118039
|
logger = new Logger(RabbitMQService.name);
|
|
117887
118040
|
async publish(exchange, routingKey, data, options) {
|
|
@@ -117948,13 +118101,13 @@ class RabbitMQModule {
|
|
|
117948
118101
|
}
|
|
117949
118102
|
RabbitMQModule = __legacyDecorateClassTS([
|
|
117950
118103
|
Module({
|
|
117951
|
-
providers: [RabbitMQService],
|
|
117952
|
-
exports: [RabbitMQService],
|
|
118104
|
+
providers: [RabbitMQService, RabbitMQDeadLetterService],
|
|
118105
|
+
exports: [RabbitMQService, RabbitMQDeadLetterService],
|
|
117953
118106
|
global: true
|
|
117954
118107
|
})
|
|
117955
118108
|
], RabbitMQModule);
|
|
117956
118109
|
// lib/rabbitmq/decorators/consumer.decorator.ts
|
|
117957
|
-
var
|
|
118110
|
+
var import_reflect_metadata22 = __toESM(require_Reflect(), 1);
|
|
117958
118111
|
function RabbitConsumer() {
|
|
117959
118112
|
return (target2) => {
|
|
117960
118113
|
Reflect.defineMetadata(RABBITMQ_CONSUMER_METADATA, true, target2);
|
|
@@ -117962,7 +118115,7 @@ function RabbitConsumer() {
|
|
|
117962
118115
|
};
|
|
117963
118116
|
}
|
|
117964
118117
|
// lib/rabbitmq/decorators/subscribe.decorator.ts
|
|
117965
|
-
var
|
|
118118
|
+
var import_reflect_metadata23 = __toESM(require_Reflect(), 1);
|
|
117966
118119
|
function RabbitSubscribe(options) {
|
|
117967
118120
|
return (target2, propertyKey) => {
|
|
117968
118121
|
const existing = target2[RABBITMQ_SUBSCRIBE_SYMBOL] ?? [];
|
|
@@ -117976,7 +118129,7 @@ function RabbitSubscribe(options) {
|
|
|
117976
118129
|
};
|
|
117977
118130
|
}
|
|
117978
118131
|
// lib/guard.ts
|
|
117979
|
-
var
|
|
118132
|
+
var import_reflect_metadata24 = __toESM(require_Reflect(), 1);
|
|
117980
118133
|
|
|
117981
118134
|
// lib/utils/is-class.ts
|
|
117982
118135
|
function isClass(fn3) {
|
|
@@ -118048,7 +118201,7 @@ function Jwt() {
|
|
|
118048
118201
|
};
|
|
118049
118202
|
}
|
|
118050
118203
|
// lib/jwt/jwt-module.ts
|
|
118051
|
-
var
|
|
118204
|
+
var import_reflect_metadata25 = __toESM(require_Reflect(), 1);
|
|
118052
118205
|
|
|
118053
118206
|
class JwtModule {
|
|
118054
118207
|
static options;
|
|
@@ -118064,7 +118217,7 @@ class JwtModule {
|
|
|
118064
118217
|
}
|
|
118065
118218
|
}
|
|
118066
118219
|
// lib/schedule/cron/cron.ts
|
|
118067
|
-
var
|
|
118220
|
+
var import_reflect_metadata26 = __toESM(require_Reflect(), 1);
|
|
118068
118221
|
function Cron(expression) {
|
|
118069
118222
|
if (!expression) {
|
|
118070
118223
|
throw new Error("Invalid cron expression.");
|
|
@@ -118084,7 +118237,7 @@ function Cron(expression) {
|
|
|
118084
118237
|
};
|
|
118085
118238
|
}
|
|
118086
118239
|
// lib/schedule/timeout/timeout.ts
|
|
118087
|
-
var
|
|
118240
|
+
var import_reflect_metadata27 = __toESM(require_Reflect(), 1);
|
|
118088
118241
|
function Timeout(delay2) {
|
|
118089
118242
|
if (!delay2 || delay2 < 0) {
|
|
118090
118243
|
throw new Error("Delay must be a positive number.");
|
|
@@ -118104,7 +118257,7 @@ function Timeout(delay2) {
|
|
|
118104
118257
|
};
|
|
118105
118258
|
}
|
|
118106
118259
|
// lib/testing/testing-module-builder.ts
|
|
118107
|
-
var
|
|
118260
|
+
var import_reflect_metadata28 = __toESM(require_Reflect(), 1);
|
|
118108
118261
|
|
|
118109
118262
|
// lib/testing/test-app.ts
|
|
118110
118263
|
class TestApp {
|
|
@@ -118266,6 +118419,7 @@ export {
|
|
|
118266
118419
|
RabbitSubscribe,
|
|
118267
118420
|
RabbitMQService,
|
|
118268
118421
|
RabbitMQModule,
|
|
118422
|
+
RabbitMQDeadLetterService,
|
|
118269
118423
|
RabbitMQConnection,
|
|
118270
118424
|
RabbitConsumer,
|
|
118271
118425
|
RENDER_METADATA,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import type { DeadLetterMessage, RequeueOptions } from "./interfaces/rabbitmq-message.interface";
|
|
3
|
+
/**
|
|
4
|
+
* Service for inspecting and reprocessing messages in Dead Letter Queues.
|
|
5
|
+
*
|
|
6
|
+
* Inject `RabbitMQDeadLetterService` wherever you need to manage failed messages –
|
|
7
|
+
* in controllers (for admin REST endpoints), scheduled tasks, or one-off scripts.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* // Requeue all stuck orders
|
|
12
|
+
* @Get('/admin/dlq/requeue')
|
|
13
|
+
* async requeueOrders() {
|
|
14
|
+
* const count = await this.dlq.requeueMessages({
|
|
15
|
+
* fromQueue: 'orders.dlq',
|
|
16
|
+
* toExchange: 'events',
|
|
17
|
+
* routingKey: 'orders.created',
|
|
18
|
+
* });
|
|
19
|
+
* return { requeued: count };
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare class RabbitMQDeadLetterService {
|
|
24
|
+
private readonly logger;
|
|
25
|
+
/**
|
|
26
|
+
* Peek at messages in a Dead Letter Queue **without permanently consuming them**.
|
|
27
|
+
*
|
|
28
|
+
* Internally uses `basic.get` + `nack(requeue=true)` so every inspected message
|
|
29
|
+
* is put back at the tail of the queue after inspection.
|
|
30
|
+
*
|
|
31
|
+
* > **Note:** Because AMQP has no native "peek" operation, there is a small
|
|
32
|
+
* > window where another consumer could receive the message between the `get`
|
|
33
|
+
* > and the `nack`. Use a dedicated DLQ that is only consumed by this service
|
|
34
|
+
* > (or consumed manually) to avoid race conditions.
|
|
35
|
+
*
|
|
36
|
+
* @param queue - Name of the dead letter queue to inspect
|
|
37
|
+
* @param count - Maximum number of messages to return. Default: `10`
|
|
38
|
+
* @returns Array of `DeadLetterMessage` wrappers with death metadata
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* const messages = await this.dlq.inspect<OrderPayload>('orders.dlq', 5);
|
|
43
|
+
* for (const msg of messages) {
|
|
44
|
+
* console.log(msg.deathInfo?.reason, msg.data);
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
inspect<T = unknown>(queue: string, count?: number): Promise<DeadLetterMessage<T>[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Move messages from a Dead Letter Queue back to an exchange for reprocessing.
|
|
51
|
+
*
|
|
52
|
+
* Each message is `ack`-ed from the DLQ only **after** it is successfully
|
|
53
|
+
* republished, so no messages are lost even if publishing fails mid-batch.
|
|
54
|
+
*
|
|
55
|
+
* @param options - Source queue, target exchange/routing-key, optional count cap
|
|
56
|
+
* @returns Number of messages successfully requeued
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* // Requeue up to 50 messages back to the original exchange
|
|
61
|
+
* const n = await this.dlq.requeueMessages({
|
|
62
|
+
* fromQueue: 'orders.dlq',
|
|
63
|
+
* toExchange: 'events',
|
|
64
|
+
* routingKey: 'orders.created',
|
|
65
|
+
* count: 50,
|
|
66
|
+
* });
|
|
67
|
+
* console.log(`Requeued ${n} messages`);
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
requeueMessages(options: RequeueOptions): Promise<number>;
|
|
71
|
+
/**
|
|
72
|
+
* Permanently discard messages from a Dead Letter Queue by acknowledging them
|
|
73
|
+
* without republishing.
|
|
74
|
+
*
|
|
75
|
+
* @param queue - Dead letter queue name
|
|
76
|
+
* @param count - Number of messages to discard. Omit to discard **all**.
|
|
77
|
+
* @returns Number of messages discarded
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```typescript
|
|
81
|
+
* await this.dlq.discardMessages('orders.dlq'); // purge all
|
|
82
|
+
* await this.dlq.discardMessages('orders.dlq', 10); // discard first 10
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
discardMessages(queue: string, count?: number): Promise<number>;
|
|
86
|
+
/**
|
|
87
|
+
* Returns the number of messages currently in a queue (including DLQs).
|
|
88
|
+
*
|
|
89
|
+
* @param queue - Queue name to check
|
|
90
|
+
* @returns Message count
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```typescript
|
|
94
|
+
* const pending = await this.dlq.messageCount('orders.dlq');
|
|
95
|
+
* console.log(`${pending} failed orders waiting`);
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
messageCount(queue: string): Promise<number>;
|
|
99
|
+
private parsePayload;
|
|
100
|
+
private extractDeathInfo;
|
|
101
|
+
private buildPublishOpts;
|
|
102
|
+
private buildWrapper;
|
|
103
|
+
}
|
|
@@ -1,4 +1,76 @@
|
|
|
1
1
|
import type { ConsumeMessage } from "amqplib";
|
|
2
|
+
/**
|
|
3
|
+
* Information extracted from the `x-death` header that RabbitMQ automatically
|
|
4
|
+
* attaches to every dead-lettered message.
|
|
5
|
+
*/
|
|
6
|
+
export interface DeadLetterDeathInfo {
|
|
7
|
+
/** Queue where the message was dead-lettered */
|
|
8
|
+
queue: string;
|
|
9
|
+
/** Exchange where the message was originally published */
|
|
10
|
+
exchange: string;
|
|
11
|
+
/** Routing keys the message was published with */
|
|
12
|
+
routingKeys: string[];
|
|
13
|
+
/** How many times this message has been dead-lettered */
|
|
14
|
+
count: number;
|
|
15
|
+
/** Reason for dead-lettering */
|
|
16
|
+
reason: "rejected" | "expired" | "maxlen" | "delivery-limit";
|
|
17
|
+
/** Timestamp when the message was dead-lettered */
|
|
18
|
+
time: Date;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A message consumed from a Dead Letter Queue with extra context and
|
|
22
|
+
* a `republish()` helper for reprocessing.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* @RabbitConsumer()
|
|
27
|
+
* export class OrderDLQConsumer {
|
|
28
|
+
* constructor(private readonly dlq: RabbitMQDeadLetterService) {}
|
|
29
|
+
*
|
|
30
|
+
* @RabbitSubscribe({ queue: 'orders.dlq' })
|
|
31
|
+
* async handle(msg: DeadLetterMessage<OrderPayload>) {
|
|
32
|
+
* console.log('Failed', msg.deathInfo?.reason, msg.data);
|
|
33
|
+
* await msg.republish('events', 'orders.created'); // retry
|
|
34
|
+
* msg.ack();
|
|
35
|
+
* }
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export interface DeadLetterMessage<T = unknown> {
|
|
40
|
+
/** Deserialized message payload */
|
|
41
|
+
data: T;
|
|
42
|
+
/** Raw amqplib ConsumeMessage */
|
|
43
|
+
raw: ConsumeMessage;
|
|
44
|
+
/**
|
|
45
|
+
* Death metadata provided by RabbitMQ via `x-death` headers.
|
|
46
|
+
* `null` if the message was not dead-lettered by the broker.
|
|
47
|
+
*/
|
|
48
|
+
deathInfo: DeadLetterDeathInfo | null;
|
|
49
|
+
/** Acknowledge and remove the message from the DLQ */
|
|
50
|
+
ack: () => void;
|
|
51
|
+
/** Negative-ack (optionally requeue back to DLQ). Default requeue: false */
|
|
52
|
+
nack: (requeue?: boolean) => void;
|
|
53
|
+
/**
|
|
54
|
+
* Republish the message to an exchange for reprocessing.
|
|
55
|
+
* The original payload is preserved as-is.
|
|
56
|
+
*/
|
|
57
|
+
republish: (exchange: string, routingKey: string, options?: RabbitPublishOptions) => Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Options for `RabbitMQDeadLetterService.requeueMessages()`.
|
|
61
|
+
*/
|
|
62
|
+
export interface RequeueOptions {
|
|
63
|
+
/** Dead letter queue to consume from */
|
|
64
|
+
fromQueue: string;
|
|
65
|
+
/** Exchange to republish the messages to */
|
|
66
|
+
toExchange: string;
|
|
67
|
+
/** Routing key for the republished messages */
|
|
68
|
+
routingKey: string;
|
|
69
|
+
/** Maximum number of messages to requeue. Omit to requeue all. */
|
|
70
|
+
count?: number;
|
|
71
|
+
/** Additional publish options applied during requeue */
|
|
72
|
+
publishOptions?: RabbitPublishOptions;
|
|
73
|
+
}
|
|
2
74
|
/**
|
|
3
75
|
* A typed RabbitMQ message received by a `@RabbitSubscribe` handler.
|
|
4
76
|
*
|
|
@@ -42,6 +42,21 @@ export interface RabbitMQQueueConfig {
|
|
|
42
42
|
deadLetterExchange?: string;
|
|
43
43
|
/** Dead letter routing key */
|
|
44
44
|
deadLetterRoutingKey?: string;
|
|
45
|
+
/**
|
|
46
|
+
* When provided, the lib will automatically:
|
|
47
|
+
* 1. Assert the `deadLetterExchange` (type controlled by `deadLetterExchangeType`)
|
|
48
|
+
* 2. Assert a queue with this name
|
|
49
|
+
* 3. Bind that queue to the DLX using `deadLetterRoutingKey` (or `""`)
|
|
50
|
+
*
|
|
51
|
+
* This removes the need to manually declare the DLX exchange + queue in your
|
|
52
|
+
* `exchanges` and `queues` arrays.
|
|
53
|
+
*/
|
|
54
|
+
deadLetterQueue?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Exchange type used when auto-asserting the dead letter exchange.
|
|
57
|
+
* Only used when `deadLetterQueue` is set. Default: `"direct"`.
|
|
58
|
+
*/
|
|
59
|
+
deadLetterExchangeType?: "direct" | "topic" | "fanout";
|
|
45
60
|
/** Maximum time (ms) a message can remain in the queue undelivered */
|
|
46
61
|
messageTtl?: number;
|
|
47
62
|
/** Maximum number of messages the queue can hold */
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import type { GetMessage, Message, Options } from "amqplib";
|
|
3
|
+
import { Injectable } from "../injectable";
|
|
4
|
+
import { Logger } from "../utils/logger";
|
|
5
|
+
import type {
|
|
6
|
+
DeadLetterDeathInfo,
|
|
7
|
+
DeadLetterMessage,
|
|
8
|
+
RabbitPublishOptions,
|
|
9
|
+
RequeueOptions,
|
|
10
|
+
} from "./interfaces/rabbitmq-message.interface";
|
|
11
|
+
import { RabbitMQConnection } from "./rabbitmq-connection";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Service for inspecting and reprocessing messages in Dead Letter Queues.
|
|
15
|
+
*
|
|
16
|
+
* Inject `RabbitMQDeadLetterService` wherever you need to manage failed messages –
|
|
17
|
+
* in controllers (for admin REST endpoints), scheduled tasks, or one-off scripts.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* // Requeue all stuck orders
|
|
22
|
+
* @Get('/admin/dlq/requeue')
|
|
23
|
+
* async requeueOrders() {
|
|
24
|
+
* const count = await this.dlq.requeueMessages({
|
|
25
|
+
* fromQueue: 'orders.dlq',
|
|
26
|
+
* toExchange: 'events',
|
|
27
|
+
* routingKey: 'orders.created',
|
|
28
|
+
* });
|
|
29
|
+
* return { requeued: count };
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
@Injectable()
|
|
34
|
+
export class RabbitMQDeadLetterService {
|
|
35
|
+
private readonly logger = new Logger(RabbitMQDeadLetterService.name);
|
|
36
|
+
|
|
37
|
+
// ─── Inspect ────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Peek at messages in a Dead Letter Queue **without permanently consuming them**.
|
|
41
|
+
*
|
|
42
|
+
* Internally uses `basic.get` + `nack(requeue=true)` so every inspected message
|
|
43
|
+
* is put back at the tail of the queue after inspection.
|
|
44
|
+
*
|
|
45
|
+
* > **Note:** Because AMQP has no native "peek" operation, there is a small
|
|
46
|
+
* > window where another consumer could receive the message between the `get`
|
|
47
|
+
* > and the `nack`. Use a dedicated DLQ that is only consumed by this service
|
|
48
|
+
* > (or consumed manually) to avoid race conditions.
|
|
49
|
+
*
|
|
50
|
+
* @param queue - Name of the dead letter queue to inspect
|
|
51
|
+
* @param count - Maximum number of messages to return. Default: `10`
|
|
52
|
+
* @returns Array of `DeadLetterMessage` wrappers with death metadata
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```typescript
|
|
56
|
+
* const messages = await this.dlq.inspect<OrderPayload>('orders.dlq', 5);
|
|
57
|
+
* for (const msg of messages) {
|
|
58
|
+
* console.log(msg.deathInfo?.reason, msg.data);
|
|
59
|
+
* }
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
async inspect<T = unknown>(
|
|
63
|
+
queue: string,
|
|
64
|
+
count = 10,
|
|
65
|
+
): Promise<DeadLetterMessage<T>[]> {
|
|
66
|
+
const pubCh = await RabbitMQConnection.getPublisherChannel();
|
|
67
|
+
const ch = await RabbitMQConnection.getConsumerChannel(
|
|
68
|
+
`__dlq_inspect_${queue}`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const results: DeadLetterMessage<T>[] = [];
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < count; i++) {
|
|
74
|
+
const raw = await ch.get(queue, { noAck: false });
|
|
75
|
+
if (!raw) break;
|
|
76
|
+
|
|
77
|
+
const data = this.parsePayload<T>(raw);
|
|
78
|
+
const deathInfo = this.extractDeathInfo(raw);
|
|
79
|
+
|
|
80
|
+
results.push(this.buildWrapper<T>(ch, raw, data, deathInfo, pubCh));
|
|
81
|
+
|
|
82
|
+
// Put message back so it is not permanently consumed
|
|
83
|
+
ch.nack(raw, false, true);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Release the inspection channel slot
|
|
87
|
+
await ch.close().catch(() => {});
|
|
88
|
+
|
|
89
|
+
return results;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Requeue ─────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Move messages from a Dead Letter Queue back to an exchange for reprocessing.
|
|
96
|
+
*
|
|
97
|
+
* Each message is `ack`-ed from the DLQ only **after** it is successfully
|
|
98
|
+
* republished, so no messages are lost even if publishing fails mid-batch.
|
|
99
|
+
*
|
|
100
|
+
* @param options - Source queue, target exchange/routing-key, optional count cap
|
|
101
|
+
* @returns Number of messages successfully requeued
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```typescript
|
|
105
|
+
* // Requeue up to 50 messages back to the original exchange
|
|
106
|
+
* const n = await this.dlq.requeueMessages({
|
|
107
|
+
* fromQueue: 'orders.dlq',
|
|
108
|
+
* toExchange: 'events',
|
|
109
|
+
* routingKey: 'orders.created',
|
|
110
|
+
* count: 50,
|
|
111
|
+
* });
|
|
112
|
+
* console.log(`Requeued ${n} messages`);
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
async requeueMessages(options: RequeueOptions): Promise<number> {
|
|
116
|
+
const { fromQueue, toExchange, routingKey, count, publishOptions } =
|
|
117
|
+
options;
|
|
118
|
+
|
|
119
|
+
const ch = await RabbitMQConnection.getConsumerChannel(
|
|
120
|
+
`__dlq_requeue_${fromQueue}`,
|
|
121
|
+
);
|
|
122
|
+
const pubCh = await RabbitMQConnection.getPublisherChannel();
|
|
123
|
+
|
|
124
|
+
let requeued = 0;
|
|
125
|
+
const max = count ?? Number.POSITIVE_INFINITY;
|
|
126
|
+
|
|
127
|
+
while (requeued < max) {
|
|
128
|
+
const raw = await ch.get(fromQueue, { noAck: false });
|
|
129
|
+
if (!raw) break;
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const pubOpts = this.buildPublishOpts(raw, publishOptions);
|
|
133
|
+
|
|
134
|
+
await new Promise<void>((resolve, reject) => {
|
|
135
|
+
pubCh.publish(toExchange, routingKey, raw.content, pubOpts, (err) => {
|
|
136
|
+
if (err) reject(err);
|
|
137
|
+
else resolve();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
ch.ack(raw);
|
|
142
|
+
requeued++;
|
|
143
|
+
} catch (err: any) {
|
|
144
|
+
// Put the message back so it is not lost
|
|
145
|
+
ch.nack(raw, false, true);
|
|
146
|
+
this.logger.error(
|
|
147
|
+
`Failed to republish message from "${fromQueue}" to "${toExchange}/${routingKey}": ${err.message}`,
|
|
148
|
+
);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await ch.close().catch(() => {});
|
|
154
|
+
|
|
155
|
+
this.logger.log(
|
|
156
|
+
`Requeued ${requeued} message(s) from "${fromQueue}" to exchange "${toExchange}" (key: "${routingKey}")`,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return requeued;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Discard ─────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Permanently discard messages from a Dead Letter Queue by acknowledging them
|
|
166
|
+
* without republishing.
|
|
167
|
+
*
|
|
168
|
+
* @param queue - Dead letter queue name
|
|
169
|
+
* @param count - Number of messages to discard. Omit to discard **all**.
|
|
170
|
+
* @returns Number of messages discarded
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```typescript
|
|
174
|
+
* await this.dlq.discardMessages('orders.dlq'); // purge all
|
|
175
|
+
* await this.dlq.discardMessages('orders.dlq', 10); // discard first 10
|
|
176
|
+
* ```
|
|
177
|
+
*/
|
|
178
|
+
async discardMessages(queue: string, count?: number): Promise<number> {
|
|
179
|
+
const ch = await RabbitMQConnection.getConsumerChannel(
|
|
180
|
+
`__dlq_discard_${queue}`,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
let discarded = 0;
|
|
184
|
+
const max = count ?? Number.POSITIVE_INFINITY;
|
|
185
|
+
|
|
186
|
+
while (discarded < max) {
|
|
187
|
+
const raw = await ch.get(queue, { noAck: false });
|
|
188
|
+
if (!raw) break;
|
|
189
|
+
|
|
190
|
+
ch.ack(raw);
|
|
191
|
+
discarded++;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await ch.close().catch(() => {});
|
|
195
|
+
|
|
196
|
+
this.logger.log(`Discarded ${discarded} message(s) from queue "${queue}"`);
|
|
197
|
+
return discarded;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Count ───────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Returns the number of messages currently in a queue (including DLQs).
|
|
204
|
+
*
|
|
205
|
+
* @param queue - Queue name to check
|
|
206
|
+
* @returns Message count
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```typescript
|
|
210
|
+
* const pending = await this.dlq.messageCount('orders.dlq');
|
|
211
|
+
* console.log(`${pending} failed orders waiting`);
|
|
212
|
+
* ```
|
|
213
|
+
*/
|
|
214
|
+
async messageCount(queue: string): Promise<number> {
|
|
215
|
+
const ch = await RabbitMQConnection.getConsumerChannel(
|
|
216
|
+
`__dlq_count_${queue}`,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const info = await ch.checkQueue(queue);
|
|
220
|
+
await ch.close().catch(() => {});
|
|
221
|
+
return info.messageCount;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── Private helpers ──────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
private parsePayload<T>(raw: GetMessage): T {
|
|
227
|
+
try {
|
|
228
|
+
return JSON.parse(raw.content.toString("utf-8")) as T;
|
|
229
|
+
} catch {
|
|
230
|
+
return raw.content.toString("utf-8") as unknown as T;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private extractDeathInfo(raw: GetMessage): DeadLetterDeathInfo | null {
|
|
235
|
+
const xDeath = raw.properties.headers?.["x-death"];
|
|
236
|
+
if (!Array.isArray(xDeath) || xDeath.length === 0) return null;
|
|
237
|
+
|
|
238
|
+
// The most recent death record is the first entry
|
|
239
|
+
const record = xDeath[0] as unknown as Record<string, unknown>;
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
queue: String(record["queue"] ?? ""),
|
|
243
|
+
exchange: String(record["exchange"] ?? ""),
|
|
244
|
+
routingKeys: Array.isArray(record["routing-keys"])
|
|
245
|
+
? (record["routing-keys"] as any[]).map(String)
|
|
246
|
+
: [],
|
|
247
|
+
count: Number(record["count"] ?? 1),
|
|
248
|
+
reason: String(
|
|
249
|
+
record["reason"] ?? "rejected",
|
|
250
|
+
) as DeadLetterDeathInfo["reason"],
|
|
251
|
+
time:
|
|
252
|
+
record["time"] instanceof Date
|
|
253
|
+
? record["time"]
|
|
254
|
+
: new Date(String(record["time"] ?? Date.now())),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private buildPublishOpts(
|
|
259
|
+
raw: GetMessage,
|
|
260
|
+
overrides?: RabbitPublishOptions,
|
|
261
|
+
): Options.Publish {
|
|
262
|
+
return {
|
|
263
|
+
persistent: overrides?.persistent ?? raw.properties.deliveryMode === 2,
|
|
264
|
+
contentType:
|
|
265
|
+
overrides?.contentType ??
|
|
266
|
+
raw.properties.contentType ??
|
|
267
|
+
"application/json",
|
|
268
|
+
contentEncoding:
|
|
269
|
+
overrides?.contentEncoding ?? raw.properties.contentEncoding ?? "utf-8",
|
|
270
|
+
headers: {
|
|
271
|
+
...raw.properties.headers,
|
|
272
|
+
...overrides?.headers,
|
|
273
|
+
// Track how many times this message has been manually requeued from a DLQ
|
|
274
|
+
"x-dlq-requeued":
|
|
275
|
+
Number(raw.properties.headers?.["x-dlq-requeued"] ?? 0) + 1,
|
|
276
|
+
},
|
|
277
|
+
correlationId: overrides?.correlationId ?? raw.properties.correlationId,
|
|
278
|
+
messageId: overrides?.messageId ?? raw.properties.messageId,
|
|
279
|
+
priority: overrides?.priority ?? raw.properties.priority,
|
|
280
|
+
expiration:
|
|
281
|
+
overrides?.expiration !== undefined
|
|
282
|
+
? String(overrides.expiration)
|
|
283
|
+
: undefined,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private buildWrapper<T>(
|
|
288
|
+
ch: any,
|
|
289
|
+
raw: GetMessage,
|
|
290
|
+
data: T,
|
|
291
|
+
deathInfo: DeadLetterDeathInfo | null,
|
|
292
|
+
pubCh: any,
|
|
293
|
+
): DeadLetterMessage<T> {
|
|
294
|
+
// GetMessage and ConsumeMessage share the same structure except for the
|
|
295
|
+
// consumerTag field in fields. Cast here for interface compatibility.
|
|
296
|
+
const asMsg = raw as unknown as Message;
|
|
297
|
+
return {
|
|
298
|
+
data,
|
|
299
|
+
raw: asMsg as any,
|
|
300
|
+
deathInfo,
|
|
301
|
+
ack: () => ch.ack(raw),
|
|
302
|
+
nack: (requeue = false) => ch.nack(raw, false, requeue),
|
|
303
|
+
republish: async (
|
|
304
|
+
exchange: string,
|
|
305
|
+
routingKey: string,
|
|
306
|
+
options?: RabbitPublishOptions,
|
|
307
|
+
) => {
|
|
308
|
+
const pubOpts = this.buildPublishOpts(raw, options);
|
|
309
|
+
await new Promise<void>((resolve, reject) => {
|
|
310
|
+
pubCh.publish(
|
|
311
|
+
exchange,
|
|
312
|
+
routingKey,
|
|
313
|
+
raw.content,
|
|
314
|
+
pubOpts,
|
|
315
|
+
(err: Error | null) => {
|
|
316
|
+
if (err) reject(err);
|
|
317
|
+
else resolve();
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -1,5 +1,88 @@
|
|
|
1
1
|
import type { ConsumeMessage } from "amqplib";
|
|
2
2
|
|
|
3
|
+
// ─── Dead Letter types ──────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Information extracted from the `x-death` header that RabbitMQ automatically
|
|
7
|
+
* attaches to every dead-lettered message.
|
|
8
|
+
*/
|
|
9
|
+
export interface DeadLetterDeathInfo {
|
|
10
|
+
/** Queue where the message was dead-lettered */
|
|
11
|
+
queue: string;
|
|
12
|
+
/** Exchange where the message was originally published */
|
|
13
|
+
exchange: string;
|
|
14
|
+
/** Routing keys the message was published with */
|
|
15
|
+
routingKeys: string[];
|
|
16
|
+
/** How many times this message has been dead-lettered */
|
|
17
|
+
count: number;
|
|
18
|
+
/** Reason for dead-lettering */
|
|
19
|
+
reason: "rejected" | "expired" | "maxlen" | "delivery-limit";
|
|
20
|
+
/** Timestamp when the message was dead-lettered */
|
|
21
|
+
time: Date;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A message consumed from a Dead Letter Queue with extra context and
|
|
26
|
+
* a `republish()` helper for reprocessing.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* @RabbitConsumer()
|
|
31
|
+
* export class OrderDLQConsumer {
|
|
32
|
+
* constructor(private readonly dlq: RabbitMQDeadLetterService) {}
|
|
33
|
+
*
|
|
34
|
+
* @RabbitSubscribe({ queue: 'orders.dlq' })
|
|
35
|
+
* async handle(msg: DeadLetterMessage<OrderPayload>) {
|
|
36
|
+
* console.log('Failed', msg.deathInfo?.reason, msg.data);
|
|
37
|
+
* await msg.republish('events', 'orders.created'); // retry
|
|
38
|
+
* msg.ack();
|
|
39
|
+
* }
|
|
40
|
+
* }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export interface DeadLetterMessage<T = unknown> {
|
|
44
|
+
/** Deserialized message payload */
|
|
45
|
+
data: T;
|
|
46
|
+
/** Raw amqplib ConsumeMessage */
|
|
47
|
+
raw: ConsumeMessage;
|
|
48
|
+
/**
|
|
49
|
+
* Death metadata provided by RabbitMQ via `x-death` headers.
|
|
50
|
+
* `null` if the message was not dead-lettered by the broker.
|
|
51
|
+
*/
|
|
52
|
+
deathInfo: DeadLetterDeathInfo | null;
|
|
53
|
+
/** Acknowledge and remove the message from the DLQ */
|
|
54
|
+
ack: () => void;
|
|
55
|
+
/** Negative-ack (optionally requeue back to DLQ). Default requeue: false */
|
|
56
|
+
nack: (requeue?: boolean) => void;
|
|
57
|
+
/**
|
|
58
|
+
* Republish the message to an exchange for reprocessing.
|
|
59
|
+
* The original payload is preserved as-is.
|
|
60
|
+
*/
|
|
61
|
+
republish: (
|
|
62
|
+
exchange: string,
|
|
63
|
+
routingKey: string,
|
|
64
|
+
options?: RabbitPublishOptions,
|
|
65
|
+
) => Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Options for `RabbitMQDeadLetterService.requeueMessages()`.
|
|
70
|
+
*/
|
|
71
|
+
export interface RequeueOptions {
|
|
72
|
+
/** Dead letter queue to consume from */
|
|
73
|
+
fromQueue: string;
|
|
74
|
+
/** Exchange to republish the messages to */
|
|
75
|
+
toExchange: string;
|
|
76
|
+
/** Routing key for the republished messages */
|
|
77
|
+
routingKey: string;
|
|
78
|
+
/** Maximum number of messages to requeue. Omit to requeue all. */
|
|
79
|
+
count?: number;
|
|
80
|
+
/** Additional publish options applied during requeue */
|
|
81
|
+
publishOptions?: RabbitPublishOptions;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Standard message ───────────────────────────────────────────────────────
|
|
85
|
+
|
|
3
86
|
/**
|
|
4
87
|
* A typed RabbitMQ message received by a `@RabbitSubscribe` handler.
|
|
5
88
|
*
|
|
@@ -44,6 +44,21 @@ export interface RabbitMQQueueConfig {
|
|
|
44
44
|
deadLetterExchange?: string;
|
|
45
45
|
/** Dead letter routing key */
|
|
46
46
|
deadLetterRoutingKey?: string;
|
|
47
|
+
/**
|
|
48
|
+
* When provided, the lib will automatically:
|
|
49
|
+
* 1. Assert the `deadLetterExchange` (type controlled by `deadLetterExchangeType`)
|
|
50
|
+
* 2. Assert a queue with this name
|
|
51
|
+
* 3. Bind that queue to the DLX using `deadLetterRoutingKey` (or `""`)
|
|
52
|
+
*
|
|
53
|
+
* This removes the need to manually declare the DLX exchange + queue in your
|
|
54
|
+
* `exchanges` and `queues` arrays.
|
|
55
|
+
*/
|
|
56
|
+
deadLetterQueue?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Exchange type used when auto-asserting the dead letter exchange.
|
|
59
|
+
* Only used when `deadLetterQueue` is set. Default: `"direct"`.
|
|
60
|
+
*/
|
|
61
|
+
deadLetterExchangeType?: "direct" | "topic" | "fanout";
|
|
47
62
|
/** Maximum time (ms) a message can remain in the queue undelivered */
|
|
48
63
|
messageTtl?: number;
|
|
49
64
|
/** Maximum number of messages the queue can hold */
|
|
@@ -243,6 +243,38 @@ export class RabbitMQConnection {
|
|
|
243
243
|
`Queue "${q.name}" bound to exchange "${binding.exchange}" with key "${binding.routingKey ?? ""}"`,
|
|
244
244
|
);
|
|
245
245
|
}
|
|
246
|
+
|
|
247
|
+
// ── Auto Dead Letter topology ──────────────────────────────────
|
|
248
|
+
// When `deadLetterQueue` + `deadLetterExchange` are both set, the lib
|
|
249
|
+
// automatically asserts the DLX exchange, the DLQ queue, and their binding.
|
|
250
|
+
// This removes the need to declare them manually in exchanges/queues arrays.
|
|
251
|
+
if (q.deadLetterQueue && q.deadLetterExchange) {
|
|
252
|
+
const dlxType = q.deadLetterExchangeType ?? "direct";
|
|
253
|
+
await channel.assertExchange(q.deadLetterExchange, dlxType, {
|
|
254
|
+
durable: true,
|
|
255
|
+
autoDelete: false,
|
|
256
|
+
});
|
|
257
|
+
logger.log(
|
|
258
|
+
`Dead letter exchange asserted: [${dlxType}] ${q.deadLetterExchange}`,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
await channel.assertQueue(q.deadLetterQueue, {
|
|
262
|
+
durable: true,
|
|
263
|
+
exclusive: false,
|
|
264
|
+
autoDelete: false,
|
|
265
|
+
});
|
|
266
|
+
logger.log(`Dead letter queue asserted: ${q.deadLetterQueue}`);
|
|
267
|
+
|
|
268
|
+
const dlxBindingKey = q.deadLetterRoutingKey ?? "";
|
|
269
|
+
await channel.bindQueue(
|
|
270
|
+
q.deadLetterQueue,
|
|
271
|
+
q.deadLetterExchange,
|
|
272
|
+
dlxBindingKey,
|
|
273
|
+
);
|
|
274
|
+
logger.log(
|
|
275
|
+
`DLQ "${q.deadLetterQueue}" bound to DLX "${q.deadLetterExchange}" with key "${dlxBindingKey}"`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
246
278
|
}
|
|
247
279
|
} finally {
|
|
248
280
|
await channel.close().catch(() => {});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Module } from "../module";
|
|
2
|
+
import { RabbitMQDeadLetterService } from "./dead-letter.service";
|
|
2
3
|
import type { RabbitMQModuleOptions } from "./interfaces/rabbitmq-options.interface";
|
|
3
4
|
import { RabbitMQService } from "./rabbitmq.service";
|
|
4
5
|
import { RabbitMQConnection } from "./rabbitmq-connection";
|
|
@@ -33,8 +34,8 @@ import { RabbitMQConnection } from "./rabbitmq-connection";
|
|
|
33
34
|
* ```
|
|
34
35
|
*/
|
|
35
36
|
@Module({
|
|
36
|
-
providers: [RabbitMQService],
|
|
37
|
-
exports: [RabbitMQService],
|
|
37
|
+
providers: [RabbitMQService, RabbitMQDeadLetterService],
|
|
38
|
+
exports: [RabbitMQService, RabbitMQDeadLetterService],
|
|
38
39
|
global: true,
|
|
39
40
|
})
|
|
40
41
|
export class RabbitMQModule {
|