@onlineapps/mq-client-core 4.2.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +623 -0
- package/README.md +99 -11
- package/package.json +1 -1
- package/src/BaseClient.js +105 -29
- package/src/config/configSchema.js +21 -4
- package/src/config/defaultConfig.js +14 -4
- package/src/config/deliveryPolicy.js +86 -6
- package/src/config/queueConfig.js +68 -39
- package/src/config.js +5 -1
- package/src/defaults.js +7 -1
- package/src/index.js +41 -4
- package/src/layers/PublishLayer.js +113 -5
- package/src/monitoring-publish.js +89 -40
- package/src/scheduler-request.js +32 -11
- package/src/transports/rabbitmqClient.js +1237 -316
- package/src/utils/decodeMessage.js +123 -0
- package/src/utils/publishErrors.js +256 -17
- package/jest.config.js +0 -15
- package/jest.integration.config.js +0 -16
- package/src/workers/RecoveryWorker.js +0 -130
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)
|
|
@@ -114,7 +119,7 @@ The library owns NO logger. `logger` is required and validated in the
|
|
|
114
119
|
logger fails the constructor, it does not fall back to `console`.
|
|
115
120
|
|
|
116
121
|
The same object is forwarded to everything the client builds — transport,
|
|
117
|
-
publish layer,
|
|
122
|
+
publish layer, buffers — so one service configures one
|
|
118
123
|
channel and the whole client writes there. Levels: `info` for lifecycle
|
|
119
124
|
(connected, consumer registered, channel recovered), `debug` for
|
|
120
125
|
per-message and setup detail, `warn`/`error` for the rest. An error
|
|
@@ -130,6 +135,11 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
|
|
|
130
135
|
- `connect(options?)` - Connect to RabbitMQ
|
|
131
136
|
- `disconnect()` - Disconnect from RabbitMQ
|
|
132
137
|
- `publish(queue, message, options?)` - Publish message to queue
|
|
138
|
+
- `publishTelemetry(queue, message, options?)` - Publish the client's OWN event (the
|
|
139
|
+
monitoring helpers below use it) on a confirm channel of its own, opened with the
|
|
140
|
+
first such publish. A refusal there closes that channel only, never the one a
|
|
141
|
+
business message is waiting on (d.724). The channel is chosen by this call, never by
|
|
142
|
+
the queue name.
|
|
133
143
|
- `consume(queue, handler, options?)` - Consume messages from queue; resolves with
|
|
134
144
|
the broker's consumer tag. A failing handler is answered by the delivery policy,
|
|
135
145
|
never by a bare requeue — see § `consume()` — the delivery contract.
|
|
@@ -184,7 +194,10 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
|
|
|
184
194
|
`flushed`, plus a per-queue breakdown; the second renders the same counters as
|
|
185
195
|
Prometheus text.
|
|
186
196
|
- `getChannelState()` / `getConsumerState()` / `getBufferState()` - Liveness of the
|
|
187
|
-
|
|
197
|
+
channels (`publisher`, `queue`, `consumer` and `telemetry`, each `{ exists, closed,
|
|
198
|
+
ready }`; `telemetry` opens with the first telemetry publish and reads
|
|
199
|
+
`{ exists: false, closed: true, ready: false }` until then — d.1007), the consumers
|
|
200
|
+
this client holds, and what is waiting in the
|
|
188
201
|
publish buffer (`{ size, inMemory }` — there is one buffer). All five refuse
|
|
189
202
|
before `connect()` has built a transport, rather than answering with an invented
|
|
190
203
|
zero. `getConsumerState()` answers `{ tracked, active }`: `tracked` counts the
|
|
@@ -193,7 +206,9 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
|
|
|
193
206
|
this client takes no message off some queue it registered.
|
|
194
207
|
- `disconnectAll()` (static, on the class and on the package export) - Close every
|
|
195
208
|
client this module holds; see § Shutdown.
|
|
196
|
-
- `onError(callback)` - Register error handler
|
|
209
|
+
- `onError(callback)` - Register error handler for connection and channel faults. A
|
|
210
|
+
failed publish is NOT reported here: its verdict is the rejection of `publish()`
|
|
211
|
+
(d.811b).
|
|
197
212
|
- `onFatal(callback)` - Register handler for the permanent loss of the
|
|
198
213
|
connection (see § Connection loss)
|
|
199
214
|
|
|
@@ -299,6 +314,13 @@ const { deliveryPolicy } = require('@onlineapps/mq-client-core');
|
|
|
299
314
|
deliveryPolicy.ATTEMPTS_HEADER; // 'x-oa-delivery-attempts' — read it, never retype it
|
|
300
315
|
```
|
|
301
316
|
|
|
317
|
+
**The option rules have one owner, and it is exported.** `consume()` validates its options
|
|
318
|
+
through `deliveryPolicy.validateConsumeOptions(options, resolveDefaultMaxAttempts)`, which
|
|
319
|
+
returns `{ maxAttempts, classify }` or throws the `ValidationError` `consume()` throws, in
|
|
320
|
+
the same words. A consumer's test double of `consume()` calls it instead of copying the
|
|
321
|
+
rules. `resolveDefaultMaxAttempts` is a function, called only when the options leave the
|
|
322
|
+
budget open (d.1005).
|
|
323
|
+
|
|
302
324
|
**The `message_dlq` event** goes to `monitoring.workflow` and carries `status: 'rejected'`
|
|
303
325
|
— the broker's own word for this death, what it stamps into `x-death[0].reason`. It is
|
|
304
326
|
published **only when the envelope carries a `workflow_id`**. Without one nothing is
|
|
@@ -308,6 +330,11 @@ queue through the DLQ dashboard. `monitoring.workflow` keys every trace on that
|
|
|
308
330
|
event naming no workflow is either refused as faulty telemetry or — with a marker like
|
|
309
331
|
`'unknown'` in its place — written as a row claiming a workflow by that name.
|
|
310
332
|
|
|
333
|
+
It also names WHAT died: `operation`, written **only when the envelope carried one** — the
|
|
334
|
+
invocation envelope has the field, the workflow message never does — and left out entirely
|
|
335
|
+
otherwise, with no `unknown` and no `null` standing in for it. The run's identity stays one
|
|
336
|
+
key, `workflow_id`; no event on `monitoring.workflow` carries a `correlation_id`.
|
|
337
|
+
|
|
311
338
|
**`x-message-ttl` and the budget do not cooperate.** The service queues carry a TTL
|
|
312
339
|
(`<service>.queue` 30 s, `<service>.workflow` 5 min) and each attempt re-publishes the
|
|
313
340
|
message, which restarts that clock. So the TTL bounds ONE attempt, never the whole budget,
|
|
@@ -380,7 +407,8 @@ exponential backoff, up to `maxReconnectAttempts` times. That whole budget is on
|
|
|
380
407
|
where the socket is already back.** "Connected" means the client can be used, and a
|
|
381
408
|
re-established connection whose channels are still being recreated cannot publish or
|
|
382
409
|
consume. The two answers therefore agree at every instant: the moment `isConnected()`
|
|
383
|
-
turns `true`, `getChannelState()` reports
|
|
410
|
+
turns `true`, `getChannelState()` reports the publisher, queue and consumer channels ready
|
|
411
|
+
(the telemetry channel is opened on demand, so it may still read as not opened).
|
|
384
412
|
|
|
385
413
|
**Standing down is not dying, and the difference is the point.** An outage the
|
|
386
414
|
broker will come back from must not turn a client into a corpse
|
|
@@ -507,9 +535,12 @@ A missing queue is answered by who owns the name, not by the client's scope:
|
|
|
507
535
|
Publishing must never create an owned queue: `sendToQueue()`/`assertQueue()` would
|
|
508
536
|
declare it with default arguments — no TTL, no DLQ — and the owner's later
|
|
509
537
|
`setupServiceQueues()` then fails with 406 `PRECONDITION_FAILED` because the
|
|
510
|
-
arguments no longer match. The rule is enforced in
|
|
511
|
-
|
|
512
|
-
`
|
|
538
|
+
arguments no longer match. The rule is enforced in ONE place: the 404 branch of
|
|
539
|
+
`_publishOnce()` refuses the publish and declares nothing, and the caller gets the
|
|
540
|
+
verdict as the rejection — `PublishError` with the `QueueNotFoundError` as its
|
|
541
|
+
`cause` (`queueName`, `kind`, and per class the owner and the Fix). Nothing else
|
|
542
|
+
answers a missing queue: the worker that once did, from the client's `error`
|
|
543
|
+
channel, is gone (d.971).
|
|
513
544
|
|
|
514
545
|
Which of the three sentences the refusal carries is decided by the ownership class
|
|
515
546
|
in the error (`kind`), which the central declaration answers — never by how the
|
|
@@ -530,7 +561,9 @@ configured publishers declared one exchange two ways, and a client whose
|
|
|
530
561
|
that was itself faultless. Measured against the live broker: `inequivalent arg
|
|
531
562
|
'type' … received 'direct' but current is 'fanout'`. An exchange nobody declared
|
|
532
563
|
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.
|
|
564
|
+
published first — the same rule as for queues, one object up. The publish answers it
|
|
565
|
+
as a `PermanentPublishError` with the broker's frame in `cause`: one attempt, nothing
|
|
566
|
+
buffered, because no retry and no reconnect declares the exchange (d.693).
|
|
534
567
|
|
|
535
568
|
### The publish buffer — one buffer, every priority
|
|
536
569
|
|
|
@@ -605,6 +638,26 @@ it did — every refusal wore the sentence "Expected: the queue to exist before
|
|
|
605
638
|
attaches to it", which for a queue with no declared route is false in every clause and
|
|
606
639
|
sent the reader to fix something that was not broken.
|
|
607
640
|
|
|
641
|
+
**The monitoring publish helper asks the same question.**
|
|
642
|
+
`publishToMonitoringResilient()` never throws: a broker that is down, or a monitoring
|
|
643
|
+
queue that does not exist yet, is reported through the logger and answered `false`.
|
|
644
|
+
Which of its two sentences the operator reads — "queue … not available" or "failed to
|
|
645
|
+
publish" — is decided by `isQueueUnavailableError(error, queueName)`, and since d.676
|
|
646
|
+
that decision walks the error's `cause` chain and reads **classes and codes only**:
|
|
647
|
+
`QueueNotFoundError`, `ConnectionError`, `TransientPublishError`, amqplib's numeric
|
|
648
|
+
`code === 404`, and the socket codes `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT`. It
|
|
649
|
+
walks the chain because `BaseClient.publish()` wraps every transport failure in a
|
|
650
|
+
`PublishError` and puts the real class in `cause` — so the classes were never the object
|
|
651
|
+
the function was handed, and until d.676 only its message substrings ever fired. Measured
|
|
652
|
+
consequence of that: a `QueueNotFoundError` with `kind: 'unowned'` answered "not a queue
|
|
653
|
+
problem", and an error whose text merely mentioned `404` answered that it was one.
|
|
654
|
+
|
|
655
|
+
The helper publishes through `mqClient.publishTelemetry()` (d.724), so a refused
|
|
656
|
+
monitoring event cannot close the channel a business message is waiting on. A client
|
|
657
|
+
without that method — anything that is not a `BaseClient` or a subclass of it — is
|
|
658
|
+
reported with a `warn` and answered `false`; the event is not sent on the business
|
|
659
|
+
channel instead.
|
|
660
|
+
|
|
608
661
|
### queueConfig — the classification, as a declared export
|
|
609
662
|
|
|
610
663
|
The table above is not a copy: the names, their arguments and the two predicates
|
|
@@ -667,9 +720,10 @@ topology.telemetryLogsQueue = 'mine'; // TypeError — the object is froz
|
|
|
667
720
|
| `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
721
|
| `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
722
|
| `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
|
|
723
|
+
| `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
724
|
| `telemetryMetricsQueue` | `telemetry.metrics.queue` | The same exchange, the same consumer — metric samples. |
|
|
672
725
|
| `telemetryTracesQueue` | `telemetry.traces.queue` | The same exchange, the same consumer — spans. |
|
|
726
|
+
| `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
727
|
|
|
674
728
|
- **Frozen on purpose.** An importer must not be able to rewrite platform topology: a
|
|
675
729
|
name overridden on one side of a fanout loses the events silently, which is why batch
|
|
@@ -795,6 +849,12 @@ requestedBy })` take. One declaration record, and nothing else:
|
|
|
795
849
|
is a missing record of work that happened; a lost registration is work that will not happen.
|
|
796
850
|
- `scheduler.requestQueue` is the queue name, composed from the declaration in `queueConfig`
|
|
797
851
|
— a caller never types `scheduler.request`.
|
|
852
|
+
- **A request that cannot be built is told apart by code, never by wording.** Every refusal of
|
|
853
|
+
a builder — the port's key check, the declaration rules, tenancy, the key, the requester, the
|
|
854
|
+
event — carries `error.code === scheduler.SCHEDULER_REQUEST_INVALID`; a failed send (no
|
|
855
|
+
connection, a publish the broker refused) never does. The wrapper's boot uses exactly this to
|
|
856
|
+
isolate one bad record as a failed source while a broker that is gone still stops the boot
|
|
857
|
+
([`scheduler` 020](/docs/governance/confirmations/scheduler.md)).
|
|
798
858
|
|
|
799
859
|
Two more things a service says on that queue, and one send for all of them:
|
|
800
860
|
|
|
@@ -847,6 +907,34 @@ await port.cancel({ id });
|
|
|
847
907
|
under the wrapper, which fills it from the envelope of the run, and an operator script that does
|
|
848
908
|
not boot the wrapper at all and works row by row.
|
|
849
909
|
|
|
910
|
+
### decodeMessage — a delivery, decoded by one rail
|
|
911
|
+
|
|
912
|
+
`consume()` hands its handler the RAW amqplib delivery. `decodeMessage(raw)` is the one
|
|
913
|
+
public way from that delivery to the message it carries (d.789), so a consumer does not
|
|
914
|
+
keep its own copy of the translation.
|
|
915
|
+
|
|
916
|
+
```javascript
|
|
917
|
+
const { decodeMessage } = require('@onlineapps/mq-client-core');
|
|
918
|
+
|
|
919
|
+
await client.consume(queue, async (raw) => {
|
|
920
|
+
const { body, headers, properties, fields } = decodeMessage(raw);
|
|
921
|
+
// …
|
|
922
|
+
});
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
- It returns the decoded body, and `headers`, `properties` and `fields` exactly as they
|
|
926
|
+
arrived.
|
|
927
|
+
- It refuses with `ValidationError`, naming what arrived: a delivery that is not an
|
|
928
|
+
object, `content` that is neither a `Buffer` nor a string, an empty body, or a
|
|
929
|
+
DECLARED content type other than `application/json`. No declaration is fine — the
|
|
930
|
+
platform envelope does not require one.
|
|
931
|
+
- A body that is not JSON is the `SerializationError` the serializer throws, passed on
|
|
932
|
+
unchanged — the refused bytes in `payload`, the parser's reason in `cause`.
|
|
933
|
+
- It carries no `retryable` flag: what happens to the message is decided by the
|
|
934
|
+
caller's `classify(error)` (§ `consume()` — the delivery contract), on the error's
|
|
935
|
+
class.
|
|
936
|
+
- It is additive: `consume()` still hands over the raw delivery.
|
|
937
|
+
|
|
850
938
|
### redactUrl — a connection URL that is safe to log
|
|
851
939
|
|
|
852
940
|
`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
|
|
|
@@ -736,6 +758,18 @@ class BaseClient {
|
|
|
736
758
|
* DLQ, as the monitoring intake requires (`api/shared/TODO.md`, INFRA-monitoring
|
|
737
759
|
* 2026-09-11); an event with no `status` is refused there under its own name.
|
|
738
760
|
*
|
|
761
|
+
* `operation` says WHAT died, and it is written ONLY when the envelope carried
|
|
762
|
+
* one: the invocation envelope has the field
|
|
763
|
+
* (`docs/biz/10-invocation/mq-envelope.md` § Inbound envelope), the workflow
|
|
764
|
+
* message of `docs/biz/70-contracts/workflow-message.md` §3.2 never does, and
|
|
765
|
+
* this event reports what it found rather than a marker. It has a reader
|
|
766
|
+
* already — `readReported(data.operation)` in the monitoring consumer
|
|
767
|
+
* (`infra/api_monitoring/src/consumer/apiflow-extension.js`), from where the
|
|
768
|
+
* value reaches the Loki line as `operation`; until it was written here, every
|
|
769
|
+
* dead letter arrived there as `null`. The run's identity stays ONE key,
|
|
770
|
+
* `workflow_id`: no event on this queue carries a `correlation_id`
|
|
771
|
+
* (`docs/biz/70-contracts/monitoring-events.md` § Rules).
|
|
772
|
+
*
|
|
739
773
|
* @param {Object} event - As the transport reports it.
|
|
740
774
|
* @returns {Promise<void>}
|
|
741
775
|
* @private
|
|
@@ -745,16 +779,22 @@ class BaseClient {
|
|
|
745
779
|
const serviceName = runtimeCfg.get('serviceName', this._config.serviceName);
|
|
746
780
|
|
|
747
781
|
let workflowId = null;
|
|
782
|
+
let operation = null;
|
|
748
783
|
try {
|
|
749
784
|
const envelope = serializer.deserialize(content);
|
|
750
|
-
if (envelope !== null && typeof envelope === 'object'
|
|
751
|
-
&& envelope.workflow_id !== '') {
|
|
752
|
-
|
|
785
|
+
if (envelope !== null && typeof envelope === 'object') {
|
|
786
|
+
if (typeof envelope.workflow_id === 'string' && envelope.workflow_id !== '') {
|
|
787
|
+
workflowId = envelope.workflow_id;
|
|
788
|
+
}
|
|
789
|
+
if (typeof envelope.operation === 'string' && envelope.operation !== '') {
|
|
790
|
+
operation = envelope.operation;
|
|
791
|
+
}
|
|
753
792
|
}
|
|
754
793
|
} catch (parseErr) {
|
|
755
794
|
// A payload that is not JSON carries no envelope, so it carries no
|
|
756
795
|
// workflow_id either. Same outcome as an envelope without one.
|
|
757
796
|
workflowId = null;
|
|
797
|
+
operation = null;
|
|
758
798
|
}
|
|
759
799
|
|
|
760
800
|
if (workflowId === null) {
|
|
@@ -775,29 +815,64 @@ class BaseClient {
|
|
|
775
815
|
return;
|
|
776
816
|
}
|
|
777
817
|
|
|
818
|
+
const payload = {
|
|
819
|
+
event_type: 'message_dlq',
|
|
820
|
+
workflow_id: workflowId,
|
|
821
|
+
service_name: serviceName,
|
|
822
|
+
// The broker's word for this death; see the method comment.
|
|
823
|
+
status: 'rejected',
|
|
824
|
+
queue,
|
|
825
|
+
attempts,
|
|
826
|
+
max_attempts: maxAttempts,
|
|
827
|
+
classification,
|
|
828
|
+
error: {
|
|
829
|
+
message: error && error.message,
|
|
830
|
+
name: error && error.name
|
|
831
|
+
},
|
|
832
|
+
timestamp: new Date().toISOString()
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
// …and WHAT died, when the envelope says so. The key is written only when
|
|
836
|
+
// the envelope carried one, in the one spelling the invocation envelope
|
|
837
|
+
// uses; an envelope without it leaves the event without the key rather than
|
|
838
|
+
// with an invented marker, exactly as workflow_id is handled above.
|
|
839
|
+
if (operation !== null) {
|
|
840
|
+
payload.operation = operation;
|
|
841
|
+
}
|
|
842
|
+
|
|
778
843
|
await publishToMonitoringWorkflow(
|
|
779
844
|
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
|
-
},
|
|
845
|
+
payload,
|
|
796
846
|
this._logger,
|
|
797
847
|
{ queue }
|
|
798
848
|
);
|
|
799
849
|
}
|
|
800
850
|
|
|
851
|
+
/**
|
|
852
|
+
* Publish one of the CLIENT'S OWN events — the messages nobody asked it to send.
|
|
853
|
+
*
|
|
854
|
+
* Same path as `publish()` in every respect but one: the send goes out on a channel of
|
|
855
|
+
* its own (`transports/rabbitmqClient.js` § _ensurePublisherChannel, role
|
|
856
|
+
* `'telemetry'`), so a refusal of an event can never close the channel a caller's
|
|
857
|
+
* message is waiting for a confirm on.
|
|
858
|
+
*
|
|
859
|
+
* WHY IT IS A SEPARATE METHOD and not a flag on `publish()`: the caller of `publish()`
|
|
860
|
+
* is sending its own message and has no business choosing a channel; the only code that
|
|
861
|
+
* may say "this is the client talking about itself" is the code that IS the client
|
|
862
|
+
* talking about itself (`src/monitoring-publish.js`). Measured cost of the shared
|
|
863
|
+
* channel, d.711: in a vhost where `monitoring.workflow.fanout` was not declared, the
|
|
864
|
+
* `404` for a dead-letter event took the business publish beside it down with it — 3 of
|
|
865
|
+
* 7 runs of a suite that had nothing to do with telemetry.
|
|
866
|
+
*
|
|
867
|
+
* @param {string} queue - Target queue name.
|
|
868
|
+
* @param {Object|Buffer|string} message - Payload to send.
|
|
869
|
+
* @param {Object} [options] - The same overrides `publish()` takes.
|
|
870
|
+
* @returns {Promise<void>}
|
|
871
|
+
*/
|
|
872
|
+
async publishTelemetry(queue, message, options = {}) {
|
|
873
|
+
return this.publish(queue, message, { ...options, channelRole: 'telemetry' });
|
|
874
|
+
}
|
|
875
|
+
|
|
801
876
|
/**
|
|
802
877
|
* Assert queue existence (delegates to transport).
|
|
803
878
|
* @param {string} queue - Queue name
|
|
@@ -959,7 +1034,8 @@ class BaseClient {
|
|
|
959
1034
|
*
|
|
960
1035
|
* Settling is idempotent: the transport marks the delivery via `msg._mqProcessed`
|
|
961
1036
|
* and `consume()` skips its own auto-ack for a message the handler already
|
|
962
|
-
* settled (`
|
|
1037
|
+
* settled (`RabbitMQClient.consume()` and `_applyDeliveryPolicy()` in
|
|
1038
|
+
* `transports/rabbitmqClient.js`). Calling this from
|
|
963
1039
|
* inside a consume handler is therefore supported, not a double-settle.
|
|
964
1040
|
*
|
|
965
1041
|
* Until 2026-08-28 this method was declared TWICE in this class; the second,
|
|
@@ -143,8 +143,9 @@ module.exports = {
|
|
|
143
143
|
type: 'string',
|
|
144
144
|
minLength: 1,
|
|
145
145
|
description:
|
|
146
|
-
'Name this client publishes under and names its queues with.
|
|
147
|
-
+ 'ENV SERVICE_NAME
|
|
146
|
+
'Name this client publishes under and names its queues with. Required: the explicit '
|
|
147
|
+
+ 'value, else ENV SERVICE_NAME — there is no module default, because a name this '
|
|
148
|
+
+ 'library invented would name a service that does not exist (d.770).',
|
|
148
149
|
},
|
|
149
150
|
connectionName: {
|
|
150
151
|
type: 'string',
|
|
@@ -248,8 +249,24 @@ module.exports = {
|
|
|
248
249
|
},
|
|
249
250
|
publishMaxRetries: {
|
|
250
251
|
type: 'integer',
|
|
251
|
-
|
|
252
|
-
|
|
252
|
+
// ONE, not zero (d.685b). The key counts ATTEMPTS, the first one included, so `0`
|
|
253
|
+
// is not a smaller amount of retrying — it is a publish that never happens: the
|
|
254
|
+
// loop `while (attempt < this._maxRetries)` does not run its body, the message is
|
|
255
|
+
// never offered to the broker and is not buffered on the way out either (measured,
|
|
256
|
+
// d.684). `PublishLayer`'s constructor has refused it by name since that batch,
|
|
257
|
+
// while this declaration still said it was legal — one key with two answers, and
|
|
258
|
+
// the one a service author READS said the wrong one. The declaration is the
|
|
259
|
+
// prescriptive half (`doc-code-binding.md` §1), so it is the half that moved.
|
|
260
|
+
//
|
|
261
|
+
// Both doors stay: this one refuses the value at CONSTRUCTION of a `BaseClient`,
|
|
262
|
+
// the layer's refuses it wherever a transport is built past the schema
|
|
263
|
+
// (`transports/transportFactory.js` validates nothing) or the caller hands the
|
|
264
|
+
// layer its own `options.maxRetries`.
|
|
265
|
+
//
|
|
266
|
+
// "Do not retry" is `publishMaxRetries: 1`; leaving the retry rail is
|
|
267
|
+
// `publishRetryEnabled: false`. Both are explicit (`architecture-principles.md` §8).
|
|
268
|
+
minimum: 1,
|
|
269
|
+
description: 'How many publish attempts one message gets before the publish fails; at least 1, because the first attempt is one of them.',
|
|
253
270
|
},
|
|
254
271
|
publishRetryBaseDelay: {
|
|
255
272
|
type: 'integer',
|
|
@@ -16,9 +16,15 @@
|
|
|
16
16
|
* (`change-discipline.md`), and the `||` was worse than the duplication: it means
|
|
17
17
|
* "if falsy", not "if absent", and the schema declares `minimum: 0` for these
|
|
18
18
|
* knobs. A caller who wrote `reconnectBaseDelay: 0` ("retry immediately"),
|
|
19
|
-
* `
|
|
20
|
-
* the first close") had the value replaced by its opposite, without a
|
|
21
|
-
* Measured on 2026-09-14: twelve knobs swallowed a written `0`.
|
|
19
|
+
* `publishRetryBaseDelay: 0` ("no wait between attempts") or `thrashingThreshold: 0`
|
|
20
|
+
* ("alert on the first close") had the value replaced by its opposite, without a
|
|
21
|
+
* word. Measured on 2026-09-14: twelve knobs swallowed a written `0`.
|
|
22
|
+
*
|
|
23
|
+
* `publishMaxRetries: 0` was named among those examples until d.684, glossed as "do
|
|
24
|
+
* not retry". It never meant that: the key counts ATTEMPTS, the first one included
|
|
25
|
+
* (see it below, and `configSchema.js`), so `0` says "never offer this message to the
|
|
26
|
+
* broker" — which is what the loop did. It is now refused by name at construction
|
|
27
|
+
* (`layers/PublishLayer.js`); "one attempt, no retry" is `1`.
|
|
22
28
|
*
|
|
23
29
|
* `??` was not the answer either. It fixes the falsy half and leaves the value
|
|
24
30
|
* written in two places, which is the half that rots.
|
|
@@ -100,7 +106,11 @@ module.exports = {
|
|
|
100
106
|
|
|
101
107
|
publishRetryEnabled: true,
|
|
102
108
|
|
|
103
|
-
// How many attempts one publish gets before it is buffered or reported
|
|
109
|
+
// How many attempts one publish gets before it is buffered or reported — the first
|
|
110
|
+
// attempt included, so `1` is "one attempt, no retry". At least 1: `0` would mean the
|
|
111
|
+
// message is never offered to the broker, and it is refused by name rather than
|
|
112
|
+
// obeyed (`layers/PublishLayer.js`, d.684). To leave the retry rail entirely, write
|
|
113
|
+
// `publishRetryEnabled: false`.
|
|
104
114
|
publishMaxRetries: 3,
|
|
105
115
|
|
|
106
116
|
// First wait between two attempts, in ms, multiplied by
|
|
@@ -51,16 +51,22 @@
|
|
|
51
51
|
* environment form goes through numeric coercion, because an environment
|
|
52
52
|
* variable has no other shape (`../config.js`, `../defaults.js`).
|
|
53
53
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* (`
|
|
58
|
-
*
|
|
59
|
-
*
|
|
54
|
+
* The option rules — `requeueOnError` a boolean, `requeueOnError: false` meaning
|
|
55
|
+
* one attempt and contradicting any other `maxAttempts`, the budget an integer
|
|
56
|
+
* >= 1, `classify` a function — live in `validateConsumeOptions` below, the one
|
|
57
|
+
* owner the transport's `consume()` calls and the package exports (d.1005), so a
|
|
58
|
+
* consumer's test double asks the same function instead of copying the rules.
|
|
59
|
+
* Every refusal opens with the literal context of the call it guards,
|
|
60
|
+
* `[RabbitMQClient]`, written at the throw site: a context passed in as a
|
|
61
|
+
* parameter would be invisible to the guard that checks it
|
|
62
|
+
* (`tests/unit/error-message-contract.test.js`), and the texts are the ones
|
|
63
|
+
* `consume()` has always thrown.
|
|
60
64
|
*
|
|
61
65
|
* @see ../../README.md § consume() — the delivery contract
|
|
62
66
|
*/
|
|
63
67
|
|
|
68
|
+
const { ValidationError } = require('../utils/errorHandler');
|
|
69
|
+
|
|
64
70
|
/** Header carrying how many attempts a message has already cost. Never a literal elsewhere. */
|
|
65
71
|
const ATTEMPTS_HEADER = 'x-oa-delivery-attempts';
|
|
66
72
|
|
|
@@ -153,6 +159,79 @@ function classifyError(classify, error) {
|
|
|
153
159
|
return { classification: answer, failure: null };
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Validate the options of `consume()` and resolve the delivery policy they ask for.
|
|
164
|
+
*
|
|
165
|
+
* `requeueOnError` is the SAME policy as `maxAttempts` said in one word, never a
|
|
166
|
+
* second one: `false` means a budget of one attempt, `true` or absent leaves the
|
|
167
|
+
* budget to `maxAttempts` or the default. `requeueOnError: false` together with
|
|
168
|
+
* any `maxAttempts` other than 1 is refused rather than resolved by preferring
|
|
169
|
+
* one of the two. An explicit `maxAttempts` is taken exactly as written — never
|
|
170
|
+
* coerced. `classify` defaults to `defaultClassification`.
|
|
171
|
+
*
|
|
172
|
+
* @param {Object} options the options passed to `consume()`
|
|
173
|
+
* @param {function(): number} resolveDefaultMaxAttempts returns the budget when
|
|
174
|
+
* the caller names none — the transport resolves its configuration there
|
|
175
|
+
* (explicit → env RABBITMQ_MAX_DELIVERY_ATTEMPTS → defaults.js). Called ONLY
|
|
176
|
+
* when the options leave the budget open: resolving it reads configuration,
|
|
177
|
+
* which can refuse on its own, and an explicit `maxAttempts` or
|
|
178
|
+
* `requeueOnError: false` never needed it. Its value is validated like an
|
|
179
|
+
* explicit one.
|
|
180
|
+
* @returns {{maxAttempts: number, classify: function(Error): string}}
|
|
181
|
+
* @throws {ValidationError} the first rule the options break, in the words
|
|
182
|
+
* `consume()` refuses them with
|
|
183
|
+
*/
|
|
184
|
+
function validateConsumeOptions(options, resolveDefaultMaxAttempts) {
|
|
185
|
+
if (options.requeueOnError !== undefined && typeof options.requeueOnError !== 'boolean') {
|
|
186
|
+
throw new ValidationError(
|
|
187
|
+
'[RabbitMQClient] Invalid requeueOnError - Expected: true or false, got '
|
|
188
|
+
+ `${JSON.stringify(options.requeueOnError)}. Fix: pass consume(queue, handler, `
|
|
189
|
+
+ '{ requeueOnError: false }) to dead-letter on the first failure, or leave it out and '
|
|
190
|
+
+ 'let the delivery budget decide.'
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const noRequeue = options.requeueOnError === false;
|
|
195
|
+
|
|
196
|
+
if (noRequeue && options.maxAttempts !== undefined && options.maxAttempts !== 1) {
|
|
197
|
+
throw new ValidationError(
|
|
198
|
+
'[RabbitMQClient] Contradictory delivery policy - requeueOnError: false means one attempt, '
|
|
199
|
+
+ `but maxAttempts says ${JSON.stringify(options.maxAttempts)}. Expected: one statement about `
|
|
200
|
+
+ 'how many times a handler may run. Fix: drop requeueOnError to keep the budget, or drop '
|
|
201
|
+
+ 'maxAttempts to dead-letter on the first failure.'
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const maxAttempts = noRequeue
|
|
206
|
+
? 1
|
|
207
|
+
: options.maxAttempts === undefined
|
|
208
|
+
? resolveDefaultMaxAttempts()
|
|
209
|
+
: options.maxAttempts;
|
|
210
|
+
|
|
211
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
212
|
+
throw new ValidationError(
|
|
213
|
+
'[RabbitMQClient] Invalid delivery budget - maxAttempts must be an integer >= 1, got '
|
|
214
|
+
+ `${JSON.stringify(maxAttempts)}. Fix: pass consume(queue, handler, { maxAttempts: <integer >= 1> }), `
|
|
215
|
+
+ 'or leave it out and let the module default (defaults.js maxDeliveryAttempts, '
|
|
216
|
+
+ 'env RABBITMQ_MAX_DELIVERY_ATTEMPTS) decide.'
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const classify = options.classify === undefined
|
|
221
|
+
? defaultClassification
|
|
222
|
+
: options.classify;
|
|
223
|
+
|
|
224
|
+
if (typeof classify !== 'function') {
|
|
225
|
+
throw new ValidationError(
|
|
226
|
+
'[RabbitMQClient] Invalid error classifier - classify must be a function returning '
|
|
227
|
+
+ `'transient' or 'permanent', got ${typeof options.classify}. Fix: pass consume(queue, handler, `
|
|
228
|
+
+ '{ classify: (error) => … }), or leave it out and every error is treated as transient.'
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { maxAttempts, classify };
|
|
233
|
+
}
|
|
234
|
+
|
|
156
235
|
module.exports = {
|
|
157
236
|
ATTEMPTS_HEADER,
|
|
158
237
|
TRANSIENT,
|
|
@@ -162,4 +241,5 @@ module.exports = {
|
|
|
162
241
|
defaultClassification,
|
|
163
242
|
readAttemptsMade,
|
|
164
243
|
classifyError,
|
|
244
|
+
validateConsumeOptions,
|
|
165
245
|
};
|