@onlineapps/mq-client-core 4.2.0 → 5.1.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 +768 -0
- package/README.md +149 -19
- package/package.json +1 -1
- package/src/BaseClient.js +126 -31
- package/src/buffer/InMemoryBuffer.js +98 -13
- package/src/buffer/MessageBuffer.js +5 -3
- package/src/config/configSchema.js +33 -10
- package/src/config/defaultConfig.js +21 -10
- 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 +264 -27
- package/src/monitoring-initiated.js +6 -9
- package/src/monitoring-publish.js +89 -40
- package/src/scheduler-request.js +32 -11
- package/src/transports/rabbitmqClient.js +1782 -390
- package/src/utils/decodeMessage.js +123 -0
- package/src/utils/publishErrors.js +256 -17
- package/jest.config.js +0 -15
- package/jest.integration.config.js +0 -16
- package/src/workers/RecoveryWorker.js +0 -130
package/README.md
CHANGED
|
@@ -36,17 +36,21 @@ const BaseClient = require('@onlineapps/mq-client-core');
|
|
|
36
36
|
const mqClient = new BaseClient({
|
|
37
37
|
type: 'rabbitmq',
|
|
38
38
|
host: 'amqp://localhost:5672',
|
|
39
|
+
serviceName: 'api_gateway', // REQUIRED unless ENV SERVICE_NAME is set
|
|
39
40
|
logger // REQUIRED: info/warn/error/debug
|
|
40
41
|
});
|
|
41
42
|
|
|
42
43
|
await mqClient.connect();
|
|
43
44
|
await mqClient.publish('workflow.init', { workflowId: '123', data: '...' });
|
|
44
|
-
await mqClient.publish('workflow.
|
|
45
|
+
await mqClient.publish('workflow.completed', { workflowId: '123', data: '...' });
|
|
45
46
|
```
|
|
46
47
|
|
|
47
48
|
### Configuration
|
|
48
49
|
|
|
49
|
-
**Required:** `type`, `host`, `logger
|
|
50
|
+
**Required:** `type`, `host`, `logger`, and `serviceName` — the explicit key, or ENV
|
|
51
|
+
`SERVICE_NAME`. The library invents no name: a client with neither is refused at
|
|
52
|
+
construction (d.770), because a default would file every monitoring event, and name every
|
|
53
|
+
queue and connection, under a service that does not exist. Every other key is optional — but it has to
|
|
50
54
|
be a key this client declares: a name nothing declares is **refused at construction**,
|
|
51
55
|
named, together with the declared key it is closest to. There is no "anything else"
|
|
52
56
|
door (`src/config/configSchema.js`, `additionalProperties: false`). A key the client
|
|
@@ -59,6 +63,7 @@ package.
|
|
|
59
63
|
type: 'rabbitmq', // Required
|
|
60
64
|
host: 'amqp://...', // Required
|
|
61
65
|
logger, // Required: info/warn/error/debug
|
|
66
|
+
serviceName: 'billing', // Required unless ENV SERVICE_NAME; names the connection and every event
|
|
62
67
|
exchange: '', // Optional exchange
|
|
63
68
|
durable: true, // Optional (default: true)
|
|
64
69
|
prefetch: 1, // Optional (default: 1)
|
|
@@ -81,11 +86,18 @@ says" — and every connection on the platform ran on the broker's number rather
|
|
|
81
86
|
the configured one (measured on the dev broker: 39 live connections, `timeout: 60`
|
|
82
87
|
on all of them).
|
|
83
88
|
|
|
84
|
-
`brokerAnswerTimeout` bounds **
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
89
101
|
|
|
90
102
|
`maxReconnectAttempts` and `maxReconnectCycles` resolve the usual way — explicit
|
|
91
103
|
config → env (`RABBITMQ_MAX_RECONNECT_ATTEMPTS`, `RABBITMQ_MAX_RECONNECT_CYCLES`)
|
|
@@ -114,7 +126,7 @@ The library owns NO logger. `logger` is required and validated in the
|
|
|
114
126
|
logger fails the constructor, it does not fall back to `console`.
|
|
115
127
|
|
|
116
128
|
The same object is forwarded to everything the client builds — transport,
|
|
117
|
-
publish layer,
|
|
129
|
+
publish layer, buffers — so one service configures one
|
|
118
130
|
channel and the whole client writes there. Levels: `info` for lifecycle
|
|
119
131
|
(connected, consumer registered, channel recovered), `debug` for
|
|
120
132
|
per-message and setup detail, `warn`/`error` for the rest. An error
|
|
@@ -129,7 +141,15 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
|
|
|
129
141
|
|
|
130
142
|
- `connect(options?)` - Connect to RabbitMQ
|
|
131
143
|
- `disconnect()` - Disconnect from RabbitMQ
|
|
132
|
-
- `publish(queue, message, options?)` - Publish message to queue
|
|
144
|
+
- `publish(queue, message, options?)` - Publish message to queue. `options.bufferOnFailure`
|
|
145
|
+
(boolean, default `true`): `false` leaves the publish buffer for this one call — a
|
|
146
|
+
transiently failed message is NOT held and no reconnect replays it; the thrown
|
|
147
|
+
`PublishError` then carries `buffered: false` (see § The publish buffer).
|
|
148
|
+
- `publishTelemetry(queue, message, options?)` - Publish the client's OWN event (the
|
|
149
|
+
monitoring helpers below use it) on a confirm channel of its own, opened with the
|
|
150
|
+
first such publish. A refusal there closes that channel only, never the one a
|
|
151
|
+
business message is waiting on (d.724). The channel is chosen by this call, never by
|
|
152
|
+
the queue name.
|
|
133
153
|
- `consume(queue, handler, options?)` - Consume messages from queue; resolves with
|
|
134
154
|
the broker's consumer tag. A failing handler is answered by the delivery policy,
|
|
135
155
|
never by a bare requeue — see § `consume()` — the delivery contract.
|
|
@@ -184,7 +204,10 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
|
|
|
184
204
|
`flushed`, plus a per-queue breakdown; the second renders the same counters as
|
|
185
205
|
Prometheus text.
|
|
186
206
|
- `getChannelState()` / `getConsumerState()` / `getBufferState()` - Liveness of the
|
|
187
|
-
|
|
207
|
+
channels (`publisher`, `queue`, `consumer` and `telemetry`, each `{ exists, closed,
|
|
208
|
+
ready }`; `telemetry` opens with the first telemetry publish and reads
|
|
209
|
+
`{ exists: false, closed: true, ready: false }` until then — d.1007), the consumers
|
|
210
|
+
this client holds, and what is waiting in the
|
|
188
211
|
publish buffer (`{ size, inMemory }` — there is one buffer). All five refuse
|
|
189
212
|
before `connect()` has built a transport, rather than answering with an invented
|
|
190
213
|
zero. `getConsumerState()` answers `{ tracked, active }`: `tracked` counts the
|
|
@@ -193,7 +216,9 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
|
|
|
193
216
|
this client takes no message off some queue it registered.
|
|
194
217
|
- `disconnectAll()` (static, on the class and on the package export) - Close every
|
|
195
218
|
client this module holds; see § Shutdown.
|
|
196
|
-
- `onError(callback)` - Register error handler
|
|
219
|
+
- `onError(callback)` - Register error handler for connection and channel faults. A
|
|
220
|
+
failed publish is NOT reported here: its verdict is the rejection of `publish()`
|
|
221
|
+
(d.811b).
|
|
197
222
|
- `onFatal(callback)` - Register handler for the permanent loss of the
|
|
198
223
|
connection (see § Connection loss)
|
|
199
224
|
|
|
@@ -299,6 +324,13 @@ const { deliveryPolicy } = require('@onlineapps/mq-client-core');
|
|
|
299
324
|
deliveryPolicy.ATTEMPTS_HEADER; // 'x-oa-delivery-attempts' — read it, never retype it
|
|
300
325
|
```
|
|
301
326
|
|
|
327
|
+
**The option rules have one owner, and it is exported.** `consume()` validates its options
|
|
328
|
+
through `deliveryPolicy.validateConsumeOptions(options, resolveDefaultMaxAttempts)`, which
|
|
329
|
+
returns `{ maxAttempts, classify }` or throws the `ValidationError` `consume()` throws, in
|
|
330
|
+
the same words. A consumer's test double of `consume()` calls it instead of copying the
|
|
331
|
+
rules. `resolveDefaultMaxAttempts` is a function, called only when the options leave the
|
|
332
|
+
budget open (d.1005).
|
|
333
|
+
|
|
302
334
|
**The `message_dlq` event** goes to `monitoring.workflow` and carries `status: 'rejected'`
|
|
303
335
|
— the broker's own word for this death, what it stamps into `x-death[0].reason`. It is
|
|
304
336
|
published **only when the envelope carries a `workflow_id`**. Without one nothing is
|
|
@@ -308,6 +340,11 @@ queue through the DLQ dashboard. `monitoring.workflow` keys every trace on that
|
|
|
308
340
|
event naming no workflow is either refused as faulty telemetry or — with a marker like
|
|
309
341
|
`'unknown'` in its place — written as a row claiming a workflow by that name.
|
|
310
342
|
|
|
343
|
+
It also names WHAT died: `operation`, written **only when the envelope carried one** — the
|
|
344
|
+
invocation envelope has the field, the workflow message never does — and left out entirely
|
|
345
|
+
otherwise, with no `unknown` and no `null` standing in for it. The run's identity stays one
|
|
346
|
+
key, `workflow_id`; no event on `monitoring.workflow` carries a `correlation_id`.
|
|
347
|
+
|
|
311
348
|
**`x-message-ttl` and the budget do not cooperate.** The service queues carry a TTL
|
|
312
349
|
(`<service>.queue` 30 s, `<service>.workflow` 5 min) and each attempt re-publishes the
|
|
313
350
|
message, which restarts that clock. So the TTL bounds ONE attempt, never the whole budget,
|
|
@@ -380,7 +417,8 @@ exponential backoff, up to `maxReconnectAttempts` times. That whole budget is on
|
|
|
380
417
|
where the socket is already back.** "Connected" means the client can be used, and a
|
|
381
418
|
re-established connection whose channels are still being recreated cannot publish or
|
|
382
419
|
consume. The two answers therefore agree at every instant: the moment `isConnected()`
|
|
383
|
-
turns `true`, `getChannelState()` reports
|
|
420
|
+
turns `true`, `getChannelState()` reports the publisher, queue and consumer channels ready
|
|
421
|
+
(the telemetry channel is opened on demand, so it may still read as not opened).
|
|
384
422
|
|
|
385
423
|
**Standing down is not dying, and the difference is the point.** An outage the
|
|
386
424
|
broker will come back from must not turn a client into a corpse
|
|
@@ -507,9 +545,12 @@ A missing queue is answered by who owns the name, not by the client's scope:
|
|
|
507
545
|
Publishing must never create an owned queue: `sendToQueue()`/`assertQueue()` would
|
|
508
546
|
declare it with default arguments — no TTL, no DLQ — and the owner's later
|
|
509
547
|
`setupServiceQueues()` then fails with 406 `PRECONDITION_FAILED` because the
|
|
510
|
-
arguments no longer match. The rule is enforced in
|
|
511
|
-
|
|
512
|
-
`
|
|
548
|
+
arguments no longer match. The rule is enforced in ONE place: the 404 branch of
|
|
549
|
+
`_publishOnce()` refuses the publish and declares nothing, and the caller gets the
|
|
550
|
+
verdict as the rejection — `PublishError` with the `QueueNotFoundError` as its
|
|
551
|
+
`cause` (`queueName`, `kind`, and per class the owner and the Fix). Nothing else
|
|
552
|
+
answers a missing queue: the worker that once did, from the client's `error`
|
|
553
|
+
channel, is gone (d.971).
|
|
513
554
|
|
|
514
555
|
Which of the three sentences the refusal carries is decided by the ownership class
|
|
515
556
|
in the error (`kind`), which the central declaration answers — never by how the
|
|
@@ -530,15 +571,49 @@ configured publishers declared one exchange two ways, and a client whose
|
|
|
530
571
|
that was itself faultless. Measured against the live broker: `inequivalent arg
|
|
531
572
|
'type' … received 'direct' but current is 'fanout'`. An exchange nobody declared
|
|
532
573
|
is now a broker-side 404 on the publish, not an exchange invented by whoever
|
|
533
|
-
published first — the same rule as for queues, one object up.
|
|
574
|
+
published first — the same rule as for queues, one object up. The publish answers it
|
|
575
|
+
as a `PermanentPublishError` with the broker's frame in `cause`: one attempt, nothing
|
|
576
|
+
buffered, because no retry and no reconnect declares the exchange (d.693).
|
|
534
577
|
|
|
535
578
|
### The publish buffer — one buffer, every priority
|
|
536
579
|
|
|
537
580
|
A publish that fails transiently (the connection is closing, a channel is being
|
|
538
581
|
recreated, the broker did not confirm in time) is held in memory — bounded by
|
|
539
582
|
`publishBufferMaxSize` messages and `publishBufferTtlMs` each — and replayed after
|
|
540
|
-
the reconnect, critical priority first. `getBufferState()` reports it and
|
|
541
|
-
`publish:buffered` / `buffer:flush:failed` announce it.
|
|
583
|
+
the reconnect or after the broker unblocks the connection (below), critical priority first. `getBufferState()` reports it and
|
|
584
|
+
`publish:buffered` / `buffer:flush:failed` announce it. That includes a publish whose
|
|
585
|
+
confirm was still pending when the channel or the connection died under it (the broker
|
|
586
|
+
closed it, restarted or stopped): the broker said nothing about that message, so it is
|
|
587
|
+
transient, never a refusal — only a `nack` or a numbered close frame naming this send
|
|
588
|
+
is permanent (d.1134).
|
|
589
|
+
|
|
590
|
+
**One call can leave the buffer: `publish(queue, message, { bufferOnFailure: false })`.**
|
|
591
|
+
It is for a caller that keeps the message durably elsewhere and must not have a second
|
|
592
|
+
copy replayed behind its back — the orchestrator writing a `publish_pending` record
|
|
593
|
+
(`docs/governance/confirmations/step-publish-failure.md` 001). On each of the three
|
|
594
|
+
transient paths (retries exhausted, `publishRetryEnabled: false`, the reconnect a retry
|
|
595
|
+
waited for failed) the error `publish()` throws carries `buffered` (boolean): `true` only
|
|
596
|
+
when the buffer holds the message for the next reconnect, `false` when nothing will
|
|
597
|
+
replay it. A permanent refusal never reaches the buffer and carries no `buffered`. A
|
|
598
|
+
value that is not a boolean is refused before any attempt (`ValidationError`). Retry
|
|
599
|
+
stays the library's either way — the option decides only what happens after it.
|
|
600
|
+
|
|
601
|
+
**Every failed publish says whether its frame left the client: `error.sent`** (boolean, on
|
|
602
|
+
the `PublishError` from `publish()` and on its cause; conf
|
|
603
|
+
`docs/governance/confirmations/mq-publish-buffer.md` 001). `true` when any attempt wrote the
|
|
604
|
+
frame to the channel — the broker may hold it, and after a memory alarm deliver it, although
|
|
605
|
+
the call failed; `false` when no attempt got that far. It says nothing about whether the
|
|
606
|
+
broker stored the message. The buffer entry records the same fact, and it decides what
|
|
607
|
+
happens when the outage ends:
|
|
608
|
+
|
|
609
|
+
| The outage ends with | A held message whose frame was never sent | … whose frame was sent |
|
|
610
|
+
|---|---|---|
|
|
611
|
+
| a reconnect (`reconnected`) | re-sent | re-sent — the old socket and what the broker had not read are gone |
|
|
612
|
+
| 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 |
|
|
613
|
+
|
|
614
|
+
`connection.blocked` is logged as a `warn` with the broker's reason. A held message that
|
|
615
|
+
outlives `publishBufferTtlMs` is logged as an `error` naming its queue or exchange, the
|
|
616
|
+
count, the age and `sent` — `sent=false` is a message lost for good.
|
|
542
617
|
|
|
543
618
|
**There is no persistent buffer, and `persistentBufferEnabled` /
|
|
544
619
|
`persistentRedisClient` are refused by name.** They declared one until
|
|
@@ -605,6 +680,26 @@ it did — every refusal wore the sentence "Expected: the queue to exist before
|
|
|
605
680
|
attaches to it", which for a queue with no declared route is false in every clause and
|
|
606
681
|
sent the reader to fix something that was not broken.
|
|
607
682
|
|
|
683
|
+
**The monitoring publish helper asks the same question.**
|
|
684
|
+
`publishToMonitoringResilient()` never throws: a broker that is down, or a monitoring
|
|
685
|
+
queue that does not exist yet, is reported through the logger and answered `false`.
|
|
686
|
+
Which of its two sentences the operator reads — "queue … not available" or "failed to
|
|
687
|
+
publish" — is decided by `isQueueUnavailableError(error, queueName)`, and since d.676
|
|
688
|
+
that decision walks the error's `cause` chain and reads **classes and codes only**:
|
|
689
|
+
`QueueNotFoundError`, `ConnectionError`, `TransientPublishError`, amqplib's numeric
|
|
690
|
+
`code === 404`, and the socket codes `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT`. It
|
|
691
|
+
walks the chain because `BaseClient.publish()` wraps every transport failure in a
|
|
692
|
+
`PublishError` and puts the real class in `cause` — so the classes were never the object
|
|
693
|
+
the function was handed, and until d.676 only its message substrings ever fired. Measured
|
|
694
|
+
consequence of that: a `QueueNotFoundError` with `kind: 'unowned'` answered "not a queue
|
|
695
|
+
problem", and an error whose text merely mentioned `404` answered that it was one.
|
|
696
|
+
|
|
697
|
+
The helper publishes through `mqClient.publishTelemetry()` (d.724), so a refused
|
|
698
|
+
monitoring event cannot close the channel a business message is waiting on. A client
|
|
699
|
+
without that method — anything that is not a `BaseClient` or a subclass of it — is
|
|
700
|
+
reported with a `warn` and answered `false`; the event is not sent on the business
|
|
701
|
+
channel instead.
|
|
702
|
+
|
|
608
703
|
### queueConfig — the classification, as a declared export
|
|
609
704
|
|
|
610
705
|
The table above is not a copy: the names, their arguments and the two predicates
|
|
@@ -667,9 +762,10 @@ topology.telemetryLogsQueue = 'mine'; // TypeError — the object is froz
|
|
|
667
762
|
| `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`). |
|
|
668
763
|
| `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, …)`). |
|
|
669
764
|
| `deliveryResourceEventsQueue` | `delivery.resource.events` | Bound to the `monitoring.resource` exchange by `resource.changed.#`; published by the monitoring watcher `resourceChangedPublisher.js`, drained by `ResourceChangedConsumer.js` in the delivery endpoint. |
|
|
670
|
-
| `telemetryLogsQueue` | `telemetry.logs.queue` | Bound to `telemetry.exchange
|
|
765
|
+
| `telemetryLogsQueue` | `telemetry.logs.queue` | Bound to `telemetry.exchange` by `telemetry.logs.*`; every telemetry producer publishes there with a routing key of the shape `telemetry.<signal>.<service_name>` (logs: `telemetry.logs.<service_name>`); drained by the monitoring consumer (`api/infra/api_monitoring/src/consumer/index.js`, `topology.TELEMETRY_LOGS_QUEUE`). |
|
|
671
766
|
| `telemetryMetricsQueue` | `telemetry.metrics.queue` | The same exchange, the same consumer — metric samples. |
|
|
672
767
|
| `telemetryTracesQueue` | `telemetry.traces.queue` | The same exchange, the same consumer — spans. |
|
|
768
|
+
| `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). |
|
|
673
769
|
|
|
674
770
|
- **Frozen on purpose.** An importer must not be able to rewrite platform topology: a
|
|
675
771
|
name overridden on one side of a fanout loses the events silently, which is why batch
|
|
@@ -795,6 +891,12 @@ requestedBy })` take. One declaration record, and nothing else:
|
|
|
795
891
|
is a missing record of work that happened; a lost registration is work that will not happen.
|
|
796
892
|
- `scheduler.requestQueue` is the queue name, composed from the declaration in `queueConfig`
|
|
797
893
|
— a caller never types `scheduler.request`.
|
|
894
|
+
- **A request that cannot be built is told apart by code, never by wording.** Every refusal of
|
|
895
|
+
a builder — the port's key check, the declaration rules, tenancy, the key, the requester, the
|
|
896
|
+
event — carries `error.code === scheduler.SCHEDULER_REQUEST_INVALID`; a failed send (no
|
|
897
|
+
connection, a publish the broker refused) never does. The wrapper's boot uses exactly this to
|
|
898
|
+
isolate one bad record as a failed source while a broker that is gone still stops the boot
|
|
899
|
+
([`scheduler` 020](/docs/governance/confirmations/scheduler.md)).
|
|
798
900
|
|
|
799
901
|
Two more things a service says on that queue, and one send for all of them:
|
|
800
902
|
|
|
@@ -847,6 +949,34 @@ await port.cancel({ id });
|
|
|
847
949
|
under the wrapper, which fills it from the envelope of the run, and an operator script that does
|
|
848
950
|
not boot the wrapper at all and works row by row.
|
|
849
951
|
|
|
952
|
+
### decodeMessage — a delivery, decoded by one rail
|
|
953
|
+
|
|
954
|
+
`consume()` hands its handler the RAW amqplib delivery. `decodeMessage(raw)` is the one
|
|
955
|
+
public way from that delivery to the message it carries (d.789), so a consumer does not
|
|
956
|
+
keep its own copy of the translation.
|
|
957
|
+
|
|
958
|
+
```javascript
|
|
959
|
+
const { decodeMessage } = require('@onlineapps/mq-client-core');
|
|
960
|
+
|
|
961
|
+
await client.consume(queue, async (raw) => {
|
|
962
|
+
const { body, headers, properties, fields } = decodeMessage(raw);
|
|
963
|
+
// …
|
|
964
|
+
});
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
- It returns the decoded body, and `headers`, `properties` and `fields` exactly as they
|
|
968
|
+
arrived.
|
|
969
|
+
- It refuses with `ValidationError`, naming what arrived: a delivery that is not an
|
|
970
|
+
object, `content` that is neither a `Buffer` nor a string, an empty body, or a
|
|
971
|
+
DECLARED content type other than `application/json`. No declaration is fine — the
|
|
972
|
+
platform envelope does not require one.
|
|
973
|
+
- A body that is not JSON is the `SerializationError` the serializer throws, passed on
|
|
974
|
+
unchanged — the refused bytes in `payload`, the parser's reason in `cause`.
|
|
975
|
+
- It carries no `retryable` flag: what happens to the message is decided by the
|
|
976
|
+
caller's `classify(error)` (§ `consume()` — the delivery contract), on the error's
|
|
977
|
+
class.
|
|
978
|
+
- It is additive: `consume()` still hands over the raw delivery.
|
|
979
|
+
|
|
850
980
|
### redactUrl — a connection URL that is safe to log
|
|
851
981
|
|
|
852
982
|
`RABBITMQ_URL` carries the broker account, so anything that renders it verbatim
|
package/package.json
CHANGED
package/src/BaseClient.js
CHANGED
|
@@ -318,6 +318,12 @@ class BaseClient {
|
|
|
318
318
|
// Apply resolved runtime config
|
|
319
319
|
this._config.host = resolved.host;
|
|
320
320
|
this._config.heartbeat = this._config.heartbeat ?? resolved.heartbeat;
|
|
321
|
+
// The resolved identity is WRITTEN BACK, so the config carries what the client runs
|
|
322
|
+
// under whichever leg produced it — the explicit key or ENV SERVICE_NAME. Until d.770
|
|
323
|
+
// only the explicit key reached `_config.serviceName`, and everything downstream that
|
|
324
|
+
// needed the name asked the runtime config again; two readers of one fact, and the
|
|
325
|
+
// transport's copy answered the module default nobody wrote.
|
|
326
|
+
this._config.serviceName = resolved.serviceName;
|
|
321
327
|
this._config.connectionName =
|
|
322
328
|
this._config.connectionName ||
|
|
323
329
|
`${resolved.serviceName}:${process.pid}`;
|
|
@@ -381,20 +387,36 @@ class BaseClient {
|
|
|
381
387
|
});
|
|
382
388
|
this._config.host = resolved.host;
|
|
383
389
|
this._config.heartbeat = this._config.heartbeat ?? resolved.heartbeat;
|
|
390
|
+
this._config.serviceName = resolved.serviceName;
|
|
384
391
|
this._config.connectionName =
|
|
385
392
|
this._config.connectionName ||
|
|
386
393
|
`${resolved.serviceName}:${process.pid}`;
|
|
387
394
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
395
|
+
this._logger.debug('[BaseClient] Creating transport');
|
|
396
|
+
// Instantiate appropriate transport: RabbitMQClient. `this._config` carries
|
|
397
|
+
// the validated logger, so the transport logs through the same channel —
|
|
398
|
+
// it builds none of its own.
|
|
399
|
+
//
|
|
400
|
+
// OUTSIDE THE `try` ON PURPOSE (d.736b). What this line can throw is a
|
|
401
|
+
// refusal of the CONFIGURATION — a target that is not a non-empty string, a
|
|
402
|
+
// URL declaring its own heartbeat, an `onFatal` that is not a function, a
|
|
403
|
+
// `type` no transport implements. Nothing has been asked of the broker yet,
|
|
404
|
+
// and nothing about the answer will change on a second attempt. Until this
|
|
405
|
+
// batch the `catch` below turned every one of them into `ConnectionError`
|
|
406
|
+
// ("a reachable broker … check the MQ url, credentials and network"), which
|
|
407
|
+
// is a transient fault by name — and `ServiceWrapper._initializeMQ()` reads
|
|
408
|
+
// it as one: it retries `connect()` `wrapper.mq.connectionRetries` times and
|
|
409
|
+
// stands down only for `code: 'MQ_CONNECTION_FATAL'` (10 attempts, 2 s base,
|
|
410
|
+
// 30 s cap — `service-wrapper/config/runtime-defaults.json`), so a typo in a
|
|
411
|
+
// URL cost ~200 s of backoff and then sent the operator to look at the
|
|
412
|
+
// network. The refusal already names the key and the fix
|
|
413
|
+
// (`architecture-principles.md` §5); it is passed on as it is.
|
|
414
|
+
this._transport = transportFactory.create(this._config);
|
|
415
|
+
this._logger.debug('[BaseClient] Transport created', {
|
|
416
|
+
transport: this._transport.constructor.name,
|
|
417
|
+
});
|
|
397
418
|
|
|
419
|
+
try {
|
|
398
420
|
// Register internal error propagation
|
|
399
421
|
this._transport.on('error', (err) => this._handleError(err));
|
|
400
422
|
|
|
@@ -472,9 +494,14 @@ class BaseClient {
|
|
|
472
494
|
* infrastructure or business queue is created by its owner before anything
|
|
473
495
|
* publishes to it, and an ownerless name is declared with the arguments the
|
|
474
496
|
* central `queueConfig` resolves (`transports/rabbitmqClient.js`, the 404 branch).
|
|
497
|
+
* @param {boolean} [options.bufferOnFailure=true] - `false` = a transiently failed message
|
|
498
|
+
* is NOT held in the library's buffer, so no reconnect replays it; for a caller that keeps
|
|
499
|
+
* the message durably elsewhere. Owned by `layers/PublishLayer.js`; a non-boolean is refused.
|
|
475
500
|
* @returns {Promise<void>}
|
|
476
501
|
* @throws {ConnectionError} If not connected.
|
|
477
|
-
* @throws {PublishError} If publish fails.
|
|
502
|
+
* @throws {PublishError} If publish fails. After a transient failure (retries exhausted,
|
|
503
|
+
* retry disabled, the awaited reconnect failed) it carries `buffered` (boolean): `true`
|
|
504
|
+
* only when the buffer holds the message for the next reconnect.
|
|
478
505
|
*/
|
|
479
506
|
async publish(queue, message, options = {}) {
|
|
480
507
|
if (!this._connected || !this._transport) {
|
|
@@ -517,7 +544,7 @@ class BaseClient {
|
|
|
517
544
|
// answer as d.297 one call over (`_consumerRefusal()`): name no likeliest
|
|
518
545
|
// cause, carry the one actually caught. `cause` is unchanged, so a caller
|
|
519
546
|
// deciding on the class still reads the object.
|
|
520
|
-
|
|
547
|
+
const failure = new PublishError(
|
|
521
548
|
`[BaseClient] Failed to publish to queue "${queue}": ${describeThrown(err)} - `
|
|
522
549
|
+ 'Expected: publish() to hand the message to the broker. '
|
|
523
550
|
+ 'Fix: act on the reason above — this layer does not know which failure it was, and naming the '
|
|
@@ -526,6 +553,20 @@ class BaseClient {
|
|
|
526
553
|
queue,
|
|
527
554
|
err
|
|
528
555
|
);
|
|
556
|
+
// Whether the library's buffer holds the message is decided and written by
|
|
557
|
+
// `PublishLayer` (options.bufferOnFailure, d.1122); it is carried up here unchanged so
|
|
558
|
+
// the caller reads it on the error it catches. Absent when the failure never reached
|
|
559
|
+
// the buffer decision — "not asked" is not "false".
|
|
560
|
+
if (err && typeof err.buffered === 'boolean') {
|
|
561
|
+
failure.buffered = err.buffered;
|
|
562
|
+
}
|
|
563
|
+
// Whether any attempt wrote the frame (d.1145, conf `mq-publish-buffer` 001): carried
|
|
564
|
+
// like `buffered`, so the caller — the orchestrator's `publish_pending` record
|
|
565
|
+
// (`step-publish-failure` 003) — reads it on the error it catches.
|
|
566
|
+
if (err && typeof err.sent === 'boolean') {
|
|
567
|
+
failure.sent = err.sent;
|
|
568
|
+
}
|
|
569
|
+
throw failure;
|
|
529
570
|
}
|
|
530
571
|
}
|
|
531
572
|
|
|
@@ -736,6 +777,18 @@ class BaseClient {
|
|
|
736
777
|
* DLQ, as the monitoring intake requires (`api/shared/TODO.md`, INFRA-monitoring
|
|
737
778
|
* 2026-09-11); an event with no `status` is refused there under its own name.
|
|
738
779
|
*
|
|
780
|
+
* `operation` says WHAT died, and it is written ONLY when the envelope carried
|
|
781
|
+
* one: the invocation envelope has the field
|
|
782
|
+
* (`docs/biz/10-invocation/mq-envelope.md` § Inbound envelope), the workflow
|
|
783
|
+
* message of `docs/biz/70-contracts/workflow-message.md` §3.2 never does, and
|
|
784
|
+
* this event reports what it found rather than a marker. It has a reader
|
|
785
|
+
* already — `readReported(data.operation)` in the monitoring consumer
|
|
786
|
+
* (`infra/api_monitoring/src/consumer/apiflow-extension.js`), from where the
|
|
787
|
+
* value reaches the Loki line as `operation`; until it was written here, every
|
|
788
|
+
* dead letter arrived there as `null`. The run's identity stays ONE key,
|
|
789
|
+
* `workflow_id`: no event on this queue carries a `correlation_id`
|
|
790
|
+
* (`docs/biz/70-contracts/monitoring-events.md` § Rules).
|
|
791
|
+
*
|
|
739
792
|
* @param {Object} event - As the transport reports it.
|
|
740
793
|
* @returns {Promise<void>}
|
|
741
794
|
* @private
|
|
@@ -745,16 +798,22 @@ class BaseClient {
|
|
|
745
798
|
const serviceName = runtimeCfg.get('serviceName', this._config.serviceName);
|
|
746
799
|
|
|
747
800
|
let workflowId = null;
|
|
801
|
+
let operation = null;
|
|
748
802
|
try {
|
|
749
803
|
const envelope = serializer.deserialize(content);
|
|
750
|
-
if (envelope !== null && typeof envelope === 'object'
|
|
751
|
-
&& envelope.workflow_id !== '') {
|
|
752
|
-
|
|
804
|
+
if (envelope !== null && typeof envelope === 'object') {
|
|
805
|
+
if (typeof envelope.workflow_id === 'string' && envelope.workflow_id !== '') {
|
|
806
|
+
workflowId = envelope.workflow_id;
|
|
807
|
+
}
|
|
808
|
+
if (typeof envelope.operation === 'string' && envelope.operation !== '') {
|
|
809
|
+
operation = envelope.operation;
|
|
810
|
+
}
|
|
753
811
|
}
|
|
754
812
|
} catch (parseErr) {
|
|
755
813
|
// A payload that is not JSON carries no envelope, so it carries no
|
|
756
814
|
// workflow_id either. Same outcome as an envelope without one.
|
|
757
815
|
workflowId = null;
|
|
816
|
+
operation = null;
|
|
758
817
|
}
|
|
759
818
|
|
|
760
819
|
if (workflowId === null) {
|
|
@@ -775,29 +834,64 @@ class BaseClient {
|
|
|
775
834
|
return;
|
|
776
835
|
}
|
|
777
836
|
|
|
837
|
+
const payload = {
|
|
838
|
+
event_type: 'message_dlq',
|
|
839
|
+
workflow_id: workflowId,
|
|
840
|
+
service_name: serviceName,
|
|
841
|
+
// The broker's word for this death; see the method comment.
|
|
842
|
+
status: 'rejected',
|
|
843
|
+
queue,
|
|
844
|
+
attempts,
|
|
845
|
+
max_attempts: maxAttempts,
|
|
846
|
+
classification,
|
|
847
|
+
error: {
|
|
848
|
+
message: error && error.message,
|
|
849
|
+
name: error && error.name
|
|
850
|
+
},
|
|
851
|
+
timestamp: new Date().toISOString()
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
// …and WHAT died, when the envelope says so. The key is written only when
|
|
855
|
+
// the envelope carried one, in the one spelling the invocation envelope
|
|
856
|
+
// uses; an envelope without it leaves the event without the key rather than
|
|
857
|
+
// with an invented marker, exactly as workflow_id is handled above.
|
|
858
|
+
if (operation !== null) {
|
|
859
|
+
payload.operation = operation;
|
|
860
|
+
}
|
|
861
|
+
|
|
778
862
|
await publishToMonitoringWorkflow(
|
|
779
863
|
this,
|
|
780
|
-
|
|
781
|
-
event_type: 'message_dlq',
|
|
782
|
-
workflow_id: workflowId,
|
|
783
|
-
service_name: serviceName,
|
|
784
|
-
// The broker's word for this death; see the method comment.
|
|
785
|
-
status: 'rejected',
|
|
786
|
-
queue,
|
|
787
|
-
attempts,
|
|
788
|
-
max_attempts: maxAttempts,
|
|
789
|
-
classification,
|
|
790
|
-
error: {
|
|
791
|
-
message: error && error.message,
|
|
792
|
-
name: error && error.name
|
|
793
|
-
},
|
|
794
|
-
timestamp: new Date().toISOString()
|
|
795
|
-
},
|
|
864
|
+
payload,
|
|
796
865
|
this._logger,
|
|
797
866
|
{ queue }
|
|
798
867
|
);
|
|
799
868
|
}
|
|
800
869
|
|
|
870
|
+
/**
|
|
871
|
+
* Publish one of the CLIENT'S OWN events — the messages nobody asked it to send.
|
|
872
|
+
*
|
|
873
|
+
* Same path as `publish()` in every respect but one: the send goes out on a channel of
|
|
874
|
+
* its own (`transports/rabbitmqClient.js` § _ensurePublisherChannel, role
|
|
875
|
+
* `'telemetry'`), so a refusal of an event can never close the channel a caller's
|
|
876
|
+
* message is waiting for a confirm on.
|
|
877
|
+
*
|
|
878
|
+
* WHY IT IS A SEPARATE METHOD and not a flag on `publish()`: the caller of `publish()`
|
|
879
|
+
* is sending its own message and has no business choosing a channel; the only code that
|
|
880
|
+
* may say "this is the client talking about itself" is the code that IS the client
|
|
881
|
+
* talking about itself (`src/monitoring-publish.js`). Measured cost of the shared
|
|
882
|
+
* channel, d.711: in a vhost where `monitoring.workflow.fanout` was not declared, the
|
|
883
|
+
* `404` for a dead-letter event took the business publish beside it down with it — 3 of
|
|
884
|
+
* 7 runs of a suite that had nothing to do with telemetry.
|
|
885
|
+
*
|
|
886
|
+
* @param {string} queue - Target queue name.
|
|
887
|
+
* @param {Object|Buffer|string} message - Payload to send.
|
|
888
|
+
* @param {Object} [options] - The same overrides `publish()` takes.
|
|
889
|
+
* @returns {Promise<void>}
|
|
890
|
+
*/
|
|
891
|
+
async publishTelemetry(queue, message, options = {}) {
|
|
892
|
+
return this.publish(queue, message, { ...options, channelRole: 'telemetry' });
|
|
893
|
+
}
|
|
894
|
+
|
|
801
895
|
/**
|
|
802
896
|
* Assert queue existence (delegates to transport).
|
|
803
897
|
* @param {string} queue - Queue name
|
|
@@ -959,7 +1053,8 @@ class BaseClient {
|
|
|
959
1053
|
*
|
|
960
1054
|
* Settling is idempotent: the transport marks the delivery via `msg._mqProcessed`
|
|
961
1055
|
* and `consume()` skips its own auto-ack for a message the handler already
|
|
962
|
-
* settled (`
|
|
1056
|
+
* settled (`RabbitMQClient.consume()` and `_applyDeliveryPolicy()` in
|
|
1057
|
+
* `transports/rabbitmqClient.js`). Calling this from
|
|
963
1058
|
* inside a consume handler is therefore supported, not a double-settle.
|
|
964
1059
|
*
|
|
965
1060
|
* Until 2026-08-28 this method was declared TWICE in this class; the second,
|