@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/CHANGELOG.md +766 -0
- package/README.md +226 -11
- package/package.json +1 -1
- package/src/BaseClient.js +105 -29
- package/src/config/configSchema.js +21 -4
- package/src/config/defaultConfig.js +14 -4
- package/src/config/deliveryPolicy.js +86 -6
- package/src/config/queueConfig.js +141 -60
- package/src/config/recurrencesSchema.js +121 -0
- package/src/config.js +5 -1
- package/src/defaults.js +7 -1
- package/src/index.js +67 -4
- package/src/layers/PublishLayer.js +113 -5
- package/src/monitoring-publish.js +89 -40
- package/src/scheduler-request.js +502 -0
- package/src/transports/rabbitmqClient.js +1303 -318
- package/src/utils/decodeMessage.js +123 -0
- package/src/utils/publishErrors.js +256 -17
- package/jest.config.js +0 -15
- package/jest.integration.config.js +0 -16
- package/src/workers/RecoveryWorker.js +0 -130
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` — `
|
|
55
|
-
// means "
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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
|
-
|
|
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 {
|
|
21
|
+
const { ConnectionError } = require('./utils/errorHandler');
|
|
22
22
|
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
23
|
-
const {
|
|
23
|
+
const {
|
|
24
|
+
AMQP_NOT_FOUND,
|
|
25
|
+
QueueNotFoundError,
|
|
26
|
+
TransientPublishError
|
|
27
|
+
} = require('./utils/publishErrors');
|
|
24
28
|
|
|
25
29
|
/**
|
|
26
|
-
*
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
192
|
+
await mqClient.publishTelemetry(queueName, message, publishOptions);
|
|
144
193
|
|
|
145
194
|
logger.debug(`Published to ${queueName}`, {
|
|
146
195
|
...context,
|