@onlineapps/mq-client-core 1.0.83 → 2.0.1-rc.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/README.md +24 -0
- package/jest.config.js +15 -0
- package/jest.integration.config.js +16 -0
- package/package.json +6 -5
- package/src/BaseClient.js +13 -25
- package/src/config/queueConfig.js +12 -12
- package/src/transports/rabbitmqClient.js +95 -48
- package/src/utils/publishErrors.js +50 -4
- package/src/workers/RecoveryWorker.js +33 -6
- package/tests/unit/queueConfig-delivery.test.js +0 -38
- package/tests/unit/queueConfig-validation.test.js +0 -28
package/README.md
CHANGED
|
@@ -62,6 +62,30 @@ await mqClient.publish('workflow.control', { workflowId: '123', data: '...' });
|
|
|
62
62
|
- `isConnected()` - Check connection status
|
|
63
63
|
- `onError(callback)` - Register error handler
|
|
64
64
|
|
|
65
|
+
`publish()` and the workflow helpers built on it resolve to `undefined`; failure is
|
|
66
|
+
signalled by a thrown `PublishError`, never by a falsy return.
|
|
67
|
+
|
|
68
|
+
### Queue ownership — publishing never creates an owned queue
|
|
69
|
+
|
|
70
|
+
A missing queue is answered by who owns the name, not by the client's scope:
|
|
71
|
+
|
|
72
|
+
| Name | Owner | Missing at publish time |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `workflow.*`, `registry.*`, `infrastructure.*`, `validation.*`, `monitoring.*`, `telemetry.*`, `delivery.*` | the infrastructure service that declares it | `QueueNotFoundError` (`kind: 'infrastructure'`) |
|
|
75
|
+
| `{service}.workflow`, `{service}.queue`, `{service}.dlq` | the owning service, via `setupServiceQueues()` **after** registration | `QueueNotFoundError` (`kind: 'business'`) |
|
|
76
|
+
| anything else | nobody | created with default options, if `recoveryScope` allows it |
|
|
77
|
+
|
|
78
|
+
Publishing must never create an owned queue: `sendToQueue()`/`assertQueue()` would
|
|
79
|
+
declare it with default arguments — no TTL, no DLQ — and the owner's later
|
|
80
|
+
`setupServiceQueues()` then fails with 406 `PRECONDITION_FAILED` because the
|
|
81
|
+
arguments no longer match. The rule is enforced in two places, and both are
|
|
82
|
+
required: the 404 branch of `_publishOnce()` refuses the publish, and
|
|
83
|
+
`RecoveryWorker.handleQueueNotFound()` refuses to create the queue behind it.
|
|
84
|
+
|
|
85
|
+
`recoveryScope: 'business'` (the default every `ConnectorMQClient` sets) says the
|
|
86
|
+
client belongs to a business service. It has never meant "may create business
|
|
87
|
+
queues".
|
|
88
|
+
|
|
65
89
|
## Architecture
|
|
66
90
|
|
|
67
91
|
```
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default tier: unit + component only.
|
|
5
|
+
*
|
|
6
|
+
* `tests/integration` is excluded here and served by `jest.integration.config.js`
|
|
7
|
+
* instead, because that tier fails fast on a missing `RABBITMQ_URL` — folding it
|
|
8
|
+
* into the default run would make `npm test` unrunnable without a broker, and the
|
|
9
|
+
* usual "fix" for that is a skip, which is how a suite quietly leaves the
|
|
10
|
+
* regression loop (`.claude/rules/service-refactoring.md` #4).
|
|
11
|
+
*/
|
|
12
|
+
module.exports = {
|
|
13
|
+
testEnvironment: 'node',
|
|
14
|
+
testPathIgnorePatterns: ['/node_modules/', '/tests/integration/']
|
|
15
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Integration tier: runs against the live broker, never skipped.
|
|
5
|
+
*
|
|
6
|
+
* `globalSetup` aborts the whole run before Jest builds a describe tree when
|
|
7
|
+
* `RABBITMQ_URL` is missing or the broker is unreachable — see
|
|
8
|
+
* `tests/integration/setup.js`.
|
|
9
|
+
*/
|
|
10
|
+
module.exports = {
|
|
11
|
+
testEnvironment: 'node',
|
|
12
|
+
testMatch: ['**/tests/integration/**/*.test.js'],
|
|
13
|
+
testTimeout: 30000,
|
|
14
|
+
globalSetup: './tests/integration/setup.js',
|
|
15
|
+
verbose: true
|
|
16
|
+
};
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/mq-client-core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1-rc.1",
|
|
4
4
|
"description": "Core MQ client library for RabbitMQ - shared by infrastructure services and connectors",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "jest",
|
|
8
8
|
"test:unit": "jest --testPathPattern=tests/unit",
|
|
9
9
|
"test:component": "jest --testPathPattern=tests/component",
|
|
10
|
-
"test:integration": "jest --
|
|
10
|
+
"test:integration": "jest --config=jest.integration.config.js"
|
|
11
11
|
},
|
|
12
12
|
"keywords": [
|
|
13
13
|
"rabbitmq",
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
"author": "OnlineApps",
|
|
19
19
|
"license": "MIT",
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"
|
|
21
|
+
"@onlineapps/infra-logger": "2.0.0",
|
|
22
|
+
"@onlineapps/runtime-config": "1.0.3",
|
|
22
23
|
"ajv": "^8.12.0",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
24
|
+
"amqplib": "^0.10.3",
|
|
25
|
+
"lodash.merge": "^4.6.2"
|
|
25
26
|
},
|
|
26
27
|
"devDependencies": {
|
|
27
28
|
"jest": "^29.7.0"
|
package/src/BaseClient.js
CHANGED
|
@@ -263,6 +263,19 @@ class BaseClient {
|
|
|
263
263
|
|
|
264
264
|
/**
|
|
265
265
|
* Acknowledge a raw broker message (delegates to transport).
|
|
266
|
+
*
|
|
267
|
+
* Settling is idempotent: the transport marks the delivery via `msg._mqProcessed`
|
|
268
|
+
* and `consume()` skips its own auto-ack for a message the handler already
|
|
269
|
+
* settled (`transports/rabbitmqClient.js:1769` and `:2268`). Calling this from
|
|
270
|
+
* inside a consume handler is therefore supported, not a double-settle.
|
|
271
|
+
*
|
|
272
|
+
* Until 2026-08-28 this method was declared TWICE in this class; the second,
|
|
273
|
+
* later declaration silently overrode this one and omitted the
|
|
274
|
+
* `typeof this._transport.ack !== 'function'` capability check below, so a
|
|
275
|
+
* transport without `ack` failed with `TypeError: this._transport.ack is not a
|
|
276
|
+
* function` instead of the actionable message. Same defect shape as the
|
|
277
|
+
* duplicate pair removed from the storage connector.
|
|
278
|
+
*
|
|
266
279
|
* @param {Object} msg - Broker message object
|
|
267
280
|
* @returns {Promise<void>}
|
|
268
281
|
*/
|
|
@@ -292,31 +305,6 @@ class BaseClient {
|
|
|
292
305
|
return await this._transport.nack(msg, options);
|
|
293
306
|
}
|
|
294
307
|
|
|
295
|
-
/**
|
|
296
|
-
* Acknowledges a RabbitMQ message.
|
|
297
|
-
* @param {Object} msg - RabbitMQ message object.
|
|
298
|
-
* @returns {Promise<void>}
|
|
299
|
-
*/
|
|
300
|
-
async ack(msg) {
|
|
301
|
-
if (!this._connected || !this._transport) {
|
|
302
|
-
throw new ConnectionError('Cannot ack: client is not connected');
|
|
303
|
-
}
|
|
304
|
-
return this._transport.ack(msg);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Negative-acknowledges a RabbitMQ message.
|
|
309
|
-
* @param {Object} msg - RabbitMQ message object.
|
|
310
|
-
* @param {Object} [options] - Options such as { requeue: boolean }.
|
|
311
|
-
* @returns {Promise<void>}
|
|
312
|
-
*/
|
|
313
|
-
async nack(msg, options = {}) {
|
|
314
|
-
if (!this._connected || !this._transport) {
|
|
315
|
-
throw new ConnectionError('Cannot nack: client is not connected');
|
|
316
|
-
}
|
|
317
|
-
return this._transport.nack(msg, options);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
308
|
/**
|
|
321
309
|
* Registers a global error handler. Internal or transport-level errors will be forwarded here.
|
|
322
310
|
* @param {function(Error): void} callback
|
|
@@ -174,15 +174,12 @@ module.exports = {
|
|
|
174
174
|
},
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
|
-
* delivery.result
|
|
177
|
+
* `delivery.result` used to be defined here as an "audit" queue. It never had a
|
|
178
|
+
* consumer in any commit, so everything published to it expired unread inside its
|
|
179
|
+
* 24h TTL. Delivery outcome is persisted durably (and redacted) through
|
|
180
|
+
* monitoring.workflow -> workflow_traces instead — see docs/architecture/delivery.md.
|
|
181
|
+
* Do not re-add it; enrich the monitoring.workflow event instead.
|
|
178
182
|
*/
|
|
179
|
-
result: {
|
|
180
|
-
durable: true,
|
|
181
|
-
arguments: {
|
|
182
|
-
'x-message-ttl': 86400000, // 24 hours TTL for audit traceability
|
|
183
|
-
'x-max-length': 50000
|
|
184
|
-
}
|
|
185
|
-
},
|
|
186
183
|
|
|
187
184
|
/**
|
|
188
185
|
* delivery.websocket.notify - Notifications for Gateway websocket hub
|
|
@@ -370,10 +367,13 @@ module.exports = {
|
|
|
370
367
|
*
|
|
371
368
|
* CRITICAL: This is the ONLY queue used for service-to-Registry communication.
|
|
372
369
|
* Registry listener processes different message types based on msg.type:
|
|
373
|
-
* - type: 'register'
|
|
374
|
-
* - type: 'heartbeat'
|
|
375
|
-
* - type: '
|
|
376
|
-
*
|
|
370
|
+
* - type: 'register' - Service registration requests (full spec + operations)
|
|
371
|
+
* - type: 'heartbeat' - Periodic health check messages (sent every 10s)
|
|
372
|
+
* - type: 'deregister' - Voluntary shutdown notice
|
|
373
|
+
* (The legacy 'apiDescription' / 'apiDescriptionRequest' types are no
|
|
374
|
+
* longer supported — see operations-registry-contract.md §2.)
|
|
375
|
+
*
|
|
376
|
+
*
|
|
377
377
|
* Architecture rationale:
|
|
378
378
|
* - Single consumer on registry.register handles all message types
|
|
379
379
|
* - Simplifies queue management (one queue instead of multiple)
|
|
@@ -13,37 +13,47 @@ const {
|
|
|
13
13
|
PermanentPublishError,
|
|
14
14
|
QueueNotFoundError,
|
|
15
15
|
classifyPublishError,
|
|
16
|
+
missingInfrastructureQueueMessage,
|
|
16
17
|
} = require('../utils/publishErrors');
|
|
18
|
+
const { createLogger } = require('@onlineapps/infra-logger');
|
|
17
19
|
const PublishLayer = require('../layers/PublishLayer');
|
|
18
20
|
const RecoveryWorker = require('../workers/RecoveryWorker');
|
|
19
21
|
const PublishMonitor = require('../monitoring/PublishMonitor');
|
|
20
22
|
const runtimeCfg = require('../config');
|
|
21
23
|
|
|
24
|
+
/** AMQP basic.publish fields forwarded from publish() options (amqplib Options.Publish). */
|
|
25
|
+
const AMQP_MESSAGE_PROPERTY_KEYS = [
|
|
26
|
+
'correlationId',
|
|
27
|
+
'replyTo',
|
|
28
|
+
'messageId',
|
|
29
|
+
'timestamp',
|
|
30
|
+
'type',
|
|
31
|
+
'priority',
|
|
32
|
+
'expiration',
|
|
33
|
+
'contentType',
|
|
34
|
+
'contentEncoding',
|
|
35
|
+
'userId',
|
|
36
|
+
'appId',
|
|
37
|
+
];
|
|
38
|
+
|
|
22
39
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
40
|
+
* Merge persistent + headers with whitelisted AMQP message properties for sendToQueue/publish.
|
|
41
|
+
* @param {Object} options
|
|
42
|
+
* @param {boolean} persistent
|
|
43
|
+
* @param {Object} headers
|
|
44
|
+
* @returns {Object}
|
|
28
45
|
*/
|
|
29
|
-
function
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
function buildAmqpMessageProperties(options, persistent, headers) {
|
|
47
|
+
const props = { persistent };
|
|
48
|
+
if (headers && typeof headers === 'object') {
|
|
49
|
+
props.headers = headers;
|
|
50
|
+
}
|
|
51
|
+
for (const key of AMQP_MESSAGE_PROPERTY_KEYS) {
|
|
52
|
+
if (options[key] !== undefined && options[key] !== null && options[key] !== '') {
|
|
53
|
+
props[key] = options[key];
|
|
37
54
|
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return {
|
|
41
|
-
input: (contextId, action, data) => console.log(prefix(contextId, action), toJson(data)),
|
|
42
|
-
output: (contextId, action, data) => console.log(prefix(contextId, action), toJson(data)),
|
|
43
|
-
process: (contextId, action, data) => console.log(prefix(contextId, action), toJson(data)),
|
|
44
|
-
lifecycle: (contextId, action, data) => console.log(prefix(contextId, action), toJson(data)),
|
|
45
|
-
fail: (contextId, action, data) => console.error(prefix(contextId, action), toJson(data)),
|
|
46
|
-
};
|
|
55
|
+
}
|
|
56
|
+
return props;
|
|
47
57
|
}
|
|
48
58
|
|
|
49
59
|
class RabbitMQClient extends EventEmitter {
|
|
@@ -132,7 +142,14 @@ class RabbitMQClient extends EventEmitter {
|
|
|
132
142
|
this._criticalHealthStartTime = null; // Track when critical health started
|
|
133
143
|
|
|
134
144
|
// Create structured logger for infrastructure logging (module-owned)
|
|
135
|
-
|
|
145
|
+
// ONE implementation of the infrastructure logger, in
|
|
146
|
+
// @onlineapps/infra-logger. A private copy lived here until 2026-08-29,
|
|
147
|
+
// justified as avoiding a dependency cycle — a cycle that is not there:
|
|
148
|
+
// the comment named infrastructure-tools, but the logger is infra-logger,
|
|
149
|
+
// which declares no @onlineapps dependency at all. The copy meanwhile
|
|
150
|
+
// missed the identity fail-fast that 2.0.0 added (change-discipline.md:
|
|
151
|
+
// one fact, one owner).
|
|
152
|
+
this._log = createLogger('mq-client-core', 'transport');
|
|
136
153
|
|
|
137
154
|
// Publish layer (retry + buffer)
|
|
138
155
|
this._publishLayer = new PublishLayer({
|
|
@@ -1283,10 +1300,16 @@ class RabbitMQClient extends EventEmitter {
|
|
|
1283
1300
|
try {
|
|
1284
1301
|
// Ensure queue exists if publishing directly to queue and using default exchange
|
|
1285
1302
|
if (!exchange) {
|
|
1303
|
+
// RPC / client reply targets: server-named exclusive queues (amq.gen-*) or direct reply-to
|
|
1304
|
+
// exist only on the client's connection — another connection cannot checkQueue them.
|
|
1305
|
+
const skipQueueExistencePrecheck =
|
|
1306
|
+
queue === 'amq.rabbitmq.reply-to' || queue.startsWith('amq.gen-');
|
|
1307
|
+
|
|
1286
1308
|
// CRITICAL: For infrastructure queues, they should already exist with specific arguments from queueConfig
|
|
1287
1309
|
// Use queueChannel (regular channel) for checkQueue/assertQueue to avoid RPC reply queue issues
|
|
1288
1310
|
// If queue doesn't exist (404), we should NOT auto-create it - infrastructure queues must be created explicitly
|
|
1289
1311
|
// This prevents creating queues with wrong arguments (no TTL) which causes 406 errors later
|
|
1312
|
+
if (!skipQueueExistencePrecheck) {
|
|
1290
1313
|
try {
|
|
1291
1314
|
// Ensure queue channel exists and is open (auto-recreates if closed)
|
|
1292
1315
|
await this._ensureQueueChannel();
|
|
@@ -1302,27 +1325,44 @@ class RabbitMQClient extends EventEmitter {
|
|
|
1302
1325
|
// Queue exists - proceed to publish
|
|
1303
1326
|
} catch (checkErr) {
|
|
1304
1327
|
// If queue doesn't exist (404), this je ERROR – ale rozlišujeme infra vs. non-infra:
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
//
|
|
1328
|
+
// 404 na chybějící frontu se rozpadá na tři případy podle vlastnictví:
|
|
1329
|
+
// - Infrastrukturní fronty (`queueConfig.isInfrastructureQueue`) zakládají
|
|
1330
|
+
// infra služby předem → QueueNotFoundError(kind='infrastructure').
|
|
1331
|
+
// - Business fronty (`queueConfig.isBusinessQueue`, tj. `{service}.{workflow|queue|dlq}`)
|
|
1332
|
+
// zakládá vlastnící služba přes setupServiceQueues() po registraci
|
|
1333
|
+
// → QueueNotFoundError(kind='business'). Publisher je nikdy nevytváří.
|
|
1334
|
+
// - Ostatní jména (ani infra, ani business vzor) se smějí založit
|
|
1335
|
+
// s default parametry, pokud to scope RecoveryWorkeru dovoluje.
|
|
1308
1336
|
if (checkErr.code === 404) {
|
|
1309
|
-
//
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
queue.startsWith('validation.');
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
if (isInfraQueue) {
|
|
1337
|
+
// `../config/queueConfig` is a sibling module of this file; the require
|
|
1338
|
+
// cannot fail. It used to sit in a try/catch whose `catch` re-implemented
|
|
1339
|
+
// `isInfrastructureQueue` as an inline prefix list — a fallback
|
|
1340
|
+
// (`architecture-principles.md` §3) that had also gone stale: it was
|
|
1341
|
+
// missing `telemetry.` and `delivery.`, both of which the real
|
|
1342
|
+
// queueConfig classifies as infrastructure. A fallback that answers
|
|
1343
|
+
// differently from the thing it stands in for is a second, wrong source
|
|
1344
|
+
// of truth, so it is gone.
|
|
1345
|
+
const queueConfig = require('../config/queueConfig');
|
|
1346
|
+
|
|
1347
|
+
if (queueConfig.isInfrastructureQueue(queue)) {
|
|
1324
1348
|
// Jasný, hlasitý signál pro infra služby – fronta chybí, je to programátorská chyba
|
|
1325
|
-
throw new QueueNotFoundError(queue, true, checkErr);
|
|
1349
|
+
throw new QueueNotFoundError(queue, true, checkErr, 'infrastructure');
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// Business queues (`{service}.{workflow|queue|dlq}`) MUST exist before
|
|
1353
|
+
// publish. `sendToQueue()`/`assertQueue()` with default options would
|
|
1354
|
+
// create them with no TTL and no DLQ, and the owning service's later
|
|
1355
|
+
// `setupServiceQueues()` then hits 406 PRECONDITION_FAILED because the
|
|
1356
|
+
// arguments no longer match. The queue is created by the owner AFTER
|
|
1357
|
+
// registration; a publisher that creates it is a programming error.
|
|
1358
|
+
//
|
|
1359
|
+
// The rule and this exact wording came from
|
|
1360
|
+
// `conn-infra-mq/src/transports/rabbitmqClient.js:264-277` — a transport
|
|
1361
|
+
// that nothing in `src/` instantiated, so the guard protected nobody
|
|
1362
|
+
// while this file, the one every client actually runs, auto-created the
|
|
1363
|
+
// queue and only printed a warning.
|
|
1364
|
+
if (queueConfig.isBusinessQueue(queue)) {
|
|
1365
|
+
throw new QueueNotFoundError(queue, false, checkErr, 'business');
|
|
1326
1366
|
}
|
|
1327
1367
|
// For non-infrastructure queues, check if queue creation is allowed before auto-creating
|
|
1328
1368
|
// Check recovery scope and filter
|
|
@@ -1358,6 +1398,7 @@ class RabbitMQClient extends EventEmitter {
|
|
|
1358
1398
|
throw checkErr;
|
|
1359
1399
|
}
|
|
1360
1400
|
}
|
|
1401
|
+
}
|
|
1361
1402
|
// Publish to queue using ConfirmChannel (for publisher confirms)
|
|
1362
1403
|
// Channel is guaranteed to be open (ensured above)
|
|
1363
1404
|
console.log(`[RabbitMQClient] [mq-client-core] [PUBLISH] Sending message to queue "${queue}" (size: ${buffer.length} bytes)`);
|
|
@@ -1383,7 +1424,11 @@ class RabbitMQClient extends EventEmitter {
|
|
|
1383
1424
|
|
|
1384
1425
|
// Send message with callback
|
|
1385
1426
|
try {
|
|
1386
|
-
originalChannel.sendToQueue(
|
|
1427
|
+
originalChannel.sendToQueue(
|
|
1428
|
+
queue,
|
|
1429
|
+
buffer,
|
|
1430
|
+
buildAmqpMessageProperties(options, persistent, headers),
|
|
1431
|
+
(err, ok) => {
|
|
1387
1432
|
callbackInvoked = true;
|
|
1388
1433
|
this._clearTimeout(timeout);
|
|
1389
1434
|
|
|
@@ -1483,7 +1528,12 @@ class RabbitMQClient extends EventEmitter {
|
|
|
1483
1528
|
reject(new Error(`Exchange publish confirmation timeout for exchange "${exchange}" after ${this._publishConfirmationTimeout}ms`));
|
|
1484
1529
|
}, this._publishConfirmationTimeout);
|
|
1485
1530
|
|
|
1486
|
-
this._channel.publish(
|
|
1531
|
+
this._channel.publish(
|
|
1532
|
+
exchange,
|
|
1533
|
+
routingKey,
|
|
1534
|
+
buffer,
|
|
1535
|
+
buildAmqpMessageProperties(options, persistent, headers),
|
|
1536
|
+
(err, ok) => {
|
|
1487
1537
|
this._clearTimeout(timeout);
|
|
1488
1538
|
if (err) {
|
|
1489
1539
|
console.error(`[RabbitMQClient] [mq-client-core] [PUBLISH] Exchange publish callback error:`, err.message);
|
|
@@ -1617,10 +1667,7 @@ class RabbitMQClient extends EventEmitter {
|
|
|
1617
1667
|
console.log(`[RabbitMQClient] [mq-client-core] [CONSUMER] ✓ Infrastructure queue ${queue} exists (consumer will proceed)`);
|
|
1618
1668
|
} catch (checkErr) {
|
|
1619
1669
|
if (checkErr.code === 404) {
|
|
1620
|
-
throw new Error(
|
|
1621
|
-
`Infrastructure queue ${queue} is missing. ` +
|
|
1622
|
-
'Ownership rule: infrastructure queues must be created by their owning infrastructure service via initInfrastructureQueues().'
|
|
1623
|
-
);
|
|
1670
|
+
throw new Error(missingInfrastructureQueueMessage(queue));
|
|
1624
1671
|
}
|
|
1625
1672
|
throw checkErr;
|
|
1626
1673
|
}
|
|
@@ -36,18 +36,63 @@ class QueueNotFoundError extends PermanentPublishError {
|
|
|
36
36
|
* @param {string} queueName
|
|
37
37
|
* @param {boolean} isInfrastructure
|
|
38
38
|
* @param {Error} [cause]
|
|
39
|
+
* @param {('infrastructure'|'business'|'unknown')} [kind] - Which ownership rule
|
|
40
|
+
* was violated. Defaults to `'infrastructure'`/`'unknown'` from
|
|
41
|
+
* `isInfrastructure`, so existing two- and three-argument callers are unchanged.
|
|
39
42
|
*/
|
|
40
|
-
constructor(queueName, isInfrastructure, cause) {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
constructor(queueName, isInfrastructure, cause, kind) {
|
|
44
|
+
const resolvedKind = kind || (isInfrastructure ? 'infrastructure' : 'unknown');
|
|
45
|
+
let baseMessage;
|
|
46
|
+
if (resolvedKind === 'infrastructure') {
|
|
47
|
+
baseMessage =
|
|
48
|
+
`Cannot publish to infrastructure queue ${queueName}: queue does not exist. ` +
|
|
49
|
+
'Infrastructure queues must be created explicitly with correct arguments ' +
|
|
50
|
+
'(TTL, max-length, etc.) before publishing.';
|
|
51
|
+
} else if (resolvedKind === 'business') {
|
|
52
|
+
baseMessage =
|
|
53
|
+
`Cannot publish to business queue ${queueName}: queue does not exist. ` +
|
|
54
|
+
'Expected: business queues are created by their owning service via ' +
|
|
55
|
+
'setupServiceQueues() AFTER successful registration, with the arguments ' +
|
|
56
|
+
'queueConfig prescribes (TTL, DLQ). Fix: call setupServiceQueues() before ' +
|
|
57
|
+
'publishing — publishing must never create the queue, because sendToQueue() ' +
|
|
58
|
+
'would create it with default arguments (no TTL, no DLQ).';
|
|
59
|
+
} else {
|
|
60
|
+
baseMessage = `Queue ${queueName} does not exist`;
|
|
61
|
+
}
|
|
44
62
|
super(baseMessage, cause);
|
|
45
63
|
this.name = 'QueueNotFoundError';
|
|
46
64
|
this.queueName = queueName;
|
|
47
65
|
this.isInfrastructure = !!isInfrastructure;
|
|
66
|
+
this.kind = resolvedKind;
|
|
48
67
|
}
|
|
49
68
|
}
|
|
50
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The message a CONSUMER gets when an infrastructure queue it must attach to
|
|
72
|
+
* does not exist (TODO §22).
|
|
73
|
+
*
|
|
74
|
+
* It used to state the rule and leave the reader stuck: it named
|
|
75
|
+
* `initInfrastructureQueues()`, a function this package does not export and
|
|
76
|
+
* never has. `mq-client-core` sits deliberately BELOW `infrastructure-tools`
|
|
77
|
+
* and must not depend on it, so naming the owning package is the only thing
|
|
78
|
+
* that can bridge the two — and without it the fix is unfindable.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} queueName
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
function missingInfrastructureQueueMessage(queueName) {
|
|
84
|
+
if (!queueName) {
|
|
85
|
+
throw new Error('[publishErrors] queueName is required - Expected the name of the missing infrastructure queue');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return `[RabbitMQClient] Infrastructure queue ${queueName} is missing - `
|
|
89
|
+
+ 'Expected: it to be created by the infrastructure service that owns it, before any consumer starts. '
|
|
90
|
+
+ 'Consumers never create infrastructure queues: sendToQueue() and consume() would declare them with '
|
|
91
|
+
+ 'default arguments (no TTL, no max-length) and every later declaration would fail with 406. '
|
|
92
|
+
+ 'Fix: start the owning infrastructure service, which calls initInfrastructureQueues() from '
|
|
93
|
+
+ '@onlineapps/infrastructure-tools.';
|
|
94
|
+
}
|
|
95
|
+
|
|
51
96
|
/**
|
|
52
97
|
* Best-effort klasifikace chyb z publishu do specializovaných typů.
|
|
53
98
|
* Vrací původní chybu, pokud neodpovídá žádnému známému patternu.
|
|
@@ -97,6 +142,7 @@ function classifyPublishError(err) {
|
|
|
97
142
|
}
|
|
98
143
|
|
|
99
144
|
module.exports = {
|
|
145
|
+
missingInfrastructureQueueMessage,
|
|
100
146
|
TransientPublishError,
|
|
101
147
|
PermanentPublishError,
|
|
102
148
|
QueueNotFoundError,
|
|
@@ -7,11 +7,18 @@ const { QueueNotFoundError } = require('../utils/publishErrors');
|
|
|
7
7
|
*
|
|
8
8
|
* Scope:
|
|
9
9
|
* - infrastructure: connection recovery, channel recreation, consumer re-registration (NENÍ queue creation)
|
|
10
|
-
* - business:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
10
|
+
* - business: totéž + zakládání front, ale POUZE těch, které nemají vlastníka
|
|
11
|
+
* (viz Queue Creation níže). Scope říká, ČÍ ten klient je — ne co smí založit.
|
|
12
|
+
*
|
|
13
|
+
* Queue Creation — vlastnictví fronty rozhoduje, ne scope:
|
|
14
|
+
* - infrastrukturní fronty (`queueConfig.isInfrastructureQueue`) zakládá vlastnící
|
|
15
|
+
* infra služba předem → tento worker je nikdy nezaloží, chybu propustí dál;
|
|
16
|
+
* - business fronty (`queueConfig.isBusinessQueue`, tj. `{service}.{workflow|queue|dlq}`)
|
|
17
|
+
* zakládá vlastnící služba přes `setupServiceQueues()` po registraci, s argumenty
|
|
18
|
+
* z queueConfig (TTL, DLQ) → tento worker je nikdy nezaloží, chybu propustí dál;
|
|
19
|
+
* - ostatní jména (ani infra, ani business vzor) smí založit, pokud to scope
|
|
20
|
+
* a `queueCreationFilter` dovolují — buď přes `queueCreationCallback`
|
|
21
|
+
* (deleguje na QueueManager), nebo přímo.
|
|
15
22
|
*/
|
|
16
23
|
class RecoveryWorker {
|
|
17
24
|
/**
|
|
@@ -66,7 +73,27 @@ class RecoveryWorker {
|
|
|
66
73
|
throw error;
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
// Business queues
|
|
76
|
+
// Business queues ({service}.{workflow|queue|dlq}) must exist too — they are
|
|
77
|
+
// created by their owning service via setupServiceQueues() AFTER registration,
|
|
78
|
+
// with the arguments queueConfig prescribes.
|
|
79
|
+
//
|
|
80
|
+
// Measured 2026-08-28: without this branch the ownership guard in
|
|
81
|
+
// `_publishOnce` was defeated by its own error path. The publish was refused
|
|
82
|
+
// correctly, the caller saw PublishError — and then this worker, listening for
|
|
83
|
+
// QueueNotFoundError on the client's error event, created the very queue the
|
|
84
|
+
// guard had just refused ("[RecoveryWorker] ✓ Created queue '<q>' (direct)"),
|
|
85
|
+
// with default arguments and no TTL. Refusing at one door while opening the
|
|
86
|
+
// other is not a guard.
|
|
87
|
+
//
|
|
88
|
+
// `_scope: 'business'` says the CLIENT belongs to a business service. It has
|
|
89
|
+
// never meant "may create business queues", and ConnectorMQClient.js:74 sets
|
|
90
|
+
// it for every biz service, so that reading made the rule vacuous.
|
|
91
|
+
if (error.kind === 'business') {
|
|
92
|
+
this._logger?.error?.(`[RecoveryWorker] Cannot create business queue '${error.queueName}' - it is created by its owning service via setupServiceQueues() after registration`);
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Everything else - create if scope allows
|
|
70
97
|
if (this._queueCreationEnabled) {
|
|
71
98
|
// Check filter if provided
|
|
72
99
|
if (this._queueCreationFilter && !this._queueCreationFilter(error.queueName)) {
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const queueConfig = require('../../src/config/queueConfig');
|
|
4
|
-
|
|
5
|
-
describe('queueConfig delivery infrastructure', () => {
|
|
6
|
-
test('delivery.retry configuration', () => {
|
|
7
|
-
const config = queueConfig.getInfrastructureQueueConfig('delivery.retry');
|
|
8
|
-
expect(config).toEqual({
|
|
9
|
-
durable: true,
|
|
10
|
-
arguments: {
|
|
11
|
-
'x-max-length': 10000
|
|
12
|
-
}
|
|
13
|
-
});
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test('delivery.result configuration', () => {
|
|
17
|
-
const config = queueConfig.getInfrastructureQueueConfig('delivery.result');
|
|
18
|
-
expect(config).toEqual({
|
|
19
|
-
durable: true,
|
|
20
|
-
arguments: {
|
|
21
|
-
'x-message-ttl': 86400000,
|
|
22
|
-
'x-max-length': 50000
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test('delivery.websocket.notify configuration', () => {
|
|
28
|
-
const config = queueConfig.getInfrastructureQueueConfig('delivery.websocket.notify');
|
|
29
|
-
expect(config).toEqual({
|
|
30
|
-
durable: true,
|
|
31
|
-
arguments: {
|
|
32
|
-
'x-message-ttl': 60000,
|
|
33
|
-
'x-max-length': 10000
|
|
34
|
-
}
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const queueConfig = require('../../src/config/queueConfig');
|
|
4
|
-
|
|
5
|
-
describe('queueConfig validation infrastructure', () => {
|
|
6
|
-
test('validation.requests configuration', () => {
|
|
7
|
-
const config = queueConfig.getInfrastructureQueueConfig('validation.requests');
|
|
8
|
-
expect(config).toEqual({
|
|
9
|
-
durable: true,
|
|
10
|
-
arguments: {
|
|
11
|
-
'x-message-ttl': 300000,
|
|
12
|
-
'x-max-length': 10000
|
|
13
|
-
}
|
|
14
|
-
});
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test('validation.responses configuration', () => {
|
|
18
|
-
const config = queueConfig.getInfrastructureQueueConfig('validation.responses');
|
|
19
|
-
expect(config).toEqual({
|
|
20
|
-
durable: true,
|
|
21
|
-
arguments: {
|
|
22
|
-
'x-message-ttl': 300000,
|
|
23
|
-
'x-max-length': 10000
|
|
24
|
-
}
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|