@onlineapps/mq-client-core 2.0.1-rc.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +153 -0
- package/README.md +491 -8
- package/package.json +12 -8
- package/src/BaseClient.js +953 -80
- package/src/buffer/InMemoryBuffer.js +50 -9
- package/src/buffer/MessageBuffer.js +20 -52
- package/src/config/composeConfig.js +60 -0
- package/src/config/configSchema.js +398 -17
- package/src/config/defaultConfig.js +169 -15
- package/src/config/deliveryPolicy.js +165 -0
- package/src/config/queueConfig.js +738 -64
- package/src/config.js +29 -0
- package/src/defaults.js +43 -0
- package/src/index.js +91 -2
- package/src/layers/PublishLayer.js +83 -37
- package/src/monitoring/PublishMonitor.js +11 -4
- package/src/monitoring-publish.js +81 -54
- package/src/transports/rabbitmqClient.js +2698 -738
- package/src/transports/transportFactory.js +9 -2
- package/src/utils/errorHandler.js +83 -4
- package/src/utils/nearestKey.js +101 -0
- package/src/utils/publishErrors.js +95 -10
- package/src/utils/redactCredentials.js +106 -0
- package/src/utils/serializer.js +12 -2
- package/src/workers/RecoveryWorker.js +58 -81
- package/src/buffer/RedisBuffer.js +0 -57
|
@@ -1,18 +1,207 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* The platform's dead-letter exchange.
|
|
5
|
+
*
|
|
6
|
+
* A name three services must agree on has to have ONE owner, or it has as many
|
|
7
|
+
* as the places that type it: this file typed the name four times, and
|
|
8
|
+
* `@onlineapps/conn-infra-mq` reads it back out of a queue's arguments
|
|
9
|
+
* (`getBusinessQueueConfig('queue')['x-dead-letter-exchange']`) because there was
|
|
10
|
+
* nothing to import. Same reasoning as `src/index.js` § topology, which gave the
|
|
11
|
+
* queue names that travel on the wire one declaration each.
|
|
12
|
+
*
|
|
13
|
+
* Exported as `deadLetterExchange()` — a getter, not the bare constant, so an
|
|
14
|
+
* importer cannot rebind platform topology.
|
|
15
|
+
*/
|
|
16
|
+
const DEAD_LETTER_EXCHANGE = 'dlx';
|
|
17
|
+
|
|
3
18
|
/**
|
|
4
19
|
* queueConfig.js
|
|
5
20
|
*
|
|
6
|
-
* Central configuration for INFRASTRUCTURE queues
|
|
7
|
-
*
|
|
8
|
-
* by
|
|
21
|
+
* Central configuration for INFRASTRUCTURE queues, and for the TEMPLATES of the
|
|
22
|
+
* queues a business service owns. `{service}.workflow`, `{service}.queue` and
|
|
23
|
+
* `{service}.dlq` are created by the service itself via
|
|
24
|
+
* QueueManager.setupServiceQueues(); the registry-client templates are declared here
|
|
25
|
+
* for the same reason — one declaration per queue, which is what the dead-letter gate
|
|
26
|
+
* of consume() reads.
|
|
9
27
|
*
|
|
10
28
|
* This ensures consistent configuration for infrastructure queues across all services.
|
|
11
29
|
*
|
|
12
30
|
* NOTE: This is part of mq-client-core (spodní vrstva) because it's used by both:
|
|
13
31
|
* - Infrastructure services (via mq-client-core)
|
|
14
32
|
* - Business services (via conn-infra-mq connector)
|
|
33
|
+
*
|
|
34
|
+
* Every lookup below fails fast with a `ValidationError`: an unknown queue name is a
|
|
35
|
+
* caller mistake, not a runtime condition, and the package has one error rail
|
|
36
|
+
* (`utils/errorHandler.js`) rather than a mix of typed and bare errors.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const { ValidationError } = require('../utils/errorHandler');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The prefix each section's keys carry ON THE WIRE.
|
|
43
|
+
*
|
|
44
|
+
* A section name is NOT automatically the prefix of the names it declares, and
|
|
45
|
+
* assuming it was is the defect d.267 fixed: `deliveryEvents` declares three
|
|
46
|
+
* `delivery.` queues, so a lookup that stripped `delivery.` and read only the
|
|
47
|
+
* `delivery` section answered "not defined" for every one of them. The mapping is
|
|
48
|
+
* stated once, here, and every lookup and every message derives from it rather
|
|
49
|
+
* than repeating a list by hand (`single-source-of-truth.md`).
|
|
50
|
+
*
|
|
51
|
+
* `business` is absent on purpose: it holds TEMPLATES (`{service}.workflow`), not
|
|
52
|
+
* full names, so no prefix composes a wire name from it.
|
|
53
|
+
*/
|
|
54
|
+
const SECTION_PREFIXES = Object.freeze({
|
|
55
|
+
workflow: 'workflow',
|
|
56
|
+
delivery: 'delivery',
|
|
57
|
+
deliveryEvents: 'delivery',
|
|
58
|
+
validation: 'validation',
|
|
59
|
+
infrastructure: 'infrastructure',
|
|
60
|
+
monitoring: 'monitoring',
|
|
61
|
+
telemetry: 'telemetry',
|
|
62
|
+
alerts: 'alert',
|
|
63
|
+
registry: 'registry'
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Every infrastructure prefix and the lookup that answers for it — ONE map, read by
|
|
68
|
+
* the classifier, by the dispatch and by the refusal message alike.
|
|
69
|
+
*
|
|
70
|
+
* It is a map rather than three lists because of d.269: `isInfrastructureQueue()`
|
|
71
|
+
* accepted `telemetry.` while `getInfrastructureQueueConfig()` had no branch for it,
|
|
72
|
+
* so a telemetry name was classified as infrastructure and then refused as not being
|
|
73
|
+
* one — and the refusal listed four prefixes while the classifier knew seven, because
|
|
74
|
+
* the sentence was typed by hand. A prefix the classifier knows but nothing can look
|
|
75
|
+
* up is not expressible here (`single-source-of-truth.md`).
|
|
76
|
+
*/
|
|
77
|
+
const INFRASTRUCTURE_PREFIX_LOOKUPS = Object.freeze({
|
|
78
|
+
workflow: 'getWorkflowQueueConfig',
|
|
79
|
+
registry: 'getRegistryQueueConfig',
|
|
80
|
+
infrastructure: 'getInfrastructureHealthQueueConfig',
|
|
81
|
+
validation: 'getValidationQueueConfig',
|
|
82
|
+
monitoring: 'getMonitoringQueueConfig',
|
|
83
|
+
telemetry: 'getTelemetryQueueConfig',
|
|
84
|
+
delivery: 'getDeliveryQueueConfig'
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const INFRASTRUCTURE_PREFIXES = Object.freeze(Object.keys(INFRASTRUCTURE_PREFIX_LOOKUPS));
|
|
88
|
+
|
|
89
|
+
/** Every section declaring names under one prefix — derived, never a second list. */
|
|
90
|
+
const sectionsForPrefix = (prefix) =>
|
|
91
|
+
Object.keys(SECTION_PREFIXES).filter((section) => SECTION_PREFIXES[section] === prefix);
|
|
92
|
+
|
|
93
|
+
const DELIVERY_SECTIONS = sectionsForPrefix('delivery');
|
|
94
|
+
|
|
95
|
+
/** A `{placeholder}` segment of a business template key: matches any ONE name part. */
|
|
96
|
+
const TEMPLATE_PLACEHOLDER = /^\{[^.{}]+\}$/;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One business template key as a SHAPE: the parts a queue name must carry after the
|
|
100
|
+
* service name, with `null` where the template accepts anything (`registry.events`
|
|
101
|
+
* -> `['registry', 'events']`; a key writing `registry.{kind}` would give
|
|
102
|
+
* `['registry', null]`).
|
|
103
|
+
*/
|
|
104
|
+
const templateShape = (template) =>
|
|
105
|
+
template.split('.').map((part) => (TEMPLATE_PLACEHOLDER.test(part) ? null : part));
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Match a queue name against the templates the `business` section declares.
|
|
109
|
+
*
|
|
110
|
+
* The section IS the list — d.278 removed the hand-typed `['workflow', 'queue',
|
|
111
|
+
* 'dlq']`, which had to be edited in step with the templates and therefore could
|
|
112
|
+
* disagree with them. A template nobody can express here is a queue nobody can
|
|
113
|
+
* classify, which is the state `{service}.registry` was in: declared nowhere,
|
|
114
|
+
* classified as neither kind, and so refused by the dead-letter gate of consume().
|
|
115
|
+
*
|
|
116
|
+
* @param {Object} business - The `business` section (templates keyed by their shape).
|
|
117
|
+
* @param {string} queueName - Full queue name.
|
|
118
|
+
* @returns {{serviceName: string, queueType: string}|null} The service and the
|
|
119
|
+
* template key that declares the queue's arguments, or `null` for a name no
|
|
120
|
+
* template matches.
|
|
121
|
+
* @throws {ValidationError} If two templates match one name - a defect in this file,
|
|
122
|
+
* never a runtime condition, and one that would otherwise be resolved by key order.
|
|
15
123
|
*/
|
|
124
|
+
const matchBusinessTemplate = (business, queueName) => {
|
|
125
|
+
const parts = queueName.split('.');
|
|
126
|
+
if (parts.length < 2 || parts[0] === '') return null;
|
|
127
|
+
|
|
128
|
+
const tail = parts.slice(1);
|
|
129
|
+
const matched = Object.keys(business).filter((template) => {
|
|
130
|
+
const shape = templateShape(template);
|
|
131
|
+
return (
|
|
132
|
+
shape.length === tail.length
|
|
133
|
+
&& shape.every((part, index) => (part === null ? tail[index] !== '' : part === tail[index]))
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (matched.length > 1) {
|
|
138
|
+
throw new ValidationError(
|
|
139
|
+
`[queueConfig] Business queue ${queueName} matches more than one template: ${matched.join(', ')} - `
|
|
140
|
+
+ 'Expected: exactly one template declares a name, so one queue has one definition. '
|
|
141
|
+
+ 'Fix: make the overlapping keys in the `business` section of src/config/queueConfig.js distinct.'
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return matched.length === 1 ? { serviceName: parts[0], queueType: matched[0] } : null;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Every full wire name the infrastructure sections DECLARE.
|
|
150
|
+
*
|
|
151
|
+
* Derived from the sections themselves — `SECTION_PREFIXES` says which prefix each
|
|
152
|
+
* one puts on the wire, and only sections whose prefix has a lookup take part, so a
|
|
153
|
+
* name this set contains can always be resolved (the invariant d.269 established).
|
|
154
|
+
* `alerts` is therefore absent, exactly as it is today: nothing looks up `alert.`.
|
|
155
|
+
*
|
|
156
|
+
* @param {Object} config - The queueConfig object (its sections are read from `this`).
|
|
157
|
+
* @returns {Set<string>} The declared names, e.g. `registry.register`, `workflow.dlq`.
|
|
158
|
+
*/
|
|
159
|
+
const declaredInfrastructureNames = (config) => {
|
|
160
|
+
const names = new Set();
|
|
161
|
+
|
|
162
|
+
for (const [section, prefix] of Object.entries(SECTION_PREFIXES)) {
|
|
163
|
+
if (!Object.prototype.hasOwnProperty.call(INFRASTRUCTURE_PREFIX_LOOKUPS, prefix)) continue;
|
|
164
|
+
for (const key of Object.keys(config[section] || {})) {
|
|
165
|
+
names.add(`${prefix}.${key}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return names;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The two ways out of "this configuration declares no such queue", named in the
|
|
174
|
+
* refusal itself.
|
|
175
|
+
*
|
|
176
|
+
* A name like `registry.foo` can be two different mistakes, and the message cannot
|
|
177
|
+
* tell which: an infrastructure queue nobody declared, or a queue owned by a service
|
|
178
|
+
* named `registry` written in a shape no template matches. Naming only the first sends
|
|
179
|
+
* the reader to add a platform queue for something a service owns — the very confusion
|
|
180
|
+
* d.279 came from. The service-owned alternatives are rendered from the `business`
|
|
181
|
+
* templates, never typed out, so the sentence cannot fall behind them.
|
|
182
|
+
*
|
|
183
|
+
* @param {Object} business - The `business` section.
|
|
184
|
+
* @param {string} queueName - The full name that was not found.
|
|
185
|
+
* @returns {string} The `Fix:` sentence (`architecture-principles.md` §5).
|
|
186
|
+
*/
|
|
187
|
+
const waysOut = (business, queueName) => {
|
|
188
|
+
const parts = queueName.split('.');
|
|
189
|
+
const owner = parts[0];
|
|
190
|
+
const declare = 'Fix: declare the entry in src/config/queueConfig.js if the platform owns the queue';
|
|
191
|
+
|
|
192
|
+
// Only the templates a name of THIS shape could take: offering `registry.registry.events`
|
|
193
|
+
// as the fix for `registry.foo` would be a suggestion that cannot be followed.
|
|
194
|
+
const owned = Object.keys(business)
|
|
195
|
+
.filter((template) => template.split('.').length === parts.length - 1)
|
|
196
|
+
.map((template) => `${owner}.${template}`);
|
|
197
|
+
|
|
198
|
+
if (owned.length === 0) {
|
|
199
|
+
return `${declare}; no queue a service owns has this shape, so the name is not a service-owned one either.`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return `${declare}, or — if the service named "${owner}" owns it — use one of the names that service's own `
|
|
203
|
+
+ `queues take (${owned.join(', ')}), which this configuration already classifies.`;
|
|
204
|
+
};
|
|
16
205
|
|
|
17
206
|
module.exports = {
|
|
18
207
|
/**
|
|
@@ -65,23 +254,67 @@ module.exports = {
|
|
|
65
254
|
|
|
66
255
|
/**
|
|
67
256
|
* workflow.completed - Completed workflows
|
|
257
|
+
*
|
|
258
|
+
* Dead-letters to workflow.dlq over the DEFAULT exchange, where the routing key
|
|
259
|
+
* is the destination queue name — the shape workflow.init/workflow.control
|
|
260
|
+
* already use. The dispatcher rejects a message here on an invalid payload, a
|
|
261
|
+
* schema violation and a delivery failure; until d.278 the broker dropped each
|
|
262
|
+
* one, because the queue declared nowhere to put it.
|
|
263
|
+
*
|
|
264
|
+
* @see api/docs/governance/confirmations/mq-consumer-contract.md 003
|
|
68
265
|
*/
|
|
69
266
|
completed: {
|
|
70
267
|
durable: true,
|
|
71
268
|
arguments: {
|
|
72
269
|
'x-message-ttl': 300000, // 5 minutes TTL
|
|
73
|
-
'x-max-length': 10000
|
|
270
|
+
'x-max-length': 10000,
|
|
271
|
+
'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
|
|
272
|
+
'x-dead-letter-routing-key': 'workflow.dlq'
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* workflow.delivery_requested - a run handed over to the Delivery Dispatcher.
|
|
278
|
+
*
|
|
279
|
+
* The hand-over is NOT the end of a run. A cookbook declaring `delivery.on_result`
|
|
280
|
+
* has one step left when the document is ready to leave the platform: the step that
|
|
281
|
+
* records how the delivery went. Publishing that hand-over on `workflow.completed`
|
|
282
|
+
* made the terminal message mean "not finished yet", and left the real end of such a
|
|
283
|
+
* run without a message at all. The owner gave the hand-over a name of its own:
|
|
284
|
+
* `workflow.delivery_requested`, carrying `status: "delivery_requested"`, while
|
|
285
|
+
* `workflow.completed` stays the single terminal message.
|
|
286
|
+
*
|
|
287
|
+
* Same arguments as `workflow.completed` — a hand-over is the same payload with a
|
|
288
|
+
* different verb, read by the same consumer, so it expires and caps the same way —
|
|
289
|
+
* and the same dead-letter destination: the mechanical rule of confirmation
|
|
290
|
+
* `mq-consumer-contract` 003 routes every queue of this family to `workflow.dlq`
|
|
291
|
+
* over the DEFAULT exchange, where the routing key IS the destination queue's name.
|
|
292
|
+
*
|
|
293
|
+
* @see api/docs/governance/confirmations/delivery-receipt-chain.md 002, 003
|
|
294
|
+
* @see api/docs/governance/confirmations/mq-consumer-contract.md 003
|
|
295
|
+
*/
|
|
296
|
+
delivery_requested: {
|
|
297
|
+
durable: true,
|
|
298
|
+
arguments: {
|
|
299
|
+
'x-message-ttl': 300000, // 5 minutes TTL, as workflow.completed
|
|
300
|
+
'x-max-length': 10000,
|
|
301
|
+
'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
|
|
302
|
+
'x-dead-letter-routing-key': 'workflow.dlq'
|
|
74
303
|
}
|
|
75
304
|
},
|
|
76
305
|
|
|
77
306
|
/**
|
|
78
307
|
* workflow.failed - Failed workflows (DLQ entry point)
|
|
79
|
-
* Workflows land here after max retries exhausted, waiting for human decision
|
|
308
|
+
* Workflows land here after max retries exhausted, waiting for human decision.
|
|
309
|
+
*
|
|
310
|
+
* No TTL: the queue is read by an operator, not by a service, and it has no
|
|
311
|
+
* dead-letter exchange — an expiring message is a deleted message.
|
|
312
|
+
*
|
|
313
|
+
* @see api/docs/governance/confirmations/mq-consumer-contract.md 002
|
|
80
314
|
*/
|
|
81
315
|
failed: {
|
|
82
316
|
durable: true,
|
|
83
317
|
arguments: {
|
|
84
|
-
'x-message-ttl': 300000, // 5 minutes TTL
|
|
85
318
|
'x-max-length': 10000
|
|
86
319
|
}
|
|
87
320
|
},
|
|
@@ -89,17 +322,28 @@ module.exports = {
|
|
|
89
322
|
/**
|
|
90
323
|
* workflow.discarded - Discarded workflows (final state)
|
|
91
324
|
* Workflows land here after human decision to discard from DLQ
|
|
325
|
+
*
|
|
326
|
+
* Dead-letters to workflow.dlq, like every other queue of this family
|
|
327
|
+
* (confirmation mq-consumer-contract 003). The operator's own queues —
|
|
328
|
+
* workflow.failed and workflow.dlq — are the two that keep no onward route.
|
|
92
329
|
*/
|
|
93
330
|
discarded: {
|
|
94
331
|
durable: true,
|
|
95
332
|
arguments: {
|
|
96
333
|
'x-message-ttl': 300000, // 5 minutes TTL
|
|
97
|
-
'x-max-length': 10000
|
|
334
|
+
'x-max-length': 10000,
|
|
335
|
+
'x-dead-letter-exchange': '',
|
|
336
|
+
'x-dead-letter-routing-key': 'workflow.dlq'
|
|
98
337
|
}
|
|
99
338
|
},
|
|
100
339
|
|
|
101
340
|
/**
|
|
102
341
|
* workflow.dlq - Dead letter queue for workflows
|
|
342
|
+
*
|
|
343
|
+
* The end of the road for this family: read by an operator through the DLQ
|
|
344
|
+
* dashboard contract, never forwarded onward. No TTL and no dead-letter route,
|
|
345
|
+
* for the same reason workflow.failed has neither — an expiring or forwarded
|
|
346
|
+
* message is one the operator never gets to decide about.
|
|
103
347
|
*/
|
|
104
348
|
dlq: {
|
|
105
349
|
durable: true,
|
|
@@ -111,9 +355,22 @@ module.exports = {
|
|
|
111
355
|
},
|
|
112
356
|
|
|
113
357
|
/**
|
|
114
|
-
* Business queue templates
|
|
115
|
-
*
|
|
116
|
-
*
|
|
358
|
+
* Business queue templates - every queue a business service OWNS.
|
|
359
|
+
*
|
|
360
|
+
* A key here is the queue name's tail after the service name, and it is read as a
|
|
361
|
+
* PATTERN: a `{placeholder}` segment matches any one part. That is what makes
|
|
362
|
+
* `isBusinessQueue()`/`parseBusinessQueue()` derive from this section instead of
|
|
363
|
+
* repeating a list of types by hand, and it is the shape
|
|
364
|
+
* docs/standards/queue-ownership.md prescribes - "infrastructure knows them by
|
|
365
|
+
* that pattern, never by a list of names".
|
|
366
|
+
*
|
|
367
|
+
* Every one of them dead-letters over `dlx` to `{service}.dlq`, the queue each
|
|
368
|
+
* service already has and the one binding d.198a left behind
|
|
369
|
+
* (api/docs/governance/confirmations/mq-consumer-contract.md 003).
|
|
370
|
+
*
|
|
371
|
+
* `{service}.workflow`, `{service}.queue` and `{service}.dlq` are created by
|
|
372
|
+
* QueueManager.setupServiceQueues(); the registry-client queues by
|
|
373
|
+
* @onlineapps/conn-orch-registry.
|
|
117
374
|
*/
|
|
118
375
|
business: {
|
|
119
376
|
/**
|
|
@@ -125,7 +382,7 @@ module.exports = {
|
|
|
125
382
|
arguments: {
|
|
126
383
|
'x-message-ttl': 300000, // 5 minutes TTL
|
|
127
384
|
'x-max-length': 10000, // Max 10k messages
|
|
128
|
-
'x-dead-letter-exchange':
|
|
385
|
+
'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
|
|
129
386
|
'x-dead-letter-routing-key': '{service}.dlq' // Placeholder, replaced with actual service name
|
|
130
387
|
}
|
|
131
388
|
},
|
|
@@ -139,7 +396,7 @@ module.exports = {
|
|
|
139
396
|
arguments: {
|
|
140
397
|
'x-message-ttl': 30000, // 30 seconds TTL
|
|
141
398
|
'x-max-length': 10000, // Max 10k messages
|
|
142
|
-
'x-dead-letter-exchange':
|
|
399
|
+
'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
|
|
143
400
|
'x-dead-letter-routing-key': '{service}.dlq' // Placeholder, replaced with actual service name
|
|
144
401
|
}
|
|
145
402
|
},
|
|
@@ -154,6 +411,41 @@ module.exports = {
|
|
|
154
411
|
// No TTL for DLQ - messages should persist
|
|
155
412
|
'x-max-length': 50000 // Higher limit for DLQ
|
|
156
413
|
}
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* {service}.registry - The service's own queue for registry control messages
|
|
418
|
+
* (register.confirmed and the rest), asserted by @onlineapps/conn-orch-registry.
|
|
419
|
+
*
|
|
420
|
+
* No TTL and no length cap: that is what the queues carry on the broker today
|
|
421
|
+
* (measured 2026-09-12, every `<service>.registry` reports `arguments = {}`), and
|
|
422
|
+
* a value invented here would be a second truth about a running queue. What the
|
|
423
|
+
* template adds is the dead-letter route the queue never had.
|
|
424
|
+
*/
|
|
425
|
+
registry: {
|
|
426
|
+
durable: true,
|
|
427
|
+
arguments: {
|
|
428
|
+
'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
|
|
429
|
+
'x-dead-letter-routing-key': '{service}.dlq' // Placeholder, replaced with actual service name
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* {service}.registry.events - The service's copy of the registry event fanout,
|
|
435
|
+
* bound to the `registry.changes` exchange by @onlineapps/conn-orch-registry.
|
|
436
|
+
*
|
|
437
|
+
* TTL and cap are the ones that connector asserts today, kept to the value, so
|
|
438
|
+
* moving it onto this template cannot produce a 406 on a queue that already
|
|
439
|
+
* exists with them.
|
|
440
|
+
*/
|
|
441
|
+
'registry.events': {
|
|
442
|
+
durable: true,
|
|
443
|
+
arguments: {
|
|
444
|
+
'x-message-ttl': 60000, // 1 minute TTL - a stale index event is useless
|
|
445
|
+
'x-max-length': 1000,
|
|
446
|
+
'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
|
|
447
|
+
'x-dead-letter-routing-key': '{service}.dlq'
|
|
448
|
+
}
|
|
157
449
|
}
|
|
158
450
|
},
|
|
159
451
|
|
|
@@ -169,7 +461,9 @@ module.exports = {
|
|
|
169
461
|
retry: {
|
|
170
462
|
durable: true,
|
|
171
463
|
arguments: {
|
|
172
|
-
'x-max-length': 10000 // Allow up to 10k pending retry messages
|
|
464
|
+
'x-max-length': 10000, // Allow up to 10k pending retry messages
|
|
465
|
+
'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
|
|
466
|
+
'x-dead-letter-routing-key': 'delivery.dlq'
|
|
173
467
|
}
|
|
174
468
|
},
|
|
175
469
|
|
|
@@ -188,7 +482,28 @@ module.exports = {
|
|
|
188
482
|
durable: true,
|
|
189
483
|
arguments: {
|
|
190
484
|
'x-message-ttl': 60000, // Notifications older than 60s are irrelevant
|
|
191
|
-
'x-max-length': 10000
|
|
485
|
+
'x-max-length': 10000,
|
|
486
|
+
'x-dead-letter-exchange': '',
|
|
487
|
+
'x-dead-letter-routing-key': 'delivery.dlq'
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* delivery.dlq - Dead letter queue of the whole delivery family
|
|
493
|
+
*
|
|
494
|
+
* Every delivery queue — the dispatcher's retry and websocket queues, and the
|
|
495
|
+
* four event queues the endpoint consumes — rejects into this one, over the
|
|
496
|
+
* default exchange, so no binding has to exist for the route to work. Declared
|
|
497
|
+
* with the arguments every other dead-letter queue carries: durable, capped, no
|
|
498
|
+
* TTL, no onward route. The operator's dashboard matches `*.dlq`, which is why
|
|
499
|
+
* the family gets its own queue rather than sharing one with the rest of the
|
|
500
|
+
* infrastructure (confirmation mq-consumer-contract 003, variant A2 rejected).
|
|
501
|
+
*/
|
|
502
|
+
dlq: {
|
|
503
|
+
durable: true,
|
|
504
|
+
arguments: {
|
|
505
|
+
// No TTL for DLQ - messages should persist
|
|
506
|
+
'x-max-length': 50000 // Higher limit for DLQ
|
|
192
507
|
}
|
|
193
508
|
}
|
|
194
509
|
},
|
|
@@ -233,6 +548,23 @@ module.exports = {
|
|
|
233
548
|
'x-message-ttl': 10000, // 10 seconds TTL (prevent stale data)
|
|
234
549
|
'x-max-length': 1000
|
|
235
550
|
}
|
|
551
|
+
},
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* infrastructure.health.events - Fanout exchange for infrastructure health events
|
|
555
|
+
*
|
|
556
|
+
* Registry publishes; the monitoring consumer and the delivery endpoint each bind a
|
|
557
|
+
* queue of their own (`monitoring.infrastructure.health.events`,
|
|
558
|
+
* `delivery.health.events`). The name is a contract between THREE sides of one
|
|
559
|
+
* wire, which is why batch 243g-C removed the per-service env override: an override
|
|
560
|
+
* on one side loses the events silently. It then left the name as a private
|
|
561
|
+
* constant in each of the three services, so d.270 gave it an owner here and
|
|
562
|
+
* `src/index.js` publishes it.
|
|
563
|
+
*/
|
|
564
|
+
'health.events': {
|
|
565
|
+
type: 'exchange',
|
|
566
|
+
exchangeType: 'fanout',
|
|
567
|
+
durable: true
|
|
236
568
|
}
|
|
237
569
|
},
|
|
238
570
|
|
|
@@ -298,6 +630,56 @@ module.exports = {
|
|
|
298
630
|
type: 'exchange',
|
|
299
631
|
exchangeType: 'fanout',
|
|
300
632
|
durable: true
|
|
633
|
+
},
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* monitoring.infrastructure.health.events - the monitoring consumer's own copy of
|
|
637
|
+
* the infrastructure health events, bound to the infrastructure.health.events
|
|
638
|
+
* fanout exchange. Declared with `durable` and nothing else, exactly as the
|
|
639
|
+
* consumer creates it.
|
|
640
|
+
*/
|
|
641
|
+
'infrastructure.health.events': {
|
|
642
|
+
durable: true
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Telemetry queue configurations
|
|
648
|
+
*
|
|
649
|
+
* The OpenTelemetry intake of the monitoring consumer: every service publishes
|
|
650
|
+
* logs, metrics and spans to `telemetry.exchange` with a routing key carrying its
|
|
651
|
+
* own name, and these three queues are bound to it
|
|
652
|
+
* (`docs/standards/monitoring-queues.md`, `docs/architecture/monitoring.md`).
|
|
653
|
+
*
|
|
654
|
+
* The values are the ones the queues are created with today; this config is their
|
|
655
|
+
* declaration, so whoever asserts them reads it here rather than retyping it.
|
|
656
|
+
*/
|
|
657
|
+
telemetry: {
|
|
658
|
+
/** telemetry.logs.queue - OpenTelemetry log records, drained into Loki. */
|
|
659
|
+
'logs.queue': {
|
|
660
|
+
durable: true,
|
|
661
|
+
arguments: {
|
|
662
|
+
'x-message-ttl': 60000, // 1 minute TTL for unprocessed messages
|
|
663
|
+
'x-max-length': 100000 // Max 100k messages in queue
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
|
|
667
|
+
/** telemetry.metrics.queue - OpenTelemetry metric samples. */
|
|
668
|
+
'metrics.queue': {
|
|
669
|
+
durable: true,
|
|
670
|
+
arguments: {
|
|
671
|
+
'x-message-ttl': 60000,
|
|
672
|
+
'x-max-length': 100000
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
|
|
676
|
+
/** telemetry.traces.queue - OpenTelemetry spans. */
|
|
677
|
+
'traces.queue': {
|
|
678
|
+
durable: true,
|
|
679
|
+
arguments: {
|
|
680
|
+
'x-message-ttl': 60000,
|
|
681
|
+
'x-max-length': 100000
|
|
682
|
+
}
|
|
301
683
|
}
|
|
302
684
|
},
|
|
303
685
|
|
|
@@ -314,7 +696,9 @@ module.exports = {
|
|
|
314
696
|
durable: true,
|
|
315
697
|
arguments: {
|
|
316
698
|
'x-message-ttl': 60000, // 60s TTL - real-time events, stale ones are useless
|
|
317
|
-
'x-max-length': 10000
|
|
699
|
+
'x-max-length': 10000,
|
|
700
|
+
'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
|
|
701
|
+
'x-dead-letter-routing-key': 'delivery.dlq'
|
|
318
702
|
}
|
|
319
703
|
},
|
|
320
704
|
|
|
@@ -326,7 +710,9 @@ module.exports = {
|
|
|
326
710
|
durable: true,
|
|
327
711
|
arguments: {
|
|
328
712
|
'x-message-ttl': 60000,
|
|
329
|
-
'x-max-length': 5000
|
|
713
|
+
'x-max-length': 5000,
|
|
714
|
+
'x-dead-letter-exchange': '',
|
|
715
|
+
'x-dead-letter-routing-key': 'delivery.dlq'
|
|
330
716
|
}
|
|
331
717
|
},
|
|
332
718
|
|
|
@@ -338,7 +724,33 @@ module.exports = {
|
|
|
338
724
|
durable: true,
|
|
339
725
|
arguments: {
|
|
340
726
|
'x-message-ttl': 300000, // 5 minutes - alerts are more important
|
|
341
|
-
'x-max-length': 5000
|
|
727
|
+
'x-max-length': 5000,
|
|
728
|
+
'x-dead-letter-exchange': '',
|
|
729
|
+
'x-dead-letter-routing-key': 'delivery.dlq'
|
|
730
|
+
}
|
|
731
|
+
},
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* delivery.resource.events - Resource-changed events for WS clients
|
|
735
|
+
* Bound to the monitoring.resource exchange by the routing pattern
|
|
736
|
+
* `resource.changed.#` - the one binding of this family that is not a fanout
|
|
737
|
+
* (docs/standards/queue-ownership.md).
|
|
738
|
+
*
|
|
739
|
+
* The queue has existed on the broker since the endpoint started asserting it
|
|
740
|
+
* with `{ durable: true }` and nothing else; it was the only queue of the
|
|
741
|
+
* family this configuration did not declare, so the d.259 gate had nothing to
|
|
742
|
+
* read for it. The arguments are its sibling's — same TTL, same cap — because
|
|
743
|
+
* it carries the same kind of real-time event.
|
|
744
|
+
*
|
|
745
|
+
* @see api/docs/governance/confirmations/mq-consumer-contract.md 003
|
|
746
|
+
*/
|
|
747
|
+
'resource.events': {
|
|
748
|
+
durable: true,
|
|
749
|
+
arguments: {
|
|
750
|
+
'x-message-ttl': 60000,
|
|
751
|
+
'x-max-length': 10000,
|
|
752
|
+
'x-dead-letter-exchange': '',
|
|
753
|
+
'x-dead-letter-routing-key': 'delivery.dlq'
|
|
342
754
|
}
|
|
343
755
|
}
|
|
344
756
|
},
|
|
@@ -398,6 +810,42 @@ module.exports = {
|
|
|
398
810
|
}
|
|
399
811
|
},
|
|
400
812
|
|
|
813
|
+
/**
|
|
814
|
+
* The full wire name of a declared entry: the section's prefix + the entry's key.
|
|
815
|
+
*
|
|
816
|
+
* Composed here, never retyped by a caller — and never composed from the section
|
|
817
|
+
* NAME, because a section name is not automatically a prefix (`deliveryEvents`
|
|
818
|
+
* declares `delivery.` names, `alerts` declares `alert.`). The entry must exist:
|
|
819
|
+
* a name this configuration does not declare would be asserted against the broker
|
|
820
|
+
* by whoever imported it, which is how a typo becomes a second queue.
|
|
821
|
+
*
|
|
822
|
+
* @param {string} section - Section holding the entry (e.g. 'deliveryEvents').
|
|
823
|
+
* @param {string} key - The entry's key within that section (e.g. 'health.events').
|
|
824
|
+
* @returns {string} The full name (e.g. 'delivery.health.events').
|
|
825
|
+
* @throws {ValidationError} If the section composes no wire name, or the entry is undeclared.
|
|
826
|
+
*/
|
|
827
|
+
queueName(section, key) {
|
|
828
|
+
const prefix = SECTION_PREFIXES[section];
|
|
829
|
+
|
|
830
|
+
if (prefix === undefined) {
|
|
831
|
+
throw new ValidationError(
|
|
832
|
+
`[queueConfig] Cannot compose a name: section "${section}" declares no wire prefix - `
|
|
833
|
+
+ `Expected: one of ${Object.keys(SECTION_PREFIXES).join(', ')}. `
|
|
834
|
+
+ 'Fix: name a section that declares full queue names; "business" holds templates, not names.'
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (!this[section]?.[key]) {
|
|
839
|
+
throw new ValidationError(
|
|
840
|
+
`[queueConfig] Cannot compose a name: queueConfig declares no "${key}" under "${section}" - `
|
|
841
|
+
+ `Expected: the entry to exist before its name is published. `
|
|
842
|
+
+ 'Fix: declare it in src/config/queueConfig.js, or use the key the owning service declares.'
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return `${prefix}.${key}`;
|
|
847
|
+
},
|
|
848
|
+
|
|
401
849
|
/**
|
|
402
850
|
* Get infrastructure queue configuration by type and name
|
|
403
851
|
* @param {string} type - Queue type: 'workflow', 'registry'
|
|
@@ -407,7 +855,11 @@ module.exports = {
|
|
|
407
855
|
getQueueConfig(type, name) {
|
|
408
856
|
const config = this[type]?.[name];
|
|
409
857
|
if (!config) {
|
|
410
|
-
throw new
|
|
858
|
+
throw new ValidationError(
|
|
859
|
+
`[queueConfig] Infrastructure queue config not found: ${type}.${name} - `
|
|
860
|
+
+ `Expected: queueConfig to define the "${name}" entry under "${type}". `
|
|
861
|
+
+ waysOut(this.business, `${type}.${name}`)
|
|
862
|
+
);
|
|
411
863
|
}
|
|
412
864
|
|
|
413
865
|
// Deep clone to avoid modifying original
|
|
@@ -422,7 +874,10 @@ module.exports = {
|
|
|
422
874
|
getWorkflowQueueConfig(queueName) {
|
|
423
875
|
const parts = queueName.split('.');
|
|
424
876
|
if (parts.length !== 2 || parts[0] !== 'workflow') {
|
|
425
|
-
throw new
|
|
877
|
+
throw new ValidationError(
|
|
878
|
+
`[queueConfig] Invalid workflow queue name: ${queueName}. Expected format: workflow.{name} - `
|
|
879
|
+
+ 'Fix: pass exactly two dot-separated parts whose first part is "workflow", e.g. "workflow.init".'
|
|
880
|
+
);
|
|
426
881
|
}
|
|
427
882
|
return this.getQueueConfig('workflow', parts[1]);
|
|
428
883
|
},
|
|
@@ -435,37 +890,103 @@ module.exports = {
|
|
|
435
890
|
getRegistryQueueConfig(queueName) {
|
|
436
891
|
const parts = queueName.split('.');
|
|
437
892
|
if (parts.length !== 2 || parts[0] !== 'registry') {
|
|
438
|
-
throw new
|
|
893
|
+
throw new ValidationError(
|
|
894
|
+
`[queueConfig] Invalid registry queue name: ${queueName}. Expected format: registry.{name} - `
|
|
895
|
+
+ 'Fix: pass exactly two dot-separated parts whose first part is "registry", e.g. "registry.register".'
|
|
896
|
+
);
|
|
439
897
|
}
|
|
440
898
|
return this.getQueueConfig('registry', parts[1]);
|
|
441
899
|
},
|
|
442
900
|
|
|
443
901
|
/**
|
|
444
|
-
*
|
|
902
|
+
* The infrastructure prefixes this configuration classifies by, in declaration
|
|
903
|
+
* order. Published so a caller — `initInfrastructureQueues()`, a queue-ownership
|
|
904
|
+
* probe, a document generator — reads the list instead of keeping a copy of it.
|
|
905
|
+
* @returns {ReadonlyArray<string>} Prefixes WITHOUT the trailing dot.
|
|
906
|
+
*/
|
|
907
|
+
infrastructurePrefixes() {
|
|
908
|
+
return INFRASTRUCTURE_PREFIXES;
|
|
909
|
+
},
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Check if queue name is an infrastructure queue.
|
|
913
|
+
*
|
|
914
|
+
* Three questions, in this order, because a prefix alone answers the wrong one:
|
|
915
|
+
*
|
|
916
|
+
* 1. **Does an infrastructure section declare this exact name?** Then it is
|
|
917
|
+
* infrastructure, whatever else it resembles — the declaration is the platform's
|
|
918
|
+
* own statement about that queue (`workflow.dlq`, `delivery.dlq`).
|
|
919
|
+
* 2. **Does it match a business template?** Then a service owns it. `registry` is a
|
|
920
|
+
* legal service name — `docs/biz/60-templates/naming.md` § Infrastructure Services
|
|
921
|
+
* gives it as the example — and the service by that name has `registry.workflow`,
|
|
922
|
+
* `registry.queue` and `registry.dlq` on the broker today, with exactly the
|
|
923
|
+
* arguments the templates prescribe (measured 2026-09-13). Before d.279 the
|
|
924
|
+
* `registry.` prefix claimed them for the platform, the registry lookup then had no
|
|
925
|
+
* entry for `queue`/`workflow`/`dlq`, and so `getDeadLetterRoute()` threw for a
|
|
926
|
+
* queue whose route is declared — which the d.259 consume gate reads as "no route",
|
|
927
|
+
* refusing the owner's own consumer.
|
|
928
|
+
* 3. **Otherwise, does it carry an infrastructure prefix?** Then it is infrastructure
|
|
929
|
+
* TERRITORY: declared nowhere, but a publisher must still refuse to auto-create it
|
|
930
|
+
* with default arguments rather than treat it as an ordinary name
|
|
931
|
+
* (`rabbitmqClient.js`, the 404 branch). The lookup that owns the prefix is what
|
|
932
|
+
* reports the missing declaration.
|
|
933
|
+
*
|
|
445
934
|
* @param {string} queueName - Queue name to check
|
|
446
935
|
* @returns {boolean} True if infrastructure queue
|
|
447
936
|
*/
|
|
448
937
|
isInfrastructureQueue(queueName) {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
938
|
+
if (declaredInfrastructureNames(this).has(queueName)) {
|
|
939
|
+
return true;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (matchBusinessTemplate(this.business, queueName) !== null) {
|
|
943
|
+
return false;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
return INFRASTRUCTURE_PREFIXES.some((prefix) => queueName.startsWith(`${prefix}.`));
|
|
456
947
|
},
|
|
457
948
|
|
|
458
949
|
/**
|
|
459
|
-
* Get delivery queue configuration
|
|
460
|
-
*
|
|
950
|
+
* Get delivery queue configuration.
|
|
951
|
+
*
|
|
952
|
+
* The `delivery.` prefix is declared by TWO sections — `delivery` (queues the
|
|
953
|
+
* dispatcher owns) and `deliveryEvents` (queues the delivery endpoint consumes) —
|
|
954
|
+
* so the lookup searches both. Which section holds a name is authoring history;
|
|
955
|
+
* the prefix is what travels on the wire, and a caller knows only that.
|
|
956
|
+
*
|
|
957
|
+
* @param {string} queueName - Queue name (e.g., 'delivery.retry', 'delivery.health.events')
|
|
461
958
|
* @returns {Object} Queue configuration
|
|
959
|
+
* @throws {ValidationError} If no section declares the name, or — a defect, not a
|
|
960
|
+
* runtime condition — if more than one does.
|
|
462
961
|
*/
|
|
463
962
|
getDeliveryQueueConfig(queueName) {
|
|
464
963
|
if (!queueName.startsWith('delivery.')) {
|
|
465
|
-
throw new
|
|
964
|
+
throw new ValidationError(
|
|
965
|
+
`[queueConfig] Queue ${queueName} is not a delivery queue - Expected: a name starting with "delivery.". `
|
|
966
|
+
+ 'Fix: call getInfrastructureQueueConfig(), which routes each prefix to the right lookup.'
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
const name = queueName.slice('delivery.'.length);
|
|
970
|
+
const sections = DELIVERY_SECTIONS.filter((section) => this[section]?.[name]);
|
|
971
|
+
|
|
972
|
+
if (sections.length > 1) {
|
|
973
|
+
throw new ValidationError(
|
|
974
|
+
`[queueConfig] Delivery queue ${queueName} is declared in more than one section: ${sections.join(', ')} - `
|
|
975
|
+
+ 'Expected: exactly one section declares a name, so one queue has one definition. '
|
|
976
|
+
+ 'Fix: delete the duplicate entry in src/config/queueConfig.js.'
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (sections.length === 0) {
|
|
981
|
+
throw new ValidationError(
|
|
982
|
+
`[queueConfig] Infrastructure queue config not found: ${queueName} - `
|
|
983
|
+
+ `Expected: queueConfig to define the "${name}" entry under one of the delivery sections `
|
|
984
|
+
+ `(${DELIVERY_SECTIONS.join(', ')}). `
|
|
985
|
+
+ waysOut(this.business, queueName)
|
|
986
|
+
);
|
|
466
987
|
}
|
|
467
|
-
|
|
468
|
-
return this.getQueueConfig(
|
|
988
|
+
|
|
989
|
+
return this.getQueueConfig(sections[0], name);
|
|
469
990
|
},
|
|
470
991
|
|
|
471
992
|
/**
|
|
@@ -475,39 +996,48 @@ module.exports = {
|
|
|
475
996
|
*/
|
|
476
997
|
getValidationQueueConfig(queueName) {
|
|
477
998
|
if (!queueName.startsWith('validation.')) {
|
|
478
|
-
throw new
|
|
999
|
+
throw new ValidationError(
|
|
1000
|
+
`[queueConfig] Queue ${queueName} is not a validation queue - Expected: a name starting with "validation.". `
|
|
1001
|
+
+ 'Fix: call getInfrastructureQueueConfig(), which routes each prefix to the right lookup.'
|
|
1002
|
+
);
|
|
479
1003
|
}
|
|
480
1004
|
const name = queueName.replace('validation.', '');
|
|
481
1005
|
return this.getQueueConfig('validation', name);
|
|
482
1006
|
},
|
|
483
1007
|
|
|
484
1008
|
/**
|
|
485
|
-
* Check if queue name is a business queue
|
|
1009
|
+
* Check if queue name is a business queue - a queue a business service OWNS.
|
|
1010
|
+
*
|
|
1011
|
+
* Answered from the templates the `business` section declares, so the classifier
|
|
1012
|
+
* and the arguments can never disagree: `{service}.workflow`, `{service}.queue`,
|
|
1013
|
+
* `{service}.dlq` and the two registry-client queues.
|
|
1014
|
+
*
|
|
486
1015
|
* @param {string} queueName - Queue name to check
|
|
487
1016
|
* @returns {boolean} True if business queue
|
|
488
1017
|
*/
|
|
489
1018
|
isBusinessQueue(queueName) {
|
|
490
|
-
|
|
491
|
-
// where type is: workflow, queue, dlq
|
|
492
|
-
const parts = queueName.split('.');
|
|
493
|
-
if (parts.length !== 2) return false;
|
|
494
|
-
return ['workflow', 'queue', 'dlq'].includes(parts[1]);
|
|
1019
|
+
return this.parseBusinessQueue(queueName) !== null;
|
|
495
1020
|
},
|
|
496
1021
|
|
|
497
1022
|
/**
|
|
498
1023
|
* Parse business queue name to extract service name and queue type
|
|
1024
|
+
*
|
|
1025
|
+
* A name an infrastructure section DECLARES is never a business queue, however well
|
|
1026
|
+
* it fits a template: `workflow.dlq`, `delivery.dlq` and `monitoring.workflow` all
|
|
1027
|
+
* match one, and all three are declared platform queues. Until d.279 both classifiers
|
|
1028
|
+
* answered `true` for them, so which arguments such a queue got was decided by the
|
|
1029
|
+
* order of branches in whichever caller asked.
|
|
1030
|
+
*
|
|
499
1031
|
* @param {string} queueName - Business queue name (e.g., 'hello-service.workflow')
|
|
500
|
-
* @returns {
|
|
1032
|
+
* @returns {{serviceName: string, queueType: string}|null} The service and the
|
|
1033
|
+
* template key to pass to getBusinessQueueConfig(), or null if not a business queue
|
|
501
1034
|
*/
|
|
502
1035
|
parseBusinessQueue(queueName) {
|
|
503
|
-
if (
|
|
1036
|
+
if (declaredInfrastructureNames(this).has(queueName)) {
|
|
504
1037
|
return null;
|
|
505
1038
|
}
|
|
506
|
-
|
|
507
|
-
return
|
|
508
|
-
serviceName: parts[0],
|
|
509
|
-
queueType: parts[1]
|
|
510
|
-
};
|
|
1039
|
+
|
|
1040
|
+
return matchBusinessTemplate(this.business, queueName);
|
|
511
1041
|
},
|
|
512
1042
|
|
|
513
1043
|
/**
|
|
@@ -519,7 +1049,12 @@ module.exports = {
|
|
|
519
1049
|
getBusinessQueueConfig(queueType, serviceName) {
|
|
520
1050
|
const template = this.business?.[queueType];
|
|
521
1051
|
if (!template) {
|
|
522
|
-
throw new
|
|
1052
|
+
throw new ValidationError(
|
|
1053
|
+
`[queueConfig] Business queue template not found: ${queueType} - `
|
|
1054
|
+
+ `Expected: one of the declared business queue templates (${Object.keys(this.business).join(', ')}). `
|
|
1055
|
+
+ 'Fix: pass a declared template key - parseBusinessQueue() returns it for any business queue name - '
|
|
1056
|
+
+ 'or add the template to the `business` section of src/config/queueConfig.js.'
|
|
1057
|
+
);
|
|
523
1058
|
}
|
|
524
1059
|
|
|
525
1060
|
// Deep clone to avoid modifying original
|
|
@@ -544,7 +1079,10 @@ module.exports = {
|
|
|
544
1079
|
getInfrastructureHealthQueueConfig(queueName) {
|
|
545
1080
|
const parts = queueName.split('.');
|
|
546
1081
|
if (parts.length < 2 || parts[0] !== 'infrastructure') {
|
|
547
|
-
throw new
|
|
1082
|
+
throw new ValidationError(
|
|
1083
|
+
`[queueConfig] Invalid infrastructure health queue name: ${queueName}. Expected format: infrastructure.{name} - `
|
|
1084
|
+
+ 'Fix: pass at least two dot-separated parts whose first part is "infrastructure", e.g. "infrastructure.health.checks".'
|
|
1085
|
+
);
|
|
548
1086
|
}
|
|
549
1087
|
const name = parts.slice(1).join('.');
|
|
550
1088
|
return this.getQueueConfig('infrastructure', name);
|
|
@@ -557,32 +1095,168 @@ module.exports = {
|
|
|
557
1095
|
*/
|
|
558
1096
|
getMonitoringQueueConfig(queueName) {
|
|
559
1097
|
if (!queueName.startsWith('monitoring.')) {
|
|
560
|
-
throw new
|
|
1098
|
+
throw new ValidationError(
|
|
1099
|
+
`[queueConfig] Queue ${queueName} is not a monitoring queue - Expected: a name starting with "monitoring.". `
|
|
1100
|
+
+ 'Fix: call getInfrastructureQueueConfig(), which routes each prefix to the right lookup.'
|
|
1101
|
+
);
|
|
561
1102
|
}
|
|
562
1103
|
const name = queueName.replace('monitoring.', '');
|
|
563
1104
|
return this.getQueueConfig('monitoring', name);
|
|
564
1105
|
},
|
|
565
1106
|
|
|
1107
|
+
/**
|
|
1108
|
+
* Get telemetry queue configuration
|
|
1109
|
+
* @param {string} queueName - Queue name (e.g., 'telemetry.logs.queue')
|
|
1110
|
+
* @returns {Object} Queue configuration
|
|
1111
|
+
*/
|
|
1112
|
+
getTelemetryQueueConfig(queueName) {
|
|
1113
|
+
if (!queueName.startsWith('telemetry.')) {
|
|
1114
|
+
throw new ValidationError(
|
|
1115
|
+
`[queueConfig] Queue ${queueName} is not a telemetry queue - Expected: a name starting with "telemetry.". `
|
|
1116
|
+
+ 'Fix: call getInfrastructureQueueConfig(), which routes each prefix to the right lookup.'
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
const name = queueName.slice('telemetry.'.length);
|
|
1120
|
+
return this.getQueueConfig('telemetry', name);
|
|
1121
|
+
},
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* The COMPLETE declaration a queue is brought into being with — `durable` and
|
|
1125
|
+
* every argument, dead-letter route included.
|
|
1126
|
+
*
|
|
1127
|
+
* ONE owner of the mapping name → options. Every path that declares a queue
|
|
1128
|
+
* reads it here and nowhere else: the public `assertQueue()` of the transport
|
|
1129
|
+
* (and through it `RecoveryWorker.createQueue()`, the cookbook-router's
|
|
1130
|
+
* QueueManager and the publish path's 404 branch), and the consumer's
|
|
1131
|
+
* `_prepareQueueForConsume()`.
|
|
1132
|
+
*
|
|
1133
|
+
* Until d.410 those two worked the same three lines out separately —
|
|
1134
|
+
* `{ durable: cfg.durable !== false, arguments: { ...cfg.arguments } }`, once
|
|
1135
|
+
* per path — and the consumer then declared on the raw queue channel instead of
|
|
1136
|
+
* the public rail. Two implementations of one concern
|
|
1137
|
+
* (`change-discipline.md` § One rail per concern). They agreed on the config of
|
|
1138
|
+
* the day, so nothing failed; what they cost is the guarantee: edit one copy and
|
|
1139
|
+
* the other keeps declaring the old arguments, and whichever declarer the broker
|
|
1140
|
+
* hears second is answered `406 PRECONDITION_FAILED` — the exact failure the
|
|
1141
|
+
* central config exists to prevent, and the one a service hits at boot, when its
|
|
1142
|
+
* consumer meets a queue somebody else already declared differently.
|
|
1143
|
+
*
|
|
1144
|
+
* Ownership is NOT decided here. This answers only "what does the declaration
|
|
1145
|
+
* say", never "who may declare it": the publish path still refuses to create an
|
|
1146
|
+
* infrastructure or business queue (`docs/standards/queue-ownership.md`), and
|
|
1147
|
+
* the consumer still CHECKS an infrastructure queue rather than asserting it.
|
|
1148
|
+
*
|
|
1149
|
+
* @param {string} queueName - Full queue name.
|
|
1150
|
+
* @returns {{durable: boolean, arguments: Object}|null} A fresh copy of the
|
|
1151
|
+
* declaration, or `null` when no section declares this name — there is no
|
|
1152
|
+
* declaration to hand out, and the caller that needs one says so itself.
|
|
1153
|
+
* @throws {ValidationError} If the name belongs to a section that has no entry
|
|
1154
|
+
* for it. A missing DEFINITION is reported by the lookup that owns it, never
|
|
1155
|
+
* flattened into "nothing declares it" — same contract as
|
|
1156
|
+
* `getDeadLetterRoute()` below.
|
|
1157
|
+
*/
|
|
1158
|
+
declarationOptions(queueName) {
|
|
1159
|
+
let config;
|
|
1160
|
+
|
|
1161
|
+
if (this.isInfrastructureQueue(queueName)) {
|
|
1162
|
+
config = this.getInfrastructureQueueConfig(queueName);
|
|
1163
|
+
} else if (this.isBusinessQueue(queueName)) {
|
|
1164
|
+
const parsed = this.parseBusinessQueue(queueName);
|
|
1165
|
+
config = this.getBusinessQueueConfig(parsed.queueType, parsed.serviceName);
|
|
1166
|
+
} else {
|
|
1167
|
+
return null;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
return {
|
|
1171
|
+
durable: config.durable !== false,
|
|
1172
|
+
arguments: { ...config.arguments }
|
|
1173
|
+
};
|
|
1174
|
+
},
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* The dead-letter route this configuration declares for a queue.
|
|
1178
|
+
*
|
|
1179
|
+
* The DECLARATION is the only source of truth available on the consumer path.
|
|
1180
|
+
* The broker cannot be asked: AMQP answers `checkQueue()` with `queue.declare-ok`,
|
|
1181
|
+
* which carries `{ queue, messageCount, consumerCount }` and no arguments at all
|
|
1182
|
+
* (measured against the live broker, 2026-09-12), so a consumer has no way to
|
|
1183
|
+
* read a running queue's `x-dead-letter-*` over the channel it consumes on.
|
|
1184
|
+
* Whoever declares a queue here declares its route here.
|
|
1185
|
+
*
|
|
1186
|
+
* A route is BOTH parts. With `x-dead-letter-exchange: ''` — the default
|
|
1187
|
+
* exchange, which the shared workflow queues use — the routing key IS the
|
|
1188
|
+
* destination queue name, so a declaration without it would send a rejected
|
|
1189
|
+
* message back to the queue it came from: a loop, not a dead-letter route.
|
|
1190
|
+
*
|
|
1191
|
+
* @param {string} queueName - Full queue name.
|
|
1192
|
+
* @returns {{exchange: string, routingKey: string}|null} The declared route, or
|
|
1193
|
+
* `null` when this configuration declares none — including a name no section
|
|
1194
|
+
* classifies, such as a temporary `rpc.reply.*` queue.
|
|
1195
|
+
* @throws {ValidationError} If the name belongs to a section that has no entry
|
|
1196
|
+
* for it. A missing DEFINITION is reported by the lookup that owns it, never
|
|
1197
|
+
* flattened into "declares no route".
|
|
1198
|
+
*/
|
|
1199
|
+
getDeadLetterRoute(queueName) {
|
|
1200
|
+
let args;
|
|
1201
|
+
|
|
1202
|
+
if (this.isInfrastructureQueue(queueName)) {
|
|
1203
|
+
args = this.getInfrastructureQueueConfig(queueName).arguments;
|
|
1204
|
+
} else if (this.isBusinessQueue(queueName)) {
|
|
1205
|
+
const parsed = this.parseBusinessQueue(queueName);
|
|
1206
|
+
args = this.getBusinessQueueConfig(parsed.queueType, parsed.serviceName).arguments;
|
|
1207
|
+
} else {
|
|
1208
|
+
return null;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
if (args === null || typeof args !== 'object') {
|
|
1212
|
+
return null;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const exchange = args['x-dead-letter-exchange'];
|
|
1216
|
+
const routingKey = args['x-dead-letter-routing-key'];
|
|
1217
|
+
|
|
1218
|
+
if (typeof exchange !== 'string' || typeof routingKey !== 'string' || routingKey === '') {
|
|
1219
|
+
return null;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
return { exchange, routingKey };
|
|
1223
|
+
},
|
|
1224
|
+
|
|
566
1225
|
/**
|
|
567
1226
|
* Get infrastructure queue configuration by queue name (auto-detect type)
|
|
1227
|
+
*
|
|
1228
|
+
* The prefix decides the lookup, read from `INFRASTRUCTURE_PREFIX_LOOKUPS` — the
|
|
1229
|
+
* same map `isInfrastructureQueue()` classifies by, so the two can never disagree.
|
|
1230
|
+
*
|
|
568
1231
|
* @param {string} queueName - Full queue name (e.g., 'workflow.init', 'registry.register', 'infrastructure.health.checks', 'monitoring.workflow')
|
|
569
1232
|
* @returns {Object} Queue configuration
|
|
570
1233
|
*/
|
|
571
1234
|
getInfrastructureQueueConfig(queueName) {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
} else if (queueName.startsWith('validation.')) {
|
|
581
|
-
return this.getValidationQueueConfig(queueName);
|
|
582
|
-
} else if (queueName.startsWith('delivery.')) {
|
|
583
|
-
return this.getDeliveryQueueConfig(queueName);
|
|
584
|
-
} else {
|
|
585
|
-
throw new Error(`Queue ${queueName} is not an infrastructure queue. Infrastructure queues must start with 'workflow.', 'registry.', 'infrastructure.', or 'monitoring.'`);
|
|
1235
|
+
const prefix = INFRASTRUCTURE_PREFIXES.find((candidate) => queueName.startsWith(`${candidate}.`));
|
|
1236
|
+
|
|
1237
|
+
if (prefix === undefined) {
|
|
1238
|
+
throw new ValidationError(
|
|
1239
|
+
`[queueConfig] Queue ${queueName} is not an infrastructure queue. Infrastructure queues must start with `
|
|
1240
|
+
+ `${INFRASTRUCTURE_PREFIXES.map((name) => `'${name}.'`).join(', ')} - `
|
|
1241
|
+
+ 'Fix: use isBusinessQueue()/getBusinessQueueConfig() for a service-owned queue, or correct the prefix.'
|
|
1242
|
+
);
|
|
586
1243
|
}
|
|
1244
|
+
|
|
1245
|
+
return this[INFRASTRUCTURE_PREFIX_LOOKUPS[prefix]](queueName);
|
|
1246
|
+
},
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* The platform's dead-letter exchange, as the one thing to import instead of
|
|
1250
|
+
* the literal.
|
|
1251
|
+
*
|
|
1252
|
+
* Every business template routes here (`x-dead-letter-exchange`), and so does
|
|
1253
|
+
* `getDeadLetterRoute()` for a business queue. `@onlineapps/conn-infra-mq` binds
|
|
1254
|
+
* `<service>.dlq` to it and had to read the name back out of a queue's arguments
|
|
1255
|
+
* for want of an export.
|
|
1256
|
+
*
|
|
1257
|
+
* @returns {string}
|
|
1258
|
+
*/
|
|
1259
|
+
deadLetterExchange() {
|
|
1260
|
+
return DEAD_LETTER_EXCHANGE;
|
|
587
1261
|
}
|
|
588
1262
|
};
|