@onlineapps/mq-client-core 4.2.0 → 5.0.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.
@@ -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
package/src/index.js CHANGED
@@ -8,9 +8,6 @@
8
8
  *
9
9
  * NOTE: Infrastructure orchestration utilities (waitForInfrastructureReady, initInfrastructureQueues)
10
10
  * have been moved to @onlineapps/infrastructure-tools to keep this library focused on core MQ operations.
11
- *
12
- * @version 1.0.49
13
- * @updated 2024-12-08 - Test publish-library.sh workflow
14
11
  */
15
12
 
16
13
  const BaseClient = require('./BaseClient');
@@ -48,9 +45,11 @@ const {
48
45
  buildCancelRequest,
49
46
  publishSchedulerRequest,
50
47
  createSchedulerPublisher,
48
+ SCHEDULER_REQUEST_INVALID,
51
49
  } = require('./scheduler-request');
52
50
  const queueConfig = require('./config/queueConfig');
53
51
  const deliveryPolicy = require('./config/deliveryPolicy');
52
+ const { decodeMessage } = require('./utils/decodeMessage');
54
53
  const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('@onlineapps/logger-contract');
55
54
 
56
55
  // Export BaseClient as default (constructor), with additional named exports
@@ -119,6 +118,8 @@ module.exports.scheduler = {
119
118
  buildCancelRequest,
120
119
  publishSchedulerRequest,
121
120
  createSchedulerPublisher,
121
+ // The code on every refusal of a builder, and on nothing a failed send throws.
122
+ SCHEDULER_REQUEST_INVALID,
122
123
  };
123
124
  /** The only public way to reach the central queue classification; `src/config/queueConfig` is an internal path, not a contract. */
124
125
  module.exports.queueConfig = queueConfig;
@@ -175,7 +176,25 @@ module.exports.topology = Object.freeze({
175
176
  /** The same consumer's metric-sample intake on that exchange. */
176
177
  telemetryMetricsQueue: queueConfig.queueName('telemetry', 'metrics.queue'),
177
178
  /** The same consumer's span intake on that exchange. */
178
- telemetryTracesQueue: queueConfig.queueName('telemetry', 'traces.queue')
179
+ telemetryTracesQueue: queueConfig.queueName('telemetry', 'traces.queue'),
180
+ /**
181
+ * Topic exchange: every telemetry producer on the platform publishes logs, metrics and
182
+ * spans here with a routing key of the shape `telemetry.<signal>.<service_name>`, and
183
+ * the three queues above are bound to it.
184
+ *
185
+ * Declared in `queueConfig` since d.708, which ended the state where one name had three
186
+ * owners: a module constant in the monitoring consumer
187
+ * (`api/infra/api_monitoring/src/consumer/topology.js`) and a config default in each of
188
+ * monitoring-core's two publishers. The declaration alone does not finish that move —
189
+ * the consumer resolves every platform name it does not own through THIS export, and
190
+ * `src/config/queueConfig` is an internal path rather than a contract (see the note on
191
+ * the module export above). So the name it must import belongs here, the same step d.461
192
+ * made for the three queues it is bound to.
193
+ *
194
+ * @see api/infra/api_monitoring/src/consumer/index.js — assertMonitoringTopology()
195
+ * @see api/shared/monitoring/monitoring-core/src/exporters/rabbitmq.js
196
+ */
197
+ telemetryExchange: queueConfig.queueName('telemetry', 'exchange')
179
198
  });
180
199
  /**
181
200
  * The dead-letter policy of `consume()` as a contract, not as an implementation
@@ -186,6 +205,24 @@ module.exports.topology = Object.freeze({
186
205
  * return one of those two words.
187
206
  */
188
207
  module.exports.deliveryPolicy = deliveryPolicy;
208
+ /**
209
+ * The one rail from a delivery to the message it carries.
210
+ *
211
+ * `consume()` hands its handler the RAW amqplib delivery, so every consumer wrote that
212
+ * translation itself: d.788 measured FOUR copies (dispatcher, scheduler, service-wrapper,
213
+ * registry) which disagree about what happens to a BAD message — dead-letter, loss rail,
214
+ * `INVALID_JSON` reply, or requeue for ever — and two of which pass a delivery with no
215
+ * `content` on AS the body.
216
+ *
217
+ * The decoding was never the missing part (`utils/serializer.js` has done it all along);
218
+ * the missing part was this door, because `src/…` is an internal path rather than a
219
+ * contract. Exported ADDITIVELY: nothing in this package calls it and `consume()` is
220
+ * unchanged, because converting those four changes the fate of a malformed message —
221
+ * BREAKING, and for the registry only after its queue has a dead-letter route (d.784).
222
+ *
223
+ * @see ./utils/decodeMessage.js — what it refuses, and with which of this package's classes
224
+ */
225
+ module.exports.decodeMessage = decodeMessage;
189
226
  /**
190
227
  * Redaction of the credential a connection URL carries, as a DECLARED export.
191
228
  *
@@ -51,10 +51,10 @@ class PublishLayer {
51
51
  // transport constructor reads it from there (d.292 §5).
52
52
  //
53
53
  // Asked with `=== undefined`, never with `||`. `||` means "if falsy", so the
54
- // three numbers below lost a legally written `0` — `publishMaxRetries: 0`
55
- // means "do not retry a publish", and it arrived here as 3. d.292 removed
56
- // exactly that shape from the transport and said in as many words that this
57
- // layer still carried its own copies; this is that floor (d.311).
54
+ // three numbers below lost a legally written `0` — `publishRetryBaseDelay: 0`
55
+ // means "retry immediately", and it arrived here as 100. d.292 removed exactly
56
+ // that shape from the transport and said in as many words that this layer still
57
+ // carried its own copies; this is that floor (d.311).
58
58
  this._retryEnabled = options.retryEnabled !== undefined
59
59
  ? options.retryEnabled
60
60
  : (this._client._publishRetryEnabled !== false);
@@ -71,6 +71,45 @@ class PublishLayer {
71
71
  ? options.retryBackoffMultiplier
72
72
  : this._client._publishRetryBackoffMultiplier;
73
73
 
74
+ // `publishMaxRetries` counts ATTEMPTS, the first one included — that is what the
75
+ // key means in every place it is written down (`config/configSchema.js`,
76
+ // `config/defaultConfig.js`, `docs/architecture/mq-publish-reliability.md` § the
77
+ // config table, `docs/standards/rabbitmq-channel-best-practices.md`), and what
78
+ // `_publishWithRetry()` below does for every value from 1 up.
79
+ //
80
+ // So `0` is not "do not retry" — three comments in this package said it was, and
81
+ // the loop obeyed the key instead: `while (attempt < this._maxRetries)` never runs
82
+ // its body, so the message is never offered to the broker, is NOT buffered on the
83
+ // way out, and the caller is handed an error blaming this layer for a defect its
84
+ // own configuration caused (measured, d.684). "One attempt, no retry" is `1`, and
85
+ // leaving the retry rail entirely is `publishRetryEnabled: false`; both are
86
+ // explicit, and neither needs a magic zero (`architecture-principles.md` §8).
87
+ //
88
+ // Refused HERE, at construction, because this is the one place both doors meet —
89
+ // the caller's `options.maxRetries` and the client's resolved config — and a value
90
+ // that can only fail is refused before it is used, not when a publish needs it
91
+ // (§4 fail-fast).
92
+ //
93
+ // `config/configSchema.js` refuses the same value too since d.685b (`minimum: 1`),
94
+ // and that is not a second rail for one concern: the schema answers what may be
95
+ // WRITTEN in a client's configuration, this answers what may be USED as a number of
96
+ // attempts — and the second is reachable without the first. Measured 2026-09-20: the
97
+ // schema is compiled and applied in exactly one place, `BaseClient`'s constructor,
98
+ // while the only `new RabbitMQClient` in the workspace is
99
+ // `transports/transportFactory.js`, which validates nothing. The two also answer at
100
+ // different moments, because `BaseClient` builds the transport in `connect()` rather
101
+ // than in its constructor — so a client built past the front desk still arrives here
102
+ // with whatever it was given.
103
+ if (!Number.isInteger(this._maxRetries) || this._maxRetries < 1) {
104
+ throw new ValidationError(
105
+ `[PublishLayer] publishMaxRetries must be at least 1 - got ${JSON.stringify(this._maxRetries)}. `
106
+ + 'Expected: a whole number of publish attempts, because it counts ATTEMPTS, the first one '
107
+ + 'included — 0 means the message is never offered to the broker at all. '
108
+ + 'Fix: write publishMaxRetries: 1 for one attempt and no retry, or publishRetryEnabled: false '
109
+ + 'to leave the retry rail entirely.'
110
+ );
111
+ }
112
+
74
113
  // Buffer
75
114
  this._buffer = new MessageBuffer({
76
115
  inMemory: options.bufferConfig?.inMemory,
@@ -118,7 +157,48 @@ class PublishLayer {
118
157
  let lastError = null;
119
158
  let attempt = 0;
120
159
 
160
+ /**
161
+ * The broker's numbered refusal of THIS message, if it arrived after the confirmation
162
+ * budget had already answered an attempt.
163
+ *
164
+ * Measured (d.801): when the budget is shorter than the broker's answer, the attempt
165
+ * is answered "transient" while the frame underneath says 404 — and the loop below
166
+ * used to publish the message again, `publishMaxRetries` times, and then buffer it,
167
+ * where the flush re-offers it on every reconnect until its TTL drops it. d.801b made
168
+ * that verdict audible (`publish:refused-late`); this listener is what ACTS on it.
169
+ *
170
+ * No new evidence and no new timer: the frame is read where it has always been read
171
+ * (the transport's `channel._closeError` + `_closeFrameNamesThisSend()`), the event is
172
+ * the one already emitted, and the subscription lives exactly as long as this publish.
173
+ */
174
+ let lateRefusal = null;
175
+ const onRefusedLate = (event) => {
176
+ if (event.queue === queue && event.verdict === 'PermanentPublishError' && event.error) {
177
+ lateRefusal = event.error;
178
+ }
179
+ };
180
+ this._client.on('publish:refused-late', onRefusedLate);
181
+
182
+ try {
121
183
  while (attempt < this._maxRetries) {
184
+ // A message the broker has REFUSED is not published again. The question is asked
185
+ // before every attempt but the first, because the first is what earns the answer.
186
+ if (attempt > 0 && lateRefusal !== null) {
187
+ this._logger.warn(
188
+ `[PublishLayer] The broker refused this message while attempt ${attempt} was in flight, `
189
+ + `so attempt ${attempt + 1} of ${this._maxRetries} is not made for queue "${queue}" - `
190
+ + `${lateRefusal.message}`
191
+ );
192
+ this._client.emit('publish:failed', {
193
+ queue,
194
+ attempt,
195
+ error: lateRefusal.message,
196
+ retryable: false,
197
+ refusedLate: true
198
+ });
199
+ throw lateRefusal;
200
+ }
201
+
122
202
  attempt++;
123
203
 
124
204
  try {
@@ -133,7 +213,12 @@ class PublishLayer {
133
213
  this._logger.debug(`[PublishLayer] Retry attempt ${attempt}/${this._maxRetries} for queue "${queue}"`);
134
214
  }
135
215
 
136
- await this._client._publishOnce(queue, buffer, options);
216
+ // WHICH attempt this is travels with the send, and is read by exactly one thing:
217
+ // the transport's report of a broker verdict that arrived after the confirmation
218
+ // budget had already answered (`publish:refused-late`, d.801b). It reaches no
219
+ // broker property — `buildAmqpMessageProperties()` copies only the AMQP keys it
220
+ // declares — and it changes nothing about the publish itself.
221
+ await this._client._publishOnce(queue, buffer, { ...options, attempt });
137
222
 
138
223
  // Track success (always emit, even for first attempt)
139
224
  this._client.emit('publish:success', {
@@ -198,6 +283,24 @@ class PublishLayer {
198
283
  this._retryMaxDelay
199
284
  );
200
285
  this._logger.warn(`[PublishLayer] Retryable error for queue "${queue}" (attempt ${attempt}/${this._maxRetries}), waiting ${delay}ms before retry: ${err.message}`);
286
+ // THE BARE TIMER IS THE POINT, and it is not a copy of the transport's rail.
287
+ //
288
+ // The transport sleeps through `_setTimeout()`, which files the handle in
289
+ // `_activeTimers` so that a teardown can END the wait (d.759e). That is right
290
+ // THERE: what waits is the client's own recovery loop, and a teardown must
291
+ // stop it. It is wrong HERE, because what waits is the CALLER's `publish()`.
292
+ // Měřeno d.766 on this runtime: a sleep armed through `_setTimeout()` and
293
+ // swept by `_clearAllTimers()` mid-wait NEVER settles (the same sleep on the
294
+ // bare timer always does), so the one rail would turn a disconnect during a
295
+ // backoff into a `publish()` that never returns. The layer HAS the client
296
+ // (injected, required in the constructor), so this is a difference of
297
+ // obligation and not of reach.
298
+ //
299
+ // One owner at this level: the retry wait is the layer's, it is always
300
+ // awaited by the caller, and it always ends.
301
+ // @see ../transports/rabbitmqClient.js — `_setInterval` / `_clearAllTimers`
302
+ // @see ../../tests/unit/publish-max-retries-counts-attempts.test.js — the
303
+ // control case that fails if this is ever "unified"
201
304
  await new Promise(resolve => setTimeout(resolve, delay));
202
305
  continue;
203
306
  }
@@ -235,6 +338,11 @@ class PublishLayer {
235
338
  null
236
339
  )
237
340
  );
341
+ } finally {
342
+ // The subscription lives exactly as long as this publish: no listener survives the
343
+ // call that opened it, whichever way the loop ended.
344
+ this._client.removeListener('publish:refused-late', onRefusedLate);
345
+ }
238
346
  }
239
347
 
240
348
  /**
@@ -18,19 +18,66 @@
18
18
  * await publishToMonitoringWorkflow(mqClient, { event_type: 'completed', workflow_id: '...', ... }, logger);
19
19
  */
20
20
 
21
- const { PublishError, ConnectionError } = require('./utils/errorHandler');
21
+ const { ConnectionError } = require('./utils/errorHandler');
22
22
  const { assertLogger } = require('@onlineapps/logger-contract');
23
- const { QueueNotFoundError, classifyPublishError } = require('./utils/publishErrors');
23
+ const {
24
+ AMQP_NOT_FOUND,
25
+ QueueNotFoundError,
26
+ TransientPublishError
27
+ } = require('./utils/publishErrors');
24
28
 
25
29
  /**
26
- * Check if error indicates queue doesn't exist or RabbitMQ is unavailable
30
+ * The socket-level codes that mean the broker was not reachable at all. Node sets
31
+ * them, not this package, and they are codes rather than words.
32
+ */
33
+ const UNREACHABLE_SOCKET_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT']);
34
+
35
+ /**
36
+ * How deep a cause chain this function is willing to walk.
27
37
  *
28
- * The queue is named because the last question it asks —
29
- * `classifyPublishError()` — builds a `QueueNotFoundError`, and that error carries
30
- * a queue name it must be told (d.435); the caller has had it all along.
38
+ * The measured chain is three links — `PublishError` (BaseClient) → the publish
39
+ * classification → amqplib's raw error — so the bound is an upper limit on a real
40
+ * shape, not a guess about an unknown one. It exists because `error.cause` is set by
41
+ * whoever threw, and a chain that points back at itself would otherwise spin.
42
+ */
43
+ const MAX_CAUSE_DEPTH = 8;
44
+
45
+ /**
46
+ * Does this error — or anything it was caused by — mean the queue was not there to
47
+ * publish into, or the broker was not reachable?
48
+ *
49
+ * DECIDED BY CLASS AND CODE, NEVER BY THE MESSAGE. Until d.676 the decision read
50
+ * substrings: `'not connected'`, `'Connection closed'`, `'NOT_FOUND'`, `'404'`,
51
+ * `'no queue'`, `'queue does not exist'`, `'Channel closed'`. Every one of those
52
+ * sentences belongs to somebody else — amqplib writes the broker's refusal, this
53
+ * package writes its own — so the classification broke on any rewording and nothing
54
+ * reported the break (`docs/biz/70-contracts/error-handling.md` §6: "Message-based
55
+ * routing; breaks on any wording change — branch on `err.code`").
56
+ *
57
+ * WHY THE CHAIN AND NOT THE ERROR ITSELF: `BaseClient.publish()` wraps every
58
+ * transport failure in a `PublishError` and puts the real error in `cause`. So the
59
+ * object handed to this function is the wrapper, its `instanceof` tests never
60
+ * matched, and only the substrings ever fired — by accident, because the wrapper's
61
+ * message quotes the cause's sentence. Two shapes were measured answering `false`
62
+ * although the queue really was unavailable: a `QueueNotFoundError` with
63
+ * `kind: 'unowned'`, whose sentence carries none of the substrings, and a
64
+ * retry-exhausted publish, whose sentence quotes no cause at all.
65
+ *
66
+ * The carriers this walks for, and what each stands for:
67
+ * - `QueueNotFoundError` — the publish path's own ownership refusal (the queue is
68
+ * absent, whichever owner should have created it);
69
+ * - `ConnectionError` — the client is not connected, or the connection dropped
70
+ * (`CONNECTION_CLOSED_UNEXPECTEDLY`);
71
+ * - `TransientPublishError` — the channel or connection went away mid-publish;
72
+ * amqplib's `IllegalOperationError` ("Channel closed"), which carries no code of
73
+ * its own, reaches here as exactly this class;
74
+ * - `code === 404` — amqplib's raw `NOT_FOUND`;
75
+ * - `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT` — the broker was unreachable.
31
76
  *
32
77
  * @param {Error} error - The error a publish to `queueName` produced.
33
- * @param {string} queueName - The queue that publish was aimed at.
78
+ * @param {string} queueName - The queue that publish was aimed at. Required since
79
+ * d.435 and part of the exported contract; it is what the refusal below names.
80
+ * @returns {boolean}
34
81
  * @private
35
82
  */
36
83
  function isQueueUnavailableError(error, queueName) {
@@ -43,39 +90,21 @@ function isQueueUnavailableError(error, queueName) {
43
90
  );
44
91
  }
45
92
 
46
- if (!error) return false;
47
-
48
- const errorMessage = error.message || String(error);
49
- const errorCode = error.code || '';
50
- const errorName = error.name || '';
51
-
52
- // Connection errors
53
- if (error instanceof ConnectionError ||
54
- errorCode === 'ECONNREFUSED' ||
55
- errorCode === 'ENOTFOUND' ||
56
- errorCode === 'ETIMEDOUT' ||
57
- errorMessage.includes('not connected') ||
58
- errorMessage.includes('Connection closed')) {
59
- return true;
60
- }
61
-
62
- // Queue doesn't exist errors
63
- if (error instanceof QueueNotFoundError ||
64
- errorName === 'QueueNotFoundError' ||
65
- errorMessage.includes('NOT_FOUND') ||
66
- errorMessage.includes('404') ||
67
- errorMessage.includes('no queue') ||
68
- errorMessage.includes('queue does not exist') ||
69
- errorMessage.includes('Channel closed')) {
70
- return true;
71
- }
72
-
73
- // Classify publish errors
74
- const classified = classifyPublishError(error, queueName);
75
- if (classified instanceof QueueNotFoundError) {
76
- return true;
93
+ let link = error;
94
+
95
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && link instanceof Error; depth += 1) {
96
+ if (link instanceof QueueNotFoundError
97
+ || link instanceof ConnectionError
98
+ || link instanceof TransientPublishError
99
+ || link.code === AMQP_NOT_FOUND
100
+ || UNREACHABLE_SOCKET_CODES.has(link.code)) {
101
+ return true;
102
+ }
103
+
104
+ if (link.cause === link) break;
105
+ link = link.cause;
77
106
  }
78
-
107
+
79
108
  return false;
80
109
  }
81
110
 
@@ -115,6 +144,26 @@ async function publishToMonitoringResilient(mqClient, queueName, message, logger
115
144
  return false;
116
145
  }
117
146
 
147
+ // The client's own events go out on the client's own channel (d.724). Said here, in the
148
+ // one place that knows these messages are the client talking about itself; `publish()`
149
+ // would put them on the channel carrying the caller's business messages, where a
150
+ // refusal of an event closes the channel and takes those with it (measured d.711).
151
+ // Reported and skipped rather than thrown, like every other precondition in this
152
+ // function: it is fail-safe about publishing by contract (§ Principles above), and a
153
+ // transport without the method would otherwise turn a monitoring event into a caller's
154
+ // exception.
155
+ if (typeof mqClient.publishTelemetry !== 'function') {
156
+ logger.warn(
157
+ `Monitoring publish skipped: the client has no publishTelemetry() (queue: ${queueName}) - `
158
+ + 'Expected: a client whose telemetry has a channel of its own, so a refused event cannot '
159
+ + 'close the channel a business message is waiting on. '
160
+ + 'Fix: pass a @onlineapps/mq-client-core BaseClient, or a client that forwards '
161
+ + 'publishTelemetry() to one.',
162
+ context
163
+ );
164
+ return false;
165
+ }
166
+
118
167
  if (!message || !message.event_type) {
119
168
  logger.warn(`Monitoring publish skipped: message missing event_type (queue: ${queueName})`, {
120
169
  ...context,
@@ -140,7 +189,7 @@ async function publishToMonitoringResilient(mqClient, queueName, message, logger
140
189
  const publishOptions = queueName === 'monitoring.workflow'
141
190
  ? { exchange: WORKFLOW_FANOUT, routingKey: '' }
142
191
  : {};
143
- await mqClient.publish(queueName, message, publishOptions);
192
+ await mqClient.publishTelemetry(queueName, message, publishOptions);
144
193
 
145
194
  logger.debug(`Published to ${queueName}`, {
146
195
  ...context,