@spinajs/queue-amqp-transport 2.0.482 → 2.0.485

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.
@@ -0,0 +1,148 @@
1
+ import { IQueueMessage, IQueueConnectionOptions, QueueClient, QueueMessage } from '@spinajs/queue';
2
+ import { Channel, ChannelModel, ConfirmChannel, ConsumeMessage, Options } from 'amqplib';
3
+ import { Constructor } from '@spinajs/di';
4
+ import { ResiliencePipeline } from '@spinajs/util';
5
+ /**
6
+ * Everything needed to re-establish a subscription after a reconnect. amqplib consumers do not
7
+ * survive a dropped connection, so we keep descriptors and replay them on every (re)connect.
8
+ */
9
+ interface ISubscriptionDescriptor {
10
+ channel: string;
11
+ callback: (e: IQueueMessage) => Promise<void>;
12
+ subscriptionId?: string;
13
+ durable: boolean;
14
+ /** Runtime state - refreshed on every (re)connect. */
15
+ consumerTag?: string;
16
+ queue?: string;
17
+ }
18
+ interface IPendingEmit {
19
+ message: IQueueMessage;
20
+ resolve: () => void;
21
+ reject: (err: unknown) => void;
22
+ }
23
+ export declare class AmqpQueueClient extends QueueClient {
24
+ protected Connection: ChannelModel;
25
+ /** Confirm channel used for publishing so emit() resolves only after a broker ack. */
26
+ protected PublishChannel: ConfirmChannel;
27
+ /** Separate channel for consumers so publisher back-pressure cannot stall consumer acks. */
28
+ protected ConsumeChannel: Channel;
29
+ protected Subscriptions: Map<string, ISubscriptionDescriptor>;
30
+ protected AssertedQueues: Set<string>;
31
+ protected AssertedExchanges: Set<string>;
32
+ protected AssertedRetryQueues: Set<string>;
33
+ /** Messages emitted while disconnected - flushed on (re)connect. */
34
+ protected PendingEmits: IPendingEmit[];
35
+ protected Connected: boolean;
36
+ protected Disposing: boolean;
37
+ protected ReconnectTimer?: ReturnType<typeof setTimeout>;
38
+ /** Publisher-side resilience: time-bound + retry each publish so transient broker hiccups
39
+ * don't lose an emit. Safe against the resulting duplicates because consumers dedupe by JobId. */
40
+ protected EmitPipeline: ResiliencePipeline<void>;
41
+ get ClientId(): string;
42
+ protected get TopicPrefix(): string;
43
+ protected get ReconnectDelay(): number;
44
+ protected get Prefetch(): number;
45
+ get IsConnected(): boolean;
46
+ constructor(options: IQueueConnectionOptions);
47
+ resolve(): Promise<void>;
48
+ /**
49
+ * Creates the underlying amqplib connection. Isolated as a seam so tests can inject a fake
50
+ * broker without a real server ( mirrors the STOMP transport's createClient seam ).
51
+ */
52
+ protected createConnection(url: string | Options.Connect, socketOptions: Record<string, unknown>): Promise<ChannelModel>;
53
+ /**
54
+ * Builds the amqplib connection target from the configured options.
55
+ *
56
+ * A url-style host ( `amqp://.../vhost` ) is parsed into an explicit {@link Options.Connect}
57
+ * object rather than passed as a raw string, so every setting - crucially the credentials - is a
58
+ * clear field instead of being buried in the url. amqplib derives credentials for a *string* url
59
+ * only from the url's own userinfo, which is why the discrete `login` / `password` config fields
60
+ * were previously ignored for a url host. Credentials embedded in the url still win; the config
61
+ * fields are the fallback when the url carries none ( matching amqplib's own precedence ).
62
+ */
63
+ protected buildConnectionTarget(): Options.Connect;
64
+ /** Connect-options object built from the discrete `host` / `port` / `login` / `password` fields. */
65
+ protected connectOptionsFromFields(hostname: string): Options.Connect;
66
+ dispose(): Promise<void>;
67
+ /**
68
+ * Establishes the connection + channels, then replays subscriptions and flushes buffered emits.
69
+ * Called on initial resolve and on every reconnect.
70
+ */
71
+ protected connect(): Promise<void>;
72
+ /**
73
+ * Handles an unexpected connection drop by scheduling a reconnect ( unless we are disposing ).
74
+ */
75
+ protected onConnectionLost(): void;
76
+ protected scheduleReconnect(): void;
77
+ emit(message: IQueueMessage): Promise<void>;
78
+ subscribe(channelOrMessage: string | Constructor<QueueMessage>, callback: (e: IQueueMessage) => Promise<void>, subscriptionId?: string, durable?: boolean): Promise<void>;
79
+ unsubscribe(channelOrMessage: string | Constructor<QueueMessage>, _removeDurable?: boolean): void;
80
+ /**
81
+ * Re-establishes every known subscription. Called after a (re)connect.
82
+ */
83
+ protected replaySubscriptions(): Promise<void>;
84
+ /**
85
+ * Declares the queue for a subscription and starts consuming, wiring ack/nack + retry/dead-letter.
86
+ */
87
+ protected startConsumer(descriptor: ISubscriptionDescriptor): Promise<void>;
88
+ /**
89
+ * Handles a message whose consumer callback rejected.
90
+ *
91
+ * Events are fire-and-forget - logged and acked ( dropped ). Jobs are retried up to their
92
+ * RetryCount by re-publishing to a TTL "retry" queue that dead-letters back to the work queue
93
+ * after an exponential delay, then dead-lettered once retries are exhausted.
94
+ */
95
+ protected handleFailedMessage(msg: ConsumeMessage, qMessage: IQueueMessage, channel: string, err: unknown): void;
96
+ /**
97
+ * Republishes a failed job to a per-delay TTL retry queue that dead-letters back to the work
98
+ * queue after the delay, giving broker-side ( crash-safe ) exponential backoff. Then acks original.
99
+ */
100
+ protected retryJob(msg: ConsumeMessage, qMessage: IQueueMessage, workQueue: string, nextAttempt: number, reason: string): Promise<void>;
101
+ /**
102
+ * Publishes a failed/unparseable message to the dead-letter queue and acks the original to
103
+ * unblock the source queue. When no dead-letter queue is configured the message is dropped
104
+ * ( acked ) with a warning - we never nack-loop a poison message forever.
105
+ */
106
+ protected deadLetterRaw(msg: ConsumeMessage, dlq: string | undefined, channel: string, reason: string, attempt?: number): void;
107
+ /**
108
+ * Exponential backoff ( ms ) for the given retry attempt, based on `Options.retryDelay`.
109
+ * Returns 0 ( immediate redelivery ) when no base delay is configured.
110
+ */
111
+ protected retryBackoff(attempt: number): number;
112
+ /**
113
+ * Publisher-side resilience pipeline: bounds each publish by a timeout and retries transient
114
+ * failures / nacks with exponential backoff. Consumer dedup ( by JobId ) makes the resulting
115
+ * rare duplicates harmless.
116
+ */
117
+ protected buildEmitPipeline(): ResiliencePipeline<void>;
118
+ protected publishMessage(message: IQueueMessage): Promise<void>;
119
+ /** Asserts the destination ( once ) and publishes a single message on the confirm channel. */
120
+ protected publishToChannel(channel: string, buffer: Buffer, options: Options.Publish): Promise<void>;
121
+ /**
122
+ * Publishes on the confirm channel and resolves only once the broker acks the message.
123
+ */
124
+ protected publishWithConfirm(exchange: string, routingKey: string, content: Buffer, options: Options.Publish): Promise<void>;
125
+ protected flushPendingEmits(): Promise<void>;
126
+ /**
127
+ * Declares ( and for topics binds ) the broker queue a subscription should consume from.
128
+ */
129
+ protected assertSubscriptionQueue(channel: string, subscriptionId?: string, durable?: boolean): Promise<string>;
130
+ /**
131
+ * Declares a durable work queue, asserting it on the broker only once per connection.
132
+ */
133
+ protected assertQueue(channel: Channel | ConfirmChannel, name: string): Promise<string>;
134
+ /**
135
+ * Declares a durable fanout exchange, asserting it on the broker only once per connection.
136
+ */
137
+ protected assertExchange(channel: Channel | ConfirmChannel, name: string): Promise<void>;
138
+ /**
139
+ * Declares a durable TTL retry queue that dead-letters expired messages back to `workQueue`
140
+ * ( via the default exchange, routing key = queue name ). One queue per distinct delay.
141
+ */
142
+ protected assertRetryQueue(workQueue: string, delay: number): Promise<string>;
143
+ protected isTopic(channel: string): boolean;
144
+ protected closeChannel(channel?: Channel | ConfirmChannel): Promise<void>;
145
+ protected warnOnUnsupportedScheduling(message: IQueueMessage): void;
146
+ }
147
+ export {};
148
+ //# sourceMappingURL=connection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../../src/connection.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,523 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ import { InvalidArgument, UnexpectedServerError } from '@spinajs/exceptions';
11
+ import { QueueClient, QueueMessageType } from '@spinajs/queue';
12
+ import amqp from 'amqplib';
13
+ import _ from 'lodash';
14
+ import { Injectable, PerInstanceCheck } from '@spinajs/di';
15
+ import { BackoffType, ResiliencePipelineBuilder } from '@spinajs/util';
16
+ /**
17
+ * Default prefix that marks a channel as a topic ( pub-sub / fanout exchange ).
18
+ * Every other channel is treated as a durable work queue ( single consumer, job semantics ).
19
+ *
20
+ * This keeps parity with the STOMP transport convention of `/topic/...` vs `/queue/...`.
21
+ */
22
+ const DEFAULT_TOPIC_PREFIX = '/topic/';
23
+ /** Header carrying the retry attempt count across redeliveries. */
24
+ const RETRY_COUNT_HEADER = 'x-retry-count';
25
+ let AmqpQueueClient = class AmqpQueueClient extends QueueClient {
26
+ get ClientId() {
27
+ return this.Options.clientId ?? this.Options.name;
28
+ }
29
+ get TopicPrefix() {
30
+ return this.Options.options?.topicPrefix ?? DEFAULT_TOPIC_PREFIX;
31
+ }
32
+ get ReconnectDelay() {
33
+ return this.Options.reconnectDelay ?? 5000;
34
+ }
35
+ get Prefetch() {
36
+ return this.Options.options?.prefetch ?? 1;
37
+ }
38
+ get IsConnected() {
39
+ return this.Connected;
40
+ }
41
+ constructor(options) {
42
+ super(options);
43
+ this.Subscriptions = new Map();
44
+ // destinations already declared on the broker, so we assert each one only once per connection
45
+ // ( assert is a network round-trip ). Cleared on reconnect because channels are recreated.
46
+ this.AssertedQueues = new Set();
47
+ this.AssertedExchanges = new Set();
48
+ this.AssertedRetryQueues = new Set();
49
+ /** Messages emitted while disconnected - flushed on (re)connect. */
50
+ this.PendingEmits = [];
51
+ this.Connected = false;
52
+ this.Disposing = false;
53
+ /** Publisher-side resilience: time-bound + retry each publish so transient broker hiccups
54
+ * don't lose an emit. Safe against the resulting duplicates because consumers dedupe by JobId. */
55
+ this.EmitPipeline = this.buildEmitPipeline();
56
+ }
57
+ async resolve() {
58
+ await this.connect();
59
+ }
60
+ /**
61
+ * Creates the underlying amqplib connection. Isolated as a seam so tests can inject a fake
62
+ * broker without a real server ( mirrors the STOMP transport's createClient seam ).
63
+ */
64
+ createConnection(url, socketOptions) {
65
+ return amqp.connect(url, socketOptions);
66
+ }
67
+ /**
68
+ * Builds the amqplib connection target from the configured options.
69
+ *
70
+ * A url-style host ( `amqp://.../vhost` ) is parsed into an explicit {@link Options.Connect}
71
+ * object rather than passed as a raw string, so every setting - crucially the credentials - is a
72
+ * clear field instead of being buried in the url. amqplib derives credentials for a *string* url
73
+ * only from the url's own userinfo, which is why the discrete `login` / `password` config fields
74
+ * were previously ignored for a url host. Credentials embedded in the url still win; the config
75
+ * fields are the fallback when the url carries none ( matching amqplib's own precedence ).
76
+ */
77
+ buildConnectionTarget() {
78
+ const host = this.Options.host;
79
+ // discrete-fields host ( no scheme ) - build straight from the individual config fields
80
+ if (!host || !host.includes('://')) {
81
+ return this.connectOptionsFromFields(host ?? 'localhost');
82
+ }
83
+ let parsed;
84
+ try {
85
+ parsed = new URL(host);
86
+ }
87
+ catch {
88
+ // not a parseable url - treat the whole value as a bare hostname and use the config fields
89
+ return this.connectOptionsFromFields(host);
90
+ }
91
+ const protocol = parsed.protocol.replace(/:$/, '') || 'amqp';
92
+ // amqplib treats the url's credentials as authoritative when it carries either field; only when
93
+ // it carries neither do we fall back to the login / password config fields.
94
+ const urlHasCredentials = parsed.username !== '' || parsed.password !== '';
95
+ const target = {
96
+ protocol,
97
+ // strip IPv6 brackets - amqplib uses hostname verbatim as the socket host in object mode
98
+ hostname: parsed.hostname.replace(/^\[|\]$/g, ''),
99
+ port: parsed.port ? Number(parsed.port) : this.Options.port ?? (protocol === 'amqps' ? 5671 : 5672),
100
+ username: urlHasCredentials ? decodeURIComponent(parsed.username) : this.Options.login,
101
+ password: urlHasCredentials ? decodeURIComponent(parsed.password) : this.Options.password,
102
+ // keep the vhost percent-encoded - amqplib unescapes it exactly once ( decoding here too would
103
+ // double-decode, eg. %2f -> / -> wrong vhost ). An empty path keeps the broker default.
104
+ vhost: parsed.pathname && parsed.pathname !== '/' ? parsed.pathname.slice(1) : '/',
105
+ };
106
+ // connection tuning params live in the url query string in string-mode; carry the ones amqplib
107
+ // understands over so switching to object-mode does not silently drop them.
108
+ const heartbeat = parsed.searchParams.get('heartbeat');
109
+ const frameMax = parsed.searchParams.get('frameMax');
110
+ const locale = parsed.searchParams.get('locale');
111
+ if (heartbeat !== null)
112
+ target.heartbeat = Number(heartbeat);
113
+ if (frameMax !== null)
114
+ target.frameMax = Number(frameMax);
115
+ if (locale !== null)
116
+ target.locale = locale;
117
+ return target;
118
+ }
119
+ /** Connect-options object built from the discrete `host` / `port` / `login` / `password` fields. */
120
+ connectOptionsFromFields(hostname) {
121
+ return {
122
+ protocol: 'amqp',
123
+ hostname,
124
+ port: this.Options.port ?? 5672,
125
+ username: this.Options.login,
126
+ password: this.Options.password,
127
+ vhost: this.Options.options?.vhost ?? '/',
128
+ };
129
+ }
130
+ async dispose() {
131
+ this.Log.info(`Disposing queue connection ${this.Options.name} ...`);
132
+ this.Disposing = true;
133
+ if (this.ReconnectTimer) {
134
+ clearTimeout(this.ReconnectTimer);
135
+ this.ReconnectTimer = undefined;
136
+ }
137
+ await this.closeChannel(this.PublishChannel);
138
+ await this.closeChannel(this.ConsumeChannel);
139
+ try {
140
+ if (this.Connection) {
141
+ await this.Connection.close();
142
+ }
143
+ }
144
+ catch (err) {
145
+ this.Log.warn(`Error while closing AMQP connection for ${this.Options.name}: ${err?.message}`);
146
+ }
147
+ this.Connected = false;
148
+ this.Subscriptions.clear();
149
+ this.Log.success(`AMQP connection ${this.Options.name} disposed`);
150
+ }
151
+ /**
152
+ * Establishes the connection + channels, then replays subscriptions and flushes buffered emits.
153
+ * Called on initial resolve and on every reconnect.
154
+ */
155
+ async connect() {
156
+ this.Log.info(`Connecting to AMQP queue at ${this.Options.host ?? 'localhost'} with client-id: ${this.ClientId} ...`);
157
+ // channels are recreated - forget what the previous ( dead ) channels had asserted
158
+ this.AssertedQueues.clear();
159
+ this.AssertedExchanges.clear();
160
+ this.AssertedRetryQueues.clear();
161
+ try {
162
+ // resolve the connection target from config. A url-style host is parsed into an explicit
163
+ // Options.Connect object ( see buildConnectionTarget ) so the credentials and every other
164
+ // setting are clear fields instead of being hidden inside a url string.
165
+ const url = this.buildConnectionTarget();
166
+ this.Connection = await this.createConnection(url, {
167
+ clientProperties: { connection_name: this.ClientId },
168
+ ...this.Options.options,
169
+ });
170
+ }
171
+ catch (err) {
172
+ throw new UnexpectedServerError(`Cannot connect to AMQP queue server at ${this.Options.host ?? 'localhost'}`, err);
173
+ }
174
+ this.Connection.on('error', (err) => {
175
+ this.Log.error(`AMQP connection error, client-id: ${this.ClientId}, name: ${this.Options.name}: ${err?.message}`);
176
+ });
177
+ this.Connection.on('close', () => {
178
+ this.onConnectionLost();
179
+ });
180
+ // confirm channel for publishing ( emit() waits for the broker ack ), plain channel for consuming
181
+ this.PublishChannel = await this.Connection.createConfirmChannel();
182
+ this.ConsumeChannel = await this.Connection.createChannel();
183
+ // limit in-flight unacked messages per consumer. Defaults to 1 ( fair dispatch, mirrors STOMP
184
+ // `activemq.prefetchSize: 1` ). Raise via options.prefetch to trade fairness for throughput.
185
+ await this.ConsumeChannel.prefetch(this.Prefetch);
186
+ this.Connected = true;
187
+ this.Log.success(`Connected to AMQP broker, client-id: ${this.ClientId}`);
188
+ await this.replaySubscriptions();
189
+ await this.flushPendingEmits();
190
+ }
191
+ /**
192
+ * Handles an unexpected connection drop by scheduling a reconnect ( unless we are disposing ).
193
+ */
194
+ onConnectionLost() {
195
+ if (this.Disposing || !this.Connected) {
196
+ return;
197
+ }
198
+ this.Connected = false;
199
+ this.Log.warn(`AMQP connection ${this.Options.name} lost, scheduling reconnect in ${this.ReconnectDelay}ms`);
200
+ this.scheduleReconnect();
201
+ }
202
+ scheduleReconnect() {
203
+ if (this.ReconnectTimer || this.Disposing) {
204
+ return;
205
+ }
206
+ this.ReconnectTimer = setTimeout(() => {
207
+ this.ReconnectTimer = undefined;
208
+ this.connect().catch((err) => {
209
+ this.Log.error(`AMQP reconnect for ${this.Options.name} failed: ${err?.message}. Retrying in ${this.ReconnectDelay}ms`);
210
+ this.scheduleReconnect();
211
+ });
212
+ }, this.ReconnectDelay);
213
+ }
214
+ async emit(message) {
215
+ this.warnOnUnsupportedScheduling(message);
216
+ // buffer while disconnected so callers can emit during a reconnect window
217
+ if (!this.Connected) {
218
+ return new Promise((resolve, reject) => {
219
+ this.PendingEmits.push({ message, resolve, reject });
220
+ });
221
+ }
222
+ return this.publishMessage(message);
223
+ }
224
+ async subscribe(channelOrMessage, callback, subscriptionId, durable) {
225
+ const channels = _.isString(channelOrMessage) ? [channelOrMessage] : this.getChannelForMessage(channelOrMessage);
226
+ for (const c of channels) {
227
+ if (this.Subscriptions.has(c)) {
228
+ this.Log.warn(`Channel ${c} already subscribed !`);
229
+ continue;
230
+ }
231
+ const descriptor = { channel: c, callback, subscriptionId, durable: !!durable };
232
+ this.Subscriptions.set(c, descriptor);
233
+ await this.startConsumer(descriptor);
234
+ }
235
+ }
236
+ unsubscribe(channelOrMessage, _removeDurable) {
237
+ const channels = _.isString(channelOrMessage) ? [channelOrMessage] : this.getChannelForMessage(channelOrMessage);
238
+ for (const c of channels) {
239
+ const sub = this.Subscriptions.get(c);
240
+ if (!sub) {
241
+ continue;
242
+ }
243
+ this.Subscriptions.delete(c);
244
+ // cancel only the consumer. For durable subscriptions the bound queue is left in place
245
+ // so messages keep accumulating while no consumer is attached ( same semantics as STOMP ).
246
+ if (sub.consumerTag) {
247
+ this.ConsumeChannel.cancel(sub.consumerTag).catch((err) => {
248
+ this.Log.warn(`Error while cancelling consumer for channel ${c}: ${err?.message}`);
249
+ });
250
+ }
251
+ }
252
+ }
253
+ /**
254
+ * Re-establishes every known subscription. Called after a (re)connect.
255
+ */
256
+ async replaySubscriptions() {
257
+ for (const descriptor of this.Subscriptions.values()) {
258
+ await this.startConsumer(descriptor);
259
+ }
260
+ }
261
+ /**
262
+ * Declares the queue for a subscription and starts consuming, wiring ack/nack + retry/dead-letter.
263
+ */
264
+ async startConsumer(descriptor) {
265
+ const c = descriptor.channel;
266
+ const queue = await this.assertSubscriptionQueue(c, descriptor.subscriptionId, descriptor.durable);
267
+ descriptor.queue = queue;
268
+ const { consumerTag } = await this.ConsumeChannel.consume(queue, (msg) => {
269
+ // null is delivered when the consumer is cancelled by the broker
270
+ if (!msg) {
271
+ return;
272
+ }
273
+ let qMessage;
274
+ try {
275
+ qMessage = JSON.parse(msg.content.toString());
276
+ }
277
+ catch (err) {
278
+ this.Log.error(`Cannot parse incoming message on channel ${c}, dead-lettering it: ${err?.message}`);
279
+ this.deadLetterRaw(msg, this.Options.defaultQueueDeadLetterChannel, c, err?.message ?? String(err));
280
+ return;
281
+ }
282
+ descriptor
283
+ .callback(qMessage)
284
+ .then(() => this.ConsumeChannel.ack(msg))
285
+ .catch((err) => this.handleFailedMessage(msg, qMessage, c, err));
286
+ }, { noAck: false });
287
+ descriptor.consumerTag = consumerTag;
288
+ this.Log.success(`Channel ${c}, durable: ${descriptor.durable ? 'true' : 'false'} subscribed and ready to receive messages !`);
289
+ }
290
+ /**
291
+ * Handles a message whose consumer callback rejected.
292
+ *
293
+ * Events are fire-and-forget - logged and acked ( dropped ). Jobs are retried up to their
294
+ * RetryCount by re-publishing to a TTL "retry" queue that dead-letters back to the work queue
295
+ * after an exponential delay, then dead-lettered once retries are exhausted.
296
+ */
297
+ handleFailedMessage(msg, qMessage, channel, err) {
298
+ const reason = err?.message ?? String(err);
299
+ // events are not retried or tracked - drop them
300
+ if (qMessage.Type !== QueueMessageType.Job || this.isTopic(channel)) {
301
+ this.Log.warn(`Handler failed on channel ${channel}, dropping message ${qMessage.Name}. ${reason}`);
302
+ this.ConsumeChannel.ack(msg);
303
+ return;
304
+ }
305
+ const maxRetries = qMessage.RetryCount ?? 0;
306
+ const attempt = Number(msg.properties.headers?.[RETRY_COUNT_HEADER] ?? 0);
307
+ if (attempt < maxRetries) {
308
+ this.retryJob(msg, qMessage, channel, attempt + 1, reason).catch((retryErr) => {
309
+ this.Log.error(`Failed to reschedule job ${qMessage.Name} on ${channel}, nacking instead: ${retryErr.message}`);
310
+ this.ConsumeChannel.nack(msg, false, false);
311
+ });
312
+ return;
313
+ }
314
+ // retries exhausted - route to the dead-letter queue
315
+ this.deadLetterRaw(msg, this.getDeadLetterChannelForMessage(qMessage), channel, reason, attempt);
316
+ }
317
+ /**
318
+ * Republishes a failed job to a per-delay TTL retry queue that dead-letters back to the work
319
+ * queue after the delay, giving broker-side ( crash-safe ) exponential backoff. Then acks original.
320
+ */
321
+ async retryJob(msg, qMessage, workQueue, nextAttempt, reason) {
322
+ const delay = this.retryBackoff(nextAttempt);
323
+ const retryQueue = await this.assertRetryQueue(workQueue, delay);
324
+ this.ConsumeChannel.sendToQueue(retryQueue, msg.content, {
325
+ persistent: true,
326
+ contentType: 'application/json',
327
+ priority: msg.properties.priority,
328
+ headers: { ...msg.properties.headers, [RETRY_COUNT_HEADER]: nextAttempt },
329
+ });
330
+ this.ConsumeChannel.ack(msg);
331
+ this.Log.warn(`Job ${qMessage.Name} failed on ${workQueue}, retry ${nextAttempt}/${qMessage.RetryCount} scheduled in ${delay}ms. ${reason}`);
332
+ }
333
+ /**
334
+ * Publishes a failed/unparseable message to the dead-letter queue and acks the original to
335
+ * unblock the source queue. When no dead-letter queue is configured the message is dropped
336
+ * ( acked ) with a warning - we never nack-loop a poison message forever.
337
+ */
338
+ deadLetterRaw(msg, dlq, channel, reason, attempt) {
339
+ if (!dlq) {
340
+ this.Log.warn(`Message failed on channel ${channel}, no dead-letter queue configured - dropping. ${reason}`);
341
+ this.ConsumeChannel.ack(msg);
342
+ return;
343
+ }
344
+ this.assertQueue(this.ConsumeChannel, dlq)
345
+ .then(() => {
346
+ this.ConsumeChannel.sendToQueue(dlq, msg.content, {
347
+ persistent: true,
348
+ contentType: 'application/json',
349
+ headers: { ...msg.properties.headers, 'x-error': reason, ...(attempt !== undefined ? { [RETRY_COUNT_HEADER]: attempt } : {}) },
350
+ });
351
+ this.ConsumeChannel.ack(msg);
352
+ this.Log.warn(`Message failed on channel ${channel}, routed to dead-letter ${dlq}. ${reason}`);
353
+ })
354
+ .catch((dlqErr) => {
355
+ this.Log.error(`Failed to route message to dead-letter ${dlq}, nacking instead: ${dlqErr.message}`);
356
+ this.ConsumeChannel.nack(msg, false, false);
357
+ });
358
+ }
359
+ /**
360
+ * Exponential backoff ( ms ) for the given retry attempt, based on `Options.retryDelay`.
361
+ * Returns 0 ( immediate redelivery ) when no base delay is configured.
362
+ */
363
+ retryBackoff(attempt) {
364
+ const base = this.Options.retryDelay ?? 0;
365
+ return base > 0 ? base * 2 ** (attempt - 1) : 0;
366
+ }
367
+ /**
368
+ * Publisher-side resilience pipeline: bounds each publish by a timeout and retries transient
369
+ * failures / nacks with exponential backoff. Consumer dedup ( by JobId ) makes the resulting
370
+ * rare duplicates harmless.
371
+ */
372
+ buildEmitPipeline() {
373
+ const attempts = this.Options.options?.emitRetries ?? 3;
374
+ const timeout = this.Options.receiptTimeout ?? 5000;
375
+ const base = this.Options.retryDelay && this.Options.retryDelay > 0 ? this.Options.retryDelay : 200;
376
+ return new ResiliencePipelineBuilder()
377
+ .addRetry({ MaxRetryAttempts: attempts, Delay: base, MaxDelay: 30000, BackoffType: BackoffType.Exponential, UseJitter: true })
378
+ .addTimeout(timeout)
379
+ .build();
380
+ }
381
+ async publishMessage(message) {
382
+ const channels = this.getChannelForMessage(message);
383
+ const buffer = Buffer.from(JSON.stringify(message));
384
+ const publishOptions = {
385
+ persistent: !!message.Persistent,
386
+ contentType: 'application/json',
387
+ };
388
+ if (message.JobId) {
389
+ publishOptions.correlationId = message.JobId;
390
+ }
391
+ if (message.Priority) {
392
+ publishOptions.priority = message.Priority;
393
+ }
394
+ for (const c of channels) {
395
+ await this.EmitPipeline.execute(() => this.publishToChannel(c, buffer, publishOptions));
396
+ this.Log.trace(`Published ${message.Type} Name: ${message.Name} to channel ${c} ( ${this.Options.name} )`);
397
+ }
398
+ }
399
+ /** Asserts the destination ( once ) and publishes a single message on the confirm channel. */
400
+ async publishToChannel(channel, buffer, options) {
401
+ if (this.isTopic(channel)) {
402
+ await this.assertExchange(this.PublishChannel, channel);
403
+ await this.publishWithConfirm(channel, '', buffer, options);
404
+ }
405
+ else {
406
+ await this.assertQueue(this.PublishChannel, channel);
407
+ await this.publishWithConfirm('', channel, buffer, options);
408
+ }
409
+ }
410
+ /**
411
+ * Publishes on the confirm channel and resolves only once the broker acks the message.
412
+ */
413
+ publishWithConfirm(exchange, routingKey, content, options) {
414
+ return new Promise((resolve, reject) => {
415
+ this.PublishChannel.publish(exchange, routingKey, content, options, (err) => {
416
+ if (err) {
417
+ reject(err instanceof Error ? err : new UnexpectedServerError(`Broker nacked message on ${exchange || routingKey}`, err));
418
+ }
419
+ else {
420
+ resolve();
421
+ }
422
+ });
423
+ });
424
+ }
425
+ async flushPendingEmits() {
426
+ if (this.PendingEmits.length === 0) {
427
+ return;
428
+ }
429
+ const pending = this.PendingEmits;
430
+ this.PendingEmits = [];
431
+ this.Log.info(`Flushing ${pending.length} buffered message(s) for queue ${this.Options.name}`);
432
+ for (const p of pending) {
433
+ this.publishMessage(p.message).then(p.resolve).catch(p.reject);
434
+ }
435
+ }
436
+ /**
437
+ * Declares ( and for topics binds ) the broker queue a subscription should consume from.
438
+ */
439
+ async assertSubscriptionQueue(channel, subscriptionId, durable) {
440
+ if (!this.isTopic(channel)) {
441
+ return this.assertQueue(this.ConsumeChannel, channel);
442
+ }
443
+ await this.assertExchange(this.ConsumeChannel, channel);
444
+ let queueName;
445
+ if (durable) {
446
+ if (!subscriptionId) {
447
+ throw new InvalidArgument(`subscriptionId cannot be empty if using durable subscriptions`);
448
+ }
449
+ const q = await this.ConsumeChannel.assertQueue(subscriptionId, { durable: true, exclusive: false, autoDelete: false });
450
+ queueName = q.queue;
451
+ }
452
+ else {
453
+ // server-named, exclusive, auto-deleted queue ( unique per subscriber )
454
+ const q = await this.ConsumeChannel.assertQueue(subscriptionId ?? '', { durable: false, exclusive: !subscriptionId, autoDelete: true });
455
+ queueName = q.queue;
456
+ }
457
+ await this.ConsumeChannel.bindQueue(queueName, channel, '');
458
+ return queueName;
459
+ }
460
+ /**
461
+ * Declares a durable work queue, asserting it on the broker only once per connection.
462
+ */
463
+ async assertQueue(channel, name) {
464
+ if (!this.AssertedQueues.has(name)) {
465
+ await channel.assertQueue(name, { durable: true });
466
+ this.AssertedQueues.add(name);
467
+ }
468
+ return name;
469
+ }
470
+ /**
471
+ * Declares a durable fanout exchange, asserting it on the broker only once per connection.
472
+ */
473
+ async assertExchange(channel, name) {
474
+ if (!this.AssertedExchanges.has(name)) {
475
+ await channel.assertExchange(name, 'fanout', { durable: true });
476
+ this.AssertedExchanges.add(name);
477
+ }
478
+ }
479
+ /**
480
+ * Declares a durable TTL retry queue that dead-letters expired messages back to `workQueue`
481
+ * ( via the default exchange, routing key = queue name ). One queue per distinct delay.
482
+ */
483
+ async assertRetryQueue(workQueue, delay) {
484
+ const name = `${workQueue}.retry.${delay}`;
485
+ if (!this.AssertedRetryQueues.has(name)) {
486
+ await this.ConsumeChannel.assertQueue(name, {
487
+ durable: true,
488
+ arguments: {
489
+ 'x-message-ttl': delay,
490
+ 'x-dead-letter-exchange': '',
491
+ 'x-dead-letter-routing-key': workQueue,
492
+ },
493
+ });
494
+ this.AssertedRetryQueues.add(name);
495
+ }
496
+ return name;
497
+ }
498
+ isTopic(channel) {
499
+ return channel.startsWith(this.TopicPrefix);
500
+ }
501
+ async closeChannel(channel) {
502
+ try {
503
+ if (channel) {
504
+ await channel.close();
505
+ }
506
+ }
507
+ catch (err) {
508
+ this.Log.warn(`Error while closing AMQP channel for ${this.Options.name}: ${err?.message}`);
509
+ }
510
+ }
511
+ warnOnUnsupportedScheduling(message) {
512
+ if (message.ScheduleDelay || message.ScheduleCron || message.SchedulePeriod || message.ScheduleRepeat) {
513
+ this.Log.warn(`Message ${message.Name} has scheduling options set, but the AMQP transport does not support delayed/scheduled delivery yet. Message will be delivered immediately.`);
514
+ }
515
+ }
516
+ };
517
+ AmqpQueueClient = __decorate([
518
+ PerInstanceCheck(),
519
+ Injectable(QueueClient),
520
+ __metadata("design:paramtypes", [Object])
521
+ ], AmqpQueueClient);
522
+ export { AmqpQueueClient };
523
+ //# sourceMappingURL=connection.js.map