@onlineapps/mq-client-core 4.1.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.
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');
@@ -40,8 +37,19 @@ const {
40
37
  buildWorkflowInitiatedEvent,
41
38
  publishWorkflowInitiated,
42
39
  } = require('./monitoring-initiated');
40
+ const {
41
+ schedulerRequestQueue,
42
+ validateRecurrences,
43
+ buildRecurringRequest,
44
+ buildEventRequest,
45
+ buildCancelRequest,
46
+ publishSchedulerRequest,
47
+ createSchedulerPublisher,
48
+ SCHEDULER_REQUEST_INVALID,
49
+ } = require('./scheduler-request');
43
50
  const queueConfig = require('./config/queueConfig');
44
51
  const deliveryPolicy = require('./config/deliveryPolicy');
52
+ const { decodeMessage } = require('./utils/decodeMessage');
45
53
  const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('@onlineapps/logger-contract');
46
54
 
47
55
  // Export BaseClient as default (constructor), with additional named exports
@@ -94,6 +102,25 @@ module.exports.monitoring = {
94
102
  buildWorkflowInitiatedEvent,
95
103
  publishWorkflowInitiated,
96
104
  };
105
+ /**
106
+ * The recurrences a service declares, and how one of them is registered (d.833).
107
+ *
108
+ * `docs/architecture/scheduler.md` §7 gives this library the shape of one declared recurrence
109
+ * and the wrapper the job of re-asserting what the service answers at every boot (one hook,
110
+ * confirmation `scheduler` 011). The queue name travels with them, so no caller composes
111
+ * `scheduler.request` itself.
112
+ */
113
+ module.exports.scheduler = {
114
+ requestQueue: schedulerRequestQueue,
115
+ validateRecurrences,
116
+ buildRecurringRequest,
117
+ buildEventRequest,
118
+ buildCancelRequest,
119
+ publishSchedulerRequest,
120
+ createSchedulerPublisher,
121
+ // The code on every refusal of a builder, and on nothing a failed send throws.
122
+ SCHEDULER_REQUEST_INVALID,
123
+ };
97
124
  /** The only public way to reach the central queue classification; `src/config/queueConfig` is an internal path, not a contract. */
98
125
  module.exports.queueConfig = queueConfig;
99
126
  /**
@@ -149,7 +176,25 @@ module.exports.topology = Object.freeze({
149
176
  /** The same consumer's metric-sample intake on that exchange. */
150
177
  telemetryMetricsQueue: queueConfig.queueName('telemetry', 'metrics.queue'),
151
178
  /** The same consumer's span intake on that exchange. */
152
- 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')
153
198
  });
154
199
  /**
155
200
  * The dead-letter policy of `consume()` as a contract, not as an implementation
@@ -160,6 +205,24 @@ module.exports.topology = Object.freeze({
160
205
  * return one of those two words.
161
206
  */
162
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;
163
226
  /**
164
227
  * Redaction of the credential a connection URL carries, as a DECLARED export.
165
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,