@onlineapps/mq-client-core 4.1.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 +766 -0
- package/README.md +226 -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 +141 -60
- package/src/config/recurrencesSchema.js +121 -0
- package/src/config.js +5 -1
- package/src/defaults.js +7 -1
- package/src/index.js +67 -4
- package/src/layers/PublishLayer.js +113 -5
- package/src/monitoring-publish.js +89 -40
- package/src/scheduler-request.js +502 -0
- package/src/transports/rabbitmqClient.js +1303 -318
- 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
|
|
@@ -619,8 +672,16 @@ queueConfig.isBusinessQueue('emailer.workflow'); // true
|
|
|
619
672
|
queueConfig.getInfrastructureQueueConfig('workflow.init'); // { durable, arguments }
|
|
620
673
|
queueConfig.getDeadLetterRoute('workflow.init'); // { exchange: '', routingKey: 'workflow.failed' }
|
|
621
674
|
queueConfig.isDeadLetterTarget('workflow.failed'); // true — the end of that chain
|
|
675
|
+
queueConfig.deadLetterExchange(); // 'dlx' — composed from queueConfig.deadLetter
|
|
622
676
|
```
|
|
623
677
|
|
|
678
|
+
- The platform's dead-letter exchange is declared like every other exchange here —
|
|
679
|
+
`queueConfig.deadLetter.dlx` is `{ type: 'exchange', exchangeType: 'direct', durable: true }`,
|
|
680
|
+
the shape its founder (`@onlineapps/conn-infra-mq` `setupServiceQueues()`) asserts. Its wire
|
|
681
|
+
name carries no prefix: the `deadLetter` section declares an empty one, so
|
|
682
|
+
`queueName('deadLetter', 'dlx')` is `'dlx'`, and a reader walking the `type: 'exchange'`
|
|
683
|
+
entries finds it with the rest.
|
|
684
|
+
|
|
624
685
|
- `workflow.failed` carries no `x-message-ttl`. It is read by an operator, not by a
|
|
625
686
|
service, and it has no dead-letter exchange, so a TTL there does not move a message
|
|
626
687
|
anywhere — it deletes it. See
|
|
@@ -659,9 +720,10 @@ topology.telemetryLogsQueue = 'mine'; // TypeError — the object is froz
|
|
|
659
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`). |
|
|
660
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, …)`). |
|
|
661
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. |
|
|
662
|
-
| `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`). |
|
|
663
724
|
| `telemetryMetricsQueue` | `telemetry.metrics.queue` | The same exchange, the same consumer — metric samples. |
|
|
664
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). |
|
|
665
727
|
|
|
666
728
|
- **Frozen on purpose.** An importer must not be able to rewrite platform topology: a
|
|
667
729
|
name overridden on one side of a fanout loses the events silently, which is why batch
|
|
@@ -720,6 +782,159 @@ trade a real effect for a record of it — a lost copy is reported through the l
|
|
|
720
782
|
answers `false`. The one exception is a missing logger, which is the transport half's rule:
|
|
721
783
|
without it the promise "every failure is reported" cannot hold.
|
|
722
784
|
|
|
785
|
+
**The two builders of this package fail differently, on purpose.** This one answers `false` and
|
|
786
|
+
reports; `scheduler.publishSchedulerRequest()` below throws. A lost monitoring copy is a missing
|
|
787
|
+
record of work that happened; a lost registration is work that will never happen, and nothing
|
|
788
|
+
rings for it.
|
|
789
|
+
|
|
790
|
+
### scheduler — what a service says to the platform's owner of time
|
|
791
|
+
|
|
792
|
+
A service that runs work on a schedule ANSWERS with the recurrences it owns, through one hook
|
|
793
|
+
the wrapper asks at boot, and the wrapper re-asserts each of them as a `scheduler.request` of
|
|
794
|
+
kind `recurring` ([scheduler.md §7](/docs/architecture/scheduler.md)). Re-registration is an
|
|
795
|
+
upsert — a recurring request's `idempotency_key` IS the schedule id — so a boot that re-asserts
|
|
796
|
+
the declared state costs nothing and drifts nowhere.
|
|
797
|
+
|
|
798
|
+
ONE RAIL FOR BOTH KINDS (confirmation `scheduler` 011): the hook answers a constant list for
|
|
799
|
+
platform recurrences and a list derived from the service's own data for tenant ones. This
|
|
800
|
+
package owns the SHAPE of a record and the message built from it — never where the record comes
|
|
801
|
+
from, which is the service's business.
|
|
802
|
+
|
|
803
|
+
```javascript
|
|
804
|
+
// In the service, the hook the wrapper asks at boot (FÁZE 0.7b). Every record carries its own
|
|
805
|
+
// tenancy, derived from the service's data — at boot there is no invocation envelope to take
|
|
806
|
+
// it from (confirmations `scheduler` 002, 010):
|
|
807
|
+
new ServiceWrapper({
|
|
808
|
+
scheduler: {
|
|
809
|
+
recurrences: async () => ([
|
|
810
|
+
{ id: '31bf8bac:servant-poll', cookbookRef: 'servant-poll', cron: '0 * * * *',
|
|
811
|
+
timezone: 'Europe/Prague', tenantId: 101, workspaceId: 1, input: { source_uuid: '31bf8bac' } }
|
|
812
|
+
])
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
// What the wrapper does with the answer — the port this package builds:
|
|
817
|
+
const { scheduler } = require('@onlineapps/mq-client-core');
|
|
818
|
+
const port = scheduler.createSchedulerPublisher({ mqClient, requestedBy: serviceName });
|
|
819
|
+
for (const record of answered) await port.registerRecurring(record);
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
The port record is camelCase — `{ id, cookbookRef, cron, timezone, tenantId, workspaceId,
|
|
823
|
+
input?, catchUp? }` — and the port turns it into the declaration record below, which is what
|
|
824
|
+
`validateRecurrences()` and `buildRecurringRequest({ declaration, tenantId, workspaceId,
|
|
825
|
+
requestedBy })` take. One declaration record, and nothing else:
|
|
826
|
+
|
|
827
|
+
| Key | Required | Meaning |
|
|
828
|
+
|---|---|---|
|
|
829
|
+
| `id` | ✅ | The schedule's stable id; it travels as `idempotency_key`, so two records of one id are refused |
|
|
830
|
+
| `cookbook_ref` | ✅ | The cookbook to run. Its `triggers` must name this service and the kind `recurring`, or the scheduler refuses the request |
|
|
831
|
+
| `cron` | ✅ | Five fields, the same expression the scheduler's contract takes |
|
|
832
|
+
| `timezone` | ✅ | An explicit IANA zone; there is no implicit one |
|
|
833
|
+
| `input` | – | Handed to the cookbook unchanged — a scheduled run that writes says so in the record, while the same cookbook run by hand can be given a different input |
|
|
834
|
+
| `catch_up` | – | Absent means false, and the scheduler materialises that default |
|
|
835
|
+
|
|
836
|
+
- **Tenancy is the caller's, on every recurrence — never the environment, never a default.**
|
|
837
|
+
Both ids are mandatory and ≥ 1 (the owner rejected `0/0` as a platform scope, confirmation
|
|
838
|
+
`scheduler` 002), and a missing one is refused by name. The declaration record above does not
|
|
839
|
+
carry them; the builder takes them beside it, and the port record carries them per entry.
|
|
840
|
+
Who fills them depends on where the call comes from: the boot hook's records must carry
|
|
841
|
+
them (no envelope exists at boot), while `ctx.scheduler` inside a handler fills them from the
|
|
842
|
+
invocation envelope and refuses them from the caller.
|
|
843
|
+
- **An empty answer is an answer.** A service with no recurrences has no hook, or answers an
|
|
844
|
+
empty list, and the wrapper skips its step. An answer that does NOT hold throws — a
|
|
845
|
+
recurrence nobody registered is work that never happens, and nothing else rings for it.
|
|
846
|
+
- **`validateRecurrences(document, source)` names its source**, and the name is required: every
|
|
847
|
+
refusal quotes it, so a default here would put one caller's name on another caller's mistake.
|
|
848
|
+
- **Sending is fail-fast**, unlike the monitoring helpers beside it. A lost monitoring copy
|
|
849
|
+
is a missing record of work that happened; a lost registration is work that will not happen.
|
|
850
|
+
- `scheduler.requestQueue` is the queue name, composed from the declaration in `queueConfig`
|
|
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)).
|
|
858
|
+
|
|
859
|
+
Two more things a service says on that queue, and one send for all of them:
|
|
860
|
+
|
|
861
|
+
```javascript
|
|
862
|
+
// THAT something happened. It names no cookbook: the scheduler starts every recipe whose
|
|
863
|
+
// `triggers` declare this event, so a producer naming a recipe would map work to something it
|
|
864
|
+
// may not own.
|
|
865
|
+
await scheduler.publishSchedulerRequest(mqClient, scheduler.buildEventRequest({
|
|
866
|
+
event: { service: 'biz-ingest', name: 'job-persisted' },
|
|
867
|
+
idempotencyKey: `biz-ingest:job-persisted:${jobId}`, // the identity of the fact
|
|
868
|
+
tenantId, workspaceId, requestedBy: serviceName,
|
|
869
|
+
input: { source_uuid } // optional, handed to every cookbook it starts
|
|
870
|
+
}));
|
|
871
|
+
|
|
872
|
+
// An item back, by the key it was registered with.
|
|
873
|
+
await scheduler.publishSchedulerRequest(mqClient, scheduler.buildCancelRequest({
|
|
874
|
+
idempotencyKey: 'servant-poll',
|
|
875
|
+
requestedBy: serviceName
|
|
876
|
+
}));
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
- **The key is always the caller's.** `idempotency_key` is required for every kind, and the
|
|
880
|
+
scheduler decides a one-shot item ONCE by it — a second `event` carrying a known key is
|
|
881
|
+
answered `duplicate`. A key this package invented per call would turn every retry of one fact
|
|
882
|
+
into a second run, so no builder makes one up.
|
|
883
|
+
- **`cancel` carries no tenancy**, and needs none: the key names an item that already holds the
|
|
884
|
+
context it was registered with. Cancelling a key that names nothing is a result rather than an
|
|
885
|
+
error — the scheduler answers `cancel-nothing` — so the builder judges nothing about it.
|
|
886
|
+
- **One send, `publishSchedulerRequest()`**, for every kind: the kind is a field of the message,
|
|
887
|
+
never a second function. It is fail-fast like the registration above, and it refuses a message
|
|
888
|
+
none of these builders produced.
|
|
889
|
+
|
|
890
|
+
A service that speaks to the scheduler more than once holds a port instead of repeating its own
|
|
891
|
+
name and the queue:
|
|
892
|
+
|
|
893
|
+
```javascript
|
|
894
|
+
const port = scheduler.createSchedulerPublisher({ mqClient, requestedBy: serviceName });
|
|
895
|
+
|
|
896
|
+
await port.registerRecurring({ id, cookbookRef, cron, timezone, tenantId, workspaceId, input, catchUp });
|
|
897
|
+
await port.publishEvent({ name: 'job-persisted', idempotencyKey: jobUuid, tenantId, workspaceId, input });
|
|
898
|
+
await port.cancel({ id });
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
- **The client and the queue are decided at construction**, not at the first call: a service that
|
|
902
|
+
announces facts has to fall over at boot rather than at its first document. On a pin whose
|
|
903
|
+
`queueConfig` declares no `scheduler` section this module refuses when it is required, earlier
|
|
904
|
+
still. Every refusal names the slot it is about and how to fix it, so a defect of the wiring can
|
|
905
|
+
never be read as the operation's own error.
|
|
906
|
+
- **Tenancy stays explicit on every call**, because two kinds of caller hold this port: a service
|
|
907
|
+
under the wrapper, which fills it from the envelope of the run, and an operator script that does
|
|
908
|
+
not boot the wrapper at all and works row by row.
|
|
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
|
+
|
|
723
938
|
### redactUrl — a connection URL that is safe to log
|
|
724
939
|
|
|
725
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
|