@onlineapps/conn-orch-registry 3.0.0 → 4.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/README.md +149 -19
- package/docs/REGISTRY_CLIENT_GUIDE.md +5 -5
- package/examples/basicUsage.js +14 -3
- package/examples/event-consumer-example.js +10 -23
- package/package.json +14 -24
- package/src/events.js +2 -2
- package/src/index.js +0 -1
- package/src/queueManager.js +168 -123
- package/src/registryClient.js +365 -286
- package/src/registryEventConsumer.js +107 -52
package/src/queueManager.js
CHANGED
|
@@ -1,25 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* queueManager.js
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The one MQ connection of the registry connector, and the only surface through
|
|
5
|
+
* which this package talks to the broker.
|
|
6
6
|
*
|
|
7
7
|
* Overview:
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
8
|
+
* - Owns a single `@onlineapps/mq-client-core` client: connect, declare,
|
|
9
|
+
* publish, consume, close.
|
|
10
|
+
* - Queues managed by the microservice: `<serviceName>.registry` and
|
|
11
|
+
* `<serviceName>.registry.events`. Their names are business-queue names and
|
|
12
|
+
* their DECLARATION (durability, TTL, dead-letter route) belongs to
|
|
13
|
+
* `queueConfig` in the core — never to an options object written here.
|
|
14
|
+
* - Infrastructure queues (`registry.register`, …) are CHECKED, never
|
|
15
|
+
* asserted: they are created by the infrastructure service that owns them
|
|
16
|
+
* (`api/docs/standards/queue-ownership.md`).
|
|
17
|
+
*
|
|
18
|
+
* Why there is no `channel` on this object any more (d.198b-3): until 2026-09-14
|
|
19
|
+
* this class handed out a raw amqplib channel, and both consumers of the package
|
|
20
|
+
* settled every delivery on it by hand — `channel.ack(msg)` / `channel.nack(msg,
|
|
21
|
+
* false, false)`. That is a second implementation of the delivery contract
|
|
22
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern) and the half that
|
|
23
|
+
* cannot count: a hand-written nack knows nothing about the attempt budget, the
|
|
24
|
+
* `x-oa-delivery-attempts` header or the `message_dlq` event the core publishes.
|
|
25
|
+
* `consume()` below forwards to the core's, which owns all three.
|
|
11
26
|
*
|
|
12
27
|
* Usage:
|
|
13
|
-
* const qm = new QueueManager(amqpUrl, serviceName);
|
|
28
|
+
* const qm = new QueueManager(amqpUrl, serviceName, logger);
|
|
14
29
|
* await qm.init();
|
|
15
|
-
* await qm.
|
|
16
|
-
* const { channel } = qm;
|
|
30
|
+
* await qm.consume(`${serviceName}.registry`, handler, { requeueOnError: false });
|
|
17
31
|
*
|
|
18
|
-
* @
|
|
32
|
+
* @see node_modules/@onlineapps/mq-client-core/README.md § `consume()` — the delivery contract
|
|
33
|
+
* @module @onlineapps/conn-orch-registry/src/queueManager
|
|
19
34
|
*/
|
|
20
35
|
|
|
21
|
-
const
|
|
22
|
-
const queueConfig = require('@onlineapps/mq-client-core
|
|
36
|
+
const BaseClient = require('@onlineapps/mq-client-core');
|
|
37
|
+
const { queueConfig, redactUrl } = require('@onlineapps/mq-client-core');
|
|
38
|
+
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
23
39
|
|
|
24
40
|
/**
|
|
25
41
|
* Queue manager for the microservice connector.
|
|
@@ -28,148 +44,177 @@ class QueueManager {
|
|
|
28
44
|
/**
|
|
29
45
|
* @param {string} amqpUrl - RabbitMQ server URL (AMQP URI)
|
|
30
46
|
* @param {string} serviceName - Name of the microservice (e.g. 'invoicing')
|
|
47
|
+
* @param {Object} logger - Logger with info/warn/error/debug; required, validated here
|
|
48
|
+
* (docs/governance/confirmations/connector-logger-contract.md 001)
|
|
31
49
|
*/
|
|
32
|
-
constructor(amqpUrl, serviceName) {
|
|
33
|
-
if (!amqpUrl)
|
|
34
|
-
|
|
50
|
+
constructor(amqpUrl, serviceName, logger) {
|
|
51
|
+
if (!amqpUrl) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'[QueueManager] Missing constructor argument - amqpUrl is required. '
|
|
54
|
+
+ 'Fix: pass the AMQP URI as the first argument, e.g. new QueueManager(process.env.RABBITMQ_URL, serviceName, logger).'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (!serviceName) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
'[QueueManager] Missing constructor argument - serviceName is required. '
|
|
60
|
+
+ 'Fix: pass the registering service name as the second argument.'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
this.logger = assertLogger('QueueManager', logger, 'queue lifecycle events reach the service log');
|
|
35
64
|
this.amqpUrl = amqpUrl;
|
|
65
|
+
// The URL carries the broker credential, so nothing that reaches a message or
|
|
66
|
+
// a log line uses `this.amqpUrl` — only this redacted form. The redactor is
|
|
67
|
+
// the platform's one (`@onlineapps/mq-client-core`, d.448); a local copy stood
|
|
68
|
+
// here until d.446b, from the days when that package masked the password alone.
|
|
69
|
+
this.safeAmqpUrl = redactUrl(amqpUrl);
|
|
36
70
|
this.serviceName = serviceName;
|
|
37
|
-
this.
|
|
38
|
-
|
|
71
|
+
this.client = new BaseClient({
|
|
72
|
+
type: 'rabbitmq',
|
|
73
|
+
host: amqpUrl,
|
|
74
|
+
serviceName,
|
|
75
|
+
logger: this.logger
|
|
76
|
+
});
|
|
39
77
|
}
|
|
40
78
|
|
|
41
79
|
/**
|
|
42
|
-
*
|
|
43
|
-
* @returns {Promise<void>}
|
|
44
|
-
* @throws {Error} If
|
|
80
|
+
* Opens the connection to RabbitMQ.
|
|
81
|
+
* @returns {Promise<void>}
|
|
82
|
+
* @throws {Error} If connecting fails; the message names the broker without its credential.
|
|
45
83
|
*/
|
|
46
84
|
async init() {
|
|
47
|
-
const CONNECT_TIMEOUT = 10000; // 10 seconds
|
|
48
|
-
let connectTimeout;
|
|
49
|
-
const connectPromise = amqp.connect(this.amqpUrl);
|
|
50
|
-
const connectTimeoutPromise = new Promise((_, reject) => {
|
|
51
|
-
connectTimeout = setTimeout(() => {
|
|
52
|
-
reject(new Error(`RabbitMQ connection timeout after ${CONNECT_TIMEOUT}ms. RabbitMQ may be unavailable at ${this.amqpUrl}`));
|
|
53
|
-
}, CONNECT_TIMEOUT);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
85
|
try {
|
|
57
|
-
|
|
86
|
+
await this.client.connect();
|
|
58
87
|
} catch (error) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
88
|
+
// The core's refusal says "the configured host" without naming it — which
|
|
89
|
+
// host is exactly what a reader of a boot failure needs, so it is added
|
|
90
|
+
// here, redacted, with the core's error as the cause.
|
|
91
|
+
throw new Error(
|
|
92
|
+
`[QueueManager] Failed to connect to RabbitMQ at ${this.safeAmqpUrl} - ${error.message}. `
|
|
93
|
+
+ 'Fix: check broker health and the credentials in the AMQP URI.',
|
|
94
|
+
{ cause: error }
|
|
95
|
+
);
|
|
62
96
|
}
|
|
97
|
+
}
|
|
63
98
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}, CHANNEL_TIMEOUT);
|
|
73
|
-
});
|
|
99
|
+
/**
|
|
100
|
+
* Is the broker connection open? The one question `register()` asks before it
|
|
101
|
+
* publishes; a closed client is not reusable.
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
isConnected() {
|
|
105
|
+
return this.client.isConnected();
|
|
106
|
+
}
|
|
74
107
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Declares the business queues this service owns.
|
|
110
|
+
*
|
|
111
|
+
* The declaration — durability, TTL, cap, dead-letter route — comes from
|
|
112
|
+
* `queueConfig` inside the core and is never written here: a name the central
|
|
113
|
+
* config declares carries no caller options, and asserting one with arguments
|
|
114
|
+
* of our own is how a queue ends up declared two different ways and the broker
|
|
115
|
+
* answers the second declarer `406 PRECONDITION-FAILED`.
|
|
116
|
+
*
|
|
117
|
+
* @param {Array<string>} [queues=[]] - Business queue names owned by this service
|
|
118
|
+
* @returns {Promise<void>}
|
|
119
|
+
* @throws {Error} If a name is an infrastructure queue, or the broker refuses the declaration.
|
|
120
|
+
*/
|
|
121
|
+
async ensureQueues(queues = []) {
|
|
122
|
+
for (const q of queues) {
|
|
123
|
+
if (queueConfig.isInfrastructureQueue(q)) {
|
|
124
|
+
// Not logged before throwing: the thrown error carries the queue name and the
|
|
125
|
+
// reason, and whoever handles it decides whether that is worth a log line.
|
|
126
|
+
throw new Error(
|
|
127
|
+
`[QueueManager] [REGISTRY] Refusing to assert infrastructure queue '${q}' - `
|
|
128
|
+
+ 'these queues are owned by infrastructure services and must exist before a business service starts. '
|
|
129
|
+
+ 'Fix: let the owning infrastructure service create it, or pass a business queue name.'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const assertStartTime = Date.now();
|
|
79
134
|
try {
|
|
80
|
-
await this.
|
|
81
|
-
} catch (
|
|
82
|
-
|
|
135
|
+
await this.client.assertQueue(q);
|
|
136
|
+
} catch (assertErr) {
|
|
137
|
+
const errorMsg = assertErr.message || String(assertErr);
|
|
138
|
+
throw new Error(
|
|
139
|
+
`[QueueManager] Failed to assert queue ${q} - ${errorMsg} (code ${assertErr.code || 'N/A'}). `
|
|
140
|
+
+ 'Fix: a 406 PRECONDITION-FAILED means the queue exists with different arguments; '
|
|
141
|
+
+ 'align them or let its owner declare it.',
|
|
142
|
+
{ cause: assertErr }
|
|
143
|
+
);
|
|
83
144
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
145
|
+
this.logger.debug('[QueueManager] Queue asserted', {
|
|
146
|
+
serviceName: this.serviceName,
|
|
147
|
+
queue: q,
|
|
148
|
+
durationMs: Date.now() - assertStartTime
|
|
149
|
+
});
|
|
87
150
|
}
|
|
88
151
|
}
|
|
89
152
|
|
|
90
153
|
/**
|
|
91
|
-
*
|
|
92
|
-
* @param {
|
|
93
|
-
* @returns {Promise<
|
|
154
|
+
* Confirms an infrastructure queue exists without creating it.
|
|
155
|
+
* @param {string} queue - Queue name
|
|
156
|
+
* @returns {Promise<Object>} The broker's `queue.declare-ok`
|
|
94
157
|
*/
|
|
95
|
-
async
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
158
|
+
async checkQueue(queue) {
|
|
159
|
+
return this.client.checkQueue(queue);
|
|
160
|
+
}
|
|
99
161
|
|
|
100
|
-
|
|
101
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Declares an exchange this package binds to.
|
|
164
|
+
* @param {string} exchange - Exchange name
|
|
165
|
+
* @param {string} type - Exchange type; required, never guessed
|
|
166
|
+
* @param {Object} [options] - Exchange options
|
|
167
|
+
* @returns {Promise<Object>}
|
|
168
|
+
*/
|
|
169
|
+
async assertExchange(exchange, type, options = {}) {
|
|
170
|
+
return this.client.assertExchange(exchange, type, options);
|
|
171
|
+
}
|
|
102
172
|
|
|
103
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Binds one of this service's queues to an exchange.
|
|
175
|
+
* @param {string} queue
|
|
176
|
+
* @param {string} exchange
|
|
177
|
+
* @param {string} pattern - Routing pattern; `''` is legal for a fanout
|
|
178
|
+
* @returns {Promise<Object>}
|
|
179
|
+
*/
|
|
180
|
+
async bindQueue(queue, exchange, pattern) {
|
|
181
|
+
return this.client.bindQueue(queue, exchange, pattern);
|
|
182
|
+
}
|
|
104
183
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Publishes one message.
|
|
186
|
+
* @param {string} queue - Target queue (or the exchange's name when `options.exchange` is set)
|
|
187
|
+
* @param {Object} message - Payload; serialized by the core
|
|
188
|
+
* @param {Object} [options] - `persistent`, `exchange`, `routingKey`, `headers`
|
|
189
|
+
* @returns {Promise<void>} Resolves when published; THROWS on failure — there is no falsy return
|
|
190
|
+
*/
|
|
191
|
+
async publish(queue, message, options = {}) {
|
|
192
|
+
return this.client.publish(queue, message, options);
|
|
193
|
+
}
|
|
114
194
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Directly assert queue with canonical configuration (idempotent)
|
|
133
|
-
try {
|
|
134
|
-
const assertStartTime = Date.now();
|
|
135
|
-
const ASSERT_TIMEOUT = 5000; // 5 seconds
|
|
136
|
-
let assertTimeout;
|
|
137
|
-
const assertPromise = this.channel.assertQueue(q, queueOptions);
|
|
138
|
-
const assertTimeoutPromise = new Promise((_, reject) => {
|
|
139
|
-
assertTimeout = setTimeout(() => {
|
|
140
|
-
reject(new Error(`assertQueue timeout after ${ASSERT_TIMEOUT}ms`));
|
|
141
|
-
}, ASSERT_TIMEOUT);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
try {
|
|
145
|
-
await Promise.race([assertPromise, assertTimeoutPromise]);
|
|
146
|
-
} finally {
|
|
147
|
-
if (assertTimeout) clearTimeout(assertTimeout);
|
|
148
|
-
}
|
|
149
|
-
const assertEndTime = Date.now();
|
|
150
|
-
console.log(`[QueueManager] [REGISTRY] [QUEUE] ✓ Queue ${q} asserted (took ${assertEndTime - assertStartTime}ms)`);
|
|
151
|
-
} catch (assertErr) {
|
|
152
|
-
const errorMsg = assertErr.message || String(assertErr);
|
|
153
|
-
console.error(`[QueueManager] [REGISTRY] [QUEUE] ✗ Failed to assert queue ${q}: ${errorMsg}`);
|
|
154
|
-
console.error(`[QueueManager] [REGISTRY] [QUEUE] Error code: ${assertErr.code || 'N/A'}`);
|
|
155
|
-
throw new Error(`[QueueManager] Failed to assert queue ${q}: ${errorMsg}`);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
195
|
+
/**
|
|
196
|
+
* Attaches a consumer on the core's delivery rail.
|
|
197
|
+
*
|
|
198
|
+
* The handler reports failure by THROWING. Settling is the core's: a handler
|
|
199
|
+
* that returns is acked once, a handler that throws is counted against the
|
|
200
|
+
* delivery budget and, when the budget is spent, rejected into `<service>.dlq`
|
|
201
|
+
* with a `message_dlq` event.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} queue
|
|
204
|
+
* @param {function(Object, Object): Promise<void>} handler
|
|
205
|
+
* @param {Object} [options] - `requeueOnError`, `maxAttempts`, `classify`, `prefetch`
|
|
206
|
+
* @returns {Promise<string>} The broker's consumer tag
|
|
207
|
+
*/
|
|
208
|
+
async consume(queue, handler, options = {}) {
|
|
209
|
+
return this.client.consume(queue, handler, options);
|
|
158
210
|
}
|
|
159
211
|
|
|
160
212
|
/**
|
|
161
|
-
* Closes the
|
|
213
|
+
* Closes the connection.
|
|
162
214
|
* @returns {Promise<void>}
|
|
163
215
|
*/
|
|
164
216
|
async close() {
|
|
165
|
-
|
|
166
|
-
await this.channel.close();
|
|
167
|
-
this.channel = null;
|
|
168
|
-
}
|
|
169
|
-
if (this.conn) {
|
|
170
|
-
await this.conn.close();
|
|
171
|
-
this.conn = null;
|
|
172
|
-
}
|
|
217
|
+
await this.client.disconnect();
|
|
173
218
|
}
|
|
174
219
|
}
|
|
175
220
|
|