@onlineapps/mq-client-core 2.0.1-rc.1 → 3.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 +153 -0
- package/README.md +491 -8
- package/package.json +12 -8
- package/src/BaseClient.js +953 -80
- package/src/buffer/InMemoryBuffer.js +50 -9
- package/src/buffer/MessageBuffer.js +20 -52
- package/src/config/composeConfig.js +60 -0
- package/src/config/configSchema.js +398 -17
- package/src/config/defaultConfig.js +169 -15
- package/src/config/deliveryPolicy.js +165 -0
- package/src/config/queueConfig.js +738 -64
- package/src/config.js +29 -0
- package/src/defaults.js +43 -0
- package/src/index.js +91 -2
- package/src/layers/PublishLayer.js +83 -37
- package/src/monitoring/PublishMonitor.js +11 -4
- package/src/monitoring-publish.js +81 -54
- package/src/transports/rabbitmqClient.js +2698 -738
- package/src/transports/transportFactory.js +9 -2
- package/src/utils/errorHandler.js +83 -4
- package/src/utils/nearestKey.js +101 -0
- package/src/utils/publishErrors.js +95 -10
- package/src/utils/redactCredentials.js +106 -0
- package/src/utils/serializer.js +12 -2
- package/src/workers/RecoveryWorker.js +58 -81
- package/src/buffer/RedisBuffer.js +0 -57
package/README.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
> Status: current
|
|
2
|
+
> Owns: the base RabbitMQ client that infrastructure services and the MQ connector both build on
|
|
3
|
+
|
|
4
|
+
<!-- BEGIN GENERATED: library-uniform — regenerate: npx oa-sync-template readme-uniform --all -->
|
|
5
|
+
Uniform: [library/connector](../connector/conn-orch-validator/manifests/library.manifest.json)
|
|
6
|
+
|
|
7
|
+
Duty sections that apply:
|
|
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
|
|
10
|
+
- `connector`: L-CONNECTOR-ENV
|
|
11
|
+
<!-- END GENERATED: library-uniform -->
|
|
12
|
+
|
|
1
13
|
# @onlineapps/mq-client-core
|
|
2
14
|
|
|
3
15
|
Core MQ client library for RabbitMQ - shared by infrastructure services and connectors.
|
|
@@ -24,7 +36,7 @@ const BaseClient = require('@onlineapps/mq-client-core');
|
|
|
24
36
|
const mqClient = new BaseClient({
|
|
25
37
|
type: 'rabbitmq',
|
|
26
38
|
host: 'amqp://localhost:5672',
|
|
27
|
-
|
|
39
|
+
logger // REQUIRED: info/warn/error/debug
|
|
28
40
|
});
|
|
29
41
|
|
|
30
42
|
await mqClient.connect();
|
|
@@ -34,21 +46,83 @@ await mqClient.publish('workflow.control', { workflowId: '123', data: '...' });
|
|
|
34
46
|
|
|
35
47
|
### Configuration
|
|
36
48
|
|
|
37
|
-
**
|
|
49
|
+
**Required:** `type`, `host`, `logger`. Every other key is optional — but it has to
|
|
50
|
+
be a key this client declares: a name nothing declares is **refused at construction**,
|
|
51
|
+
named, together with the declared key it is closest to. There is no "anything else"
|
|
52
|
+
door (`src/config/configSchema.js`, `additionalProperties: false`). A key the client
|
|
53
|
+
does not read configures nothing, and until 2026-09-14 the value written under it was
|
|
54
|
+
lost in silence — `queue` rode that door from four callers for the lifetime of the
|
|
55
|
+
package.
|
|
38
56
|
|
|
39
57
|
```javascript
|
|
40
58
|
{
|
|
41
59
|
type: 'rabbitmq', // Required
|
|
42
60
|
host: 'amqp://...', // Required
|
|
43
|
-
|
|
61
|
+
logger, // Required: info/warn/error/debug
|
|
44
62
|
exchange: '', // Optional exchange
|
|
45
63
|
durable: true, // Optional (default: true)
|
|
46
64
|
prefetch: 1, // Optional (default: 1)
|
|
47
65
|
noAck: false, // Optional (default: false)
|
|
48
|
-
|
|
66
|
+
maxReconnectAttempts: 10, // Optional; env RABBITMQ_MAX_RECONNECT_ATTEMPTS — attempts per cycle
|
|
67
|
+
maxReconnectCycles: 3, // Optional; env RABBITMQ_MAX_RECONNECT_CYCLES — cycles before it is over
|
|
68
|
+
heartbeat: 30, // Optional; env RABBITMQ_HEARTBEAT — SECONDS
|
|
69
|
+
connectTimeout: 10000, // Optional; how long one handshake may take (ms)
|
|
70
|
+
brokerAnswerTimeout: 5000,// Optional; how long to wait for an answer from the broker (ms)
|
|
71
|
+
onFatal: null // Optional callback, see § Connection loss
|
|
49
72
|
}
|
|
50
73
|
```
|
|
51
74
|
|
|
75
|
+
**The heartbeat is composed into the connection URL**, because that is the only
|
|
76
|
+
place amqplib 0.10 reads it (`lib/connect.js`: `intOrDefault(query.heartbeat, 0)`).
|
|
77
|
+
A `host` that already carries `?heartbeat=` is therefore **refused**: one fact, one
|
|
78
|
+
declaration. Until 2026-09-14 the value was handed to amqplib's socket options
|
|
79
|
+
instead, where it is not read, so the handshake asked for `0` — "whatever the peer
|
|
80
|
+
says" — and every connection on the platform ran on the broker's number rather than
|
|
81
|
+
the configured one (measured on the dev broker: 39 live connections, `timeout: 60`
|
|
82
|
+
on all of them).
|
|
83
|
+
|
|
84
|
+
`brokerAnswerTimeout` bounds **both** things this client waits for the broker to
|
|
85
|
+
answer: the confirm of one publish attempt, and the `close-ok` of each handle
|
|
86
|
+
`disconnect()` closes. It was called `publishConfirmationTimeout` until
|
|
87
|
+
2026-09-14 — the name of one of its two consumers — and writing that name now is
|
|
88
|
+
refused, with the new one named.
|
|
89
|
+
|
|
90
|
+
`maxReconnectAttempts` and `maxReconnectCycles` resolve the usual way — explicit
|
|
91
|
+
config → env (`RABBITMQ_MAX_RECONNECT_ATTEMPTS`, `RABBITMQ_MAX_RECONNECT_CYCLES`)
|
|
92
|
+
→ module default in `src/defaults.js`. They are client *behaviour*, like
|
|
93
|
+
`heartbeat`, so this module owns their defaults; broker topology
|
|
94
|
+
(`host`/`RABBITMQ_URL`) stays required and is never defaulted. The first is the
|
|
95
|
+
attempts inside ONE recovery cycle, the second how many cycles there may be — see
|
|
96
|
+
§ Connection loss.
|
|
97
|
+
|
|
98
|
+
### A subclass declares its own keys
|
|
99
|
+
|
|
100
|
+
`new BaseClient(config, extraProperties?)`. The second argument is the JSON Schema
|
|
101
|
+
`properties` of a SUBCLASS, for the keys it reads off the same config object — the one
|
|
102
|
+
place the configuration contract is extended. `ConnectorMQClient`
|
|
103
|
+
([`@onlineapps/conn-infra-mq`](../connector/conn-infra-mq)) passes its own four there:
|
|
104
|
+
each side declares what it owns, so one key keeps one type, one description and one
|
|
105
|
+
owner. A subclass that redeclares a key of this library is refused, naming the key; a
|
|
106
|
+
key neither side declares is refused like any other unknown name.
|
|
107
|
+
|
|
108
|
+
### Logging — one channel, injected
|
|
109
|
+
|
|
110
|
+
The library owns NO logger. `logger` is required and validated in the
|
|
111
|
+
`BaseClient` constructor by `assertLogger()` from
|
|
112
|
+
[`@onlineapps/logger-contract`](../logger-contract): an object with
|
|
113
|
+
`info`, `warn`, `error` and `debug`. A missing or incomplete
|
|
114
|
+
logger fails the constructor, it does not fall back to `console`.
|
|
115
|
+
|
|
116
|
+
The same object is forwarded to everything the client builds — transport,
|
|
117
|
+
publish layer, recovery worker, buffers — so one service configures one
|
|
118
|
+
channel and the whole client writes there. Levels: `info` for lifecycle
|
|
119
|
+
(connected, consumer registered, channel recovered), `debug` for
|
|
120
|
+
per-message and setup detail, `warn`/`error` for the rest. An error
|
|
121
|
+
that is thrown is not also logged.
|
|
122
|
+
|
|
123
|
+
Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
|
|
124
|
+
001-004.
|
|
125
|
+
|
|
52
126
|
## API
|
|
53
127
|
|
|
54
128
|
### BaseClient
|
|
@@ -56,15 +130,299 @@ await mqClient.publish('workflow.control', { workflowId: '123', data: '...' });
|
|
|
56
130
|
- `connect(options?)` - Connect to RabbitMQ
|
|
57
131
|
- `disconnect()` - Disconnect from RabbitMQ
|
|
58
132
|
- `publish(queue, message, options?)` - Publish message to queue
|
|
59
|
-
- `consume(queue, handler, options?)` - Consume messages from queue
|
|
133
|
+
- `consume(queue, handler, options?)` - Consume messages from queue; resolves with
|
|
134
|
+
the broker's consumer tag. A failing handler is answered by the delivery policy,
|
|
135
|
+
never by a bare requeue — see § `consume()` — the delivery contract.
|
|
136
|
+
- `cancelConsumer(consumerTagOrQueue)` - Stop one consumer this client registered,
|
|
137
|
+
by the tag `consume()` returned or by the queue name. The consumer is dropped
|
|
138
|
+
from the client's registry too, so a later reconnect does not bring it back.
|
|
139
|
+
A consumer that is **tracked but not attached** — its re-registration was
|
|
140
|
+
refused, so the tag it held names nothing — is refused by name instead: there
|
|
141
|
+
is nothing on the broker to cancel, and it stays tracked so the next channel
|
|
142
|
+
recreate can re-attach it. Listen for `consumer:re-registration:failed` to
|
|
143
|
+
learn why it could not.
|
|
144
|
+
- `isConnectionFatal()` - Is this connection permanently lost (the broker refused
|
|
145
|
+
it, or every recovery cycle is spent)? The difference between "wait" and
|
|
146
|
+
"restart me" — see § Connection loss. `false` on a client that never connected:
|
|
147
|
+
a healthcheck asks this, so it answers rather than throwing.
|
|
148
|
+
- `performHealthCheck()` - Run the client's own check now and return its verdict
|
|
149
|
+
(`{ timestamp, connection, channels, consumers, queues, healthy, issues }`).
|
|
150
|
+
Also counts as a use, so a client that stood down starts its next recovery
|
|
151
|
+
cycle here. A client that TRACKS consumers and has none attached is **not
|
|
152
|
+
healthy** — `healthy: false`, with an issue naming how many it tracks — because
|
|
153
|
+
such a client takes no message off any queue. A client that registered no
|
|
154
|
+
consumer at all is a publisher and stays healthy; the check's own log line says
|
|
155
|
+
which of the two it is.
|
|
156
|
+
- `assertQueue(queue, options?)` / `checkQueue(queue)` / `purgeQueue(queue)` /
|
|
157
|
+
`deleteQueue(queue, options?)` - Queue operations, on the queue channel.
|
|
158
|
+
`assertQueue()` reads exactly four options — `durable`, `arguments`,
|
|
159
|
+
`exclusive`, `autoDelete` — and refuses any other key by name, together with
|
|
160
|
+
the declared key it is closest to. An option nobody reads declares nothing, and
|
|
161
|
+
the caller acts on a guarantee nobody gave. `exclusive` (the queue dies with
|
|
162
|
+
the connection that declared it) and `autoDelete` (it dies with its last
|
|
163
|
+
consumer) are the only way to say a queue is temporary; a key left out is left
|
|
164
|
+
out of the declaration rather than sent as `false`. For an INFRASTRUCTURE or
|
|
165
|
+
BUSINESS name the central `queueConfig` decides `durable` and `arguments` as
|
|
166
|
+
always, and a lifetime asked for on such a name is refused — those queues
|
|
167
|
+
outlive their declarer by design (see § Queue ownership).
|
|
168
|
+
- `assertExchange(exchange, type, options?)` - Declare an exchange. `type` is
|
|
169
|
+
required (`direct`, `topic`, `fanout`, `headers`): an exchange's type is a
|
|
170
|
+
topology decision, so the library does not choose one. `durable` defaults to
|
|
171
|
+
the client's configured value. This is the ONLY place this client declares an
|
|
172
|
+
exchange — `publish()` names one, it never declares it (see § Queue ownership).
|
|
173
|
+
- `bindQueue(queue, exchange, pattern)` - Bind a queue to an exchange. `pattern`
|
|
174
|
+
is required and `''` is legal — a fanout exchange ignores the key, but which
|
|
175
|
+
key a binding carries is never guessed.
|
|
60
176
|
- `ack(msg)` - Acknowledge message
|
|
61
|
-
- `nack(msg, options?)` - Negative acknowledge message
|
|
62
|
-
|
|
177
|
+
- `nack(msg, options?)` - Negative acknowledge message. `options` is an OBJECT
|
|
178
|
+
`{ requeue: boolean }`, never amqplib's positional `(msg, allUpTo, requeue)`;
|
|
179
|
+
`requeue` defaults to `true`, so discarding a message needs an explicit
|
|
180
|
+
`{ requeue: false }`.
|
|
181
|
+
- `isConnected()` - Live connection status, read from the transport
|
|
182
|
+
- `getPublishMetrics()` / `getPrometheusMetrics()` - What this client's publishes
|
|
183
|
+
have done so far: `attempts`, `successes`, `failures`, `retries`, `buffered`,
|
|
184
|
+
`flushed`, plus a per-queue breakdown; the second renders the same counters as
|
|
185
|
+
Prometheus text.
|
|
186
|
+
- `getChannelState()` / `getConsumerState()` / `getBufferState()` - Liveness of the
|
|
187
|
+
three channels, the consumers this client holds, and what is waiting in the
|
|
188
|
+
publish buffer (`{ size, inMemory }` — there is one buffer). All five refuse
|
|
189
|
+
before `connect()` has built a transport, rather than answering with an invented
|
|
190
|
+
zero.
|
|
191
|
+
- `disconnectAll()` (static, on the class and on the package export) - Close every
|
|
192
|
+
client this module holds; see § Shutdown.
|
|
63
193
|
- `onError(callback)` - Register error handler
|
|
194
|
+
- `onFatal(callback)` - Register handler for the permanent loss of the
|
|
195
|
+
connection (see § Connection loss)
|
|
64
196
|
|
|
65
197
|
`publish()` and the workflow helpers built on it resolve to `undefined`; failure is
|
|
66
198
|
signalled by a thrown `PublishError`, never by a falsy return.
|
|
67
199
|
|
|
200
|
+
### `consume()` — the delivery contract
|
|
201
|
+
|
|
202
|
+
A handler that throws does **not** get an unconditional requeue. Every failure is
|
|
203
|
+
counted, and a message that cannot succeed ends in the dead-letter queue the broker
|
|
204
|
+
routes for its queue (`x-dead-letter-routing-key`, i.e. `<service>.dlq`) instead of
|
|
205
|
+
circling for ever. One rail for business and infrastructure consumers alike
|
|
206
|
+
(`api/docs/governance/confirmations/mq-consumer-contract.md` 001 and 002 point 3).
|
|
207
|
+
|
|
208
|
+
**A queue with no declared dead-letter route is refused, at registration.** The policy
|
|
209
|
+
ends in `nack(requeue=false)`, and the broker moves that message ONLY if the queue
|
|
210
|
+
declares where to (`x-dead-letter-exchange` + `x-dead-letter-routing-key`). Where it does
|
|
211
|
+
not, the broker drops the message instead — so `consume()` throws a `ConsumeError` naming
|
|
212
|
+
the queue before any consumer is attached, rather than losing messages later. There is no
|
|
213
|
+
opt-out: a consumer that wants a queue declares that queue's route in
|
|
214
|
+
`src/config/queueConfig.js`, which is also the only source of truth the check reads — the
|
|
215
|
+
broker cannot be asked, because `checkQueue()` answers with `queue.declare-ok`, which
|
|
216
|
+
carries `{ queue, messageCount, consumerCount }` and no arguments (measured, 2026-09-12).
|
|
217
|
+
`queueConfig.getDeadLetterRoute(queue)` is the exported form of the same answer.
|
|
218
|
+
|
|
219
|
+
```javascript
|
|
220
|
+
await client.consume(queue, async (msg, delivery) => {
|
|
221
|
+
// delivery = { attempt, maxAttempts, isFinalAttempt }
|
|
222
|
+
await handle(msg);
|
|
223
|
+
}, {
|
|
224
|
+
maxAttempts: 3, // optional; module default otherwise
|
|
225
|
+
classify: (error) => error.name === 'ValidationError' ? 'permanent' : 'transient'
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
| the handler | what happens to the message |
|
|
230
|
+
|---|---|
|
|
231
|
+
| returns | acked, once |
|
|
232
|
+
| throws, error `transient`, attempts left | a copy carrying the advanced counter is published to the SAME queue, then the original is acked |
|
|
233
|
+
| throws, error `transient`, `maxAttempts` reached | `nack(requeue=false)` → the broker dead-letters it into `<service>.dlq`, and a `message_dlq` event is published |
|
|
234
|
+
| throws, error `permanent` | the same rejection, on the FIRST failure |
|
|
235
|
+
| acks or nacks the message itself | untouched — the transport's `_mqProcessed` sentinel still wins |
|
|
236
|
+
|
|
237
|
+
**`maxAttempts`** is how many times the handler may run for ONE message, not how many
|
|
238
|
+
retries follow the first run; `maxAttempts: 1` means "no second attempt". Resolution is
|
|
239
|
+
explicit → `RABBITMQ_MAX_DELIVERY_ATTEMPTS` → `defaults.js maxDeliveryAttempts` (3, the
|
|
240
|
+
value `@onlineapps/error-handler-core` already declares for the same decision). An
|
|
241
|
+
explicit value is taken exactly as written, so `'3'` and `2.5` are refused at `consume()`
|
|
242
|
+
rather than coerced; only the environment form is parsed as a number.
|
|
243
|
+
|
|
244
|
+
**`classify`** is optional. Without it every error is treated as transient — stated, not
|
|
245
|
+
implied: a permanently failing message still reaches `<service>.dlq`, after `maxAttempts`
|
|
246
|
+
attempts instead of after one. A classifier that throws, or answers with anything but
|
|
247
|
+
`'transient'` / `'permanent'`, is reported through the logger and its message is rejected
|
|
248
|
+
into the dead-letter queue: the library never guesses a classification.
|
|
249
|
+
|
|
250
|
+
**Why an attempt costs a re-publish and not a requeue.** Measured on the live broker
|
|
251
|
+
(2026-09-11): after `nack(requeue=true)` the redelivery carries `headers: {}` — the
|
|
252
|
+
message returns byte-for-byte, and `fields.redelivered` is a boolean, not a count. A
|
|
253
|
+
requeue is not a dead-letter, so the broker stamps no `x-death` to count from either.
|
|
254
|
+
The counter therefore has to travel in a header this library writes, and only a NEW
|
|
255
|
+
message can carry an advanced one. The copy is published first and the original acked
|
|
256
|
+
second, so a failed publish can duplicate a message but never lose one; when the publish
|
|
257
|
+
fails the original is requeued and the attempt is repeated rather than spent.
|
|
258
|
+
|
|
259
|
+
```javascript
|
|
260
|
+
const { deliveryPolicy } = require('@onlineapps/mq-client-core');
|
|
261
|
+
deliveryPolicy.ATTEMPTS_HEADER; // 'x-oa-delivery-attempts' — read it, never retype it
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**The `message_dlq` event** goes to `monitoring.workflow` and carries `status: 'rejected'`
|
|
265
|
+
— the broker's own word for this death, what it stamps into `x-death[0].reason`. It is
|
|
266
|
+
published **only when the envelope carries a `workflow_id`**. Without one nothing is
|
|
267
|
+
published and nothing is invented: the absence is reported through the injected logger
|
|
268
|
+
with the queue and the service name, and the message stays readable in the dead-letter
|
|
269
|
+
queue through the DLQ dashboard. `monitoring.workflow` keys every trace on that id, so an
|
|
270
|
+
event naming no workflow is either refused as faulty telemetry or — with a marker like
|
|
271
|
+
`'unknown'` in its place — written as a row claiming a workflow by that name.
|
|
272
|
+
|
|
273
|
+
**`x-message-ttl` and the budget do not cooperate.** The service queues carry a TTL
|
|
274
|
+
(`<service>.queue` 30 s, `<service>.workflow` 5 min) and each attempt re-publishes the
|
|
275
|
+
message, which restarts that clock. So the TTL bounds ONE attempt, never the whole budget,
|
|
276
|
+
and `maxAttempts` is the only bound on the total. A message that does expire mid-budget is
|
|
277
|
+
dead-lettered by the broker to the same `<service>.dlq` with `x-death[0].reason: 'expired'`
|
|
278
|
+
— and no `message_dlq` event, because the client is not involved in that move. Both
|
|
279
|
+
reasons are visible on the dead message itself; the DLQ dashboard reads them
|
|
280
|
+
(`infra/api_monitoring/src/consumer/deadLetterPeek.js`).
|
|
281
|
+
|
|
282
|
+
#### `requeueOnError` — the same policy, said in one word
|
|
283
|
+
|
|
284
|
+
`consume(queue, handler, { requeueOnError })` is folded ONTO the budget above,
|
|
285
|
+
never placed beside it:
|
|
286
|
+
|
|
287
|
+
| value | meaning |
|
|
288
|
+
|---|---|
|
|
289
|
+
| `false` | the budget is **one** attempt — the first failure rejects into `<svc>.dlq`, exactly as a `permanent` classification does |
|
|
290
|
+
| `true` or absent | the budget is whatever configuration says (`maxAttempts` → `RABBITMQ_MAX_DELIVERY_ATTEMPTS` → `defaults.js`) |
|
|
291
|
+
|
|
292
|
+
There is no third meaning. `requeueOnError: false` together with `maxAttempts`
|
|
293
|
+
above 1 is a contradiction and is **refused where it was written**, rather than
|
|
294
|
+
one of the two being silently ignored.
|
|
295
|
+
|
|
296
|
+
### Connection loss — the contract
|
|
297
|
+
|
|
298
|
+
When the broker goes away, the transport runs connection-level recovery with
|
|
299
|
+
exponential backoff, up to `maxReconnectAttempts` times. That whole budget is one
|
|
300
|
+
**cycle**. Three outcomes, no fourth:
|
|
301
|
+
|
|
302
|
+
| Outcome | What the client does |
|
|
303
|
+
|---|---|
|
|
304
|
+
| a reconnect succeeds | channels are recreated, consumers re-registered, buffered messages flushed, `reconnected` is emitted; the attempt and cycle counters reset to 0 |
|
|
305
|
+
| the cycle's attempts are spent, and the broker never answered | the client **stands down**: `connection:standby` is emitted, and the NEXT use starts the next cycle |
|
|
306
|
+
| the broker REFUSED, or every cycle is spent | the connection is declared **permanently lost** |
|
|
307
|
+
|
|
308
|
+
**While a recovery is running, `isConnected()` is `false` — including the part of it
|
|
309
|
+
where the socket is already back.** "Connected" means the client can be used, and a
|
|
310
|
+
re-established connection whose channels are still being recreated cannot publish or
|
|
311
|
+
consume. The two answers therefore agree at every instant: the moment `isConnected()`
|
|
312
|
+
turns `true`, `getChannelState()` reports all three channels ready.
|
|
313
|
+
|
|
314
|
+
**Standing down is not dying, and the difference is the point.** An outage the
|
|
315
|
+
broker will come back from must not turn a client into a corpse
|
|
316
|
+
(`docs/governance/confirmations/mq-client-lifecycle-contract.md` 001 point 3 —
|
|
317
|
+
"give up forever" is forbidden). So a spent cycle means:
|
|
318
|
+
|
|
319
|
+
- `connection:standby` is emitted with `{ cycle, cyclesMax, attempts, lastError,
|
|
320
|
+
timestamp }`, and the client holds no timer and no socket;
|
|
321
|
+
- `isConnected()` is `false`, `isConnectionFatal()` is `false`;
|
|
322
|
+
- **nothing polls.** The retry is lazy: the next `publish()`, `consume()` or
|
|
323
|
+
`performHealthCheck()` starts the next cycle, and a client nobody uses costs
|
|
324
|
+
nothing while the broker is away;
|
|
325
|
+
- a cycle that succeeds resets the counter, so the next outage starts again at
|
|
326
|
+
cycle 1.
|
|
327
|
+
|
|
328
|
+
`maxReconnectCycles` bounds how many cycles this client will ever run (explicit
|
|
329
|
+
config → `RABBITMQ_MAX_RECONNECT_CYCLES` → module default). It is what keeps the
|
|
330
|
+
lazy retry finite: when the last cycle is spent, the connection is permanently
|
|
331
|
+
lost.
|
|
332
|
+
|
|
333
|
+
**A refusal is not an outage.** If the broker ANSWERED and said no — 403
|
|
334
|
+
`ACCESS-REFUSED`, 530 `NOT-ALLOWED`, 406 `PRECONDITION-FAILED` — retrying cannot
|
|
335
|
+
change the answer, so not one further attempt is spent on it and the connection is
|
|
336
|
+
permanently lost immediately. (One refusal this client cannot recognise: amqplib
|
|
337
|
+
renders a wrong vhost as `Expected ConnectionOpenOk; got <ConnectionClose
|
|
338
|
+
channel:0>`, with the broker's 530 dropped before the error is built. That one is
|
|
339
|
+
treated as an outage and ends at the cycle cap instead.)
|
|
340
|
+
|
|
341
|
+
Permanently lost means all of this, together:
|
|
342
|
+
|
|
343
|
+
- `connection:fatal` is emitted on the transport, and the injected `onFatal`
|
|
344
|
+
callback is called — once — with an `Error` carrying
|
|
345
|
+
`code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused' | 'cycles-spent'`,
|
|
346
|
+
`attempts: <budget per cycle>` and `cycles: <cycles spent>`;
|
|
347
|
+
- `isConnected()` returns `false` — on the transport, on `BaseClient`, and
|
|
348
|
+
therefore in `conn-infra-mq`'s `getHealth().connected` and in any healthcheck
|
|
349
|
+
built on them;
|
|
350
|
+
- no further reconnect attempts are made, by a use or otherwise — the client is
|
|
351
|
+
dead until the process restarts, or until an explicit `connect()`;
|
|
352
|
+
- `publish()`/`consume()` refuse immediately instead of blocking on a recovery
|
|
353
|
+
that is not coming.
|
|
354
|
+
|
|
355
|
+
**The library never ends the process, and never listens for a signal.** Ending
|
|
356
|
+
it is the lifecycle owner's decision: a service typically logs and exits
|
|
357
|
+
non-zero from `onFatal`, so its restart policy boots it again and boot
|
|
358
|
+
re-verifies every dependency. What the library owes the owner is the truth,
|
|
359
|
+
once, on a channel that cannot be confused with an ordinary transient error —
|
|
360
|
+
and one call to close what it holds, `BaseClient.disconnectAll()`.
|
|
361
|
+
|
|
362
|
+
```javascript
|
|
363
|
+
const client = new BaseClient({
|
|
364
|
+
type: 'rabbitmq',
|
|
365
|
+
host: process.env.RABBITMQ_URL,
|
|
366
|
+
onFatal: (err) => {
|
|
367
|
+
logger.error(err.message, { code: err.code, reason: err.reason, cycles: err.cycles });
|
|
368
|
+
process.exit(1); // the SERVICE decides this, not the library
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Shutdown — `BaseClient.disconnectAll()`
|
|
374
|
+
|
|
375
|
+
The process signal belongs to the service. This library registers no
|
|
376
|
+
`SIGTERM`/`SIGINT`/`beforeExit` handler and calls `process.exit()` nowhere; what
|
|
377
|
+
it offers instead is one call the service puts in its own sequence, in its own
|
|
378
|
+
order:
|
|
379
|
+
|
|
380
|
+
```javascript
|
|
381
|
+
const { BaseClient } = require('@onlineapps/mq-client-core');
|
|
382
|
+
|
|
383
|
+
process.on('SIGTERM', async () => { // the SERVICE registers this
|
|
384
|
+
await httpServer.close();
|
|
385
|
+
await BaseClient.disconnectAll(); // every MQ client this process holds
|
|
386
|
+
await redis.quit();
|
|
387
|
+
process.exit(0); // the SERVICE decides this
|
|
388
|
+
});
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
- it closes every client constructed and not yet disconnected — **only** those:
|
|
392
|
+
a raw amqplib connection the service opened itself is not touched;
|
|
393
|
+
- every client gets its turn even if one fails; it then rejects with a
|
|
394
|
+
`ConnectionError` naming how many of how many failed, `error.cause` carrying
|
|
395
|
+
the first reason;
|
|
396
|
+
- a client that failed to close stays held, so a repeated call reaches it again;
|
|
397
|
+
- with nothing live it resolves and does nothing.
|
|
398
|
+
|
|
399
|
+
**BREAKING (2026-09-14).** Until this release the library installed the signal
|
|
400
|
+
handlers itself, on the first `BaseClient` anyone constructed, and ended them
|
|
401
|
+
with `process.exit(0)`. A service with its own shutdown sequence got a truncated
|
|
402
|
+
one: measured in the registry (`infra/api_services_registry/src/shutdown.js`,
|
|
403
|
+
dev stack 2026-08-31) as `TRACE_END mq t=12` → `TRACE_PROCESS_EXIT code=0 t=16`,
|
|
404
|
+
with four Redis clients, the HTTP server and six timers never handed back. A
|
|
405
|
+
service that relied on the library closing its clients on `SIGTERM` must now
|
|
406
|
+
call `BaseClient.disconnectAll()` from its own handler.
|
|
407
|
+
|
|
408
|
+
**BREAKING (2026-09-14).** A spent attempt budget used to be the end: the client
|
|
409
|
+
set its fatal flag on the first exhausted cycle and refused every operation for
|
|
410
|
+
the life of the process, which is what this section used to document ("the client
|
|
411
|
+
is dead until the process restarts"). A service whose broker was restarted for
|
|
412
|
+
longer than `maxReconnectAttempts × reconnectMaxDelay` therefore needed a process
|
|
413
|
+
restart to come back, even though the broker was healthy again. A caller that
|
|
414
|
+
treated `connection:fatal` as "the first outage that outlasted the budget" must
|
|
415
|
+
now read it as what it says — refused, or every cycle spent — and may see
|
|
416
|
+
`connection:standby` in between.
|
|
417
|
+
|
|
418
|
+
**BREAKING (2026-09-07).** `isConnected()` used to return a flag set once in
|
|
419
|
+
`connect()`, so it answered `true` throughout an outage and kept answering
|
|
420
|
+
`true` after the recovery budget was spent — measured that day as `true` for
|
|
421
|
+
100+ s against a broker that was down and then back up, with the client
|
|
422
|
+
permanently dead and every healthcheck green. A caller that used
|
|
423
|
+
`isConnected()` as "was connect() ever called" must now read it as "is the
|
|
424
|
+
connection up right now".
|
|
425
|
+
|
|
68
426
|
### Queue ownership — publishing never creates an owned queue
|
|
69
427
|
|
|
70
428
|
A missing queue is answered by who owns the name, not by the client's scope:
|
|
@@ -73,7 +431,7 @@ A missing queue is answered by who owns the name, not by the client's scope:
|
|
|
73
431
|
|---|---|---|
|
|
74
432
|
| `workflow.*`, `registry.*`, `infrastructure.*`, `validation.*`, `monitoring.*`, `telemetry.*`, `delivery.*` | the infrastructure service that declares it | `QueueNotFoundError` (`kind: 'infrastructure'`) |
|
|
75
433
|
| `{service}.workflow`, `{service}.queue`, `{service}.dlq` | the owning service, via `setupServiceQueues()` **after** registration | `QueueNotFoundError` (`kind: 'business'`) |
|
|
76
|
-
| anything else | nobody |
|
|
434
|
+
| anything else | nobody | `QueueNotFoundError` (`kind: 'unowned'`) — nothing is created (d.419) |
|
|
77
435
|
|
|
78
436
|
Publishing must never create an owned queue: `sendToQueue()`/`assertQueue()` would
|
|
79
437
|
declare it with default arguments — no TTL, no DLQ — and the owner's later
|
|
@@ -86,6 +444,131 @@ required: the 404 branch of `_publishOnce()` refuses the publish, and
|
|
|
86
444
|
client belongs to a business service. It has never meant "may create business
|
|
87
445
|
queues".
|
|
88
446
|
|
|
447
|
+
**An exchange is named by a publisher, never declared by one.** `publish(queue,
|
|
448
|
+
message, { exchange, routingKey })` sends to the exchange the caller names and
|
|
449
|
+
declares nothing: an exchange's type and durability are topology, they are
|
|
450
|
+
declared once in `config/queueConfig.js`, and the owning service asserts them
|
|
451
|
+
through `assertExchange()`. Until 2026-09-14 the publish path re-declared the
|
|
452
|
+
exchange on every call, with the type from `options.exchangeType || 'direct'` and
|
|
453
|
+
the durability from the PUBLISHING client's config — so two differently
|
|
454
|
+
configured publishers declared one exchange two ways, and a client whose
|
|
455
|
+
`durable` disagreed with the owner's got 406 `PRECONDITION_FAILED` from a publish
|
|
456
|
+
that was itself faultless. Measured against the live broker: `inequivalent arg
|
|
457
|
+
'type' … received 'direct' but current is 'fanout'`. An exchange nobody declared
|
|
458
|
+
is now a broker-side 404 on the publish, not an exchange invented by whoever
|
|
459
|
+
published first — the same rule as for queues, one object up.
|
|
460
|
+
|
|
461
|
+
### The publish buffer — one buffer, every priority
|
|
462
|
+
|
|
463
|
+
A publish that fails transiently (the connection is closing, a channel is being
|
|
464
|
+
recreated, the broker did not confirm in time) is held in memory — bounded by
|
|
465
|
+
`publishBufferMaxSize` messages and `publishBufferTtlMs` each — and replayed after
|
|
466
|
+
the reconnect, critical priority first. `getBufferState()` reports it and
|
|
467
|
+
`publish:buffered` / `buffer:flush:failed` announce it.
|
|
468
|
+
|
|
469
|
+
**There is no persistent buffer, and `persistentBufferEnabled` /
|
|
470
|
+
`persistentRedisClient` are refused by name.** They declared one until
|
|
471
|
+
2026-09-14: `buffer/RedisBuffer.js` was a placeholder whose `add()` stored nothing
|
|
472
|
+
and whose `flush()` returned 0, and the buffer returned after that branch without
|
|
473
|
+
falling back — so switching it on **dropped** exactly the critical-priority
|
|
474
|
+
messages it claimed to protect, while the same message at normal priority was
|
|
475
|
+
safely held. A key that inverts its own promise is worse than no key
|
|
476
|
+
(`automation-gates.md` §5), so the stub, both keys and the branch went out
|
|
477
|
+
together, and writing either name now fails the constructor with a sentence that
|
|
478
|
+
says the mechanism is gone rather than a name to rename it to.
|
|
479
|
+
|
|
480
|
+
### errors and errorCodes — recognise a state by its code, never by its wording
|
|
481
|
+
|
|
482
|
+
`errors` hands out the error classes; `errorCodes` hands out the machine-readable
|
|
483
|
+
classification a listener decides on. A service listening on the `error` channel gets
|
|
484
|
+
the transport's own connection-close notification there, and it is a STATE — the
|
|
485
|
+
connection dropped while a reconnect is already running — not a failure of whatever
|
|
486
|
+
observed it.
|
|
487
|
+
|
|
488
|
+
```javascript
|
|
489
|
+
const { errors, errorCodes } = require('@onlineapps/mq-client-core');
|
|
490
|
+
|
|
491
|
+
client.onError((err) => {
|
|
492
|
+
if (err.code === errorCodes.CONNECTION_CLOSED_UNEXPECTEDLY) {
|
|
493
|
+
return; // the client is already reconnecting; onFatal reports the permanent loss
|
|
494
|
+
}
|
|
495
|
+
logger.error(err.message, { code: err.code });
|
|
496
|
+
});
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
**Compare the code, not the message.** A comparison against the sentence breaks the day
|
|
500
|
+
somebody rewords it, and nothing reports the break: the client's own reconnect wait asked
|
|
501
|
+
for `'Connection closed unexpectedly'` while the transport emitted `'RabbitMQ connection
|
|
502
|
+
closed unexpectedly'`, one capital letter apart, so the branch written to ignore that
|
|
503
|
+
error had never once run (fixed 2026-09-08, `errors.ConnectionError` now carries `code`).
|
|
504
|
+
|
|
505
|
+
`consume()` refuses for two named reasons, and they ask for OPPOSITE actions:
|
|
506
|
+
|
|
507
|
+
| `error.code` | what happened | what fixes it |
|
|
508
|
+
|---|---|---|
|
|
509
|
+
| `CONSUMER_QUEUE_MISSING` | the queue does not exist | start the service that owns the queue — a consumer never creates one |
|
|
510
|
+
| `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 |
|
|
511
|
+
|
|
512
|
+
Any other failure carries **no** code and its `ConsumeError` says so: the reason is in
|
|
513
|
+
`error.cause`, and this layer does not name the likeliest-sounding one. Until 2026-09-14
|
|
514
|
+
it did — every refusal wore the sentence "Expected: the queue to exist before consume()
|
|
515
|
+
attaches to it", which for a queue with no declared route is false in every clause and
|
|
516
|
+
sent the reader to fix something that was not broken.
|
|
517
|
+
|
|
518
|
+
### queueConfig — the classification, as a declared export
|
|
519
|
+
|
|
520
|
+
The table above is not a copy: the names, their arguments and the two predicates
|
|
521
|
+
that decide them live in `src/config/queueConfig.js`, and the package exports that
|
|
522
|
+
module.
|
|
523
|
+
|
|
524
|
+
```javascript
|
|
525
|
+
const { queueConfig } = require('@onlineapps/mq-client-core');
|
|
526
|
+
|
|
527
|
+
queueConfig.isInfrastructureQueue('workflow.init'); // true
|
|
528
|
+
queueConfig.isBusinessQueue('emailer.workflow'); // true
|
|
529
|
+
queueConfig.getInfrastructureQueueConfig('workflow.init'); // { durable, arguments }
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
- `workflow.failed` carries no `x-message-ttl`. It is read by an operator, not by a
|
|
533
|
+
service, and it has no dead-letter exchange, so a TTL there does not move a message
|
|
534
|
+
anywhere — it deletes it. See
|
|
535
|
+
`api/docs/governance/confirmations/mq-consumer-contract.md` 002.
|
|
536
|
+
|
|
537
|
+
**That require is the contract.** Reaching into
|
|
538
|
+
`@onlineapps/mq-client-core/src/config/queueConfig` is an internal path the package
|
|
539
|
+
never promised — it happens to resolve today, and it breaks silently the day the
|
|
540
|
+
file moves. Callers holding the deep path switch to the named export; callers that
|
|
541
|
+
receive `queueConfig` by injection (`initInfrastructureQueues` in
|
|
542
|
+
`@onlineapps/infrastructure-tools`) pass it from here.
|
|
543
|
+
|
|
544
|
+
### redactUrl — a connection URL that is safe to log
|
|
545
|
+
|
|
546
|
+
`RABBITMQ_URL` carries the broker account, so anything that renders it verbatim
|
|
547
|
+
puts that account in the service log and, through the monitoring consumer, in
|
|
548
|
+
Loki. `redactUrl` removes the WHOLE userinfo — the account name is the other half
|
|
549
|
+
of the credential, and the reader of a log line came for the host and the port.
|
|
550
|
+
|
|
551
|
+
```javascript
|
|
552
|
+
const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('@onlineapps/mq-client-core');
|
|
553
|
+
|
|
554
|
+
redactUrl('amqp://oa_dev:secret@queuer:5672/oa_vhost'); // 'amqp://queuer:5672/oa_vhost'
|
|
555
|
+
redactUrl('amqp://queuer:5672'); // unchanged — nothing to remove
|
|
556
|
+
redactUrl('queuer:5672'); // '<unparseable-url>'
|
|
557
|
+
redactUrl(undefined); // '<unparseable-url>'
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
- A value that cannot be read as a URL is answered with `UNPARSEABLE_PLACEHOLDER`,
|
|
561
|
+
never echoed: that is precisely the case where nobody can say whether the string
|
|
562
|
+
holds a credential.
|
|
563
|
+
- It never throws — its callers are log lines and error messages, where a throw
|
|
564
|
+
replaces the report with a second incident. The client's own connection target
|
|
565
|
+
goes through `redactConnectionTarget()` instead, which refuses a non-string
|
|
566
|
+
outright, because `host` is a declared config key and a wrong type there is a
|
|
567
|
+
boot-time defect.
|
|
568
|
+
- It is the SAME function this package uses internally, and the same semantics as
|
|
569
|
+
`shared/service-common/src/redactUrl.js`. A dependant that renders a broker URL
|
|
570
|
+
imports it from here rather than keeping a copy.
|
|
571
|
+
|
|
89
572
|
## Architecture
|
|
90
573
|
|
|
91
574
|
```
|
package/package.json
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/mq-client-core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Core MQ client library for RabbitMQ - shared by infrastructure services and connectors",
|
|
5
|
+
"oa": {
|
|
6
|
+
"category": "connector"
|
|
7
|
+
},
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=24.0.0 <25"
|
|
10
|
+
},
|
|
5
11
|
"main": "src/index.js",
|
|
6
12
|
"scripts": {
|
|
7
|
-
"test": "
|
|
8
|
-
"test:unit": "jest
|
|
9
|
-
"test:component": "jest --testPathPattern=tests/component",
|
|
13
|
+
"test": "npm run test:unit && npm run test:integration",
|
|
14
|
+
"test:unit": "jest tests/unit",
|
|
10
15
|
"test:integration": "jest --config=jest.integration.config.js"
|
|
11
16
|
},
|
|
12
17
|
"keywords": [
|
|
@@ -18,11 +23,10 @@
|
|
|
18
23
|
"author": "OnlineApps",
|
|
19
24
|
"license": "MIT",
|
|
20
25
|
"dependencies": {
|
|
21
|
-
"@onlineapps/
|
|
22
|
-
"@onlineapps/runtime-config": "1.0
|
|
26
|
+
"@onlineapps/logger-contract": "1.1.0",
|
|
27
|
+
"@onlineapps/runtime-config": "1.1.0",
|
|
23
28
|
"ajv": "^8.12.0",
|
|
24
|
-
"amqplib": "^0.10.3"
|
|
25
|
-
"lodash.merge": "^4.6.2"
|
|
29
|
+
"amqplib": "^0.10.3"
|
|
26
30
|
},
|
|
27
31
|
"devDependencies": {
|
|
28
32
|
"jest": "^29.7.0"
|