@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
package/src/BaseClient.js
CHANGED
|
@@ -7,32 +7,288 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const Ajv = require('ajv');
|
|
10
|
-
const
|
|
10
|
+
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
11
11
|
|
|
12
12
|
const configSchema = require('./config/configSchema');
|
|
13
13
|
const defaultConfig = require('./config/defaultConfig');
|
|
14
|
+
const composeConfig = require('./config/composeConfig');
|
|
14
15
|
const runtimeCfg = require('./config');
|
|
15
16
|
const transportFactory = require('./transports/transportFactory');
|
|
16
17
|
const serializer = require('./utils/serializer');
|
|
18
|
+
const { redactConnectionTarget } = require('./utils/redactCredentials');
|
|
19
|
+
const { nameUnknownKeys } = require('./utils/nearestKey');
|
|
20
|
+
// The one rail for monitoring publishes in this package; a dead-letter event
|
|
21
|
+
// travels it like every other monitoring event, never on a second path.
|
|
22
|
+
const { publishToMonitoringWorkflow } = require('./monitoring-publish');
|
|
17
23
|
const {
|
|
18
24
|
ConnectionError,
|
|
19
25
|
PublishError,
|
|
20
26
|
ConsumeError,
|
|
21
27
|
ValidationError,
|
|
22
28
|
SerializationError,
|
|
29
|
+
CONSUMER_QUEUE_MISSING,
|
|
30
|
+
CONSUMER_DEAD_LETTER_ROUTE_MISSING,
|
|
23
31
|
} = require('./utils/errorHandler');
|
|
24
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Names that are NOT keys of this client.
|
|
35
|
+
*
|
|
36
|
+
* An entry either names the key the value belongs under (`key`), or says the
|
|
37
|
+
* concept itself is gone (`key: null`). Both are refused where the caller wrote
|
|
38
|
+
* them, by one loop, because it is one concern — a name this client does not
|
|
39
|
+
* read, answered before its value can be lost in silence.
|
|
40
|
+
*
|
|
41
|
+
* `url` is `@onlineapps/conn-infra-mq`'s name for the broker URL (its
|
|
42
|
+
* `ConnectorMQClient` JSDoc documents `config.url`); this client's name is `host`.
|
|
43
|
+
* Until 2026-09-14 the transport read the two keys as `host || url`, and the
|
|
44
|
+
* right-hand side was unreachable: `host` is required by the schema and
|
|
45
|
+
* resolved from `RABBITMQ_URL` when the caller omits it (`./config.js`). So a
|
|
46
|
+
* caller who wrote `url` connected to whatever the environment said, and their
|
|
47
|
+
* value was dropped without a word.
|
|
48
|
+
*
|
|
49
|
+
* The key is not accepted as an alias — one concern, one rail
|
|
50
|
+
* (`change-discipline.md`). Leaving it undeclared would refuse it since d.291b
|
|
51
|
+
* (`config/configSchema.js` ends with `additionalProperties: false`), but it
|
|
52
|
+
* would refuse it as an unknown NAME: the schema can say the key does not exist,
|
|
53
|
+
* it cannot say which key the value belongs under. That sentence is the whole
|
|
54
|
+
* point of this list, so the list stays, ahead of the schema rather than beside
|
|
55
|
+
* it — every name it does not know falls through and is answered by Ajv
|
|
56
|
+
* (`architecture-principles.md` §3, §4, §5). It is checked BEFORE the schema and
|
|
57
|
+
* before the runtime resolution of `host`, so the answer is about the config the
|
|
58
|
+
* caller wrote rather than about the environment the process happens to run in.
|
|
59
|
+
*/
|
|
60
|
+
const NOT_A_KEY_OF_THIS_CLIENT = Object.freeze({
|
|
61
|
+
url: Object.freeze({
|
|
62
|
+
key: 'host',
|
|
63
|
+
what: 'the broker URL',
|
|
64
|
+
consequence:
|
|
65
|
+
'This client never reads "url", so the value written under it would be discarded and the '
|
|
66
|
+
+ 'connection made with RABBITMQ_URL instead.',
|
|
67
|
+
}),
|
|
68
|
+
/**
|
|
69
|
+
* Renamed in d.299, and refused by name rather than accepted as an alias: the
|
|
70
|
+
* number bounds every ANSWER this client waits for from the broker — the publish
|
|
71
|
+
* confirm AND the close-ok of each handle `disconnect()` closes (d.283) — so a
|
|
72
|
+
* name owned by one of its two consumers made the other invisible. An alias
|
|
73
|
+
* would be the transition shim `architecture-principles.md` §11 forbids; a
|
|
74
|
+
* silent drop would repeat exactly what `url` did.
|
|
75
|
+
*/
|
|
76
|
+
publishConfirmationTimeout: Object.freeze({
|
|
77
|
+
key: 'brokerAnswerTimeout',
|
|
78
|
+
what: 'the budget for an answer from the broker',
|
|
79
|
+
consequence:
|
|
80
|
+
'The key was renamed: it bounds the publish confirm AND the close-ok of every handle '
|
|
81
|
+
+ 'disconnect() closes, so it no longer carries the name of one of the two. A value written '
|
|
82
|
+
+ 'under the old name would be ignored and the default used instead.',
|
|
83
|
+
}),
|
|
84
|
+
/**
|
|
85
|
+
* Removed in d.343 with the mechanism they declared, and refused by name
|
|
86
|
+
* rather than quietly ignored. `buffer/RedisBuffer.js` was a stub: its `add()`
|
|
87
|
+
* and `flush()` stored and returned nothing, so `persistentBufferEnabled:
|
|
88
|
+
* true` sent every critical-priority message that failed transiently into it
|
|
89
|
+
* and the message was DROPPED — the opposite of what the key promised. That is
|
|
90
|
+
* a declaration with no mechanism (`automation-gates.md` §5).
|
|
91
|
+
* @see docs/architecture/mq-publish-reliability.md — the node that owns the
|
|
92
|
+
* publish-reliability model, and the buffering it describes. Nothing in the
|
|
93
|
+
* platform ever set either key outside a test (measured across `api`,
|
|
94
|
+
* `api_biz`, `infra`, `fe_adminui`, 2026-09-14). A silent drop would leave a
|
|
95
|
+
* caller believing in persistence that never existed, which is exactly the
|
|
96
|
+
* failure `url` taught this list.
|
|
97
|
+
*/
|
|
98
|
+
persistentBufferEnabled: Object.freeze({
|
|
99
|
+
key: null,
|
|
100
|
+
what: 'a publish buffer that survives a process restart',
|
|
101
|
+
consequence:
|
|
102
|
+
'This client has ONE buffer, the in-memory one (publishBufferMaxSize / publishBufferTtlMs). '
|
|
103
|
+
+ 'The Redis buffer this key switched on stored nothing, so the critical messages it was '
|
|
104
|
+
+ 'meant to protect were dropped instead of buffered.',
|
|
105
|
+
}),
|
|
106
|
+
persistentRedisClient: Object.freeze({
|
|
107
|
+
key: null,
|
|
108
|
+
what: 'the Redis client of a persistent publish buffer',
|
|
109
|
+
consequence:
|
|
110
|
+
'The persistent buffer it fed is gone (see persistentBufferEnabled): it never wrote to '
|
|
111
|
+
+ 'Redis at all, so the client injected here was held and never used.',
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The whole sentence for a name this client does not read.
|
|
117
|
+
*
|
|
118
|
+
* A helper rather than two branches at the throw site: the message contract
|
|
119
|
+
* (`tests/unit/error-message-contract.test.js`) reads every caller-facing
|
|
120
|
+
* construction and can only see that it opens with `[Context]` when the sentence
|
|
121
|
+
* is one literal or one named helper. Two facts, two sentences — the value
|
|
122
|
+
* belongs under another name, or the thing it configured no longer exists — and
|
|
123
|
+
* both open the same way (`architecture-principles.md` §5).
|
|
124
|
+
*
|
|
125
|
+
* @param {string} written - the key the caller wrote
|
|
126
|
+
* @param {{key: string|null, what: string, consequence: string}} meant
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
function notAKeyOfThisClientMessage(written, meant) {
|
|
130
|
+
if (meant.key === null) {
|
|
131
|
+
return `[BaseClient] Configuration key "${written}" does not exist - Expected: no such key; `
|
|
132
|
+
+ `${meant.what} is not something this client has. `
|
|
133
|
+
+ `Fix: remove "${written}" from the configuration. ${meant.consequence}`;
|
|
134
|
+
}
|
|
135
|
+
return `[BaseClient] Configuration key "${written}" does not exist - Expected: ${meant.what} under "${meant.key}". `
|
|
136
|
+
+ `Fix: rename "${written}" to "${meant.key}". ${meant.consequence}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The whole sentence for a config Ajv rejected.
|
|
141
|
+
*
|
|
142
|
+
* One helper and one throw site, for the reason `notAKeyOfThisClientMessage()`
|
|
143
|
+
* exists: the message contract (`tests/unit/error-message-contract.test.js`) can
|
|
144
|
+
* only see that a construction opens with `[Context]` when its argument is one
|
|
145
|
+
* literal or one named helper.
|
|
146
|
+
*
|
|
147
|
+
* Two shapes, because the two failures are read by different people. A wrong
|
|
148
|
+
* TYPE on a declared key is a value problem — the paths in `error.details` are
|
|
149
|
+
* what the caller acts on. An UNDECLARED key is a name problem, and the name has
|
|
150
|
+
* to be in the sentence: the caller believes they configured something, and
|
|
151
|
+
* nothing in `error.details` tells them that the key was never read
|
|
152
|
+
* (`architecture-principles.md` §5).
|
|
153
|
+
*
|
|
154
|
+
* @param {string[]} unknownKeys - Keys no side of the schema declares; may be empty.
|
|
155
|
+
* @param {string[]} declaredKeys - Every key the schema declares, subclass keys included.
|
|
156
|
+
* @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
function invalidConfigurationMessage(unknownKeys, declaredKeys) {
|
|
159
|
+
if (unknownKeys.length === 0) {
|
|
160
|
+
return '[BaseClient] Invalid configuration - Expected: the constructor config to satisfy the client schema. '
|
|
161
|
+
+ 'Fix: correct the fields listed in error.details, each of which names its path and the reason it was rejected.';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const named = nameUnknownKeys(unknownKeys, declaredKeys);
|
|
165
|
+
|
|
166
|
+
return `[BaseClient] Configuration key not declared by this client: ${named} - `
|
|
167
|
+
+ `Expected: only the keys src/config/configSchema.js declares and the ones a subclass adds through `
|
|
168
|
+
+ `super(config, extraProperties) - ${declaredKeys.length} in total for this client. `
|
|
169
|
+
+ 'Fix: correct the spelling, or remove the key - a key this client does not read configures nothing, '
|
|
170
|
+
+ 'and the value written under it is lost in silence. A key that belongs to a subclass is declared in '
|
|
171
|
+
+ 'that subclass schema, never here.';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The schema this construction validates against: the library's own, plus the
|
|
176
|
+
* properties a SUBCLASS declares.
|
|
177
|
+
*
|
|
178
|
+
* The one place a subclass extends the configuration contract. `ConnectorMQClient`
|
|
179
|
+
* (`@onlineapps/conn-infra-mq`) hands the caller's config straight to `super()` and
|
|
180
|
+
* then reads four keys of its own off the same `this._config`; the library cannot
|
|
181
|
+
* declare those — they configure mechanisms that live there — and that package must
|
|
182
|
+
* not declare the library's, or one type and one description would exist twice and
|
|
183
|
+
* be free to drift (`change-discipline.md` § One rail per concern). So each side
|
|
184
|
+
* declares what it owns, and the subclass passes its half here.
|
|
185
|
+
*
|
|
186
|
+
* A subclass REDECLARING a library key is refused rather than merged: silently
|
|
187
|
+
* letting the subclass win would be exactly the second rail this argument exists to
|
|
188
|
+
* avoid, and silently letting the library win would leave the subclass author
|
|
189
|
+
* believing a type they wrote is in force.
|
|
190
|
+
*
|
|
191
|
+
* The library schema is never mutated — a subclass must not change what every other
|
|
192
|
+
* caller of this client is validated against.
|
|
193
|
+
*
|
|
194
|
+
* @param {Object|null} extraProperties - JSON Schema `properties` of the subclass.
|
|
195
|
+
* @returns {Object} The schema to compile.
|
|
196
|
+
* @throws {ValidationError} If the argument is not a plain object of properties, or
|
|
197
|
+
* redeclares a key the library already owns.
|
|
198
|
+
*/
|
|
199
|
+
function schemaWithSubclassProperties(extraProperties) {
|
|
200
|
+
if (extraProperties === null || extraProperties === undefined) {
|
|
201
|
+
return configSchema;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (typeof extraProperties !== 'object' || Array.isArray(extraProperties)) {
|
|
205
|
+
throw new ValidationError(
|
|
206
|
+
'[BaseClient] Subclass configuration properties must be an object - '
|
|
207
|
+
+ 'Expected: the `properties` object of the subclass JSON Schema as the second constructor argument, '
|
|
208
|
+
+ 'or nothing at all. '
|
|
209
|
+
+ `Fix: pass \`super(config, mySchema.properties)\`; received ${Array.isArray(extraProperties) ? 'an array' : typeof extraProperties}.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const key of Object.keys(extraProperties)) {
|
|
214
|
+
if (Object.prototype.hasOwnProperty.call(configSchema.properties, key)) {
|
|
215
|
+
throw new ValidationError(
|
|
216
|
+
`[BaseClient] Subclass configuration property "${key}" is already declared by this client - `
|
|
217
|
+
+ 'Expected: a subclass declares only the keys it reads itself, so one key keeps one type, one '
|
|
218
|
+
+ 'description and one owner. '
|
|
219
|
+
+ `Fix: remove "${key}" from the subclass schema and read it from src/config/configSchema.js, `
|
|
220
|
+
+ 'or rename the subclass key if it means something else.'
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
...configSchema,
|
|
227
|
+
properties: { ...configSchema.properties, ...extraProperties }
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
25
231
|
class BaseClient {
|
|
26
232
|
/**
|
|
27
233
|
* @param {Object} config - User-supplied configuration.
|
|
28
|
-
* @
|
|
234
|
+
* @param {Object} [extraProperties] - JSON Schema `properties` a SUBCLASS declares
|
|
235
|
+
* for the keys IT reads off the same config object. The one place the
|
|
236
|
+
* configuration contract is extended; see `schemaWithSubclassProperties()`.
|
|
237
|
+
* A direct caller of this client passes nothing.
|
|
238
|
+
* @throws {ValidationError} If required fields are missing or invalid, if a key
|
|
239
|
+
* neither side declares is present, or if the subclass declaration is malformed.
|
|
29
240
|
*/
|
|
30
|
-
constructor(config) {
|
|
31
|
-
|
|
32
|
-
|
|
241
|
+
constructor(config, extraProperties = null) {
|
|
242
|
+
// The client reports its whole connection lifecycle and every consumer it
|
|
243
|
+
// registers. Until 2026-09-07 it wrote those to `console` while `config.logger`
|
|
244
|
+
// sat in the schema unused — an ambient channel no service configures and no
|
|
245
|
+
// collector attributes. Owner confirmation
|
|
246
|
+
// `docs/governance/confirmations/connector-logger-contract.md` 001/003.
|
|
247
|
+
// Validated BEFORE the schema, so the caller gets the actionable contract
|
|
248
|
+
// message rather than a generic Ajv complaint about a missing property.
|
|
249
|
+
this._logger = assertLogger(
|
|
250
|
+
'BaseClient',
|
|
251
|
+
config && config.logger,
|
|
252
|
+
'the client reports its connection lifecycle and every consumer it registers',
|
|
253
|
+
'pass config.logger (the logger your service already built)'
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// A name this client does not read, refused before its value can be lost
|
|
257
|
+
// (see NOT_A_KEY_OF_THIS_CLIENT above).
|
|
258
|
+
for (const [written, meant] of Object.entries(NOT_A_KEY_OF_THIS_CLIENT)) {
|
|
259
|
+
if (config && Object.prototype.hasOwnProperty.call(config, written)) {
|
|
260
|
+
throw new ValidationError(
|
|
261
|
+
notAKeyOfThisClientMessage(written, meant),
|
|
262
|
+
[{
|
|
263
|
+
path: `/${written}`,
|
|
264
|
+
message: meant.key === null
|
|
265
|
+
? `does not exist; ${meant.what} was removed`
|
|
266
|
+
: `does not exist; ${meant.what} is "${meant.key}"`
|
|
267
|
+
}]
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
33
271
|
|
|
34
|
-
//
|
|
35
|
-
|
|
272
|
+
// `allErrors` so a wrong config hears about every offending key at once
|
|
273
|
+
// rather than one per attempt. NO `useDefaults`: it arrived with the library's
|
|
274
|
+
// extraction (`7a47b2fa`, 2025-11-18) and never had anything to write —
|
|
275
|
+
// `config/configSchema.js` declares no `default` on any property, on purpose
|
|
276
|
+
// and under a test, because every default has an owner in
|
|
277
|
+
// `config/defaultConfig.js`, composed BEFORE this validation runs. A switch
|
|
278
|
+
// that writes nothing is not harmless: it is a standing invitation to answer
|
|
279
|
+
// "where do I put this default?" with a second rail (`automation-gates.md` §5,
|
|
280
|
+
// `change-discipline.md` § One rail per concern).
|
|
281
|
+
// The library's own declaration, plus the subclass's if there is one. Compiled
|
|
282
|
+
// per construction, like the schema it replaced: a subclass's properties are an
|
|
283
|
+
// argument, so there is no one schema to compile once.
|
|
284
|
+
const schema = schemaWithSubclassProperties(extraProperties);
|
|
285
|
+
const ajv = new Ajv({ allErrors: true });
|
|
286
|
+
const validate = ajv.compile(schema);
|
|
287
|
+
|
|
288
|
+
// The module defaults, then what the caller wrote. Composed SHALLOWLY and
|
|
289
|
+
// by reference: an injected dependency has to arrive as the object it was
|
|
290
|
+
// injected as, not as a deep copy of it (`config/composeConfig.js`).
|
|
291
|
+
this._config = composeConfig(defaultConfig, config);
|
|
36
292
|
|
|
37
293
|
// Resolve runtime config (FAIL-FAST for host)
|
|
38
294
|
const resolved = runtimeCfg.resolve({
|
|
@@ -51,16 +307,33 @@ class BaseClient {
|
|
|
51
307
|
// Validate merged config
|
|
52
308
|
const valid = validate(this._config);
|
|
53
309
|
if (!valid) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
310
|
+
// An undeclared key is reported by Ajv against the OBJECT (`instancePath: ''`)
|
|
311
|
+
// with the offending name in `params`, so it is unpacked here: a caller acting
|
|
312
|
+
// on `error.details` needs the path of the key they wrote, exactly as for a
|
|
313
|
+
// wrong type one line below.
|
|
314
|
+
const details = validate.errors.map((err) => (
|
|
315
|
+
err.keyword === 'additionalProperties'
|
|
316
|
+
? {
|
|
317
|
+
path: `/${err.params.additionalProperty}`,
|
|
318
|
+
message: 'is not a configuration key of this client',
|
|
319
|
+
}
|
|
320
|
+
: { path: err.instancePath, message: err.message }
|
|
321
|
+
));
|
|
322
|
+
const unknownKeys = validate.errors
|
|
323
|
+
.filter((err) => err.keyword === 'additionalProperties')
|
|
324
|
+
.map((err) => err.params.additionalProperty);
|
|
325
|
+
|
|
326
|
+
throw new ValidationError(
|
|
327
|
+
invalidConfigurationMessage(unknownKeys, Object.keys(schema.properties)),
|
|
328
|
+
details
|
|
329
|
+
);
|
|
59
330
|
}
|
|
60
331
|
|
|
61
332
|
this._transport = null;
|
|
62
333
|
this._connected = false;
|
|
63
334
|
this._errorHandlers = [];
|
|
335
|
+
this._fatalHandlers = [];
|
|
336
|
+
this._fatalError = null;
|
|
64
337
|
|
|
65
338
|
BaseClient._registerInstance(this);
|
|
66
339
|
}
|
|
@@ -74,9 +347,14 @@ class BaseClient {
|
|
|
74
347
|
async connect(options = {}) {
|
|
75
348
|
if (this._connected) return;
|
|
76
349
|
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
this.
|
|
350
|
+
// `host` is the broker URL and carries the account password. It is redacted
|
|
351
|
+
// on the way to the log — the only rail for that is utils/redactCredentials.
|
|
352
|
+
this._logger.debug('[BaseClient] Starting connect()', {
|
|
353
|
+
type: this._config.type,
|
|
354
|
+
host: redactConnectionTarget(this._config.host),
|
|
355
|
+
});
|
|
356
|
+
// Overrides compose the same way as the constructor's sources do.
|
|
357
|
+
this._config = composeConfig(this._config, options);
|
|
80
358
|
// Ensure runtime-derived values are applied (options can override explicitly)
|
|
81
359
|
const resolved = runtimeCfg.resolve({
|
|
82
360
|
host: this._config.host,
|
|
@@ -90,20 +368,38 @@ class BaseClient {
|
|
|
90
368
|
`${resolved.serviceName}:${process.pid}`;
|
|
91
369
|
|
|
92
370
|
try {
|
|
93
|
-
|
|
94
|
-
// Instantiate appropriate transport: RabbitMQClient
|
|
371
|
+
this._logger.debug('[BaseClient] Creating transport');
|
|
372
|
+
// Instantiate appropriate transport: RabbitMQClient. `this._config` carries
|
|
373
|
+
// the validated logger, so the transport logs through the same channel —
|
|
374
|
+
// it builds none of its own.
|
|
95
375
|
this._transport = transportFactory.create(this._config);
|
|
96
|
-
|
|
376
|
+
this._logger.debug('[BaseClient] Transport created', {
|
|
377
|
+
transport: this._transport.constructor.name,
|
|
378
|
+
});
|
|
97
379
|
|
|
98
380
|
// Register internal error propagation
|
|
99
381
|
this._transport.on('error', (err) => this._handleError(err));
|
|
100
382
|
|
|
101
|
-
|
|
383
|
+
// The connection is permanently lost: the transport spent its whole
|
|
384
|
+
// recovery budget and will not try again. Flip the connected flag FIRST —
|
|
385
|
+
// publish/consume must refuse rather than block on a recovery that is not
|
|
386
|
+
// coming — and only then tell the owner.
|
|
387
|
+
this._transport.on('connection:fatal', (err) => {
|
|
388
|
+
this._connected = false;
|
|
389
|
+
this._fatalError = err;
|
|
390
|
+
this._handleFatal(err);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
this._logger.debug('[BaseClient] Calling transport.connect()');
|
|
102
394
|
await this._transport.connect(this._config);
|
|
103
|
-
|
|
395
|
+
this._logger.info('[BaseClient] Transport connected successfully');
|
|
104
396
|
this._connected = true;
|
|
105
397
|
} catch (err) {
|
|
106
|
-
throw new ConnectionError(
|
|
398
|
+
throw new ConnectionError(
|
|
399
|
+
'[BaseClient] Failed to connect to broker - Expected: a reachable broker at the configured host. '
|
|
400
|
+
+ 'Fix: read error.cause for the transport reason, then check the MQ url, credentials and network the service was started with.',
|
|
401
|
+
err
|
|
402
|
+
);
|
|
107
403
|
}
|
|
108
404
|
}
|
|
109
405
|
|
|
@@ -113,14 +409,27 @@ class BaseClient {
|
|
|
113
409
|
* @throws {Error} If disconnecting fails unexpectedly.
|
|
114
410
|
*/
|
|
115
411
|
async disconnect() {
|
|
116
|
-
|
|
412
|
+
// Keyed on the TRANSPORT, not on `_connected`: since the fatal signal
|
|
413
|
+
// clears `_connected`, a client whose connection is permanently lost still
|
|
414
|
+
// owns a transport holding timers and listeners, and must still be
|
|
415
|
+
// tearable-down. Keying on `_connected` would have made that teardown a
|
|
416
|
+
// silent no-op.
|
|
417
|
+
if (!this._transport) {
|
|
418
|
+
this._connected = false;
|
|
419
|
+
BaseClient._unregisterInstance(this);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
117
422
|
try {
|
|
118
423
|
await this._transport.disconnect();
|
|
119
424
|
this._connected = false;
|
|
120
425
|
this._transport = null;
|
|
121
426
|
BaseClient._unregisterInstance(this);
|
|
122
427
|
} catch (err) {
|
|
123
|
-
throw new
|
|
428
|
+
throw new ConnectionError(
|
|
429
|
+
'[BaseClient] Failed to disconnect from broker - Expected: the transport to close its connection cleanly. '
|
|
430
|
+
+ 'Fix: read error.cause for the transport reason; the client stays marked connected, so call disconnect() again before reusing it.',
|
|
431
|
+
err
|
|
432
|
+
);
|
|
124
433
|
}
|
|
125
434
|
}
|
|
126
435
|
|
|
@@ -128,14 +437,21 @@ class BaseClient {
|
|
|
128
437
|
* Publishes a message to the specified queue.
|
|
129
438
|
* @param {string} queue - Target queue name.
|
|
130
439
|
* @param {Object|Buffer|string} message - Payload to send.
|
|
131
|
-
* @param {Object} [options] - RabbitMQ-specific overrides (routingKey, persistent, headers
|
|
440
|
+
* @param {Object} [options] - RabbitMQ-specific overrides (routingKey, persistent, headers).
|
|
441
|
+
* Not `queueOptions`: a publisher never declares a queue's arguments. An
|
|
442
|
+
* infrastructure or business queue is created by its owner before anything
|
|
443
|
+
* publishes to it, and an ownerless name is declared with the arguments the
|
|
444
|
+
* central `queueConfig` resolves (`transports/rabbitmqClient.js`, the 404 branch).
|
|
132
445
|
* @returns {Promise<void>}
|
|
133
446
|
* @throws {ConnectionError} If not connected.
|
|
134
447
|
* @throws {PublishError} If publish fails.
|
|
135
448
|
*/
|
|
136
449
|
async publish(queue, message, options = {}) {
|
|
137
450
|
if (!this._connected || !this._transport) {
|
|
138
|
-
throw new ConnectionError(
|
|
451
|
+
throw new ConnectionError(
|
|
452
|
+
'[BaseClient] Cannot publish: client is not connected - Expected: connect() to have completed. '
|
|
453
|
+
+ 'Fix: await client.connect() before publish().'
|
|
454
|
+
);
|
|
139
455
|
}
|
|
140
456
|
|
|
141
457
|
let buffer;
|
|
@@ -149,28 +465,69 @@ class BaseClient {
|
|
|
149
465
|
buffer = Buffer.from(json, 'utf8');
|
|
150
466
|
}
|
|
151
467
|
} catch (err) {
|
|
152
|
-
throw new SerializationError(
|
|
468
|
+
throw new SerializationError(
|
|
469
|
+
'[BaseClient] Failed to serialize message - Expected: a JSON-serializable payload, a string or a Buffer. '
|
|
470
|
+
+ 'Fix: read error.cause for the reason (circular reference, BigInt, …) and error.payload for the value that was rejected.',
|
|
471
|
+
message,
|
|
472
|
+
err
|
|
473
|
+
);
|
|
153
474
|
}
|
|
154
475
|
|
|
155
476
|
try {
|
|
156
477
|
await this._transport.publish(queue, buffer, options);
|
|
157
478
|
} catch (err) {
|
|
158
|
-
throw new PublishError(
|
|
479
|
+
throw new PublishError(
|
|
480
|
+
`[BaseClient] Failed to publish to queue "${queue}" - Expected: the queue to exist and the channel to be open. `
|
|
481
|
+
+ 'Fix: read error.cause for the broker reason; error.queue names the target queue.',
|
|
482
|
+
queue,
|
|
483
|
+
err
|
|
484
|
+
);
|
|
159
485
|
}
|
|
160
486
|
}
|
|
161
487
|
|
|
162
488
|
/**
|
|
163
489
|
* Begins consuming messages from the specified queue.
|
|
490
|
+
*
|
|
491
|
+
* A handler that throws does NOT get an unconditional requeue: the delivery
|
|
492
|
+
* policy counts the attempt, gives the message one more if the error is
|
|
493
|
+
* transient and the budget allows, and otherwise rejects it into the
|
|
494
|
+
* dead-letter queue the broker routes for this queue (`<svc>.dlq`), publishing
|
|
495
|
+
* a `message_dlq` event. The full contract is in the README
|
|
496
|
+
* (§ `consume()` — the delivery contract) and in `config/deliveryPolicy.js`.
|
|
497
|
+
*
|
|
498
|
+
* That policy needs somewhere to reject TO, so `consume()` REFUSES a queue for
|
|
499
|
+
* which `queueConfig` declares no `x-dead-letter-exchange`/`-routing-key` — at
|
|
500
|
+
* registration, before a consumer is attached, with no opt-out (d.259). On such
|
|
501
|
+
* a queue the broker drops a rejected message instead of moving it, so the
|
|
502
|
+
* `message_dlq` event would report a destination nothing routed to. The fix is
|
|
503
|
+
* always to declare the route, never to consume without one.
|
|
504
|
+
*
|
|
164
505
|
* @param {string} queue - Name of the queue to consume from.
|
|
165
|
-
* @param {function(Object): Promise<void>} messageHandler -
|
|
506
|
+
* @param {function(Object, {attempt: number, maxAttempts: number, isFinalAttempt: boolean}): Promise<void>} messageHandler -
|
|
507
|
+
* Async function to process each message. Its second argument says which
|
|
508
|
+
* attempt this delivery is, so a side effect that must happen once — an RPC
|
|
509
|
+
* error reply above all — happens on the final attempt only, not on every
|
|
510
|
+
* redelivery. A handler declaring one parameter simply ignores it.
|
|
166
511
|
* @param {Object} [options] - RabbitMQ-specific overrides (prefetch, noAck, queueOptions).
|
|
512
|
+
* @param {number} [options.maxAttempts] - How many times the handler may run for ONE
|
|
513
|
+
* message before it is rejected into the dead-letter queue. Omitted → the module
|
|
514
|
+
* default (`RABBITMQ_MAX_DELIVERY_ATTEMPTS`, then `defaults.js maxDeliveryAttempts`).
|
|
515
|
+
* An integer >= 1, refused at entry otherwise.
|
|
516
|
+
* @param {function(Error): ('transient'|'permanent')} [options.classify] - Error
|
|
517
|
+
* classifier. Omitted → every error is transient, so a permanent failure still
|
|
518
|
+
* reaches the dead-letter queue, after `maxAttempts` attempts instead of one.
|
|
519
|
+
* `permanent` rejects on the first failure.
|
|
167
520
|
* @returns {Promise<void>}
|
|
168
521
|
* @throws {ConnectionError} If not connected.
|
|
522
|
+
* @throws {ValidationError} If `maxAttempts` or `classify` cannot bound a delivery.
|
|
169
523
|
* @throws {ConsumeError} If consumer setup fails.
|
|
170
524
|
*/
|
|
171
525
|
async consume(queue, messageHandler, options = {}) {
|
|
172
526
|
if (!this._connected || !this._transport) {
|
|
173
|
-
throw new ConnectionError(
|
|
527
|
+
throw new ConnectionError(
|
|
528
|
+
'[BaseClient] Cannot consume: client is not connected - Expected: connect() to have completed. '
|
|
529
|
+
+ 'Fix: await client.connect() before consume().'
|
|
530
|
+
);
|
|
174
531
|
}
|
|
175
532
|
|
|
176
533
|
// Apply prefetch and noAck overrides if provided
|
|
@@ -179,26 +536,204 @@ class BaseClient {
|
|
|
179
536
|
if (typeof prefetch === 'number') consumeOptions.prefetch = prefetch;
|
|
180
537
|
if (typeof noAck === 'boolean') consumeOptions.noAck = noAck;
|
|
181
538
|
if (options.queueOptions) consumeOptions.queueOptions = options.queueOptions;
|
|
539
|
+
// Forwarded untouched, including their absence: the transport owns the
|
|
540
|
+
// validation and the module default, so there is exactly one place where a
|
|
541
|
+
// delivery budget is decided (`config/deliveryPolicy.js`).
|
|
542
|
+
if (options.maxAttempts !== undefined) consumeOptions.maxAttempts = options.maxAttempts;
|
|
543
|
+
if (options.classify !== undefined) consumeOptions.classify = options.classify;
|
|
544
|
+
// Same rule, same reason: `requeueOnError` is a statement ABOUT the delivery
|
|
545
|
+
// budget, so it is resolved where the budget is (`config/deliveryPolicy.js`,
|
|
546
|
+
// applied in the transport), not folded into a second number here.
|
|
547
|
+
if (options.requeueOnError !== undefined) consumeOptions.requeueOnError = options.requeueOnError;
|
|
548
|
+
// The monitoring rail belongs to this layer, not to the transport: the
|
|
549
|
+
// transport knows the broker, this client knows the service it speaks for
|
|
550
|
+
// and the queue monitoring events travel on.
|
|
551
|
+
consumeOptions.onDeadLetter = (event) => this._emitDeadLetterEvent(event);
|
|
182
552
|
|
|
183
553
|
// CRITICAL: Log all consume() calls for transparency
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
554
|
+
this._logger.info(`[BaseClient] [CONSUMER] Starting consume() for queue: ${queue}`, {
|
|
555
|
+
queue,
|
|
556
|
+
});
|
|
557
|
+
this._logger.debug(`[BaseClient] [CONSUMER] Consumer setup for queue: ${queue}`, {
|
|
558
|
+
queue,
|
|
559
|
+
options: consumeOptions,
|
|
560
|
+
transport: this._transport?.constructor?.name || 'unknown',
|
|
561
|
+
connected: this._connected,
|
|
562
|
+
});
|
|
188
563
|
|
|
189
564
|
try {
|
|
190
565
|
const consumeStartTime = Date.now();
|
|
191
566
|
// Delegate message lifecycle (ack/nack/requeue) to the transport layer.
|
|
192
567
|
// BaseClient only provides a broker-agnostic surface; transport decides how to handle handler errors.
|
|
193
|
-
await this._transport.consume(
|
|
568
|
+
const consumerTag = await this._transport.consume(
|
|
569
|
+
queue,
|
|
570
|
+
async (msg, delivery) => messageHandler(msg, delivery),
|
|
571
|
+
consumeOptions
|
|
572
|
+
);
|
|
194
573
|
const consumeEndTime = Date.now();
|
|
195
|
-
|
|
574
|
+
this._logger.info(`[BaseClient] [CONSUMER] ✓ consume() completed for queue: ${queue}`, {
|
|
575
|
+
queue,
|
|
576
|
+
durationMs: consumeEndTime - consumeStartTime,
|
|
577
|
+
});
|
|
578
|
+
// The broker's name for this consumer, handed back so the caller has
|
|
579
|
+
// something to pass to `cancelConsumer()`. Without it the tag existed
|
|
580
|
+
// inside the transport alone and the L8 cancel-by-tag half of the contract
|
|
581
|
+
// had no way to be used.
|
|
582
|
+
return consumerTag;
|
|
196
583
|
} catch (err) {
|
|
197
|
-
|
|
198
|
-
|
|
584
|
+
// The failure is not logged here: it is rethrown as a ConsumeError carrying
|
|
585
|
+
// the cause, and a rethrown error is logged by whoever stops handling it
|
|
586
|
+
// (confirmation connector-logger-contract 003).
|
|
587
|
+
throw this._consumerRefusal(queue, err);
|
|
199
588
|
}
|
|
200
589
|
}
|
|
201
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Turn whatever stopped a consumer from starting into a refusal that says WHY.
|
|
593
|
+
*
|
|
594
|
+
* Until d.297 this was one sentence for every failure: "Expected: the queue to
|
|
595
|
+
* exist before consume() attaches to it … a missing queue is created by the
|
|
596
|
+
* service that owns it, never by the consumer." For a queue that genuinely does
|
|
597
|
+
* not exist it is true. For the OTHER refusal the same call makes — d.259, a
|
|
598
|
+
* queue for which `queueConfig` declares no dead-letter route — it is false in
|
|
599
|
+
* every clause: the queue exists, creating it changes nothing, and the owning
|
|
600
|
+
* service is not who has to act. The true sentence sat in `error.cause`, where
|
|
601
|
+
* a log line does not look, so the reader was sent to fix a thing that was not
|
|
602
|
+
* broken.
|
|
603
|
+
*
|
|
604
|
+
* Two classes, two sentences, each recognised by the transport's `code` rather
|
|
605
|
+
* than by its wording (`utils/errorHandler.js`, README § errors and errorCodes).
|
|
606
|
+
* Everything else gets a third sentence that claims NO cause and points at the
|
|
607
|
+
* one that knows: an invented likeliest-sounding reason is worse than an honest
|
|
608
|
+
* "read the cause", because it is acted upon (`architecture-principles.md` §5).
|
|
609
|
+
*
|
|
610
|
+
* @param {string} queue
|
|
611
|
+
* @param {Error} err - what the transport threw
|
|
612
|
+
* @returns {ConsumeError}
|
|
613
|
+
* @private
|
|
614
|
+
*/
|
|
615
|
+
_consumerRefusal(queue, err) {
|
|
616
|
+
const code = err && err.code;
|
|
617
|
+
|
|
618
|
+
if (code === CONSUMER_DEAD_LETTER_ROUTE_MISSING) {
|
|
619
|
+
return new ConsumeError(
|
|
620
|
+
`[BaseClient] Cannot consume from queue "${queue}": queueConfig declares no dead-letter route for it - `
|
|
621
|
+
+ 'Expected: the queue to declare x-dead-letter-exchange and x-dead-letter-routing-key, because the '
|
|
622
|
+
+ 'delivery policy ends a spent message with nack(requeue=false) and the broker drops it where there '
|
|
623
|
+
+ 'is nowhere to move it to. '
|
|
624
|
+
+ 'Fix: declare the route in the section of src/config/queueConfig.js that owns this queue, and bind the queue that '
|
|
625
|
+
+ 'receives it, or consume a queue that already declares one. The queue itself is fine — creating or '
|
|
626
|
+
+ 'recreating it changes nothing.',
|
|
627
|
+
queue,
|
|
628
|
+
err,
|
|
629
|
+
CONSUMER_DEAD_LETTER_ROUTE_MISSING
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (code === CONSUMER_QUEUE_MISSING) {
|
|
634
|
+
return new ConsumeError(
|
|
635
|
+
`[BaseClient] Cannot consume from queue "${queue}": the queue does not exist - `
|
|
636
|
+
+ 'Expected: it to have been created by the service that owns it, before any consumer starts. '
|
|
637
|
+
+ 'Fix: start the owning service; a consumer never creates the queue it attaches to, because it would '
|
|
638
|
+
+ 'declare it with default arguments and every later declaration would fail with 406. '
|
|
639
|
+
+ 'error.cause carries the broker reason.',
|
|
640
|
+
queue,
|
|
641
|
+
err,
|
|
642
|
+
CONSUMER_QUEUE_MISSING
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return new ConsumeError(
|
|
647
|
+
`[BaseClient] Failed to start consumer for queue "${queue}" - `
|
|
648
|
+
+ 'Expected: consume() to attach a consumer to the queue. '
|
|
649
|
+
+ 'Fix: read error.cause, which carries the reason and the action for it — this layer does not know '
|
|
650
|
+
+ 'which one it was, and naming the likeliest would send you to fix something that may not be broken.',
|
|
651
|
+
queue,
|
|
652
|
+
err
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Report one message rejected into a dead-letter queue.
|
|
658
|
+
*
|
|
659
|
+
* Published to `monitoring.workflow` ONLY when the envelope carries a
|
|
660
|
+
* `workflow_id`. Without one there is nothing to publish and nothing is
|
|
661
|
+
* invented: `monitoring.workflow` keys every trace on that id, and an event
|
|
662
|
+
* naming no workflow is either refused by the intake as faulty telemetry or —
|
|
663
|
+
* worse, when a marker like `'unknown'` is put in its place — written as a row
|
|
664
|
+
* claiming a workflow by that name (`docs/governance/confirmations/dlq-purge.md`
|
|
665
|
+
* 002; lead decision 2026-09-11 answering INFRA-monitoring). The absence is
|
|
666
|
+
* reported through the injected logger, naming the queue and the service, and
|
|
667
|
+
* the message itself stays readable in the dead-letter queue.
|
|
668
|
+
*
|
|
669
|
+
* `status` carries the broker's own word for the death — `'rejected'`, what
|
|
670
|
+
* RabbitMQ stamps into `x-death[0].reason` for exactly this move (measured on
|
|
671
|
+
* `api_services_queuer`, 2026-09-11). One vocabulary for both paths into a
|
|
672
|
+
* DLQ, as the monitoring intake requires (`api/shared/TODO.md`, INFRA-monitoring
|
|
673
|
+
* 2026-09-11); an event with no `status` is refused there under its own name.
|
|
674
|
+
*
|
|
675
|
+
* @param {Object} event - As the transport reports it.
|
|
676
|
+
* @returns {Promise<void>}
|
|
677
|
+
* @private
|
|
678
|
+
*/
|
|
679
|
+
async _emitDeadLetterEvent(event) {
|
|
680
|
+
const { queue, content, attempts, maxAttempts, classification, error } = event;
|
|
681
|
+
const serviceName = runtimeCfg.get('serviceName', this._config.serviceName);
|
|
682
|
+
|
|
683
|
+
let workflowId = null;
|
|
684
|
+
try {
|
|
685
|
+
const envelope = serializer.deserialize(content);
|
|
686
|
+
if (envelope !== null && typeof envelope === 'object' && typeof envelope.workflow_id === 'string'
|
|
687
|
+
&& envelope.workflow_id !== '') {
|
|
688
|
+
workflowId = envelope.workflow_id;
|
|
689
|
+
}
|
|
690
|
+
} catch (parseErr) {
|
|
691
|
+
// A payload that is not JSON carries no envelope, so it carries no
|
|
692
|
+
// workflow_id either. Same outcome as an envelope without one.
|
|
693
|
+
workflowId = null;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (workflowId === null) {
|
|
697
|
+
this._logger.error(
|
|
698
|
+
'[BaseClient] Dead-lettered message carries no workflow_id - Expected: the envelope of a '
|
|
699
|
+
+ 'workflow message to carry workflow_id, which monitoring.workflow keys every trace on. '
|
|
700
|
+
+ 'Fix: the message is in the dead-letter queue and readable through the DLQ dashboard; '
|
|
701
|
+
+ 'no message_dlq event was published, because an event without a workflow_id names no workflow.',
|
|
702
|
+
{
|
|
703
|
+
queue,
|
|
704
|
+
service_name: serviceName,
|
|
705
|
+
attempts,
|
|
706
|
+
max_attempts: maxAttempts,
|
|
707
|
+
classification,
|
|
708
|
+
error: error && error.message
|
|
709
|
+
}
|
|
710
|
+
);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
await publishToMonitoringWorkflow(
|
|
715
|
+
this,
|
|
716
|
+
{
|
|
717
|
+
event_type: 'message_dlq',
|
|
718
|
+
workflow_id: workflowId,
|
|
719
|
+
service_name: serviceName,
|
|
720
|
+
// The broker's word for this death; see the method comment.
|
|
721
|
+
status: 'rejected',
|
|
722
|
+
queue,
|
|
723
|
+
attempts,
|
|
724
|
+
max_attempts: maxAttempts,
|
|
725
|
+
classification,
|
|
726
|
+
error: {
|
|
727
|
+
message: error && error.message,
|
|
728
|
+
name: error && error.name
|
|
729
|
+
},
|
|
730
|
+
timestamp: new Date().toISOString()
|
|
731
|
+
},
|
|
732
|
+
this._logger,
|
|
733
|
+
{ queue }
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
202
737
|
/**
|
|
203
738
|
* Assert queue existence (delegates to transport).
|
|
204
739
|
* @param {string} queue - Queue name
|
|
@@ -207,14 +742,90 @@ class BaseClient {
|
|
|
207
742
|
*/
|
|
208
743
|
async assertQueue(queue, options = {}) {
|
|
209
744
|
if (!this._connected || !this._transport) {
|
|
210
|
-
throw new ConnectionError(
|
|
745
|
+
throw new ConnectionError(
|
|
746
|
+
'[BaseClient] Cannot assertQueue: client is not connected - Expected: connect() to have completed. '
|
|
747
|
+
+ 'Fix: await client.connect() before assertQueue().'
|
|
748
|
+
);
|
|
211
749
|
}
|
|
212
750
|
if (typeof this._transport.assertQueue !== 'function') {
|
|
213
|
-
throw new Error(
|
|
751
|
+
throw new Error(
|
|
752
|
+
'[BaseClient] Transport does not support assertQueue(queue, options) - Expected: a transport implementing assertQueue. '
|
|
753
|
+
+ 'Fix: use the rabbitmq transport, or drop the assertQueue() call for this transport type.'
|
|
754
|
+
);
|
|
214
755
|
}
|
|
215
756
|
return await this._transport.assertQueue(queue, options);
|
|
216
757
|
}
|
|
217
758
|
|
|
759
|
+
/**
|
|
760
|
+
* Declare an exchange (delegates to transport).
|
|
761
|
+
*
|
|
762
|
+
* One of the four operations `mq-client-lifecycle-contract` 001 point 4
|
|
763
|
+
* ("L8") requires this client to expose. Until d.302 the only `assertExchange`
|
|
764
|
+
* in the package was a private line on the publish path, so a service that had
|
|
765
|
+
* to declare an exchange reached around the library for a raw amqplib channel.
|
|
766
|
+
*
|
|
767
|
+
* @param {string} exchange - Exchange name.
|
|
768
|
+
* @param {string} type - `direct`, `topic`, `fanout` or `headers`; required,
|
|
769
|
+
* because an exchange's type is the caller's topology decision.
|
|
770
|
+
* @param {Object} [options] - amqplib exchange options; `durable` defaults to
|
|
771
|
+
* the client's configured value.
|
|
772
|
+
* @returns {Promise<Object>}
|
|
773
|
+
* @throws {ConnectionError} If not connected.
|
|
774
|
+
* @throws {ValidationError} If the name or type cannot declare an exchange.
|
|
775
|
+
*/
|
|
776
|
+
async assertExchange(exchange, type, options = {}) {
|
|
777
|
+
if (!this._connected || !this._transport) {
|
|
778
|
+
throw new ConnectionError(
|
|
779
|
+
'[BaseClient] Cannot assertExchange: client is not connected - Expected: connect() to have completed. '
|
|
780
|
+
+ 'Fix: await client.connect() before assertExchange().'
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
return await this._transport.assertExchange(exchange, type, options);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Bind a queue to an exchange under a routing pattern (delegates to transport).
|
|
788
|
+
*
|
|
789
|
+
* @param {string} queue - Queue to bind.
|
|
790
|
+
* @param {string} exchange - Exchange to bind it to.
|
|
791
|
+
* @param {string} pattern - Routing pattern; required, and `''` is legal (a
|
|
792
|
+
* fanout exchange ignores the key).
|
|
793
|
+
* @returns {Promise<Object>}
|
|
794
|
+
* @throws {ConnectionError} If not connected.
|
|
795
|
+
* @throws {ValidationError} If a name or the pattern cannot form a binding.
|
|
796
|
+
*/
|
|
797
|
+
async bindQueue(queue, exchange, pattern) {
|
|
798
|
+
if (!this._connected || !this._transport) {
|
|
799
|
+
throw new ConnectionError(
|
|
800
|
+
'[BaseClient] Cannot bindQueue: client is not connected - Expected: connect() to have completed. '
|
|
801
|
+
+ 'Fix: await client.connect() before bindQueue().'
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
return await this._transport.bindQueue(queue, exchange, pattern);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Stop one consumer this client registered (delegates to transport).
|
|
809
|
+
*
|
|
810
|
+
* Accepts the consumer tag `consume()` returns, or the queue name. The
|
|
811
|
+
* consumer is dropped from the client's registry as well as cancelled on the
|
|
812
|
+
* broker, so connection-level recovery does not bring it back.
|
|
813
|
+
*
|
|
814
|
+
* @param {string} consumerTagOrQueue - The tag, or the queue name.
|
|
815
|
+
* @returns {Promise<Object>}
|
|
816
|
+
* @throws {ConnectionError} If not connected.
|
|
817
|
+
* @throws {ValidationError} If this client holds no such consumer.
|
|
818
|
+
*/
|
|
819
|
+
async cancelConsumer(consumerTagOrQueue) {
|
|
820
|
+
if (!this._connected || !this._transport) {
|
|
821
|
+
throw new ConnectionError(
|
|
822
|
+
'[BaseClient] Cannot cancelConsumer: client is not connected - Expected: connect() to have completed. '
|
|
823
|
+
+ 'Fix: await client.connect() before cancelConsumer().'
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
return await this._transport.cancelConsumer(consumerTagOrQueue);
|
|
827
|
+
}
|
|
828
|
+
|
|
218
829
|
/**
|
|
219
830
|
* Check queue existence (delegates to transport).
|
|
220
831
|
* @param {string} queue - Queue name
|
|
@@ -222,10 +833,16 @@ class BaseClient {
|
|
|
222
833
|
*/
|
|
223
834
|
async checkQueue(queue) {
|
|
224
835
|
if (!this._connected || !this._transport) {
|
|
225
|
-
throw new ConnectionError(
|
|
836
|
+
throw new ConnectionError(
|
|
837
|
+
'[BaseClient] Cannot checkQueue: client is not connected - Expected: connect() to have completed. '
|
|
838
|
+
+ 'Fix: await client.connect() before checkQueue().'
|
|
839
|
+
);
|
|
226
840
|
}
|
|
227
841
|
if (typeof this._transport.checkQueue !== 'function') {
|
|
228
|
-
throw new Error(
|
|
842
|
+
throw new Error(
|
|
843
|
+
'[BaseClient] Transport does not support checkQueue(queue) - Expected: a transport implementing checkQueue. '
|
|
844
|
+
+ 'Fix: use the rabbitmq transport, or drop the checkQueue() call for this transport type.'
|
|
845
|
+
);
|
|
229
846
|
}
|
|
230
847
|
return await this._transport.checkQueue(queue);
|
|
231
848
|
}
|
|
@@ -237,10 +854,16 @@ class BaseClient {
|
|
|
237
854
|
*/
|
|
238
855
|
async purgeQueue(queue) {
|
|
239
856
|
if (!this._connected || !this._transport) {
|
|
240
|
-
throw new ConnectionError(
|
|
857
|
+
throw new ConnectionError(
|
|
858
|
+
'[BaseClient] Cannot purgeQueue: client is not connected - Expected: connect() to have completed. '
|
|
859
|
+
+ 'Fix: await client.connect() before purgeQueue().'
|
|
860
|
+
);
|
|
241
861
|
}
|
|
242
862
|
if (typeof this._transport.purgeQueue !== 'function') {
|
|
243
|
-
throw new Error(
|
|
863
|
+
throw new Error(
|
|
864
|
+
'[BaseClient] Transport does not support purgeQueue(queue) - Expected: a transport implementing purgeQueue. '
|
|
865
|
+
+ 'Fix: use the rabbitmq transport, or drop the purgeQueue() call for this transport type.'
|
|
866
|
+
);
|
|
244
867
|
}
|
|
245
868
|
return await this._transport.purgeQueue(queue);
|
|
246
869
|
}
|
|
@@ -253,10 +876,16 @@ class BaseClient {
|
|
|
253
876
|
*/
|
|
254
877
|
async deleteQueue(queue, options = {}) {
|
|
255
878
|
if (!this._connected || !this._transport) {
|
|
256
|
-
throw new ConnectionError(
|
|
879
|
+
throw new ConnectionError(
|
|
880
|
+
'[BaseClient] Cannot deleteQueue: client is not connected - Expected: connect() to have completed. '
|
|
881
|
+
+ 'Fix: await client.connect() before deleteQueue().'
|
|
882
|
+
);
|
|
257
883
|
}
|
|
258
884
|
if (typeof this._transport.deleteQueue !== 'function') {
|
|
259
|
-
throw new Error(
|
|
885
|
+
throw new Error(
|
|
886
|
+
'[BaseClient] Transport does not support deleteQueue(queue, options) - Expected: a transport implementing deleteQueue. '
|
|
887
|
+
+ 'Fix: use the rabbitmq transport, or drop the deleteQueue() call for this transport type.'
|
|
888
|
+
);
|
|
260
889
|
}
|
|
261
890
|
return await this._transport.deleteQueue(queue, options);
|
|
262
891
|
}
|
|
@@ -281,10 +910,16 @@ class BaseClient {
|
|
|
281
910
|
*/
|
|
282
911
|
async ack(msg) {
|
|
283
912
|
if (!this._connected || !this._transport) {
|
|
284
|
-
throw new ConnectionError(
|
|
913
|
+
throw new ConnectionError(
|
|
914
|
+
'[BaseClient] Cannot ack: client is not connected - Expected: connect() to have completed. '
|
|
915
|
+
+ 'Fix: await client.connect() before ack(); a message received before a reconnect can no longer be acked.'
|
|
916
|
+
);
|
|
285
917
|
}
|
|
286
918
|
if (typeof this._transport.ack !== 'function') {
|
|
287
|
-
throw new Error(
|
|
919
|
+
throw new Error(
|
|
920
|
+
'[BaseClient] Transport does not support ack(msg) - Expected: a transport implementing ack. '
|
|
921
|
+
+ 'Fix: use the rabbitmq transport, or consume with noAck for this transport type.'
|
|
922
|
+
);
|
|
288
923
|
}
|
|
289
924
|
return await this._transport.ack(msg);
|
|
290
925
|
}
|
|
@@ -292,15 +927,26 @@ class BaseClient {
|
|
|
292
927
|
/**
|
|
293
928
|
* Negative-acknowledge a raw broker message (delegates to transport).
|
|
294
929
|
* @param {Object} msg - Broker message object
|
|
295
|
-
* @param {Object} [options] - { requeue: boolean }
|
|
930
|
+
* @param {Object} [options] - { requeue: boolean }; `requeue` defaults to
|
|
931
|
+
* `true` in the transport, so a caller that wants the message discarded
|
|
932
|
+
* must pass `{ requeue: false }`. This is an options OBJECT, never
|
|
933
|
+
* amqplib's positional `(msg, allUpTo, requeue)` — see
|
|
934
|
+
* transports/rabbitmqClient.js nack() and
|
|
935
|
+
* tests/unit/nack-options-signature.test.js.
|
|
296
936
|
* @returns {Promise<void>}
|
|
297
937
|
*/
|
|
298
938
|
async nack(msg, options = {}) {
|
|
299
939
|
if (!this._connected || !this._transport) {
|
|
300
|
-
throw new ConnectionError(
|
|
940
|
+
throw new ConnectionError(
|
|
941
|
+
'[BaseClient] Cannot nack: client is not connected - Expected: connect() to have completed. '
|
|
942
|
+
+ 'Fix: await client.connect() before nack(); a message received before a reconnect can no longer be nacked.'
|
|
943
|
+
);
|
|
301
944
|
}
|
|
302
945
|
if (typeof this._transport.nack !== 'function') {
|
|
303
|
-
throw new Error(
|
|
946
|
+
throw new Error(
|
|
947
|
+
'[BaseClient] Transport does not support nack(msg, options) - Expected: a transport implementing nack. '
|
|
948
|
+
+ 'Fix: use the rabbitmq transport, or consume with noAck for this transport type.'
|
|
949
|
+
);
|
|
304
950
|
}
|
|
305
951
|
return await this._transport.nack(msg, options);
|
|
306
952
|
}
|
|
@@ -331,54 +977,281 @@ class BaseClient {
|
|
|
331
977
|
}
|
|
332
978
|
|
|
333
979
|
/**
|
|
334
|
-
*
|
|
980
|
+
* Registers a handler for the permanent loss of the broker connection.
|
|
981
|
+
*
|
|
982
|
+
* It fires at most once per client, after connection-level recovery has spent
|
|
983
|
+
* its whole budget (`RABBITMQ_MAX_RECONNECT_ATTEMPTS`). At that point the
|
|
984
|
+
* client is dead until the process restarts: it makes no further attempts,
|
|
985
|
+
* `isConnected()` returns false, and publish/consume refuse.
|
|
986
|
+
*
|
|
987
|
+
* The library does not end the process — that decision belongs to the owner
|
|
988
|
+
* of the lifecycle. A service typically logs and exits non-zero here, so its
|
|
989
|
+
* restart policy boots it again and boot re-verifies every dependency.
|
|
990
|
+
*
|
|
991
|
+
* @param {function(Error): void} callback - Receives the fatal error
|
|
992
|
+
* (`code: 'MQ_CONNECTION_FATAL'`, `attempts: <budget>`).
|
|
993
|
+
*/
|
|
994
|
+
onFatal(callback) {
|
|
995
|
+
if (typeof callback === 'function') {
|
|
996
|
+
this._fatalHandlers.push(callback);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Internal helper to invoke all registered fatal handlers.
|
|
1002
|
+
* @param {Error} error
|
|
1003
|
+
* @private
|
|
1004
|
+
*/
|
|
1005
|
+
_handleFatal(error) {
|
|
1006
|
+
this._fatalHandlers.forEach((cb) => {
|
|
1007
|
+
try {
|
|
1008
|
+
cb(error);
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
this._logger.error(`[BaseClient] onFatal handler threw: ${err.message}`, {
|
|
1011
|
+
message: err.message,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* What this client's publishes have done so far — the counters
|
|
1019
|
+
* `monitoring/PublishMonitor.js` keeps: `attempts`, `successes`, `failures`,
|
|
1020
|
+
* `retries`, `buffered`, `flushed`, plus a per-queue breakdown.
|
|
1021
|
+
*
|
|
1022
|
+
* Delegated, never recomputed: the transport owns the counting, this class
|
|
1023
|
+
* owns the surface a service can reach. Until 2026-09-14 it owned neither —
|
|
1024
|
+
* the five reporting methods were on the transport alone, and
|
|
1025
|
+
* `new RabbitMQClient` is called in exactly one place in the workspace
|
|
1026
|
+
* (`transports/transportFactory.js`, inside this library), so the counters
|
|
1027
|
+
* were documented (`docs/architecture/mq-publish-reliability.md`) and readable
|
|
1028
|
+
* by nobody.
|
|
1029
|
+
*
|
|
1030
|
+
* @returns {Object} `{ attempts, successes, failures, retries, buffered, flushed, queues }`
|
|
1031
|
+
* @throws {ConnectionError} Before `connect()` has built a transport: there is
|
|
1032
|
+
* nothing to report, and a zeroed object would be an invented answer
|
|
1033
|
+
* (`architecture-principles.md` §3).
|
|
1034
|
+
*/
|
|
1035
|
+
getPublishMetrics() {
|
|
1036
|
+
return this._reportFromTransport('getPublishMetrics');
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* The same counters as Prometheus text exposition (`mq_publish_attempts`,
|
|
1041
|
+
* `mq_publish_successes`, `mq_publish_failures`, `mq_publish_retries`,
|
|
1042
|
+
* `mq_publish_buffered`, `mq_publish_flushed`).
|
|
1043
|
+
*
|
|
1044
|
+
* @returns {string}
|
|
1045
|
+
* @throws {ConnectionError} Before `connect()` has built a transport.
|
|
1046
|
+
*/
|
|
1047
|
+
getPrometheusMetrics() {
|
|
1048
|
+
return this._reportFromTransport('getPrometheusMetrics');
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Liveness of the three channels this client runs on, each as
|
|
1053
|
+
* `{ exists, closed, ready }` — the publisher's ConfirmChannel, the queue
|
|
1054
|
+
* channel and the consumer channel (`docs/architecture/rabbitmq-channel-lifecycle.md`).
|
|
1055
|
+
*
|
|
1056
|
+
* @returns {Object} `{ publisher, queue, consumer }`
|
|
1057
|
+
* @throws {ConnectionError} Before `connect()` has built a transport.
|
|
1058
|
+
*/
|
|
1059
|
+
getChannelState() {
|
|
1060
|
+
return this._reportFromTransport('getChannelState');
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* The consumers this client currently holds: how many, and on which queues.
|
|
1065
|
+
*
|
|
1066
|
+
* @returns {Object} `{ tracked: <count>, active: [<queue names>] }`
|
|
1067
|
+
* @throws {ConnectionError} Before `connect()` has built a transport.
|
|
1068
|
+
*/
|
|
1069
|
+
getConsumerState() {
|
|
1070
|
+
return this._reportFromTransport('getConsumerState');
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* How much is waiting in the publish buffer, in memory and in Redis.
|
|
1075
|
+
*
|
|
1076
|
+
* @returns {Object} `{ size, inMemory, persistent }`
|
|
1077
|
+
* @throws {ConnectionError} Before `connect()` has built a transport.
|
|
1078
|
+
*/
|
|
1079
|
+
getBufferState() {
|
|
1080
|
+
return this._reportFromTransport('getBufferState');
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* The ONE rail for the reporting methods above: ask the transport, or say why
|
|
1085
|
+
* there is no answer.
|
|
1086
|
+
*
|
|
1087
|
+
* Keyed on the TRANSPORT, not on `_connected` — the same distinction
|
|
1088
|
+
* `disconnect()` makes. A client whose connection has just died still owns a
|
|
1089
|
+
* transport whose counters are real, and that is exactly the moment somebody
|
|
1090
|
+
* asks. What there is no answer to is a client that has never connected: it
|
|
1091
|
+
* holds no transport, so it has counted nothing, and a zeroed object would be
|
|
1092
|
+
* an invented value rather than a measurement.
|
|
1093
|
+
*
|
|
1094
|
+
* @param {string} method - The transport method to report from.
|
|
1095
|
+
* @returns {*} Whatever that method answers.
|
|
1096
|
+
* @throws {ConnectionError} If no transport has been built yet.
|
|
1097
|
+
* @private
|
|
1098
|
+
*/
|
|
1099
|
+
_reportFromTransport(method) {
|
|
1100
|
+
if (!this._transport) {
|
|
1101
|
+
throw new ConnectionError(
|
|
1102
|
+
`[BaseClient] Cannot ${method}(): the client has no transport - Expected: connect() to have built one. `
|
|
1103
|
+
+ `Fix: await client.connect() before ${method}(); a client that never connected has counted nothing, `
|
|
1104
|
+
+ 'and a zeroed answer would be invented rather than measured.'
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
return this._transport[method]();
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Is this client's connection permanently lost?
|
|
1113
|
+
*
|
|
1114
|
+
* The difference between "the broker is away" and "this connection is over" is
|
|
1115
|
+
* what a healthcheck and a restart decision turn on, and until d.341 only the
|
|
1116
|
+
* TRANSPORT could answer it — while `new RabbitMQClient` is called in exactly
|
|
1117
|
+
* one place in the whole workspace, inside this library. A service holding a
|
|
1118
|
+
* `BaseClient` had `isConnected() === false` and no way to learn which of the
|
|
1119
|
+
* two it was: wait, or restart.
|
|
1120
|
+
*
|
|
1121
|
+
* Delegated, never recomputed — the transport owns the state, this class owns
|
|
1122
|
+
* the surface. It does NOT follow the rule of the five reporting methods
|
|
1123
|
+
* (`_reportFromTransport`, which refuses before `connect()`): a counter has
|
|
1124
|
+
* nothing to report before the first connection, but "is this client's
|
|
1125
|
+
* connection permanently lost" has a true answer for a client that never had
|
|
1126
|
+
* one — no. A healthcheck asks this question, and it must not be answered by
|
|
1127
|
+
* an exception.
|
|
1128
|
+
*
|
|
1129
|
+
* @returns {boolean} true once the broker refused the connection, or every
|
|
1130
|
+
* recovery cycle has been spent (`README.md` § Connection loss).
|
|
1131
|
+
*/
|
|
1132
|
+
isConnectionFatal() {
|
|
1133
|
+
if (!this._transport || typeof this._transport.isConnectionFatal !== 'function') {
|
|
1134
|
+
return false;
|
|
1135
|
+
}
|
|
1136
|
+
return this._transport.isConnectionFatal();
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Ask the client to check itself now: connection, the three channels, and the
|
|
1141
|
+
* queues its tracked consumers run on.
|
|
1142
|
+
*
|
|
1143
|
+
* The same probe the internal health monitor runs on its interval, exposed for
|
|
1144
|
+
* the caller who wants an answer at a moment of its own choosing (before a
|
|
1145
|
+
* workflow step, from a readiness endpoint) — documented as callable in
|
|
1146
|
+
* `docs/architecture/rabbitmq-channel-lifecycle.md` and, until d.341, callable
|
|
1147
|
+
* only on a transport no service holds.
|
|
1148
|
+
*
|
|
1149
|
+
* Since d.339 it is also a USE: a client that stood down after a spent recovery
|
|
1150
|
+
* cycle starts the next one here. It does not wait for it — the answer is the
|
|
1151
|
+
* state now, "not connected", and the next call says whether the cycle worked.
|
|
1152
|
+
*
|
|
1153
|
+
* @returns {Promise<Object>} `{ timestamp, connection, channels, consumers, queues, healthy, issues }`
|
|
1154
|
+
* @throws {ConnectionError} Before `connect()` has built a transport: there is
|
|
1155
|
+
* nothing to check, and a green verdict would be an invented answer.
|
|
1156
|
+
*/
|
|
1157
|
+
async performHealthCheck() {
|
|
1158
|
+
return this._reportFromTransport('performHealthCheck');
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* Check if client is connected.
|
|
1163
|
+
*
|
|
1164
|
+
* BREAKING since 2026-09-07: the answer comes from the transport's live
|
|
1165
|
+
* connection state, not from a flag set once in `connect()`. The old version
|
|
1166
|
+
* returned `true` for the entire outage AND after the recovery budget was
|
|
1167
|
+
* spent — measured on 2026-09-07 as `true` for 100+ s against a broker that
|
|
1168
|
+
* was down and then back up, with the client permanently dead. Every
|
|
1169
|
+
* healthcheck built on this method (including `conn-infra-mq`'s
|
|
1170
|
+
* `getHealth().connected`) reported a zombie process as healthy.
|
|
1171
|
+
*
|
|
335
1172
|
* @returns {boolean} Connection status
|
|
336
1173
|
*/
|
|
337
1174
|
isConnected() {
|
|
338
|
-
|
|
1175
|
+
if (this._connected !== true) return false;
|
|
1176
|
+
if (this._transport && typeof this._transport.isConnected === 'function') {
|
|
1177
|
+
return this._transport.isConnected();
|
|
1178
|
+
}
|
|
1179
|
+
return true;
|
|
339
1180
|
}
|
|
340
1181
|
}
|
|
341
1182
|
|
|
342
1183
|
module.exports = BaseClient;
|
|
343
1184
|
|
|
1185
|
+
/**
|
|
1186
|
+
* The clients this module currently holds — every instance that has been
|
|
1187
|
+
* constructed and not yet disconnected.
|
|
1188
|
+
*
|
|
1189
|
+
* It exists for exactly one reason: `disconnectAll()` has to know what "all"
|
|
1190
|
+
* means. One `BaseClient` owns one transport, so closing one client is already
|
|
1191
|
+
* `disconnect()`; "all" can only be the set of live instances, and that set has
|
|
1192
|
+
* to be kept where the instances are created.
|
|
1193
|
+
*/
|
|
344
1194
|
BaseClient._instances = new Set();
|
|
345
|
-
BaseClient._cleanupRegistered = false;
|
|
346
|
-
BaseClient._cleanupRunning = false;
|
|
347
1195
|
|
|
348
1196
|
BaseClient._registerInstance = function registerInstance(instance) {
|
|
349
1197
|
BaseClient._instances.add(instance);
|
|
350
|
-
if (!BaseClient._cleanupRegistered) {
|
|
351
|
-
BaseClient._registerCleanupHooks();
|
|
352
|
-
}
|
|
353
1198
|
};
|
|
354
1199
|
|
|
355
1200
|
BaseClient._unregisterInstance = function unregisterInstance(instance) {
|
|
356
1201
|
BaseClient._instances.delete(instance);
|
|
357
1202
|
};
|
|
358
1203
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
1204
|
+
/**
|
|
1205
|
+
* Close every client this module holds. The service calls it; nothing in this
|
|
1206
|
+
* library calls it for them.
|
|
1207
|
+
*
|
|
1208
|
+
* This is the whole of the library's side of the shutdown contract
|
|
1209
|
+
* (`docs/governance/confirmations/mq-client-lifecycle-contract.md` 001 point 1):
|
|
1210
|
+
* the library registers no signal handler and never ends the process, and in
|
|
1211
|
+
* exchange it offers one named call the lifecycle owner can put wherever its own
|
|
1212
|
+
* sequence needs it — before the Redis clients, after the HTTP server, in the
|
|
1213
|
+
* order THAT process decided.
|
|
1214
|
+
*
|
|
1215
|
+
* What it replaced, and why the replacement is not a smaller version of it:
|
|
1216
|
+
* until this batch the library installed `SIGINT`/`SIGTERM`/`beforeExit`
|
|
1217
|
+
* listeners on the first instance anyone constructed, and that listener ended in
|
|
1218
|
+
* `process.exit(0)`. The registry service traced the result live
|
|
1219
|
+
* (`infra/api_services_registry/src/shutdown.js`, batch 140, dev stack
|
|
1220
|
+
* 2026-08-31): `TRACE_END mq t=12` → `TRACE_PROCESS_EXIT code=0 t=16`. Node
|
|
1221
|
+
* copies the listener array before it emits, so the service could not remove the
|
|
1222
|
+
* library's listener in time; the library's exit won the race after the MQ
|
|
1223
|
+
* connection had closed and BEFORE four Redis clients, the HTTP server and six
|
|
1224
|
+
* timers were handed back. The service had written a complete shutdown sequence
|
|
1225
|
+
* and got a truncated one, with nothing in its own repository to explain it.
|
|
1226
|
+
*
|
|
1227
|
+
* Failure is reported, never swallowed. The deleted hook logged a warning per
|
|
1228
|
+
* failed client and exited 0 anyway — a shutdown that could not close a client
|
|
1229
|
+
* looked exactly like one that did. Here every client gets its turn (one dead
|
|
1230
|
+
* socket must not leave the others open), and if any refused, the call rejects
|
|
1231
|
+
* with a `ConnectionError` naming how many of how many failed, carrying the
|
|
1232
|
+
* first reason as its cause. The clients that failed stay in the set, because
|
|
1233
|
+
* `disconnect()` removes an instance only when it actually closed — so a second
|
|
1234
|
+
* `disconnectAll()` reaches them again, where the hook's blanket `clear()` made
|
|
1235
|
+
* the retry a silent no-op.
|
|
1236
|
+
*
|
|
1237
|
+
* @returns {Promise<void>} Resolves when every client held by this module has
|
|
1238
|
+
* closed — including when there were none.
|
|
1239
|
+
* @throws {ConnectionError} If one or more clients could not close. `error.cause`
|
|
1240
|
+
* is the first underlying failure.
|
|
1241
|
+
*/
|
|
1242
|
+
BaseClient.disconnectAll = async function disconnectAll() {
|
|
1243
|
+
const instances = Array.from(BaseClient._instances);
|
|
1244
|
+
const results = await Promise.allSettled(instances.map((instance) => instance.disconnect()));
|
|
1245
|
+
const failures = results.filter((result) => result.status === 'rejected');
|
|
377
1246
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
1247
|
+
if (failures.length > 0) {
|
|
1248
|
+
throw new ConnectionError(
|
|
1249
|
+
`[BaseClient] Failed to disconnect ${failures.length} of ${instances.length} clients - `
|
|
1250
|
+
+ 'Expected: every client this module holds to close its transport. '
|
|
1251
|
+
+ 'Fix: read error.cause for the first transport reason; the clients that failed are still '
|
|
1252
|
+
+ 'held, so a repeated disconnectAll() reaches them again.',
|
|
1253
|
+
failures[0].reason
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
383
1256
|
};
|
|
384
1257
|
|