@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.
@@ -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
- const items = this._items.slice();
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
- await this.add(item.queue, item.buffer, item.options, item.priority);
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 before = this._items.length;
145
- this._items = this._items.filter(
146
- (item) => now - item.createdAt <= this._ttlMs
147
- );
148
- const removed = before - this._items.length;
149
- if (removed > 0) {
150
- this._logger.info(
151
- `[InMemoryBuffer] Removed ${removed} expired buffered messages`
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
- await this._inMemory.add(queue, buffer, options, priority);
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, handed to amqplib at connect. Explicit value, else ENV '
140
- + 'RABBITMQ_HEARTBEAT, else the module default (../defaults.js heartbeatSeconds).',
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. Explicit value, else '
147
- + 'ENV SERVICE_NAME, else the module default (../defaults.js serviceName).',
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
- minimum: 0,
252
- description: 'How many publish attempts one message gets before the publish fails.',
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 confirm of '
274
- + 'one publish attempt, and the close-ok of each handle disconnect() closes. One number, two '
275
- + 'consumers — it was named publishConfirmationTimeout until d.299, after the teardown had '
276
- + 'been spending it since d.283.',
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
- * `publishMaxRetries: 0` ("do not retry") or `thrashingThreshold: 0` ("alert on
20
- * the first close") had the value replaced by its opposite, without a word.
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: the confirm
114
- // of one publish attempt, and the `close-ok` of each handle `disconnect()`
115
- // closes — one number for two consumers, which is why it carries the name of
116
- // neither. It was `publishConfirmationTimeout` until d.299, a name that was
117
- // true of the publish path and false of the teardown that had used it since
118
- // d.283.
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
- * Both refusals are raised at the throw site itself
55
- * (`transports/rabbitmqClient.js` `consume()`), not here: a message this library
56
- * hands a caller must open with the context that produced it
57
- * (`architecture-principles.md` §5), and a context passed in as a parameter is
58
- * invisible to the guard that checks that
59
- * (`tests/unit/error-message-contract.test.js`).
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 this file,
133
- * never a runtime condition, and one that would otherwise be resolved by key order.
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
- if (matched.length > 1) {
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: ${matched.join(', ')} - `
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 matched.length === 1 ? { serviceName: parts[0], queueType: matched[0] } : null;
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/workflow.control
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()` derives
770
- * the whole list from these sections only when its caller passes no `queues` list;
771
- * every caller passes one, and the gateway's names exactly the five `workflow.*`
772
- * queues it owns (`infra/api_gateway/index.js`). `monitoring.dlq` therefore belongs
773
- * to the monitoring service's own call, beside the queues it consumes. Until that
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:164` already uses), and this file only
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 — `initInfrastructureQueues()`, a queue-ownership
1268
- * probe, a document generator — reads the list instead of keeping a copy of it.
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 `RecoveryWorker.createQueue()`, the cookbook-router's
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` and `workflow.control`;
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
- serviceName: { env: 'SERVICE_NAME', defaultKey: 'serviceName' },
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