@onlineapps/mq-client-core 3.0.0 → 3.0.1
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 +6 -0
- package/README.md +24 -0
- package/package.json +1 -1
- package/src/BaseClient.js +8 -0
- package/src/config/queueConfig.js +84 -0
- package/src/transports/rabbitmqClient.js +53 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [3.0.1] — 2026-09-14
|
|
8
|
+
|
|
9
|
+
- **Fixed: `consume()` accepts a TERMINAL queue — one this configuration dead-letters TO — instead of demanding a dead-letter route it must not have.** The d.259 gate refuses a queue for which `queueConfig` declares no `x-dead-letter-exchange`/`-routing-key`, because the policy ends a spent message with `nack(requeue=false)` and the broker drops what it cannot move. True of every working queue — and false of the queues that RECEIVE those moves: `workflow.failed` is named in `x-dead-letter-routing-key` by `workflow.init` and `workflow.control`, `<service>.dlq` by every business template, `workflow.dlq` and `delivery.dlq` by their own families, and the confirmed topology leaves exactly those without a route of their own (`api/docs/governance/confirmations/mq-consumer-contract.md` 002 point 2, 003 point 2 — "workflow.failed and workflow.dlq themselves keep no route"). A dead-letter route on a dead-letter queue is a loop or a chain with no end. So `workflow.failed` — the operator's queue, read by the delivery dispatcher — was unconsumable: `[BaseClient] Cannot consume from queue "workflow.failed": queueConfig declares no dead-letter route`. The gate now asks a second question, `queueConfig.isDeadLetterTarget(queue)`, **derived from the templates** (the set of every declared `x-dead-letter-routing-key`, `{service}` placeholder read through the same `templateShape()` the business templates use) rather than from a list of names that would have to be edited in step with them — the defect d.278 removed from `matchBusinessTemplate()`. The `.dlq` family needs no rule of its own: every `.dlq` this configuration declares is already the target of a declared routing key (measured 2026-09-14). Every other queue with no route is refused exactly as before, with the same sentence. A message that spends its budget on a terminal queue is rejected the same way and then DROPPED by the broker — the end of the chain is the end — never requeued for ever, and the loss is now logged at error level (`Message discarded on terminal queue "<queue>"`, with the queue, the attempts and the error) before the `message_dlq` event is published. Tests: new unit `consume-terminal-queue.test.js` (10 — the derivation from the declared keys, the terminal names and the non-terminal control, `consume('workflow.failed')` and `consume('<svc>.dlq')` attaching, the three refusals that must not change with the refusal verbatim, the discard with its log line and meta, and a routed queue as the control that says nothing about a discard); integration `dead-letter-route-required.integration.test.js` (+2 against the live broker: a message rejected from `<svc>.queue` reaches `<svc>.dlq` and is DELIVERED to a consumer there with `x-death[0].reason = 'rejected'`, and a handler failing on that terminal queue leaves the queue empty with no second delivery after 2 s — the drop no mock can measure). Three tests that encoded the old rule follow the new one: the `<svc>.dlq` refusal leg of `consume-dead-letter-route-required.test.js` (its subject moved to the new file), and the two integration control queues that used a dead-letter queue as "the queue with no route" (`delivery-events-lookup` → `monitoring.workflow`, `queue-declaration-options-one-rail` → `<svc>.events`).
|
|
10
|
+
|
|
11
|
+
## [3.0.0] — 2026-09-14
|
|
12
|
+
|
|
7
13
|
- **Changed (BREAKING for a reader of the log, not for a caller): the credential redaction removes the WHOLE userinfo, not just the password.** `utils/redactCredentials.js` masked the password (`amqp://oa_dev:***@queuer:5672`) and kept the account name, while the platform's two other redactors — `shared/service-common/src/redactUrl.js` (d.245/d.245b) and `shared/connector/conn-orch-registry/src/redactUrl.js` (d.446, whose header says plainly that it stayed local because this package masked the password only) — drop the userinfo entirely. One concern, two semantics, which is two rails (`.claude/rules/change-discipline.md` § One rail per concern). The platform one wins: `amqp://queuer:5672/vhost?heartbeat=30`, the account name gone with the password, because a name is the other half of the same credential and a log line needs neither. A value that is not a parseable URL is answered with `<unparseable-url>` instead of being echoed — previously a non-URL string came back verbatim (`api_services_queuer`) or with a `***@` prefix, and an unparseable value is exactly the case where nobody can say whether it holds a credential. `redactConnectionTarget()` still THROWS on a non-string: it guards a declared config key, where a wrong type is a boot-time defect. Tests: unit `redact-credentials.test.js` (rewritten to the new semantics, +6 over the package: the userinfo cases, the transport's `?heartbeat` query as a control, the placeholder cases and the export identity), `connect-credentials-not-logged.test.js` and `one-owner-a-floor-down.test.js` (both now assert the account name is absent too), integration `credential-redaction.integration.test.js` (+2: the LIVE broker accepts the real credential while no logger argument carries either half of it, and a real 403 refusal leaks nothing into the error, the stack or the log) (d.448).
|
|
8
14
|
- **Added:** `redactUrl` and `UNPARSEABLE_PLACEHOLDER` are exports of `src/index.js`. The dependants that render an AMQP URL into a log line or an error message (`conn-orch-registry`, `conn-infra-mq`) each carry a local copy of the same function; a copy can only be replaced by an import if the package declares one (d.448).
|
|
9
15
|
- **Fixed:** a reconnect wait ends only on an error that carries NO publish classification. d.435 taught `_waitForReconnection()` to skip a RETRYABLE publish failure, which left the same defect standing one class of error further out: a `PermanentPublishError` or a `QueueNotFoundError` from a concurrent publish still rejected every waiter, although "no retry will help this message" says nothing about whether the link is coming back. The condition is now the PRESENCE of the classification, not its verdict — `utils/publishErrors.js` sets `retryable` on every error it produces, so an error that never went through that rail carries none, and that absence is the whole test (plus the transport's own typed close, which never ended the wait). No second list of codes. Tests: unit `wait-for-reconnection-not-ended-by-a-foreign-publish.test.js` (+4, both permanent classifications built by the real rail, controls for an unclassified error and for the typed close), integration `…integration.test.js` (+1: the live broker refuses a publish to a name no declaration owns with a real 404, and that real `QueueNotFoundError` is put on the channel during a real outage — a permanent classification cannot arise while the link is down, where everything classifies transient) (d.435b).
|
package/README.md
CHANGED
|
@@ -216,6 +216,24 @@ broker cannot be asked, because `checkQueue()` answers with `queue.declare-ok`,
|
|
|
216
216
|
carries `{ queue, messageCount, consumerCount }` and no arguments (measured, 2026-09-12).
|
|
217
217
|
`queueConfig.getDeadLetterRoute(queue)` is the exported form of the same answer.
|
|
218
218
|
|
|
219
|
+
**A TERMINAL queue is consumed, and it is not an exception to that rule but its other
|
|
220
|
+
half.** A queue something else dead-letters TO — `<service>.dlq`, `workflow.failed`,
|
|
221
|
+
`workflow.dlq`, `delivery.dlq` — declares no onward route because the topology says it
|
|
222
|
+
must not have one: a dead-letter route on a dead-letter queue closes a loop or starts a
|
|
223
|
+
chain with no end (`api/docs/governance/confirmations/mq-consumer-contract.md` 002
|
|
224
|
+
point 2, 003 point 2). So the gate asks a second question, `queueConfig.isDeadLetterTarget(queue)`,
|
|
225
|
+
derived from the templates themselves — the set of every `x-dead-letter-routing-key` they
|
|
226
|
+
declare, `{service}` placeholder and all — never from a list of names. Every OTHER queue
|
|
227
|
+
with no route is refused exactly as before, with the same sentence.
|
|
228
|
+
|
|
229
|
+
What a terminal queue does NOT get is a different delivery policy: the budget is counted
|
|
230
|
+
the same way, and a message that spends it is rejected with `nack(requeue=false)` the same
|
|
231
|
+
way. The difference is what the broker then does with it — there is nowhere to move it to,
|
|
232
|
+
so it is **dropped**. That loss is deliberate (the alternative is the endless requeue this
|
|
233
|
+
policy exists to end) and it is never silent: the client logs it at error level, naming
|
|
234
|
+
the queue, the attempts and the error, and the `message_dlq` event is published as for any
|
|
235
|
+
other rejection.
|
|
236
|
+
|
|
219
237
|
```javascript
|
|
220
238
|
await client.consume(queue, async (msg, delivery) => {
|
|
221
239
|
// delivery = { attempt, maxAttempts, isFinalAttempt }
|
|
@@ -527,12 +545,18 @@ const { queueConfig } = require('@onlineapps/mq-client-core');
|
|
|
527
545
|
queueConfig.isInfrastructureQueue('workflow.init'); // true
|
|
528
546
|
queueConfig.isBusinessQueue('emailer.workflow'); // true
|
|
529
547
|
queueConfig.getInfrastructureQueueConfig('workflow.init'); // { durable, arguments }
|
|
548
|
+
queueConfig.getDeadLetterRoute('workflow.init'); // { exchange: '', routingKey: 'workflow.failed' }
|
|
549
|
+
queueConfig.isDeadLetterTarget('workflow.failed'); // true — the end of that chain
|
|
530
550
|
```
|
|
531
551
|
|
|
532
552
|
- `workflow.failed` carries no `x-message-ttl`. It is read by an operator, not by a
|
|
533
553
|
service, and it has no dead-letter exchange, so a TTL there does not move a message
|
|
534
554
|
anywhere — it deletes it. See
|
|
535
555
|
`api/docs/governance/confirmations/mq-consumer-contract.md` 002.
|
|
556
|
+
- `isDeadLetterTarget()` answers "does this configuration dead-letter TO this name",
|
|
557
|
+
derived from the declared routing keys rather than from a list. It is what lets
|
|
558
|
+
`consume()` attach to `workflow.failed` and to any `<service>.dlq` while every other
|
|
559
|
+
queue with no declared route stays refused — see § `consume()` — the delivery contract.
|
|
536
560
|
|
|
537
561
|
**That require is the contract.** Reaching into
|
|
538
562
|
`@onlineapps/mq-client-core/src/config/queueConfig` is an internal path the package
|
package/package.json
CHANGED
package/src/BaseClient.js
CHANGED
|
@@ -502,6 +502,14 @@ class BaseClient {
|
|
|
502
502
|
* `message_dlq` event would report a destination nothing routed to. The fix is
|
|
503
503
|
* always to declare the route, never to consume without one.
|
|
504
504
|
*
|
|
505
|
+
* The one queue that demand cannot be made of is the END of a chain — a queue
|
|
506
|
+
* this configuration dead-letters TO (`<service>.dlq`, `workflow.failed`). The
|
|
507
|
+
* topology withholds its route deliberately, so the gate reads a second,
|
|
508
|
+
* derived answer (`queueConfig.isDeadLetterTarget()`) and lets its consumer
|
|
509
|
+
* attach (d.452). A message that spends its budget there is rejected as
|
|
510
|
+
* anywhere else and then dropped by the broker, with an error line naming the
|
|
511
|
+
* loss — never an endless requeue. README § `consume()` — the delivery contract.
|
|
512
|
+
*
|
|
505
513
|
* @param {string} queue - Name of the queue to consume from.
|
|
506
514
|
* @param {function(Object, {attempt: number, maxAttempts: number, isFinalAttempt: boolean}): Promise<void>} messageHandler -
|
|
507
515
|
* Async function to process each message. Its second argument says which
|
|
@@ -100,6 +100,10 @@ const TEMPLATE_PLACEHOLDER = /^\{[^.{}]+\}$/;
|
|
|
100
100
|
* service name, with `null` where the template accepts anything (`registry.events`
|
|
101
101
|
* -> `['registry', 'events']`; a key writing `registry.{kind}` would give
|
|
102
102
|
* `['registry', null]`).
|
|
103
|
+
*
|
|
104
|
+
* A declared dead-letter routing key is written in the same notation
|
|
105
|
+
* (`{service}.dlq`), so `isDeadLetterTarget()` reads it through this one function
|
|
106
|
+
* rather than through a second placeholder rule.
|
|
103
107
|
*/
|
|
104
108
|
const templateShape = (template) =>
|
|
105
109
|
template.split('.').map((part) => (TEMPLATE_PLACEHOLDER.test(part) ? null : part));
|
|
@@ -169,6 +173,41 @@ const declaredInfrastructureNames = (config) => {
|
|
|
169
173
|
return names;
|
|
170
174
|
};
|
|
171
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Every name this configuration dead-letters TO — the END of a chain.
|
|
178
|
+
*
|
|
179
|
+
* Derived from the declarations themselves: the value of every
|
|
180
|
+
* `x-dead-letter-routing-key` any section writes, placeholders and all
|
|
181
|
+
* (`{service}.dlq`, `workflow.failed`, `workflow.dlq`, `delivery.dlq`). A list of
|
|
182
|
+
* names typed here would have to be edited in step with the templates and could
|
|
183
|
+
* therefore disagree with them — the defect d.278 removed from
|
|
184
|
+
* `matchBusinessTemplate()`, and the reason `declaredInfrastructureNames()` above
|
|
185
|
+
* derives its set the same way.
|
|
186
|
+
*
|
|
187
|
+
* The `.dlq` family needs no rule of its own: every `.dlq` this configuration
|
|
188
|
+
* declares — `workflow.dlq`, `delivery.dlq`, `{service}.dlq` — is already the
|
|
189
|
+
* target of a declared routing key (measured 2026-09-14), so the set covers it.
|
|
190
|
+
* A `.dlq` nothing routes to would be a queue with no writer, which is a defect
|
|
191
|
+
* in the declaration, not a case to paper over here.
|
|
192
|
+
*
|
|
193
|
+
* @param {Object} config - The queueConfig object (its sections are read from `this`).
|
|
194
|
+
* @returns {Set<string>} The routing keys, e.g. `workflow.failed`, `{service}.dlq`.
|
|
195
|
+
*/
|
|
196
|
+
const declaredDeadLetterTargets = (config) => {
|
|
197
|
+
const targets = new Set();
|
|
198
|
+
|
|
199
|
+
for (const section of Object.values(config)) {
|
|
200
|
+
if (section === null || typeof section !== 'object') continue;
|
|
201
|
+
for (const entry of Object.values(section)) {
|
|
202
|
+
if (entry === null || typeof entry !== 'object') continue;
|
|
203
|
+
const routingKey = entry.arguments && entry.arguments['x-dead-letter-routing-key'];
|
|
204
|
+
if (typeof routingKey === 'string' && routingKey !== '') targets.add(routingKey);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return targets;
|
|
209
|
+
};
|
|
210
|
+
|
|
172
211
|
/**
|
|
173
212
|
* The two ways out of "this configuration declares no such queue", named in the
|
|
174
213
|
* refusal itself.
|
|
@@ -1222,6 +1261,51 @@ module.exports = {
|
|
|
1222
1261
|
return { exchange, routingKey };
|
|
1223
1262
|
},
|
|
1224
1263
|
|
|
1264
|
+
/**
|
|
1265
|
+
* Is this queue the END of a dead-letter chain — a queue something
|
|
1266
|
+
* dead-letters TO?
|
|
1267
|
+
*
|
|
1268
|
+
* The counterpart of `getDeadLetterRoute()`, and the reason a queue may have no
|
|
1269
|
+
* route without that being a defect. `workflow.failed` is named in
|
|
1270
|
+
* `x-dead-letter-routing-key` by `workflow.init` and `workflow.control`;
|
|
1271
|
+
* `{service}.dlq` by every business template; `workflow.dlq` and `delivery.dlq`
|
|
1272
|
+
* by their own families. Giving such a queue a route of its own would either
|
|
1273
|
+
* close a loop or start an endless chain, which is why the confirmed topology
|
|
1274
|
+
* leaves them without one (`docs/governance/confirmations/mq-consumer-contract.md`
|
|
1275
|
+
* 002 point 2, 003 point 2 — "workflow.failed and workflow.dlq themselves keep
|
|
1276
|
+
* no route").
|
|
1277
|
+
*
|
|
1278
|
+
* The consumer's dead-letter gate reads this second question, so a terminal
|
|
1279
|
+
* queue is consumable while every OTHER queue with no declared route stays
|
|
1280
|
+
* refused (`transports/rabbitmqClient.js`, d.452). What happens to a message
|
|
1281
|
+
* that fails there is unchanged and stated in the README: the budget is spent,
|
|
1282
|
+
* `nack(requeue=false)` is issued, the broker DROPS the message — the end of the
|
|
1283
|
+
* chain is the end — and the loss is logged as an error rather than turned into
|
|
1284
|
+
* an endless requeue.
|
|
1285
|
+
*
|
|
1286
|
+
* Derived from the templates (`declaredDeadLetterTargets()`), never from a list
|
|
1287
|
+
* of names: a `{placeholder}` segment matches any one part, exactly as a
|
|
1288
|
+
* business template key does.
|
|
1289
|
+
*
|
|
1290
|
+
* @param {string} queueName - Full queue name.
|
|
1291
|
+
* @returns {boolean} True if this configuration dead-letters to this name.
|
|
1292
|
+
*/
|
|
1293
|
+
isDeadLetterTarget(queueName) {
|
|
1294
|
+
if (typeof queueName !== 'string' || queueName === '') return false;
|
|
1295
|
+
|
|
1296
|
+
const parts = queueName.split('.');
|
|
1297
|
+
|
|
1298
|
+
for (const target of declaredDeadLetterTargets(this)) {
|
|
1299
|
+
const shape = templateShape(target);
|
|
1300
|
+
if (shape.length !== parts.length) continue;
|
|
1301
|
+
if (shape.every((part, index) => (part === null ? parts[index] !== '' : part === parts[index]))) {
|
|
1302
|
+
return true;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
return false;
|
|
1307
|
+
},
|
|
1308
|
+
|
|
1225
1309
|
/**
|
|
1226
1310
|
* Get infrastructure queue configuration by queue name (auto-detect type)
|
|
1227
1311
|
*
|
|
@@ -3073,7 +3073,26 @@ class RabbitMQClient extends EventEmitter {
|
|
|
3073
3073
|
// arguments (measured, 2026-09-12 — see queueConfig.getDeadLetterRoute()).
|
|
3074
3074
|
// The caller's own `queueOptions` do not count either; a route supplied at
|
|
3075
3075
|
// the call site would be a second declaration of one queue's topology.
|
|
3076
|
-
|
|
3076
|
+
//
|
|
3077
|
+
// The ONE queue this demand cannot be made of is the one at the END of a
|
|
3078
|
+
// chain: a queue something else dead-letters TO. `workflow.failed` is named
|
|
3079
|
+
// in `x-dead-letter-routing-key` by `workflow.init` and `workflow.control`,
|
|
3080
|
+
// `<service>.dlq` by every business template — and the confirmed topology
|
|
3081
|
+
// leaves exactly those without a route of their own, because a dead-letter
|
|
3082
|
+
// route on a dead-letter queue is a loop or a chain with no end
|
|
3083
|
+
// (`mq-consumer-contract` 002 point 2, 003 point 2). Until d.452 the gate
|
|
3084
|
+
// asked only the first question, so the operator's own queue was
|
|
3085
|
+
// unconsumable and its reader — the delivery dispatcher — could not attach
|
|
3086
|
+
// at all. The second question is derived from the templates
|
|
3087
|
+
// (`queueConfig.isDeadLetterTarget()`), never from a list of names, so it
|
|
3088
|
+
// cannot fall behind them; every other queue with no route is refused
|
|
3089
|
+
// exactly as before, with the same sentence.
|
|
3090
|
+
//
|
|
3091
|
+
// A message that spends its budget on a terminal queue is REJECTED as
|
|
3092
|
+
// anywhere else and then dropped by the broker, which is the end of the
|
|
3093
|
+
// chain doing its job. It is never requeued for ever, and the loss is said
|
|
3094
|
+
// out loud in `_rejectDelivery()`.
|
|
3095
|
+
if (queueConfig.getDeadLetterRoute(queue) === null && !queueConfig.isDeadLetterTarget(queue)) {
|
|
3077
3096
|
throw new ConsumeError(
|
|
3078
3097
|
`[RabbitMQClient] consume(${queue}): the dead-letter policy needs a dead-letter route and `
|
|
3079
3098
|
+ 'queueConfig declares none for this queue - '
|
|
@@ -4136,13 +4155,21 @@ class RabbitMQClient extends EventEmitter {
|
|
|
4136
4155
|
* `status`.
|
|
4137
4156
|
*
|
|
4138
4157
|
* The `onDeadLetter` hook — and therefore the `message_dlq` event it publishes —
|
|
4139
|
-
* is
|
|
4140
|
-
*
|
|
4158
|
+
* is reached for a message that CAN be dead-lettered: `consume()` refuses to
|
|
4159
|
+
* register a consumer on a queue for which `queueConfig` declares no
|
|
4141
4160
|
* `x-dead-letter-exchange`/`-routing-key` (d.259), so by the time a delivery
|
|
4142
4161
|
* arrives here the route exists by declaration. Without that gate the same
|
|
4143
4162
|
* `nack(requeue=false)` made the broker DROP the message while this event
|
|
4144
4163
|
* announced it had reached `.dlq` — a report of a destination nothing routed to.
|
|
4145
4164
|
*
|
|
4165
|
+
* ONE class of queue reaches this method with no route, and it is not that
|
|
4166
|
+
* defect: the END of a chain, a queue something else dead-letters TO
|
|
4167
|
+
* (`<service>.dlq`, `workflow.failed`), whose consumer the gate admits on
|
|
4168
|
+
* purpose (d.452). There the rejection does not move the message anywhere — the
|
|
4169
|
+
* broker drops it, because the end of the chain is the end — so the loss is
|
|
4170
|
+
* logged at error level below before monitoring is told. What is NOT done is
|
|
4171
|
+
* inventing a requeue for it: that is the unbounded loop d.198b ended.
|
|
4172
|
+
*
|
|
4146
4173
|
* @param {Object} args
|
|
4147
4174
|
* @private
|
|
4148
4175
|
*/
|
|
@@ -4178,6 +4205,29 @@ class RabbitMQClient extends EventEmitter {
|
|
|
4178
4205
|
return;
|
|
4179
4206
|
}
|
|
4180
4207
|
|
|
4208
|
+
// A TERMINAL queue — one this configuration dead-letters TO — declares no
|
|
4209
|
+
// onward route by design, so the rejection just issued does not MOVE the
|
|
4210
|
+
// message: the broker drops it. That is the end of the chain working as
|
|
4211
|
+
// intended, and the alternative would be the unbounded requeue d.198b ended.
|
|
4212
|
+
// It is still a message the platform has lost, so it is said out loud, at
|
|
4213
|
+
// error level, exactly once, before monitoring is told (d.452).
|
|
4214
|
+
if (queueConfig.getDeadLetterRoute(queue) === null) {
|
|
4215
|
+
this._logger.error(
|
|
4216
|
+
`[RabbitMQClient] [mq-client-core] [CONSUMER] Message discarded on terminal queue "${queue}" - `
|
|
4217
|
+
+ 'Expected: a terminal queue is the end of the dead-letter chain, so it declares no onward '
|
|
4218
|
+
+ 'route and the broker DROPS what nack(requeue=false) rejects instead of moving it. '
|
|
4219
|
+
+ 'Fix: nothing to declare — the delivery budget is spent and the message is deliberately gone '
|
|
4220
|
+
+ 'rather than circling for ever; read the error and fix what made the handler fail.',
|
|
4221
|
+
{
|
|
4222
|
+
queue,
|
|
4223
|
+
attempts,
|
|
4224
|
+
maxAttempts,
|
|
4225
|
+
classification,
|
|
4226
|
+
error: error && error.message ? error.message : null
|
|
4227
|
+
}
|
|
4228
|
+
);
|
|
4229
|
+
}
|
|
4230
|
+
|
|
4181
4231
|
if (onDeadLetter === undefined) {
|
|
4182
4232
|
// Driving this transport directly is allowed (it is an export of this
|
|
4183
4233
|
// package), but then nothing owns the monitoring rail, and a message
|