@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.
- package/CHANGELOG.md +623 -0
- package/README.md +99 -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 +68 -39
- package/src/config.js +5 -1
- package/src/defaults.js +7 -1
- package/src/index.js +41 -4
- package/src/layers/PublishLayer.js +113 -5
- package/src/monitoring-publish.js +89 -40
- package/src/scheduler-request.js +32 -11
- package/src/transports/rabbitmqClient.js +1237 -316
- 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
|
@@ -129,8 +129,11 @@ const templateShape = (template) =>
|
|
|
129
129
|
* @returns {{serviceName: string, queueType: string}|null} The service and the
|
|
130
130
|
* template key that declares the queue's arguments, or `null` for a name no
|
|
131
131
|
* template matches.
|
|
132
|
-
* @throws {ValidationError} If two templates match one name - a defect in
|
|
133
|
-
* never a runtime condition, and one that would otherwise be resolved by key
|
|
132
|
+
* @throws {ValidationError} If two templates of the same kind match one name - a defect in
|
|
133
|
+
* this file, never a runtime condition, and one that would otherwise be resolved by key
|
|
134
|
+
* order. A literal shape and a placeholder shape matching the same name is NOT that case:
|
|
135
|
+
* the literal wins, because it declares the name while the placeholder only fits it
|
|
136
|
+
* (d.817b, `registry.events` against `registry.{instance_id}`).
|
|
134
137
|
*/
|
|
135
138
|
const matchBusinessTemplate = (business, queueName) => {
|
|
136
139
|
const parts = queueName.split('.');
|
|
@@ -145,15 +148,24 @@ const matchBusinessTemplate = (business, queueName) => {
|
|
|
145
148
|
);
|
|
146
149
|
});
|
|
147
150
|
|
|
148
|
-
|
|
151
|
+
// A LITERAL BEATS A PLACEHOLDER (d.817b). `registry.events` names a queue;
|
|
152
|
+
// `registry.{instance_id}` names a shape — so `<svc>.registry.events` fits both, and a
|
|
153
|
+
// wildcard must not claim a name another template DECLARES. It is the same tie-break this
|
|
154
|
+
// file makes one level up, where a name an infrastructure section declares is never a
|
|
155
|
+
// business queue however well it fits a template (d.279). Ambiguity between two templates
|
|
156
|
+
// of the SAME kind is still a defect and still refused below.
|
|
157
|
+
const literals = matched.filter((template) => !templateShape(template).includes(null));
|
|
158
|
+
const decided = literals.length > 0 ? literals : matched;
|
|
159
|
+
|
|
160
|
+
if (decided.length > 1) {
|
|
149
161
|
throw new ValidationError(
|
|
150
|
-
`[queueConfig] Business queue ${queueName} matches more than one template: ${
|
|
162
|
+
`[queueConfig] Business queue ${queueName} matches more than one template: ${decided.join(', ')} - `
|
|
151
163
|
+ 'Expected: exactly one template declares a name, so one queue has one definition. '
|
|
152
164
|
+ 'Fix: make the overlapping keys in the `business` section of src/config/queueConfig.js distinct.'
|
|
153
165
|
);
|
|
154
166
|
}
|
|
155
167
|
|
|
156
|
-
return
|
|
168
|
+
return decided.length === 1 ? { serviceName: parts[0], queueType: decided[0] } : null;
|
|
157
169
|
};
|
|
158
170
|
|
|
159
171
|
/**
|
|
@@ -277,33 +289,11 @@ module.exports = {
|
|
|
277
289
|
}
|
|
278
290
|
},
|
|
279
291
|
|
|
280
|
-
/**
|
|
281
|
-
* workflow.control - Shared queue for service-agnostic control-flow steps
|
|
282
|
-
* All business services listen as competing consumers (same pattern as workflow.init).
|
|
283
|
-
*
|
|
284
|
-
* Purpose:
|
|
285
|
-
* - Execute control-flow steps that are not pinned to a specific service
|
|
286
|
-
* - Keep workflow.init as a clear "gateway entrypoint" queue (no semantic overload)
|
|
287
|
-
*
|
|
288
|
-
* TTL: 30 seconds - if no business service processes message within 30s, it expires
|
|
289
|
-
* DLQ routing: Expired messages go to workflow.failed
|
|
290
|
-
*/
|
|
291
|
-
control: {
|
|
292
|
-
durable: true,
|
|
293
|
-
arguments: {
|
|
294
|
-
'x-message-ttl': 30000,
|
|
295
|
-
'x-max-length': 10000,
|
|
296
|
-
'x-dead-letter-exchange': '',
|
|
297
|
-
'x-dead-letter-routing-key': 'workflow.failed'
|
|
298
|
-
}
|
|
299
|
-
},
|
|
300
|
-
|
|
301
292
|
/**
|
|
302
293
|
* workflow.completed - Completed workflows
|
|
303
294
|
*
|
|
304
295
|
* Dead-letters to workflow.dlq over the DEFAULT exchange, where the routing key
|
|
305
|
-
* is the destination queue name — the shape workflow.init
|
|
306
|
-
* already use. The dispatcher rejects a message here on an invalid payload, a
|
|
296
|
+
* is the destination queue name — the shape workflow.init already uses. The dispatcher rejects a message here on an invalid payload, a
|
|
307
297
|
* schema violation and a delivery failure; until d.278 the broker dropped each
|
|
308
298
|
* one, because the queue declared nowhere to put it.
|
|
309
299
|
*
|
|
@@ -492,6 +482,41 @@ module.exports = {
|
|
|
492
482
|
'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
|
|
493
483
|
'x-dead-letter-routing-key': '{service}.dlq'
|
|
494
484
|
}
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* {service}.registry.{instance_id} - the queue ONE INSTANCE gets its registry answers
|
|
489
|
+
* on, beside the shared `{service}.registry` the registry uses for its own prompts.
|
|
490
|
+
*
|
|
491
|
+
* The owner settled the name and the split (`registry-instance-identity` 001,
|
|
492
|
+
* Conditions): answers travel to `<svc>.registry.<instanceId>` through the existing
|
|
493
|
+
* `responseQueue`, the shared queue carries the registry's prompts. Measured by the
|
|
494
|
+
* registry thread in d.817: without a template here `getDeadLetterRoute()` answered
|
|
495
|
+
* null, `consume()` refuses a queue with no declared route (`mq-consumer-contract`
|
|
496
|
+
* 002), and the client had to go on answering on the shared queue — the state the
|
|
497
|
+
* confirmation moved away from.
|
|
498
|
+
*
|
|
499
|
+
* ITS LIFETIME IS `x-expires` (question 14, variant A; `registry-instance-identity`
|
|
500
|
+
* 003). Every boot of a copy names a new queue (the
|
|
501
|
+
* instance id is a UUID per process unless `SERVICE_INSTANCE_ID` names one), and
|
|
502
|
+
* nothing deletes the old one — not `deregister()`, not the wrapper's shutdown, not the
|
|
503
|
+
* registry, which only checks the queue exists before it answers. A durable queue with
|
|
504
|
+
* no lifetime would therefore stay behind after every restart, crash or clean stop
|
|
505
|
+
* alike. The broker deletes a queue with `x-expires` once it has gone that long with no
|
|
506
|
+
* consumer, so a live copy (its consumer attached) keeps it and a gone copy loses it.
|
|
507
|
+
* 60 s: longer than a reconnect of a live copy, shorter than the monitoring orphan
|
|
508
|
+
* window (`queue_orphan_alert.min_orphan_minutes`, seed 5), so a dead copy's queue
|
|
509
|
+
* never opens a `no_owner` episode. One rail: the lifetime lives in this declaration
|
|
510
|
+
* only — the transport refuses a caller's `autoDelete`/`exclusive` on a classified
|
|
511
|
+
* queue, and no caller deletes it.
|
|
512
|
+
*/
|
|
513
|
+
'registry.{instance_id}': {
|
|
514
|
+
durable: true,
|
|
515
|
+
arguments: {
|
|
516
|
+
'x-expires': 60000, // 60 s with no consumer, then the broker deletes it (question 14, A)
|
|
517
|
+
'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
|
|
518
|
+
'x-dead-letter-routing-key': '{service}.dlq' // Placeholder, replaced with actual service name
|
|
519
|
+
}
|
|
495
520
|
}
|
|
496
521
|
},
|
|
497
522
|
|
|
@@ -766,11 +791,12 @@ module.exports = {
|
|
|
766
791
|
* `mq-consumer-contract` 002 point 2, 003 point 1).
|
|
767
792
|
*
|
|
768
793
|
* **A declaration is not a creation instruction. The terminal is brought into being
|
|
769
|
-
* by the owner of the prefix that NAMES it.** `initInfrastructureQueues()`
|
|
770
|
-
* the
|
|
771
|
-
*
|
|
772
|
-
* queues it owns (`infra/api_gateway/index.js`).
|
|
773
|
-
* to the monitoring service's own call, beside
|
|
794
|
+
* by the owner of the prefix that NAMES it.** `initInfrastructureQueues()` creates
|
|
795
|
+
* exactly the names its caller passes in `queues`, a list that is required since
|
|
796
|
+
* d.668 — it derives nothing from these sections, and the gateway's list names
|
|
797
|
+
* exactly the five `workflow.*` queues it owns (`infra/api_gateway/index.js`).
|
|
798
|
+
* `monitoring.dlq` therefore belongs to the monitoring service's own call, beside
|
|
799
|
+
* the queues it consumes. Until that
|
|
774
800
|
* call names it, a `nack(requeue=false)` on a queue of this family dead-letters onto
|
|
775
801
|
* a routing key no queue carries and the broker DROPS the message — measured
|
|
776
802
|
* 2026-09-16 on the dev broker: 78 queues, and the only `.dlq` among them
|
|
@@ -1096,7 +1122,8 @@ module.exports = {
|
|
|
1096
1122
|
* travels on that exchange, so the scheduler reads it there rather than asking the
|
|
1097
1123
|
* observer over HTTP or reading a database that is not its own. No producer changes; the
|
|
1098
1124
|
* binding is the scheduler's own act at boot (the pattern
|
|
1099
|
-
* `infra/api_delivery_endpoint/src/index.js
|
|
1125
|
+
* `infra/api_delivery_endpoint/src/index.js` already uses with its
|
|
1126
|
+
* `mq.bindQueueToFanout(…)` calls), and this file only
|
|
1100
1127
|
* DECLARES what that queue is.
|
|
1101
1128
|
*
|
|
1102
1129
|
* WHICH event_type values those are is NOT written here: the vocabulary has one owner and
|
|
@@ -1264,8 +1291,11 @@ module.exports = {
|
|
|
1264
1291
|
|
|
1265
1292
|
/**
|
|
1266
1293
|
* The infrastructure prefixes this configuration classifies by, in declaration
|
|
1267
|
-
* order. Published so a caller
|
|
1268
|
-
*
|
|
1294
|
+
* order. Published so a caller reads the list instead of keeping a copy of it.
|
|
1295
|
+
* It named `initInfrastructureQueues()` as such a caller until d.668, when that
|
|
1296
|
+
* function stopped deriving a queue list at all and started requiring `queues`;
|
|
1297
|
+
* what reads it today is this package's own coverage of the classification
|
|
1298
|
+
* (`tests/unit/queue-name-collision.test.js`, `queueConfig-telemetry.test.js`).
|
|
1269
1299
|
* @returns {ReadonlyArray<string>} Prefixes WITHOUT the trailing dot.
|
|
1270
1300
|
*/
|
|
1271
1301
|
infrastructurePrefixes() {
|
|
@@ -1507,8 +1537,7 @@ module.exports = {
|
|
|
1507
1537
|
*
|
|
1508
1538
|
* ONE owner of the mapping name → options. Every path that declares a queue
|
|
1509
1539
|
* reads it here and nowhere else: the public `assertQueue()` of the transport
|
|
1510
|
-
* (and through it
|
|
1511
|
-
* QueueManager and the publish path's 404 branch), and the consumer's
|
|
1540
|
+
* (and through it the cookbook-router's QueueManager), and the consumer's
|
|
1512
1541
|
* `_prepareQueueForConsume()`.
|
|
1513
1542
|
*
|
|
1514
1543
|
* Until d.410 those two worked the same three lines out separately —
|
|
@@ -1609,7 +1638,7 @@ module.exports = {
|
|
|
1609
1638
|
*
|
|
1610
1639
|
* The counterpart of `getDeadLetterRoute()`, and the reason a queue may have no
|
|
1611
1640
|
* route without that being a defect. `workflow.failed` is named in
|
|
1612
|
-
* `x-dead-letter-routing-key` by `workflow.init
|
|
1641
|
+
* `x-dead-letter-routing-key` by `workflow.init`;
|
|
1613
1642
|
* `{service}.dlq` by every business template; `workflow.dlq` and `delivery.dlq`
|
|
1614
1643
|
* by their own families. Giving such a queue a route of its own would either
|
|
1615
1644
|
* close a loop or start an endless chain, which is why the confirmed topology
|
package/src/config.js
CHANGED
|
@@ -20,7 +20,11 @@ const runtimeCfg = createRuntimeConfig({
|
|
|
20
20
|
|
|
21
21
|
// Client behavior
|
|
22
22
|
heartbeat: { env: 'RABBITMQ_HEARTBEAT', defaultKey: 'heartbeatSeconds', type: 'number' },
|
|
23
|
-
|
|
23
|
+
// The caller's own identity — required, never defaulted (d.770). Two legs, both the
|
|
24
|
+
// caller's: the explicit key, or ENV SERVICE_NAME. There is no third, because a
|
|
25
|
+
// library that invents a service name makes every row it writes name the wrong
|
|
26
|
+
// service (`architecture-principles.md` §3, and `./defaults.js` for the measurement).
|
|
27
|
+
serviceName: { env: 'SERVICE_NAME', required: true },
|
|
24
28
|
|
|
25
29
|
// Connection-level recovery budget. Resolved here (explicit → env →
|
|
26
30
|
// module default) instead of an inline `|| 10` in the transport, so the
|
package/src/defaults.js
CHANGED
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
* - This module owns defaults for MQ client behavior (heartbeat, connectionName prefix).
|
|
8
8
|
* - Infrastructure topology (RabbitMQ URL) is NOT defaulted; it must be provided via
|
|
9
9
|
* explicit config.host or ENV RABBITMQ_URL (fail-fast).
|
|
10
|
+
* - The CALLER's own identity is not defaulted either. `serviceName` used to be
|
|
11
|
+
* `'oa-service'` here until d.770: a name this module cannot know and must not invent
|
|
12
|
+
* (`architecture-principles.md` §3). What it bought was measured, and it was a lie —
|
|
13
|
+
* every client built without the key published its monitoring events, and named its
|
|
14
|
+
* queues, under a service that does not exist. It is now required, with the same two
|
|
15
|
+
* legs every caller already uses: explicit `serviceName`, or ENV `SERVICE_NAME`
|
|
16
|
+
* (`../config.js`).
|
|
10
17
|
*/
|
|
11
18
|
|
|
12
19
|
module.exports = {
|
|
13
20
|
heartbeatSeconds: 30,
|
|
14
|
-
serviceName: 'oa-service',
|
|
15
21
|
|
|
16
22
|
// How many times connection-level recovery retries before the connection is
|
|
17
23
|
// declared permanently lost. Client BEHAVIOUR, so it is owned here (the same
|
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` — `
|
|
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,
|