@onlineapps/mq-client-core 4.2.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +768 -0
- package/README.md +149 -19
- package/package.json +1 -1
- package/src/BaseClient.js +126 -31
- package/src/buffer/InMemoryBuffer.js +98 -13
- package/src/buffer/MessageBuffer.js +5 -3
- package/src/config/configSchema.js +33 -10
- package/src/config/defaultConfig.js +21 -10
- package/src/config/deliveryPolicy.js +86 -6
- package/src/config/queueConfig.js +68 -39
- package/src/config.js +5 -1
- package/src/defaults.js +7 -1
- package/src/index.js +41 -4
- package/src/layers/PublishLayer.js +264 -27
- package/src/monitoring-initiated.js +6 -9
- package/src/monitoring-publish.js +89 -40
- package/src/scheduler-request.js +32 -11
- package/src/transports/rabbitmqClient.js +1782 -390
- package/src/utils/decodeMessage.js +123 -0
- package/src/utils/publishErrors.js +256 -17
- package/jest.config.js +0 -15
- package/jest.integration.config.js +0 -16
- package/src/workers/RecoveryWorker.js +0 -130
|
@@ -10,6 +10,19 @@ const { assertLogger } = require('@onlineapps/logger-contract');
|
|
|
10
10
|
* Primárně určený pro transient scénáře (health checky apod.).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* How a log line names where a buffered message was going: its exchange when it was published
|
|
15
|
+
* to one, its queue otherwise.
|
|
16
|
+
*
|
|
17
|
+
* @param {{queue: string, options: Object}} item
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function describeTarget(item) {
|
|
21
|
+
return item.options && typeof item.options.exchange === 'string' && item.options.exchange !== ''
|
|
22
|
+
? `exchange "${item.options.exchange}"`
|
|
23
|
+
: `queue "${item.queue}"`;
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
class InMemoryBuffer {
|
|
14
27
|
/**
|
|
15
28
|
* Both numbers are REQUIRED and have no stand-in here.
|
|
@@ -61,7 +74,7 @@ class InMemoryBuffer {
|
|
|
61
74
|
this._maxSize = options.maxSize;
|
|
62
75
|
this._ttlMs = options.ttlMs;
|
|
63
76
|
|
|
64
|
-
/** @type {Array<{queue:string, buffer:Buffer, options:Object, createdAt:number, priority:string}>} */
|
|
77
|
+
/** @type {Array<{queue:string, buffer:Buffer, options:Object, createdAt:number, priority:string, sent:boolean}>} */
|
|
65
78
|
this._items = [];
|
|
66
79
|
}
|
|
67
80
|
|
|
@@ -69,7 +82,18 @@ class InMemoryBuffer {
|
|
|
69
82
|
* Přidá zprávu do bufferu.
|
|
70
83
|
* Staré/expirující zprávy jsou průběžně čištěny, při překročení kapacity odhazujeme nejstarší.
|
|
71
84
|
*/
|
|
72
|
-
async add(queue, buffer, options = {}, priority = 'normal') {
|
|
85
|
+
async add(queue, buffer, options = {}, priority = 'normal', sent) {
|
|
86
|
+
// Whether the frame of this message already LEFT the client (d.1145, conf
|
|
87
|
+
// `mq-publish-buffer` 001): the flush after `unblocked` re-sends only the entries that
|
|
88
|
+
// never did, because the broker delivers the frames it was holding by itself. Required —
|
|
89
|
+
// an entry that cannot say would be re-sent or dropped by a guess.
|
|
90
|
+
if (typeof sent !== 'boolean') {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`[InMemoryBuffer] Cannot hold a message for queue '${queue}' without knowing whether its frame was sent - `
|
|
93
|
+
+ `got sent=${JSON.stringify(sent)}. Expected: a boolean from the failed publish (error.sent). `
|
|
94
|
+
+ 'Fix: pass it from PublishLayer, which reads it off the transport\'s error.'
|
|
95
|
+
);
|
|
96
|
+
}
|
|
73
97
|
const now = Date.now();
|
|
74
98
|
this._cleanupExpired(now);
|
|
75
99
|
|
|
@@ -86,6 +110,7 @@ class InMemoryBuffer {
|
|
|
86
110
|
options,
|
|
87
111
|
createdAt: now,
|
|
88
112
|
priority,
|
|
113
|
+
sent,
|
|
89
114
|
});
|
|
90
115
|
}
|
|
91
116
|
|
|
@@ -94,7 +119,20 @@ class InMemoryBuffer {
|
|
|
94
119
|
* @param {Function} flushFn - async (queue, buffer, options) => void
|
|
95
120
|
* @returns {Promise<number>} - počet úspěšně flushnutých zpráv
|
|
96
121
|
*/
|
|
97
|
-
async flush(flushFn) {
|
|
122
|
+
async flush(flushFn, { sentEntries } = {}) {
|
|
123
|
+
// What happens to an entry whose frame already LEFT the client (d.1145, conf
|
|
124
|
+
// `mq-publish-buffer` 001): 'resend' after a reconnect — the old socket and whatever the
|
|
125
|
+
// broker had not read from it are gone; 'drop' after the broker unblocked the connection —
|
|
126
|
+
// it reads what was waiting in the socket and delivers those frames by itself, so a
|
|
127
|
+
// re-send would be one more copy. Required: a flush that guessed would do one or the other
|
|
128
|
+
// wrong.
|
|
129
|
+
if (sentEntries !== 'resend' && sentEntries !== 'drop') {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`[InMemoryBuffer] flush() needs to know what to do with a sent entry - got sentEntries=${JSON.stringify(sentEntries)}. `
|
|
132
|
+
+ "Expected: 'resend' (after a reconnect) or 'drop' (after the broker unblocked the connection). "
|
|
133
|
+
+ 'Fix: flush through PublishLayer.flushBuffered(trigger), which maps its trigger to one of them.'
|
|
134
|
+
);
|
|
135
|
+
}
|
|
98
136
|
const now = Date.now();
|
|
99
137
|
this._cleanupExpired(now);
|
|
100
138
|
|
|
@@ -103,9 +141,23 @@ class InMemoryBuffer {
|
|
|
103
141
|
}
|
|
104
142
|
|
|
105
143
|
// Zkopírujeme a vyprázdníme interní pole, abychom se vyhnuli reentrancy problémům
|
|
106
|
-
|
|
144
|
+
let items = this._items.slice();
|
|
107
145
|
this._items = [];
|
|
108
146
|
|
|
147
|
+
if (sentEntries === 'drop') {
|
|
148
|
+
for (const item of items.filter((entry) => entry.sent)) {
|
|
149
|
+
this._logger.warn(
|
|
150
|
+
`[InMemoryBuffer] A buffered message for ${describeTarget(item)} is not re-sent after the broker unblocked `
|
|
151
|
+
+ `the connection: its frame had been sent, and it was buffered ${now - item.createdAt} ms ago - Expected: the broker delivers `
|
|
152
|
+
+ 'the frames it held during the alarm by itself once it reads the socket again, so a re-send would '
|
|
153
|
+
+ 'be one more copy. Fix: nothing to do here; a consumer that must not see a message twice '
|
|
154
|
+
+ 'deduplicates it (conf mq-consumer-contract 009), and a message the broker did NOT keep is '
|
|
155
|
+
+ 'a broker-side loss this client cannot see.'
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
items = items.filter((entry) => !entry.sent);
|
|
159
|
+
}
|
|
160
|
+
|
|
109
161
|
// Kritické zprávy (priority === 'critical') házíme dopředu
|
|
110
162
|
items.sort((a, b) => {
|
|
111
163
|
if (a.priority === b.priority) return a.createdAt - b.createdAt;
|
|
@@ -125,7 +177,9 @@ class InMemoryBuffer {
|
|
|
125
177
|
this._logger.warn(
|
|
126
178
|
`[InMemoryBuffer] Failed to flush message for queue '${item.queue}': ${err.message}`
|
|
127
179
|
);
|
|
128
|
-
|
|
180
|
+
// The replay is one more attempt: if IT wrote the frame, the entry says so from now on.
|
|
181
|
+
const sent = item.sent || (err !== null && typeof err === 'object' && err.sent === true);
|
|
182
|
+
await this.add(item.queue, item.buffer, item.options, item.priority, sent);
|
|
129
183
|
}
|
|
130
184
|
}
|
|
131
185
|
|
|
@@ -140,15 +194,46 @@ class InMemoryBuffer {
|
|
|
140
194
|
return this._items.length;
|
|
141
195
|
}
|
|
142
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Drop the entries older than the TTL — and say, as an ERROR, what was dropped (d.1145, conf
|
|
199
|
+
* `mq-publish-buffer` 001).
|
|
200
|
+
*
|
|
201
|
+
* Until d.1145 this was one `info` line with a count and nothing else, the only trace of a
|
|
202
|
+
* message nobody would ever deliver (a memory alarm keeps the connection alive, so no
|
|
203
|
+
* reconnect flush comes — d.1143). One error per destination and `sent` value, with the
|
|
204
|
+
* count and the age of the oldest: a message whose frame never left is LOST, one whose frame
|
|
205
|
+
* had been sent may have been delivered by the broker on its own, and the two are never
|
|
206
|
+
* folded into one sentence.
|
|
207
|
+
*
|
|
208
|
+
* @param {number} now - the clock reading the caller took
|
|
209
|
+
* @private
|
|
210
|
+
*/
|
|
143
211
|
_cleanupExpired(now) {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
212
|
+
const expired = this._items.filter((item) => now - item.createdAt > this._ttlMs);
|
|
213
|
+
if (expired.length === 0) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
this._items = this._items.filter((item) => now - item.createdAt <= this._ttlMs);
|
|
217
|
+
|
|
218
|
+
const groups = new Map();
|
|
219
|
+
for (const item of expired) {
|
|
220
|
+
const key = `${describeTarget(item)}|${item.sent}`;
|
|
221
|
+
const group = groups.get(key) || { target: describeTarget(item), sent: item.sent, count: 0, oldestMs: 0 };
|
|
222
|
+
group.count += 1;
|
|
223
|
+
group.oldestMs = Math.max(group.oldestMs, now - item.createdAt);
|
|
224
|
+
groups.set(key, group);
|
|
225
|
+
}
|
|
226
|
+
for (const { target, sent, count, oldestMs } of groups.values()) {
|
|
227
|
+
this._logger.error(
|
|
228
|
+
`[InMemoryBuffer] ${count} buffered message(s) for ${target} expired after the buffer TTL `
|
|
229
|
+
+ `(${this._ttlMs} ms; oldest ${oldestMs} ms), sent=${sent} - `
|
|
230
|
+
+ (sent
|
|
231
|
+
? 'the frame had been sent, so the broker may have delivered it on its own; nothing re-sends it now. '
|
|
232
|
+
: 'the frame never left this client, so the message is LOST; nothing re-sends it now. ')
|
|
233
|
+
+ 'Expected: a reconnect, or the broker unblocking the connection, within publishBufferTtlMs. '
|
|
234
|
+
+ 'Fix: find why the broker stayed away that long (an alarm: management API /api/health/checks/alarms; '
|
|
235
|
+
+ 'an outage: the connection log); a message that must survive longer belongs to a caller that keeps '
|
|
236
|
+
+ 'it durably and publishes with bufferOnFailure: false.'
|
|
152
237
|
);
|
|
153
238
|
}
|
|
154
239
|
}
|
|
@@ -50,7 +50,9 @@ class MessageBuffer {
|
|
|
50
50
|
async add(queue, buffer, options = {}, meta = {}) {
|
|
51
51
|
const priority = meta.priority || 'normal';
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
// `sent` is passed through unread: the in-memory buffer owns the rule that it is required
|
|
54
|
+
// (d.1145, conf `mq-publish-buffer` 001).
|
|
55
|
+
await this._inMemory.add(queue, buffer, options, priority, meta.sent);
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/**
|
|
@@ -58,8 +60,8 @@ class MessageBuffer {
|
|
|
58
60
|
* @param {Function} flushFn - async (queue, buffer, options) => void
|
|
59
61
|
* @returns {Promise<number>} how many messages were replayed
|
|
60
62
|
*/
|
|
61
|
-
async flush(flushFn) {
|
|
62
|
-
return await this._inMemory.flush(flushFn);
|
|
63
|
+
async flush(flushFn, flushPolicy) {
|
|
64
|
+
return await this._inMemory.flush(flushFn, flushPolicy);
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
/**
|
|
@@ -136,15 +136,20 @@ module.exports = {
|
|
|
136
136
|
type: 'integer',
|
|
137
137
|
minimum: 0,
|
|
138
138
|
description:
|
|
139
|
-
'AMQP heartbeat in SECONDS,
|
|
140
|
-
+ 'RABBITMQ_HEARTBEAT, else the module default
|
|
139
|
+
'AMQP heartbeat in SECONDS, proposed to the broker in the connection URL (?heartbeat=) at '
|
|
140
|
+
+ 'connect. Explicit value, else ENV RABBITMQ_HEARTBEAT, else the module default '
|
|
141
|
+
+ '(../defaults.js heartbeatSeconds). 0 does NOT switch the heartbeat off: amqplib\'s '
|
|
142
|
+
+ 'negotiation takes whichever side sets a limit, so the broker\'s own value applies '
|
|
143
|
+
+ '(RabbitMQ 60 s unless its configuration says otherwise; measured d.1126: a paused broker '
|
|
144
|
+
+ 'detected after 179 s). Only a broker that also offers 0 disables it.',
|
|
141
145
|
},
|
|
142
146
|
serviceName: {
|
|
143
147
|
type: 'string',
|
|
144
148
|
minLength: 1,
|
|
145
149
|
description:
|
|
146
|
-
'Name this client publishes under and names its queues with.
|
|
147
|
-
+ 'ENV SERVICE_NAME
|
|
150
|
+
'Name this client publishes under and names its queues with. Required: the explicit '
|
|
151
|
+
+ 'value, else ENV SERVICE_NAME — there is no module default, because a name this '
|
|
152
|
+
+ 'library invented would name a service that does not exist (d.770).',
|
|
148
153
|
},
|
|
149
154
|
connectionName: {
|
|
150
155
|
type: 'string',
|
|
@@ -248,8 +253,24 @@ module.exports = {
|
|
|
248
253
|
},
|
|
249
254
|
publishMaxRetries: {
|
|
250
255
|
type: 'integer',
|
|
251
|
-
|
|
252
|
-
|
|
256
|
+
// ONE, not zero (d.685b). The key counts ATTEMPTS, the first one included, so `0`
|
|
257
|
+
// is not a smaller amount of retrying — it is a publish that never happens: the
|
|
258
|
+
// loop `while (attempt < this._maxRetries)` does not run its body, the message is
|
|
259
|
+
// never offered to the broker and is not buffered on the way out either (measured,
|
|
260
|
+
// d.684). `PublishLayer`'s constructor has refused it by name since that batch,
|
|
261
|
+
// while this declaration still said it was legal — one key with two answers, and
|
|
262
|
+
// the one a service author READS said the wrong one. The declaration is the
|
|
263
|
+
// prescriptive half (`doc-code-binding.md` §1), so it is the half that moved.
|
|
264
|
+
//
|
|
265
|
+
// Both doors stay: this one refuses the value at CONSTRUCTION of a `BaseClient`,
|
|
266
|
+
// the layer's refuses it wherever a transport is built past the schema
|
|
267
|
+
// (`transports/transportFactory.js` validates nothing) or the caller hands the
|
|
268
|
+
// layer its own `options.maxRetries`.
|
|
269
|
+
//
|
|
270
|
+
// "Do not retry" is `publishMaxRetries: 1`; leaving the retry rail is
|
|
271
|
+
// `publishRetryEnabled: false`. Both are explicit (`architecture-principles.md` §8).
|
|
272
|
+
minimum: 1,
|
|
273
|
+
description: 'How many publish attempts one message gets before the publish fails; at least 1, because the first attempt is one of them.',
|
|
253
274
|
},
|
|
254
275
|
publishRetryBaseDelay: {
|
|
255
276
|
type: 'integer',
|
|
@@ -270,10 +291,12 @@ module.exports = {
|
|
|
270
291
|
type: 'integer',
|
|
271
292
|
minimum: 0,
|
|
272
293
|
description:
|
|
273
|
-
'How long this client waits for an ANSWER from the broker, in milliseconds: the
|
|
274
|
-
+ 'one publish attempt,
|
|
275
|
-
+ '
|
|
276
|
-
+ '
|
|
294
|
+
'How long this client waits for an ANSWER from the broker, in milliseconds — every wait: the '
|
|
295
|
+
+ 'confirm of one publish attempt, the close-ok of each handle disconnect() closes, and every '
|
|
296
|
+
+ 'request a channel sends and waits on (queue/exchange declarations, checkQueue, bindQueue, '
|
|
297
|
+
+ 'purge, delete, consume, cancel, prefetch, channel open; conf mq-broker-answer-budget 001, '
|
|
298
|
+
+ 'd.1135). One number for one question — it was named publishConfirmationTimeout until d.299, '
|
|
299
|
+
+ 'after the teardown had been spending it since d.283.',
|
|
277
300
|
},
|
|
278
301
|
connectTimeout: {
|
|
279
302
|
type: 'integer',
|
|
@@ -16,9 +16,15 @@
|
|
|
16
16
|
* (`change-discipline.md`), and the `||` was worse than the duplication: it means
|
|
17
17
|
* "if falsy", not "if absent", and the schema declares `minimum: 0` for these
|
|
18
18
|
* knobs. A caller who wrote `reconnectBaseDelay: 0` ("retry immediately"),
|
|
19
|
-
* `
|
|
20
|
-
* the first close") had the value replaced by its opposite, without a
|
|
21
|
-
* Measured on 2026-09-14: twelve knobs swallowed a written `0`.
|
|
19
|
+
* `publishRetryBaseDelay: 0` ("no wait between attempts") or `thrashingThreshold: 0`
|
|
20
|
+
* ("alert on the first close") had the value replaced by its opposite, without a
|
|
21
|
+
* word. Measured on 2026-09-14: twelve knobs swallowed a written `0`.
|
|
22
|
+
*
|
|
23
|
+
* `publishMaxRetries: 0` was named among those examples until d.684, glossed as "do
|
|
24
|
+
* not retry". It never meant that: the key counts ATTEMPTS, the first one included
|
|
25
|
+
* (see it below, and `configSchema.js`), so `0` says "never offer this message to the
|
|
26
|
+
* broker" — which is what the loop did. It is now refused by name at construction
|
|
27
|
+
* (`layers/PublishLayer.js`); "one attempt, no retry" is `1`.
|
|
22
28
|
*
|
|
23
29
|
* `??` was not the answer either. It fixes the falsy half and leaves the value
|
|
24
30
|
* written in two places, which is the half that rots.
|
|
@@ -100,7 +106,11 @@ module.exports = {
|
|
|
100
106
|
|
|
101
107
|
publishRetryEnabled: true,
|
|
102
108
|
|
|
103
|
-
// How many attempts one publish gets before it is buffered or reported
|
|
109
|
+
// How many attempts one publish gets before it is buffered or reported — the first
|
|
110
|
+
// attempt included, so `1` is "one attempt, no retry". At least 1: `0` would mean the
|
|
111
|
+
// message is never offered to the broker, and it is refused by name rather than
|
|
112
|
+
// obeyed (`layers/PublishLayer.js`, d.684). To leave the retry rail entirely, write
|
|
113
|
+
// `publishRetryEnabled: false`.
|
|
104
114
|
publishMaxRetries: 3,
|
|
105
115
|
|
|
106
116
|
// First wait between two attempts, in ms, multiplied by
|
|
@@ -110,12 +120,13 @@ module.exports = {
|
|
|
110
120
|
publishRetryMaxDelay: 5000,
|
|
111
121
|
publishRetryBackoffMultiplier: 2,
|
|
112
122
|
|
|
113
|
-
// How long this client waits for an ANSWER from the broker, in ms
|
|
114
|
-
// of one publish attempt,
|
|
115
|
-
// closes
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
// d.
|
|
123
|
+
// How long this client waits for an ANSWER from the broker, in ms — every wait:
|
|
124
|
+
// the confirm of one publish attempt, the `close-ok` of each handle
|
|
125
|
+
// `disconnect()` closes, and every request a channel sends and waits on
|
|
126
|
+
// (conf `mq-broker-answer-budget` 001, d.1135) — one number for all of them,
|
|
127
|
+
// which is why it carries the name of none. It was `publishConfirmationTimeout`
|
|
128
|
+
// until d.299, a name that was true of the publish path and false of the
|
|
129
|
+
// teardown that had used it since d.283.
|
|
119
130
|
brokerAnswerTimeout: 5000,
|
|
120
131
|
|
|
121
132
|
// How long the connect handshake may take before the attempt is abandoned, in
|
|
@@ -51,16 +51,22 @@
|
|
|
51
51
|
* environment form goes through numeric coercion, because an environment
|
|
52
52
|
* variable has no other shape (`../config.js`, `../defaults.js`).
|
|
53
53
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* (`
|
|
58
|
-
*
|
|
59
|
-
*
|
|
54
|
+
* The option rules — `requeueOnError` a boolean, `requeueOnError: false` meaning
|
|
55
|
+
* one attempt and contradicting any other `maxAttempts`, the budget an integer
|
|
56
|
+
* >= 1, `classify` a function — live in `validateConsumeOptions` below, the one
|
|
57
|
+
* owner the transport's `consume()` calls and the package exports (d.1005), so a
|
|
58
|
+
* consumer's test double asks the same function instead of copying the rules.
|
|
59
|
+
* Every refusal opens with the literal context of the call it guards,
|
|
60
|
+
* `[RabbitMQClient]`, written at the throw site: a context passed in as a
|
|
61
|
+
* parameter would be invisible to the guard that checks it
|
|
62
|
+
* (`tests/unit/error-message-contract.test.js`), and the texts are the ones
|
|
63
|
+
* `consume()` has always thrown.
|
|
60
64
|
*
|
|
61
65
|
* @see ../../README.md § consume() — the delivery contract
|
|
62
66
|
*/
|
|
63
67
|
|
|
68
|
+
const { ValidationError } = require('../utils/errorHandler');
|
|
69
|
+
|
|
64
70
|
/** Header carrying how many attempts a message has already cost. Never a literal elsewhere. */
|
|
65
71
|
const ATTEMPTS_HEADER = 'x-oa-delivery-attempts';
|
|
66
72
|
|
|
@@ -153,6 +159,79 @@ function classifyError(classify, error) {
|
|
|
153
159
|
return { classification: answer, failure: null };
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Validate the options of `consume()` and resolve the delivery policy they ask for.
|
|
164
|
+
*
|
|
165
|
+
* `requeueOnError` is the SAME policy as `maxAttempts` said in one word, never a
|
|
166
|
+
* second one: `false` means a budget of one attempt, `true` or absent leaves the
|
|
167
|
+
* budget to `maxAttempts` or the default. `requeueOnError: false` together with
|
|
168
|
+
* any `maxAttempts` other than 1 is refused rather than resolved by preferring
|
|
169
|
+
* one of the two. An explicit `maxAttempts` is taken exactly as written — never
|
|
170
|
+
* coerced. `classify` defaults to `defaultClassification`.
|
|
171
|
+
*
|
|
172
|
+
* @param {Object} options the options passed to `consume()`
|
|
173
|
+
* @param {function(): number} resolveDefaultMaxAttempts returns the budget when
|
|
174
|
+
* the caller names none — the transport resolves its configuration there
|
|
175
|
+
* (explicit → env RABBITMQ_MAX_DELIVERY_ATTEMPTS → defaults.js). Called ONLY
|
|
176
|
+
* when the options leave the budget open: resolving it reads configuration,
|
|
177
|
+
* which can refuse on its own, and an explicit `maxAttempts` or
|
|
178
|
+
* `requeueOnError: false` never needed it. Its value is validated like an
|
|
179
|
+
* explicit one.
|
|
180
|
+
* @returns {{maxAttempts: number, classify: function(Error): string}}
|
|
181
|
+
* @throws {ValidationError} the first rule the options break, in the words
|
|
182
|
+
* `consume()` refuses them with
|
|
183
|
+
*/
|
|
184
|
+
function validateConsumeOptions(options, resolveDefaultMaxAttempts) {
|
|
185
|
+
if (options.requeueOnError !== undefined && typeof options.requeueOnError !== 'boolean') {
|
|
186
|
+
throw new ValidationError(
|
|
187
|
+
'[RabbitMQClient] Invalid requeueOnError - Expected: true or false, got '
|
|
188
|
+
+ `${JSON.stringify(options.requeueOnError)}. Fix: pass consume(queue, handler, `
|
|
189
|
+
+ '{ requeueOnError: false }) to dead-letter on the first failure, or leave it out and '
|
|
190
|
+
+ 'let the delivery budget decide.'
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const noRequeue = options.requeueOnError === false;
|
|
195
|
+
|
|
196
|
+
if (noRequeue && options.maxAttempts !== undefined && options.maxAttempts !== 1) {
|
|
197
|
+
throw new ValidationError(
|
|
198
|
+
'[RabbitMQClient] Contradictory delivery policy - requeueOnError: false means one attempt, '
|
|
199
|
+
+ `but maxAttempts says ${JSON.stringify(options.maxAttempts)}. Expected: one statement about `
|
|
200
|
+
+ 'how many times a handler may run. Fix: drop requeueOnError to keep the budget, or drop '
|
|
201
|
+
+ 'maxAttempts to dead-letter on the first failure.'
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const maxAttempts = noRequeue
|
|
206
|
+
? 1
|
|
207
|
+
: options.maxAttempts === undefined
|
|
208
|
+
? resolveDefaultMaxAttempts()
|
|
209
|
+
: options.maxAttempts;
|
|
210
|
+
|
|
211
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
212
|
+
throw new ValidationError(
|
|
213
|
+
'[RabbitMQClient] Invalid delivery budget - maxAttempts must be an integer >= 1, got '
|
|
214
|
+
+ `${JSON.stringify(maxAttempts)}. Fix: pass consume(queue, handler, { maxAttempts: <integer >= 1> }), `
|
|
215
|
+
+ 'or leave it out and let the module default (defaults.js maxDeliveryAttempts, '
|
|
216
|
+
+ 'env RABBITMQ_MAX_DELIVERY_ATTEMPTS) decide.'
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const classify = options.classify === undefined
|
|
221
|
+
? defaultClassification
|
|
222
|
+
: options.classify;
|
|
223
|
+
|
|
224
|
+
if (typeof classify !== 'function') {
|
|
225
|
+
throw new ValidationError(
|
|
226
|
+
'[RabbitMQClient] Invalid error classifier - classify must be a function returning '
|
|
227
|
+
+ `'transient' or 'permanent', got ${typeof options.classify}. Fix: pass consume(queue, handler, `
|
|
228
|
+
+ '{ classify: (error) => … }), or leave it out and every error is treated as transient.'
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { maxAttempts, classify };
|
|
233
|
+
}
|
|
234
|
+
|
|
156
235
|
module.exports = {
|
|
157
236
|
ATTEMPTS_HEADER,
|
|
158
237
|
TRANSIENT,
|
|
@@ -162,4 +241,5 @@ module.exports = {
|
|
|
162
241
|
defaultClassification,
|
|
163
242
|
readAttemptsMade,
|
|
164
243
|
classifyError,
|
|
244
|
+
validateConsumeOptions,
|
|
165
245
|
};
|
|
@@ -129,8 +129,11 @@ const templateShape = (template) =>
|
|
|
129
129
|
* @returns {{serviceName: string, queueType: string}|null} The service and the
|
|
130
130
|
* template key that declares the queue's arguments, or `null` for a name no
|
|
131
131
|
* template matches.
|
|
132
|
-
* @throws {ValidationError} If two templates match one name - a defect in
|
|
133
|
-
* never a runtime condition, and one that would otherwise be resolved by key
|
|
132
|
+
* @throws {ValidationError} If two templates of the same kind match one name - a defect in
|
|
133
|
+
* this file, never a runtime condition, and one that would otherwise be resolved by key
|
|
134
|
+
* order. A literal shape and a placeholder shape matching the same name is NOT that case:
|
|
135
|
+
* the literal wins, because it declares the name while the placeholder only fits it
|
|
136
|
+
* (d.817b, `registry.events` against `registry.{instance_id}`).
|
|
134
137
|
*/
|
|
135
138
|
const matchBusinessTemplate = (business, queueName) => {
|
|
136
139
|
const parts = queueName.split('.');
|
|
@@ -145,15 +148,24 @@ const matchBusinessTemplate = (business, queueName) => {
|
|
|
145
148
|
);
|
|
146
149
|
});
|
|
147
150
|
|
|
148
|
-
|
|
151
|
+
// A LITERAL BEATS A PLACEHOLDER (d.817b). `registry.events` names a queue;
|
|
152
|
+
// `registry.{instance_id}` names a shape — so `<svc>.registry.events` fits both, and a
|
|
153
|
+
// wildcard must not claim a name another template DECLARES. It is the same tie-break this
|
|
154
|
+
// file makes one level up, where a name an infrastructure section declares is never a
|
|
155
|
+
// business queue however well it fits a template (d.279). Ambiguity between two templates
|
|
156
|
+
// of the SAME kind is still a defect and still refused below.
|
|
157
|
+
const literals = matched.filter((template) => !templateShape(template).includes(null));
|
|
158
|
+
const decided = literals.length > 0 ? literals : matched;
|
|
159
|
+
|
|
160
|
+
if (decided.length > 1) {
|
|
149
161
|
throw new ValidationError(
|
|
150
|
-
`[queueConfig] Business queue ${queueName} matches more than one template: ${
|
|
162
|
+
`[queueConfig] Business queue ${queueName} matches more than one template: ${decided.join(', ')} - `
|
|
151
163
|
+ 'Expected: exactly one template declares a name, so one queue has one definition. '
|
|
152
164
|
+ 'Fix: make the overlapping keys in the `business` section of src/config/queueConfig.js distinct.'
|
|
153
165
|
);
|
|
154
166
|
}
|
|
155
167
|
|
|
156
|
-
return
|
|
168
|
+
return decided.length === 1 ? { serviceName: parts[0], queueType: decided[0] } : null;
|
|
157
169
|
};
|
|
158
170
|
|
|
159
171
|
/**
|
|
@@ -277,33 +289,11 @@ module.exports = {
|
|
|
277
289
|
}
|
|
278
290
|
},
|
|
279
291
|
|
|
280
|
-
/**
|
|
281
|
-
* workflow.control - Shared queue for service-agnostic control-flow steps
|
|
282
|
-
* All business services listen as competing consumers (same pattern as workflow.init).
|
|
283
|
-
*
|
|
284
|
-
* Purpose:
|
|
285
|
-
* - Execute control-flow steps that are not pinned to a specific service
|
|
286
|
-
* - Keep workflow.init as a clear "gateway entrypoint" queue (no semantic overload)
|
|
287
|
-
*
|
|
288
|
-
* TTL: 30 seconds - if no business service processes message within 30s, it expires
|
|
289
|
-
* DLQ routing: Expired messages go to workflow.failed
|
|
290
|
-
*/
|
|
291
|
-
control: {
|
|
292
|
-
durable: true,
|
|
293
|
-
arguments: {
|
|
294
|
-
'x-message-ttl': 30000,
|
|
295
|
-
'x-max-length': 10000,
|
|
296
|
-
'x-dead-letter-exchange': '',
|
|
297
|
-
'x-dead-letter-routing-key': 'workflow.failed'
|
|
298
|
-
}
|
|
299
|
-
},
|
|
300
|
-
|
|
301
292
|
/**
|
|
302
293
|
* workflow.completed - Completed workflows
|
|
303
294
|
*
|
|
304
295
|
* Dead-letters to workflow.dlq over the DEFAULT exchange, where the routing key
|
|
305
|
-
* is the destination queue name — the shape workflow.init
|
|
306
|
-
* already use. The dispatcher rejects a message here on an invalid payload, a
|
|
296
|
+
* is the destination queue name — the shape workflow.init already uses. The dispatcher rejects a message here on an invalid payload, a
|
|
307
297
|
* schema violation and a delivery failure; until d.278 the broker dropped each
|
|
308
298
|
* one, because the queue declared nowhere to put it.
|
|
309
299
|
*
|
|
@@ -492,6 +482,41 @@ module.exports = {
|
|
|
492
482
|
'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
|
|
493
483
|
'x-dead-letter-routing-key': '{service}.dlq'
|
|
494
484
|
}
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* {service}.registry.{instance_id} - the queue ONE INSTANCE gets its registry answers
|
|
489
|
+
* on, beside the shared `{service}.registry` the registry uses for its own prompts.
|
|
490
|
+
*
|
|
491
|
+
* The owner settled the name and the split (`registry-instance-identity` 001,
|
|
492
|
+
* Conditions): answers travel to `<svc>.registry.<instanceId>` through the existing
|
|
493
|
+
* `responseQueue`, the shared queue carries the registry's prompts. Measured by the
|
|
494
|
+
* registry thread in d.817: without a template here `getDeadLetterRoute()` answered
|
|
495
|
+
* null, `consume()` refuses a queue with no declared route (`mq-consumer-contract`
|
|
496
|
+
* 002), and the client had to go on answering on the shared queue — the state the
|
|
497
|
+
* confirmation moved away from.
|
|
498
|
+
*
|
|
499
|
+
* ITS LIFETIME IS `x-expires` (question 14, variant A; `registry-instance-identity`
|
|
500
|
+
* 003). Every boot of a copy names a new queue (the
|
|
501
|
+
* instance id is a UUID per process unless `SERVICE_INSTANCE_ID` names one), and
|
|
502
|
+
* nothing deletes the old one — not `deregister()`, not the wrapper's shutdown, not the
|
|
503
|
+
* registry, which only checks the queue exists before it answers. A durable queue with
|
|
504
|
+
* no lifetime would therefore stay behind after every restart, crash or clean stop
|
|
505
|
+
* alike. The broker deletes a queue with `x-expires` once it has gone that long with no
|
|
506
|
+
* consumer, so a live copy (its consumer attached) keeps it and a gone copy loses it.
|
|
507
|
+
* 60 s: longer than a reconnect of a live copy, shorter than the monitoring orphan
|
|
508
|
+
* window (`queue_orphan_alert.min_orphan_minutes`, seed 5), so a dead copy's queue
|
|
509
|
+
* never opens a `no_owner` episode. One rail: the lifetime lives in this declaration
|
|
510
|
+
* only — the transport refuses a caller's `autoDelete`/`exclusive` on a classified
|
|
511
|
+
* queue, and no caller deletes it.
|
|
512
|
+
*/
|
|
513
|
+
'registry.{instance_id}': {
|
|
514
|
+
durable: true,
|
|
515
|
+
arguments: {
|
|
516
|
+
'x-expires': 60000, // 60 s with no consumer, then the broker deletes it (question 14, A)
|
|
517
|
+
'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
|
|
518
|
+
'x-dead-letter-routing-key': '{service}.dlq' // Placeholder, replaced with actual service name
|
|
519
|
+
}
|
|
495
520
|
}
|
|
496
521
|
},
|
|
497
522
|
|
|
@@ -766,11 +791,12 @@ module.exports = {
|
|
|
766
791
|
* `mq-consumer-contract` 002 point 2, 003 point 1).
|
|
767
792
|
*
|
|
768
793
|
* **A declaration is not a creation instruction. The terminal is brought into being
|
|
769
|
-
* by the owner of the prefix that NAMES it.** `initInfrastructureQueues()`
|
|
770
|
-
* the
|
|
771
|
-
*
|
|
772
|
-
* queues it owns (`infra/api_gateway/index.js`).
|
|
773
|
-
* to the monitoring service's own call, beside
|
|
794
|
+
* by the owner of the prefix that NAMES it.** `initInfrastructureQueues()` creates
|
|
795
|
+
* exactly the names its caller passes in `queues`, a list that is required since
|
|
796
|
+
* d.668 — it derives nothing from these sections, and the gateway's list names
|
|
797
|
+
* exactly the five `workflow.*` queues it owns (`infra/api_gateway/index.js`).
|
|
798
|
+
* `monitoring.dlq` therefore belongs to the monitoring service's own call, beside
|
|
799
|
+
* the queues it consumes. Until that
|
|
774
800
|
* call names it, a `nack(requeue=false)` on a queue of this family dead-letters onto
|
|
775
801
|
* a routing key no queue carries and the broker DROPS the message — measured
|
|
776
802
|
* 2026-09-16 on the dev broker: 78 queues, and the only `.dlq` among them
|
|
@@ -1096,7 +1122,8 @@ module.exports = {
|
|
|
1096
1122
|
* travels on that exchange, so the scheduler reads it there rather than asking the
|
|
1097
1123
|
* observer over HTTP or reading a database that is not its own. No producer changes; the
|
|
1098
1124
|
* binding is the scheduler's own act at boot (the pattern
|
|
1099
|
-
* `infra/api_delivery_endpoint/src/index.js
|
|
1125
|
+
* `infra/api_delivery_endpoint/src/index.js` already uses with its
|
|
1126
|
+
* `mq.bindQueueToFanout(…)` calls), and this file only
|
|
1100
1127
|
* DECLARES what that queue is.
|
|
1101
1128
|
*
|
|
1102
1129
|
* WHICH event_type values those are is NOT written here: the vocabulary has one owner and
|
|
@@ -1264,8 +1291,11 @@ module.exports = {
|
|
|
1264
1291
|
|
|
1265
1292
|
/**
|
|
1266
1293
|
* The infrastructure prefixes this configuration classifies by, in declaration
|
|
1267
|
-
* order. Published so a caller
|
|
1268
|
-
*
|
|
1294
|
+
* order. Published so a caller reads the list instead of keeping a copy of it.
|
|
1295
|
+
* It named `initInfrastructureQueues()` as such a caller until d.668, when that
|
|
1296
|
+
* function stopped deriving a queue list at all and started requiring `queues`;
|
|
1297
|
+
* what reads it today is this package's own coverage of the classification
|
|
1298
|
+
* (`tests/unit/queue-name-collision.test.js`, `queueConfig-telemetry.test.js`).
|
|
1269
1299
|
* @returns {ReadonlyArray<string>} Prefixes WITHOUT the trailing dot.
|
|
1270
1300
|
*/
|
|
1271
1301
|
infrastructurePrefixes() {
|
|
@@ -1507,8 +1537,7 @@ module.exports = {
|
|
|
1507
1537
|
*
|
|
1508
1538
|
* ONE owner of the mapping name → options. Every path that declares a queue
|
|
1509
1539
|
* reads it here and nowhere else: the public `assertQueue()` of the transport
|
|
1510
|
-
* (and through it
|
|
1511
|
-
* QueueManager and the publish path's 404 branch), and the consumer's
|
|
1540
|
+
* (and through it the cookbook-router's QueueManager), and the consumer's
|
|
1512
1541
|
* `_prepareQueueForConsume()`.
|
|
1513
1542
|
*
|
|
1514
1543
|
* Until d.410 those two worked the same three lines out separately —
|
|
@@ -1609,7 +1638,7 @@ module.exports = {
|
|
|
1609
1638
|
*
|
|
1610
1639
|
* The counterpart of `getDeadLetterRoute()`, and the reason a queue may have no
|
|
1611
1640
|
* route without that being a defect. `workflow.failed` is named in
|
|
1612
|
-
* `x-dead-letter-routing-key` by `workflow.init
|
|
1641
|
+
* `x-dead-letter-routing-key` by `workflow.init`;
|
|
1613
1642
|
* `{service}.dlq` by every business template; `workflow.dlq` and `delivery.dlq`
|
|
1614
1643
|
* by their own families. Giving such a queue a route of its own would either
|
|
1615
1644
|
* close a loop or start an endless chain, which is why the confirmed topology
|
package/src/config.js
CHANGED
|
@@ -20,7 +20,11 @@ const runtimeCfg = createRuntimeConfig({
|
|
|
20
20
|
|
|
21
21
|
// Client behavior
|
|
22
22
|
heartbeat: { env: 'RABBITMQ_HEARTBEAT', defaultKey: 'heartbeatSeconds', type: 'number' },
|
|
23
|
-
|
|
23
|
+
// The caller's own identity — required, never defaulted (d.770). Two legs, both the
|
|
24
|
+
// caller's: the explicit key, or ENV SERVICE_NAME. There is no third, because a
|
|
25
|
+
// library that invents a service name makes every row it writes name the wrong
|
|
26
|
+
// service (`architecture-principles.md` §3, and `./defaults.js` for the measurement).
|
|
27
|
+
serviceName: { env: 'SERVICE_NAME', required: true },
|
|
24
28
|
|
|
25
29
|
// Connection-level recovery budget. Resolved here (explicit → env →
|
|
26
30
|
// module default) instead of an inline `|| 10` in the transport, so the
|
package/src/defaults.js
CHANGED
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
* - This module owns defaults for MQ client behavior (heartbeat, connectionName prefix).
|
|
8
8
|
* - Infrastructure topology (RabbitMQ URL) is NOT defaulted; it must be provided via
|
|
9
9
|
* explicit config.host or ENV RABBITMQ_URL (fail-fast).
|
|
10
|
+
* - The CALLER's own identity is not defaulted either. `serviceName` used to be
|
|
11
|
+
* `'oa-service'` here until d.770: a name this module cannot know and must not invent
|
|
12
|
+
* (`architecture-principles.md` §3). What it bought was measured, and it was a lie —
|
|
13
|
+
* every client built without the key published its monitoring events, and named its
|
|
14
|
+
* queues, under a service that does not exist. It is now required, with the same two
|
|
15
|
+
* legs every caller already uses: explicit `serviceName`, or ENV `SERVICE_NAME`
|
|
16
|
+
* (`../config.js`).
|
|
10
17
|
*/
|
|
11
18
|
|
|
12
19
|
module.exports = {
|
|
13
20
|
heartbeatSeconds: 30,
|
|
14
|
-
serviceName: 'oa-service',
|
|
15
21
|
|
|
16
22
|
// How many times connection-level recovery retries before the connection is
|
|
17
23
|
// declared permanently lost. Client BEHAVIOUR, so it is owned here (the same
|