@onlineapps/mq-client-core 5.0.0 → 6.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 +474 -0
- package/README.md +117 -13
- package/package.json +2 -2
- package/src/BaseClient.js +101 -68
- package/src/buffer/InMemoryBuffer.js +161 -13
- package/src/buffer/MessageBuffer.js +8 -3
- package/src/config/configSchema.js +15 -19
- package/src/config/defaultConfig.js +10 -11
- package/src/config/queueConfig.js +35 -1
- package/src/index.js +22 -0
- package/src/layers/PublishLayer.js +241 -35
- package/src/monitoring-initiated.js +6 -9
- package/src/transports/rabbitmqClient.js +912 -151
- package/src/utils/errorHandler.js +25 -0
- package/src/utils/publishErrors.js +49 -0
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Uniform: [library/connector](../connector/conn-orch-validator/manifests/library.
|
|
|
6
6
|
|
|
7
7
|
Duty sections that apply:
|
|
8
8
|
|
|
9
|
-
- `all`: L-MAIN, L-ENGINES, L-TESTS, L-TEST-SCRIPT, L-PACK-TESTS, L-PINS, L-NO-FILE-RANGE, L-CHANGELOG, L-README, L-README-REGION, L-CONSUMER
|
|
9
|
+
- `all`: L-MAIN, L-ENGINES, L-TESTS, L-TEST-SCRIPT, L-PACK-TESTS, L-PINS, L-NO-FILE-RANGE, L-CHANGELOG, L-PACK-CHANGELOG, L-README, L-README-REGION, L-CONSUMER
|
|
10
10
|
- `connector`: L-CONNECTOR-ENV
|
|
11
11
|
<!-- END GENERATED: library-uniform -->
|
|
12
12
|
|
|
@@ -86,11 +86,18 @@ says" — and every connection on the platform ran on the broker's number rather
|
|
|
86
86
|
the configured one (measured on the dev broker: 39 live connections, `timeout: 60`
|
|
87
87
|
on all of them).
|
|
88
88
|
|
|
89
|
-
`brokerAnswerTimeout` bounds **
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
89
|
+
`brokerAnswerTimeout` bounds **every** wait on the broker's answer — the confirm of
|
|
90
|
+
one publish attempt, the `close-ok` of each handle `disconnect()` closes, and every
|
|
91
|
+
request a channel sends and waits on: `assertQueue`, `checkQueue` (also the check before a
|
|
92
|
+
queue publish), `bindQueue`, `assertExchange`, `purgeQueue`, `deleteQueue`, `consume`,
|
|
93
|
+
`cancelConsumer`, the prefetch, and every channel open (conf `mq-broker-answer-budget` 001).
|
|
94
|
+
A request left unanswered for that long discards the channel that carried it (the next
|
|
95
|
+
operation opens a fresh one) and ends in `ConnectionError` naming the operation, its target
|
|
96
|
+
and the budget; on the publish path it is `TransientPublishError`, so retry, the buffer and
|
|
97
|
+
`bufferOnFailure: false` apply as to any transient failure. This is what ends the wait on a
|
|
98
|
+
broker in a memory alarm, which keeps the connection and its heartbeats alive while it
|
|
99
|
+
answers nothing. It was called `publishConfirmationTimeout` until 2026-09-14 — the name of
|
|
100
|
+
one of its consumers — and writing that name now is refused, with the new one named.
|
|
94
101
|
|
|
95
102
|
`maxReconnectAttempts` and `maxReconnectCycles` resolve the usual way — explicit
|
|
96
103
|
config → env (`RABBITMQ_MAX_RECONNECT_ATTEMPTS`, `RABBITMQ_MAX_RECONNECT_CYCLES`)
|
|
@@ -100,6 +107,14 @@ config → env (`RABBITMQ_MAX_RECONNECT_ATTEMPTS`, `RABBITMQ_MAX_RECONNECT_CYCLE
|
|
|
100
107
|
attempts inside ONE recovery cycle, the second how many cycles there may be — see
|
|
101
108
|
§ Connection loss.
|
|
102
109
|
|
|
110
|
+
**`criticalHealthShutdown` and `criticalHealthShutdownDelay` are refused by name** (removed
|
|
111
|
+
2026-10-06, d.1275, on the owner's word). They made the transport, after a minute
|
|
112
|
+
of critical health, emit `health:shutdown` and log that it was "triggering shutdown" — but this
|
|
113
|
+
library never ends the process (see § Connection loss), and nothing on the platform listened
|
|
114
|
+
for the event, so the line promised an action that never came. The event went with the keys.
|
|
115
|
+
Critical health is reported — `health:critical` and `healthCriticalCallback` on every failing
|
|
116
|
+
check — and what to do about the process is the lifecycle owner's decision (`onFatal`).
|
|
117
|
+
|
|
103
118
|
### A subclass declares its own keys
|
|
104
119
|
|
|
105
120
|
`new BaseClient(config, extraProperties?)`. The second argument is the JSON Schema
|
|
@@ -134,7 +149,10 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
|
|
|
134
149
|
|
|
135
150
|
- `connect(options?)` - Connect to RabbitMQ
|
|
136
151
|
- `disconnect()` - Disconnect from RabbitMQ
|
|
137
|
-
- `publish(queue, message, options?)` - Publish message to queue
|
|
152
|
+
- `publish(queue, message, options?)` - Publish message to queue. `options.bufferOnFailure`
|
|
153
|
+
(boolean, default `true`): `false` leaves the publish buffer for this one call — a
|
|
154
|
+
transiently failed message is NOT held and no reconnect replays it; the thrown
|
|
155
|
+
`PublishError` then carries `buffered: false` (see § The publish buffer).
|
|
138
156
|
- `publishTelemetry(queue, message, options?)` - Publish the client's OWN event (the
|
|
139
157
|
monitoring helpers below use it) on a confirm channel of its own, opened with the
|
|
140
158
|
first such publish. A refusal there closes that channel only, never the one a
|
|
@@ -162,7 +180,17 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
|
|
|
162
180
|
healthy** — `healthy: false`, with an issue naming how many it tracks — because
|
|
163
181
|
such a client takes no message off any queue. A client that registered no
|
|
164
182
|
consumer at all is a publisher and stays healthy; the check's own log line says
|
|
165
|
-
which of the two it is.
|
|
183
|
+
which of the two it is. `consumers.active` / `consumers.inactive` are this
|
|
184
|
+
client's own facts — a consumer is attached while the consumer channel is alive
|
|
185
|
+
and its entry holds the broker's tag (the definition `getConsumerState()` reads)
|
|
186
|
+
— so a dead QUEUE channel or an unanswered queue probe is reported as what it is,
|
|
187
|
+
a channel or probe issue, never as "no consumer" (d.1275). The probe adds what
|
|
188
|
+
only the broker knows: `queues.consumers` maps each tracked queue to the broker's
|
|
189
|
+
`consumerCount` (every consumer on the queue, not only this client's), or to
|
|
190
|
+
`'unknown'` where the probe did not run or got no answer — never `0` for "not
|
|
191
|
+
asked". A failing check emits `health:critical` and calls
|
|
192
|
+
`healthCriticalCallback`; it decides nothing about the process (there is no
|
|
193
|
+
`health:shutdown`, see § Configuration).
|
|
166
194
|
- `assertQueue(queue, options?)` / `checkQueue(queue)` / `purgeQueue(queue)` /
|
|
167
195
|
`deleteQueue(queue, options?)` - Queue operations, on the queue channel.
|
|
168
196
|
`assertQueue()` reads exactly four options — `durable`, `arguments`,
|
|
@@ -448,8 +476,21 @@ Permanently lost means all of this, together:
|
|
|
448
476
|
built on them;
|
|
449
477
|
- no further reconnect attempts are made, by a use or otherwise — the client is
|
|
450
478
|
dead until the process restarts, or until an explicit `connect()`;
|
|
451
|
-
- `publish()`/`consume()`
|
|
452
|
-
|
|
479
|
+
- `publish()`/`consume()` — and every other operation on the broker:
|
|
480
|
+
`assertQueue`, `checkQueue`, `purgeQueue`, `deleteQueue`, `assertExchange`,
|
|
481
|
+
`bindQueue`, `cancelConsumer`, `ack`, `nack` — refuse immediately instead of
|
|
482
|
+
blocking on a recovery that is not coming, with the verdict on the error
|
|
483
|
+
(`code: 'MQ_CONNECTION_FATAL'`, `reason`, the verdict as `cause`); through
|
|
484
|
+
`BaseClient` too, which after the verdict lets the lost client answer for
|
|
485
|
+
itself instead of advising `connect()`. The state readers (`isConnected()`,
|
|
486
|
+
`isConnectionFatal()`, `getConsumerState()`, `getChannelState()`,
|
|
487
|
+
`performHealthCheck()`, the metrics) do not throw: they describe the state. A `consume()`
|
|
488
|
+
that was waiting when the verdict fell, or that is asked of the lost client
|
|
489
|
+
afterwards, is refused with that verdict readable on the error (`code`,
|
|
490
|
+
`reason`) and the verdict itself in `cause`. A `publish()` in the same position
|
|
491
|
+
is refused with a `PublishError` that carries the same `code` and `reason` and
|
|
492
|
+
`buffered: false`: nothing is held, because no `reconnected` will ever come to
|
|
493
|
+
replay it (§ *The publish buffer*) — the caller holds the only copy.
|
|
453
494
|
|
|
454
495
|
**The library never ends the process, and never listens for a signal.** Ending
|
|
455
496
|
it is the lifecycle owner's decision: a service typically logs and exits
|
|
@@ -570,8 +611,65 @@ buffered, because no retry and no reconnect declares the exchange (d.693).
|
|
|
570
611
|
A publish that fails transiently (the connection is closing, a channel is being
|
|
571
612
|
recreated, the broker did not confirm in time) is held in memory — bounded by
|
|
572
613
|
`publishBufferMaxSize` messages and `publishBufferTtlMs` each — and replayed after
|
|
573
|
-
the reconnect, critical priority first. `getBufferState()` reports it and
|
|
574
|
-
`publish:buffered` / `buffer:flush:failed` announce it.
|
|
614
|
+
the reconnect or after the broker unblocks the connection (below), critical priority first. `getBufferState()` reports it and
|
|
615
|
+
`publish:buffered` / `buffer:flush:failed` announce it. That includes a publish whose
|
|
616
|
+
confirm was still pending when the channel or the connection died under it (the broker
|
|
617
|
+
closed it, restarted or stopped): the broker said nothing about that message, so it is
|
|
618
|
+
transient, never a refusal — only a `nack` or a numbered close frame naming this send
|
|
619
|
+
is permanent (d.1134).
|
|
620
|
+
|
|
621
|
+
**One call can leave the buffer: `publish(queue, message, { bufferOnFailure: false })`.**
|
|
622
|
+
It is for a caller that keeps the message durably elsewhere and must not have a second
|
|
623
|
+
copy replayed behind its back — the orchestrator writing a `publish_pending` record
|
|
624
|
+
(`docs/governance/confirmations/step-publish-failure.md` 001). On each of the four
|
|
625
|
+
transient paths (retries exhausted, `publishRetryEnabled: false`, the reconnect a retry
|
|
626
|
+
waited for failed, and a send channel the attempt could not get because the connection stayed
|
|
627
|
+
down — the recovery it waited for ended in standby or fatal, or none may start: the client is
|
|
628
|
+
permanently lost, closed by `disconnect()`, or has `reconnectEnabled: false`; B1, the owner's
|
|
629
|
+
decision of 2026-10-06) the error `publish()` throws carries `buffered` (boolean): `true` only
|
|
630
|
+
when the buffer holds the message for the next reconnect or `unblocked`, `false` when
|
|
631
|
+
nothing will replay it. A permanent refusal never reaches the buffer and carries no `buffered`. A
|
|
632
|
+
value that is not a boolean is refused before any attempt (`ValidationError`). Retry
|
|
633
|
+
stays the library's either way — the option decides only what happens after it.
|
|
634
|
+
|
|
635
|
+
The fourth path is its own class on the transport, `RecoveryEndedPublishError` (a
|
|
636
|
+
`TransientPublishError`, exported under `publishErrors`): `PublishLayer` holds the message at
|
|
637
|
+
once instead of retrying, because the next use of the client is what starts the next recovery
|
|
638
|
+
cycle. It holds it **only where a `reconnected` can still come** — the class says so in
|
|
639
|
+
`replayable`: after a spent cycle (standby) the message is held; once the client is permanently
|
|
640
|
+
lost, closed by `disconnect()`, or has `reconnectEnabled: false`, nothing will ever replay it, so
|
|
641
|
+
nothing is held and the error says `buffered: false` with the reason. When the recovery ended
|
|
642
|
+
fatally the `PublishError` carries the verdict on top — `code: 'MQ_CONNECTION_FATAL'`, `reason` —
|
|
643
|
+
and the verdict itself at the end of `cause`; a spent cycle carries no code.
|
|
644
|
+
|
|
645
|
+
**Every failed publish says whether its frame left the client: `error.sent`** (boolean, on
|
|
646
|
+
the `PublishError` from `publish()` and on its cause; conf
|
|
647
|
+
`docs/governance/confirmations/mq-publish-buffer.md` 001). `true` when any attempt wrote the
|
|
648
|
+
frame to the channel — the broker may hold it, and after a memory alarm deliver it, although
|
|
649
|
+
the call failed; `false` when no attempt got that far. It says nothing about whether the
|
|
650
|
+
broker stored the message. The buffer entry records the same fact, and it decides what
|
|
651
|
+
happens when the outage ends:
|
|
652
|
+
|
|
653
|
+
| The outage ends with | A held message whose frame was never sent | … whose frame was sent |
|
|
654
|
+
|---|---|---|
|
|
655
|
+
| a reconnect (`reconnected`) | re-sent | re-sent — the old socket and what the broker had not read are gone |
|
|
656
|
+
| the broker unblocking the connection (`connection.unblocked`, end of a memory or disk alarm) | re-sent | dropped with a `warn` — the broker delivers the frames it held by itself |
|
|
657
|
+
|
|
658
|
+
`connection.blocked` is logged as a `warn` with the broker's reason. **Nothing else flushes
|
|
659
|
+
the buffer**: an outage of the publisher CHANNEL alone, on a connection that stays up and is
|
|
660
|
+
not blocked, ends with neither event, so a message held during it is not replayed when the
|
|
661
|
+
next publish recreates the channel. Whether a channel recovery should flush too is an open
|
|
662
|
+
decision (a sent entry re-sent then may be a second copy), and the error `publish()` throws
|
|
663
|
+
says the gap in as many words: buffered for up to `publishBufferTtlMs`, replayed only after a
|
|
664
|
+
reconnect or `unblocked`, reported as LOST at expiry.
|
|
665
|
+
|
|
666
|
+
A held message that outlives `publishBufferTtlMs` is dropped **at its TTL, on the buffer's own
|
|
667
|
+
timer** — not whenever the buffer is next touched (until d.1275 the expiry ran only inside the
|
|
668
|
+
next `add()`/`flush()`/`getBufferState()`, and was measured on the dev scheduler up to 88
|
|
669
|
+
minutes late; d.1275). The timer runs on the client's timer rail, so `disconnect()` ends it. The
|
|
670
|
+
drop is logged as an `error` naming its queue or exchange, the count, the age and `sent` —
|
|
671
|
+
`sent=false` reads `LOST after <ms>` and is a message lost for good; `sent=true` reads `expired
|
|
672
|
+
after <ms>`, because the broker may have delivered it on its own.
|
|
575
673
|
|
|
576
674
|
**There is no persistent buffer, and `persistentBufferEnabled` /
|
|
577
675
|
`persistentRedisClient` are refused by name.** They declared one until
|
|
@@ -616,11 +714,15 @@ error had never once run (fixed 2026-09-08, `errors.ConnectionError` now carries
|
|
|
616
714
|
| `CONSUMER_QUEUE_MISSING` | the queue does not exist | start the service that owns the queue — a consumer never creates one |
|
|
617
715
|
| `CONSUMER_DEAD_LETTER_ROUTE_MISSING` | the queue exists, `queueConfig` declares no dead-letter route for it | declare `x-dead-letter-exchange`/`-routing-key` for that queue and bind the destination; the queue itself is fine |
|
|
618
716
|
| `MQ_CONNECTION_FATAL` (with `reason: 'broker-refused'`) | the BROKER refused this queue — 406 PRECONDITION-FAILED for a queue that exists with other arguments, 403/530 for one this account may not use | correct the topology or the permission the broker named in `error.cause`, then restart; retrying gets the same answer |
|
|
717
|
+
| `MQ_CONNECTION_FATAL` (with `reason: 'cycles-spent' \| 'broker-refused'`, and `isConnectionFatal()` now `true`) | the CONNECTION is permanently lost — the recovery this `consume()` waited for ended the client, or the client was already lost; `error.cause` is the very verdict `onFatal` received (§ *permanently lost*) | the lifecycle owner stops the process; the restart policy boots a new one against a verified broker |
|
|
619
718
|
|
|
620
719
|
The third one is the SAME verdict a refused connection carries (§ *permanently lost*),
|
|
621
720
|
because it asks the lifecycle owner for the same thing: stop retrying. It refuses the
|
|
622
721
|
QUEUE, not the connection — the client stays connected and `isConnectionFatal()` stays
|
|
623
|
-
`false`.
|
|
722
|
+
`false`. The fourth is that connection verdict itself, carried onto the `consume()` that met
|
|
723
|
+
it (one rail, `_carryVerdict()`): the code is on the error, not only in its `cause`, so
|
|
724
|
+
`BaseClient` lets it through unchanged as it does every refusal with a verdict. A recovery
|
|
725
|
+
cycle that merely ran out (standby) is no verdict and carries no code. `@onlineapps/service-wrapper` reads `code` to end FÁZE 0.7 permanently; before
|
|
624
726
|
2026-09-16 a 406 on a queue carried no code at all, so the service kept booting against a
|
|
625
727
|
queue it will never be allowed to declare.
|
|
626
728
|
|
|
@@ -716,6 +818,7 @@ topology.telemetryLogsQueue = 'mine'; // TypeError — the object is froz
|
|
|
716
818
|
|
|
717
819
|
| Key | Name on the wire | Who publishes / who drains it |
|
|
718
820
|
|---|---|---|
|
|
821
|
+
| `infrastructureHealthChecksQueue` | `infrastructure.health.checks` | Every infrastructure service publishes its health checks there (`@onlineapps/infrastructure-tools` `healthPublisher.js`); the registry drains it (`api/infra/api_services_registry/src/listeners/infrastructureHealth.listener.js`). Composed here since d.1307; no reader cites it yet — `@onlineapps/service-common` still carries the literal (`src/defaults.js`), and which side switches is the owner's decision (d.1306). |
|
|
719
822
|
| `infrastructureHealthEventsExchange` | `infrastructure.health.events` | Fanout. The registry publishes (`api/infra/api_services_registry/src/services/infrastructureEventPublisher.js`); the two queues below are bound to it. |
|
|
720
823
|
| `monitoringInfrastructureHealthEventsQueue` | `monitoring.infrastructure.health.events` | The monitoring consumer's queue on that fanout (`api/infra/api_monitoring/src/consumer/topology.js`, `MONITORING_INFRA_EVENT_QUEUE`). |
|
|
721
824
|
| `deliveryHealthEventsQueue` | `delivery.health.events` | The delivery endpoint binds it to the fanout and consumes it for WS push (`api/infra/api_delivery_endpoint/src/index.js`, `bindQueueToFanout(config.healthEventsQueue, …)`). |
|
|
@@ -724,6 +827,7 @@ topology.telemetryLogsQueue = 'mine'; // TypeError — the object is froz
|
|
|
724
827
|
| `telemetryMetricsQueue` | `telemetry.metrics.queue` | The same exchange, the same consumer — metric samples. |
|
|
725
828
|
| `telemetryTracesQueue` | `telemetry.traces.queue` | The same exchange, the same consumer — spans. |
|
|
726
829
|
| `telemetryExchange` | `telemetry.exchange` | Topic exchange the three queues above are bound to; every telemetry producer publishes to it, and the monitoring consumer asserts it (d.708b). |
|
|
830
|
+
| `workflowObserverCopyQueues` | `['monitoring.workflow', 'delivery.workflow.events', 'scheduler.workflow.events']` (frozen array) | The observers' copies of a run: the queues bound to `monitoring.workflow.fanout` for a reader that watches the run and is not part of it (the monitoring consumer, the delivery endpoint's WS feed, the scheduler's end-of-run watch). A dead letter from one of them is a lost copy of the run's telemetry, not a lost run (INFRA 559). Declared once in `queueConfig` (`WORKFLOW_OBSERVER_COPIES`) — membership cannot be read off an entry, because the binding is each consumer's own act at boot — and composed by `queueName()` (d.1336). |
|
|
727
831
|
|
|
728
832
|
- **Frozen on purpose.** An importer must not be able to rewrite platform topology: a
|
|
729
833
|
name overridden on one side of a fanout loses the events silently, which is why batch
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/mq-client-core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"description": "Core MQ client library for RabbitMQ - shared by infrastructure services and connectors",
|
|
5
5
|
"oa": {
|
|
6
6
|
"category": "connector"
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"author": "OnlineApps",
|
|
24
24
|
"license": "MIT",
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@onlineapps/logger-contract": "2.
|
|
26
|
+
"@onlineapps/logger-contract": "2.1.0",
|
|
27
27
|
"@onlineapps/runtime-config": "1.2.0",
|
|
28
28
|
"ajv": "^8.12.0",
|
|
29
29
|
"amqplib": "0.10.9"
|
package/src/BaseClient.js
CHANGED
|
@@ -28,6 +28,7 @@ const {
|
|
|
28
28
|
SerializationError,
|
|
29
29
|
CONSUMER_QUEUE_MISSING,
|
|
30
30
|
CONSUMER_DEAD_LETTER_ROUTE_MISSING,
|
|
31
|
+
carryVerdict,
|
|
31
32
|
} = require('./utils/errorHandler');
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -110,6 +111,32 @@ const NOT_A_KEY_OF_THIS_CLIENT = Object.freeze({
|
|
|
110
111
|
'The persistent buffer it fed is gone (see persistentBufferEnabled): it never wrote to '
|
|
111
112
|
+ 'Redis at all, so the client injected here was held and never used.',
|
|
112
113
|
}),
|
|
114
|
+
/**
|
|
115
|
+
* Removed in d.1275 with the `health:shutdown` event they armed, on the owner's word
|
|
116
|
+
* (2026-10-06): "volbu i událost ZRUŠIT, žádný slib". After `criticalHealthShutdownDelay`
|
|
117
|
+
* of critical health the transport emitted `health:shutdown` and logged that it was
|
|
118
|
+
* "triggering shutdown" — but the library never ends the process (`_failFatally()` says
|
|
119
|
+
* why), and nothing in the repository listened: `@onlineapps/service-wrapper` dropped its
|
|
120
|
+
* listener on 2026-09-07, no infra service ever had one (measured 2026-10-06). The four
|
|
121
|
+
* questions of `change-discipline.md` § Removing are answered in CHANGELOG.md, in the release that removed them.
|
|
122
|
+
* Refused by name, not ignored: a caller writing `false` to silence the promise needs to
|
|
123
|
+
* hear that there is no promise left to silence.
|
|
124
|
+
*/
|
|
125
|
+
criticalHealthShutdown: Object.freeze({
|
|
126
|
+
key: null,
|
|
127
|
+
what: 'a shutdown on sustained critical health',
|
|
128
|
+
consequence:
|
|
129
|
+
'The client reports critical health (health:critical, healthCriticalCallback) and decides '
|
|
130
|
+
+ 'nothing about the process; the health:shutdown event this key armed reached no listener, '
|
|
131
|
+
+ 'so it was removed. Ending the process is the lifecycle owner\'s decision (onFatal).',
|
|
132
|
+
}),
|
|
133
|
+
criticalHealthShutdownDelay: Object.freeze({
|
|
134
|
+
key: null,
|
|
135
|
+
what: 'the delay before a shutdown on sustained critical health',
|
|
136
|
+
consequence:
|
|
137
|
+
'It timed the health:shutdown event, which is gone (see criticalHealthShutdown): the client '
|
|
138
|
+
+ 'reports critical health on every failing check and decides nothing about the process.',
|
|
139
|
+
}),
|
|
113
140
|
});
|
|
114
141
|
|
|
115
142
|
/**
|
|
@@ -370,6 +397,9 @@ class BaseClient {
|
|
|
370
397
|
*/
|
|
371
398
|
async connect(options = {}) {
|
|
372
399
|
if (this._connected) return;
|
|
400
|
+
// An explicit `connect()` builds a NEW transport below, so the verdict of the old one
|
|
401
|
+
// ends here — like `_closedByCaller` on the transport, it is undone by nothing else (d.1301).
|
|
402
|
+
this._fatalError = null;
|
|
373
403
|
|
|
374
404
|
// `host` is the broker URL and carries the account password. It is redacted
|
|
375
405
|
// on the way to the log — the only rail for that is utils/redactCredentials.
|
|
@@ -485,6 +515,30 @@ class BaseClient {
|
|
|
485
515
|
}
|
|
486
516
|
}
|
|
487
517
|
|
|
518
|
+
/**
|
|
519
|
+
* The one guard every operation on the broker runs first (d.1314; the predicate is
|
|
520
|
+
* d.1301's). It refuses only the two states where "connect()" is true advice — the client
|
|
521
|
+
* never connected, or the caller closed it with `disconnect()`. After `connection:fatal`
|
|
522
|
+
* it lets the call through to the transport, which knows that state and answers it with
|
|
523
|
+
* the verdict on top (`code: 'MQ_CONNECTION_FATAL'`, `reason`, the verdict as `cause`), so
|
|
524
|
+
* no second sentence about a lost connection is written here. Until d.1314 each of the
|
|
525
|
+
* eleven operations carried its own copy of this guard, nine of them still advising
|
|
526
|
+
* `connect()` after the verdict.
|
|
527
|
+
*
|
|
528
|
+
* @param {string} op - the public method asking, used in the sentence
|
|
529
|
+
* @param {string} [tail='.'] - what ends the Fix sentence
|
|
530
|
+
* @throws {ConnectionError}
|
|
531
|
+
* @private
|
|
532
|
+
*/
|
|
533
|
+
_requireUsable(op, tail = '.') {
|
|
534
|
+
if (!this._transport || (!this._connected && !this._fatalError)) {
|
|
535
|
+
throw new ConnectionError(
|
|
536
|
+
`[BaseClient] Cannot ${op}: client is not connected - Expected: connect() to have completed. `
|
|
537
|
+
+ `Fix: await client.connect() before ${op}()${tail}`
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
488
542
|
/**
|
|
489
543
|
* Publishes a message to the specified queue.
|
|
490
544
|
* @param {string} queue - Target queue name.
|
|
@@ -494,17 +548,23 @@ class BaseClient {
|
|
|
494
548
|
* infrastructure or business queue is created by its owner before anything
|
|
495
549
|
* publishes to it, and an ownerless name is declared with the arguments the
|
|
496
550
|
* central `queueConfig` resolves (`transports/rabbitmqClient.js`, the 404 branch).
|
|
551
|
+
* @param {boolean} [options.bufferOnFailure=true] - `false` = a transiently failed message
|
|
552
|
+
* is NOT held in the library's buffer, so no reconnect replays it; for a caller that keeps
|
|
553
|
+
* the message durably elsewhere. Owned by `layers/PublishLayer.js`; a non-boolean is refused.
|
|
497
554
|
* @returns {Promise<void>}
|
|
498
555
|
* @throws {ConnectionError} If not connected.
|
|
499
|
-
* @throws {PublishError} If publish fails.
|
|
556
|
+
* @throws {PublishError} If publish fails. After a transient failure (retries exhausted,
|
|
557
|
+
* retry disabled, the awaited reconnect failed) it carries `buffered` (boolean): `true`
|
|
558
|
+
* only when the buffer holds the message for the next reconnect.
|
|
500
559
|
*/
|
|
501
560
|
async publish(queue, message, options = {}) {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
561
|
+
// A client whose connection was declared LOST is not "not connected": `connect()` is
|
|
562
|
+
// exactly what its lifecycle owner must not be told to do (d.1301). The transport still
|
|
563
|
+
// exists and answers that state itself — a `RecoveryEndedPublishError` with the verdict
|
|
564
|
+
// on top and `buffered: false` (`_ensurePublisherChannel()`, d.1300/d.1301) — so the call
|
|
565
|
+
// is let through to it; this guard keeps only the two states where the advice is true:
|
|
566
|
+
// never connected, and closed by `disconnect()`.
|
|
567
|
+
this._requireUsable('publish');
|
|
508
568
|
|
|
509
569
|
let buffer;
|
|
510
570
|
try {
|
|
@@ -539,7 +599,7 @@ class BaseClient {
|
|
|
539
599
|
// answer as d.297 one call over (`_consumerRefusal()`): name no likeliest
|
|
540
600
|
// cause, carry the one actually caught. `cause` is unchanged, so a caller
|
|
541
601
|
// deciding on the class still reads the object.
|
|
542
|
-
|
|
602
|
+
const failure = new PublishError(
|
|
543
603
|
`[BaseClient] Failed to publish to queue "${queue}": ${describeThrown(err)} - `
|
|
544
604
|
+ 'Expected: publish() to hand the message to the broker. '
|
|
545
605
|
+ 'Fix: act on the reason above — this layer does not know which failure it was, and naming the '
|
|
@@ -548,6 +608,25 @@ class BaseClient {
|
|
|
548
608
|
queue,
|
|
549
609
|
err
|
|
550
610
|
);
|
|
611
|
+
// Whether the library's buffer holds the message is decided and written by
|
|
612
|
+
// `PublishLayer` (options.bufferOnFailure, d.1122); it is carried up here unchanged so
|
|
613
|
+
// the caller reads it on the error it catches. Absent when the failure never reached
|
|
614
|
+
// the buffer decision — "not asked" is not "false".
|
|
615
|
+
if (err && typeof err.buffered === 'boolean') {
|
|
616
|
+
failure.buffered = err.buffered;
|
|
617
|
+
}
|
|
618
|
+
// Whether any attempt wrote the frame (d.1145, conf `mq-publish-buffer` 001): carried
|
|
619
|
+
// like `buffered`, so the caller — the orchestrator's `publish_pending` record
|
|
620
|
+
// (`step-publish-failure` 003) — reads it on the error it catches.
|
|
621
|
+
if (err && typeof err.sent === 'boolean') {
|
|
622
|
+
failure.sent = err.sent;
|
|
623
|
+
}
|
|
624
|
+
// The verdict of a lost connection, carried like `buffered` and `sent` (d.1300, the
|
|
625
|
+
// publish half of d.1297): a publish whose recovery ended fatally reaches the caller
|
|
626
|
+
// with `code: 'MQ_CONNECTION_FATAL'` and `reason` on the error it catches — where the
|
|
627
|
+
// lifecycle owner reads them — not two `cause` down.
|
|
628
|
+
carryVerdict(failure, err);
|
|
629
|
+
throw failure;
|
|
551
630
|
}
|
|
552
631
|
}
|
|
553
632
|
|
|
@@ -597,12 +676,11 @@ class BaseClient {
|
|
|
597
676
|
* @throws {ConsumeError} If consumer setup fails.
|
|
598
677
|
*/
|
|
599
678
|
async consume(queue, messageHandler, options = {}) {
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
}
|
|
679
|
+
// Same rule as `publish()` (d.1301): after `connection:fatal` the transport answers with
|
|
680
|
+
// the verdict on top (`_consumerChannelWithoutRecovery()`, d.1281/d.1297) and
|
|
681
|
+
// `_consumerRefusal()` lets it through unchanged; the "not connected" advice is kept
|
|
682
|
+
// for the two states where it is true.
|
|
683
|
+
this._requireUsable('consume');
|
|
606
684
|
|
|
607
685
|
// Apply prefetch and noAck overrides if provided
|
|
608
686
|
const { prefetch, noAck } = options;
|
|
@@ -880,12 +958,7 @@ class BaseClient {
|
|
|
880
958
|
* @returns {Promise<Object>}
|
|
881
959
|
*/
|
|
882
960
|
async assertQueue(queue, options = {}) {
|
|
883
|
-
|
|
884
|
-
throw new ConnectionError(
|
|
885
|
-
'[BaseClient] Cannot assertQueue: client is not connected - Expected: connect() to have completed. '
|
|
886
|
-
+ 'Fix: await client.connect() before assertQueue().'
|
|
887
|
-
);
|
|
888
|
-
}
|
|
961
|
+
this._requireUsable('assertQueue');
|
|
889
962
|
if (typeof this._transport.assertQueue !== 'function') {
|
|
890
963
|
throw new Error(
|
|
891
964
|
'[BaseClient] Transport does not support assertQueue(queue, options) - Expected: a transport implementing assertQueue. '
|
|
@@ -913,12 +986,7 @@ class BaseClient {
|
|
|
913
986
|
* @throws {ValidationError} If the name or type cannot declare an exchange.
|
|
914
987
|
*/
|
|
915
988
|
async assertExchange(exchange, type, options = {}) {
|
|
916
|
-
|
|
917
|
-
throw new ConnectionError(
|
|
918
|
-
'[BaseClient] Cannot assertExchange: client is not connected - Expected: connect() to have completed. '
|
|
919
|
-
+ 'Fix: await client.connect() before assertExchange().'
|
|
920
|
-
);
|
|
921
|
-
}
|
|
989
|
+
this._requireUsable('assertExchange');
|
|
922
990
|
return await this._transport.assertExchange(exchange, type, options);
|
|
923
991
|
}
|
|
924
992
|
|
|
@@ -934,12 +1002,7 @@ class BaseClient {
|
|
|
934
1002
|
* @throws {ValidationError} If a name or the pattern cannot form a binding.
|
|
935
1003
|
*/
|
|
936
1004
|
async bindQueue(queue, exchange, pattern) {
|
|
937
|
-
|
|
938
|
-
throw new ConnectionError(
|
|
939
|
-
'[BaseClient] Cannot bindQueue: client is not connected - Expected: connect() to have completed. '
|
|
940
|
-
+ 'Fix: await client.connect() before bindQueue().'
|
|
941
|
-
);
|
|
942
|
-
}
|
|
1005
|
+
this._requireUsable('bindQueue');
|
|
943
1006
|
return await this._transport.bindQueue(queue, exchange, pattern);
|
|
944
1007
|
}
|
|
945
1008
|
|
|
@@ -956,12 +1019,7 @@ class BaseClient {
|
|
|
956
1019
|
* @throws {ValidationError} If this client holds no such consumer.
|
|
957
1020
|
*/
|
|
958
1021
|
async cancelConsumer(consumerTagOrQueue) {
|
|
959
|
-
|
|
960
|
-
throw new ConnectionError(
|
|
961
|
-
'[BaseClient] Cannot cancelConsumer: client is not connected - Expected: connect() to have completed. '
|
|
962
|
-
+ 'Fix: await client.connect() before cancelConsumer().'
|
|
963
|
-
);
|
|
964
|
-
}
|
|
1022
|
+
this._requireUsable('cancelConsumer');
|
|
965
1023
|
return await this._transport.cancelConsumer(consumerTagOrQueue);
|
|
966
1024
|
}
|
|
967
1025
|
|
|
@@ -971,12 +1029,7 @@ class BaseClient {
|
|
|
971
1029
|
* @returns {Promise<Object>}
|
|
972
1030
|
*/
|
|
973
1031
|
async checkQueue(queue) {
|
|
974
|
-
|
|
975
|
-
throw new ConnectionError(
|
|
976
|
-
'[BaseClient] Cannot checkQueue: client is not connected - Expected: connect() to have completed. '
|
|
977
|
-
+ 'Fix: await client.connect() before checkQueue().'
|
|
978
|
-
);
|
|
979
|
-
}
|
|
1032
|
+
this._requireUsable('checkQueue');
|
|
980
1033
|
if (typeof this._transport.checkQueue !== 'function') {
|
|
981
1034
|
throw new Error(
|
|
982
1035
|
'[BaseClient] Transport does not support checkQueue(queue) - Expected: a transport implementing checkQueue. '
|
|
@@ -992,12 +1045,7 @@ class BaseClient {
|
|
|
992
1045
|
* @returns {Promise<Object>}
|
|
993
1046
|
*/
|
|
994
1047
|
async purgeQueue(queue) {
|
|
995
|
-
|
|
996
|
-
throw new ConnectionError(
|
|
997
|
-
'[BaseClient] Cannot purgeQueue: client is not connected - Expected: connect() to have completed. '
|
|
998
|
-
+ 'Fix: await client.connect() before purgeQueue().'
|
|
999
|
-
);
|
|
1000
|
-
}
|
|
1048
|
+
this._requireUsable('purgeQueue');
|
|
1001
1049
|
if (typeof this._transport.purgeQueue !== 'function') {
|
|
1002
1050
|
throw new Error(
|
|
1003
1051
|
'[BaseClient] Transport does not support purgeQueue(queue) - Expected: a transport implementing purgeQueue. '
|
|
@@ -1014,12 +1062,7 @@ class BaseClient {
|
|
|
1014
1062
|
* @returns {Promise<Object>}
|
|
1015
1063
|
*/
|
|
1016
1064
|
async deleteQueue(queue, options = {}) {
|
|
1017
|
-
|
|
1018
|
-
throw new ConnectionError(
|
|
1019
|
-
'[BaseClient] Cannot deleteQueue: client is not connected - Expected: connect() to have completed. '
|
|
1020
|
-
+ 'Fix: await client.connect() before deleteQueue().'
|
|
1021
|
-
);
|
|
1022
|
-
}
|
|
1065
|
+
this._requireUsable('deleteQueue');
|
|
1023
1066
|
if (typeof this._transport.deleteQueue !== 'function') {
|
|
1024
1067
|
throw new Error(
|
|
1025
1068
|
'[BaseClient] Transport does not support deleteQueue(queue, options) - Expected: a transport implementing deleteQueue. '
|
|
@@ -1049,12 +1092,7 @@ class BaseClient {
|
|
|
1049
1092
|
* @returns {Promise<void>}
|
|
1050
1093
|
*/
|
|
1051
1094
|
async ack(msg) {
|
|
1052
|
-
|
|
1053
|
-
throw new ConnectionError(
|
|
1054
|
-
'[BaseClient] Cannot ack: client is not connected - Expected: connect() to have completed. '
|
|
1055
|
-
+ 'Fix: await client.connect() before ack(); a message received before a reconnect can no longer be acked.'
|
|
1056
|
-
);
|
|
1057
|
-
}
|
|
1095
|
+
this._requireUsable('ack', '; a message received before a reconnect can no longer be acked.');
|
|
1058
1096
|
if (typeof this._transport.ack !== 'function') {
|
|
1059
1097
|
throw new Error(
|
|
1060
1098
|
'[BaseClient] Transport does not support ack(msg) - Expected: a transport implementing ack. '
|
|
@@ -1076,12 +1114,7 @@ class BaseClient {
|
|
|
1076
1114
|
* @returns {Promise<void>}
|
|
1077
1115
|
*/
|
|
1078
1116
|
async nack(msg, options = {}) {
|
|
1079
|
-
|
|
1080
|
-
throw new ConnectionError(
|
|
1081
|
-
'[BaseClient] Cannot nack: client is not connected - Expected: connect() to have completed. '
|
|
1082
|
-
+ 'Fix: await client.connect() before nack(); a message received before a reconnect can no longer be nacked.'
|
|
1083
|
-
);
|
|
1084
|
-
}
|
|
1117
|
+
this._requireUsable('nack', '; a message received before a reconnect can no longer be nacked.');
|
|
1085
1118
|
if (typeof this._transport.nack !== 'function') {
|
|
1086
1119
|
throw new Error(
|
|
1087
1120
|
'[BaseClient] Transport does not support nack(msg, options) - Expected: a transport implementing nack. '
|