@onlineapps/mq-client-core 4.2.0 → 5.1.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 +768 -0
- package/README.md +149 -19
- package/package.json +1 -1
- package/src/BaseClient.js +126 -31
- package/src/buffer/InMemoryBuffer.js +98 -13
- package/src/buffer/MessageBuffer.js +5 -3
- package/src/config/configSchema.js +33 -10
- package/src/config/defaultConfig.js +21 -10
- package/src/config/deliveryPolicy.js +86 -6
- package/src/config/queueConfig.js +68 -39
- package/src/config.js +5 -1
- package/src/defaults.js +7 -1
- package/src/index.js +41 -4
- package/src/layers/PublishLayer.js +264 -27
- package/src/monitoring-initiated.js +6 -9
- package/src/monitoring-publish.js +89 -40
- package/src/scheduler-request.js +32 -11
- package/src/transports/rabbitmqClient.js +1782 -390
- package/src/utils/decodeMessage.js +123 -0
- package/src/utils/publishErrors.js +256 -17
- package/jest.config.js +0 -15
- package/jest.integration.config.js +0 -16
- package/src/workers/RecoveryWorker.js +0 -130
|
@@ -18,19 +18,66 @@
|
|
|
18
18
|
* await publishToMonitoringWorkflow(mqClient, { event_type: 'completed', workflow_id: '...', ... }, logger);
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
const {
|
|
21
|
+
const { ConnectionError } = require('./utils/errorHandler');
|
|
22
22
|
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
23
|
-
const {
|
|
23
|
+
const {
|
|
24
|
+
AMQP_NOT_FOUND,
|
|
25
|
+
QueueNotFoundError,
|
|
26
|
+
TransientPublishError
|
|
27
|
+
} = require('./utils/publishErrors');
|
|
24
28
|
|
|
25
29
|
/**
|
|
26
|
-
*
|
|
30
|
+
* The socket-level codes that mean the broker was not reachable at all. Node sets
|
|
31
|
+
* them, not this package, and they are codes rather than words.
|
|
32
|
+
*/
|
|
33
|
+
const UNREACHABLE_SOCKET_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT']);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How deep a cause chain this function is willing to walk.
|
|
27
37
|
*
|
|
28
|
-
* The
|
|
29
|
-
*
|
|
30
|
-
*
|
|
38
|
+
* The measured chain is three links — `PublishError` (BaseClient) → the publish
|
|
39
|
+
* classification → amqplib's raw error — so the bound is an upper limit on a real
|
|
40
|
+
* shape, not a guess about an unknown one. It exists because `error.cause` is set by
|
|
41
|
+
* whoever threw, and a chain that points back at itself would otherwise spin.
|
|
42
|
+
*/
|
|
43
|
+
const MAX_CAUSE_DEPTH = 8;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Does this error — or anything it was caused by — mean the queue was not there to
|
|
47
|
+
* publish into, or the broker was not reachable?
|
|
48
|
+
*
|
|
49
|
+
* DECIDED BY CLASS AND CODE, NEVER BY THE MESSAGE. Until d.676 the decision read
|
|
50
|
+
* substrings: `'not connected'`, `'Connection closed'`, `'NOT_FOUND'`, `'404'`,
|
|
51
|
+
* `'no queue'`, `'queue does not exist'`, `'Channel closed'`. Every one of those
|
|
52
|
+
* sentences belongs to somebody else — amqplib writes the broker's refusal, this
|
|
53
|
+
* package writes its own — so the classification broke on any rewording and nothing
|
|
54
|
+
* reported the break (`docs/biz/70-contracts/error-handling.md` §6: "Message-based
|
|
55
|
+
* routing; breaks on any wording change — branch on `err.code`").
|
|
56
|
+
*
|
|
57
|
+
* WHY THE CHAIN AND NOT THE ERROR ITSELF: `BaseClient.publish()` wraps every
|
|
58
|
+
* transport failure in a `PublishError` and puts the real error in `cause`. So the
|
|
59
|
+
* object handed to this function is the wrapper, its `instanceof` tests never
|
|
60
|
+
* matched, and only the substrings ever fired — by accident, because the wrapper's
|
|
61
|
+
* message quotes the cause's sentence. Two shapes were measured answering `false`
|
|
62
|
+
* although the queue really was unavailable: a `QueueNotFoundError` with
|
|
63
|
+
* `kind: 'unowned'`, whose sentence carries none of the substrings, and a
|
|
64
|
+
* retry-exhausted publish, whose sentence quotes no cause at all.
|
|
65
|
+
*
|
|
66
|
+
* The carriers this walks for, and what each stands for:
|
|
67
|
+
* - `QueueNotFoundError` — the publish path's own ownership refusal (the queue is
|
|
68
|
+
* absent, whichever owner should have created it);
|
|
69
|
+
* - `ConnectionError` — the client is not connected, or the connection dropped
|
|
70
|
+
* (`CONNECTION_CLOSED_UNEXPECTEDLY`);
|
|
71
|
+
* - `TransientPublishError` — the channel or connection went away mid-publish;
|
|
72
|
+
* amqplib's `IllegalOperationError` ("Channel closed"), which carries no code of
|
|
73
|
+
* its own, reaches here as exactly this class;
|
|
74
|
+
* - `code === 404` — amqplib's raw `NOT_FOUND`;
|
|
75
|
+
* - `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT` — the broker was unreachable.
|
|
31
76
|
*
|
|
32
77
|
* @param {Error} error - The error a publish to `queueName` produced.
|
|
33
|
-
* @param {string} queueName - The queue that publish was aimed at.
|
|
78
|
+
* @param {string} queueName - The queue that publish was aimed at. Required since
|
|
79
|
+
* d.435 and part of the exported contract; it is what the refusal below names.
|
|
80
|
+
* @returns {boolean}
|
|
34
81
|
* @private
|
|
35
82
|
*/
|
|
36
83
|
function isQueueUnavailableError(error, queueName) {
|
|
@@ -43,39 +90,21 @@ function isQueueUnavailableError(error, queueName) {
|
|
|
43
90
|
);
|
|
44
91
|
}
|
|
45
92
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return true;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Queue doesn't exist errors
|
|
63
|
-
if (error instanceof QueueNotFoundError ||
|
|
64
|
-
errorName === 'QueueNotFoundError' ||
|
|
65
|
-
errorMessage.includes('NOT_FOUND') ||
|
|
66
|
-
errorMessage.includes('404') ||
|
|
67
|
-
errorMessage.includes('no queue') ||
|
|
68
|
-
errorMessage.includes('queue does not exist') ||
|
|
69
|
-
errorMessage.includes('Channel closed')) {
|
|
70
|
-
return true;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Classify publish errors
|
|
74
|
-
const classified = classifyPublishError(error, queueName);
|
|
75
|
-
if (classified instanceof QueueNotFoundError) {
|
|
76
|
-
return true;
|
|
93
|
+
let link = error;
|
|
94
|
+
|
|
95
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH && link instanceof Error; depth += 1) {
|
|
96
|
+
if (link instanceof QueueNotFoundError
|
|
97
|
+
|| link instanceof ConnectionError
|
|
98
|
+
|| link instanceof TransientPublishError
|
|
99
|
+
|| link.code === AMQP_NOT_FOUND
|
|
100
|
+
|| UNREACHABLE_SOCKET_CODES.has(link.code)) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (link.cause === link) break;
|
|
105
|
+
link = link.cause;
|
|
77
106
|
}
|
|
78
|
-
|
|
107
|
+
|
|
79
108
|
return false;
|
|
80
109
|
}
|
|
81
110
|
|
|
@@ -115,6 +144,26 @@ async function publishToMonitoringResilient(mqClient, queueName, message, logger
|
|
|
115
144
|
return false;
|
|
116
145
|
}
|
|
117
146
|
|
|
147
|
+
// The client's own events go out on the client's own channel (d.724). Said here, in the
|
|
148
|
+
// one place that knows these messages are the client talking about itself; `publish()`
|
|
149
|
+
// would put them on the channel carrying the caller's business messages, where a
|
|
150
|
+
// refusal of an event closes the channel and takes those with it (measured d.711).
|
|
151
|
+
// Reported and skipped rather than thrown, like every other precondition in this
|
|
152
|
+
// function: it is fail-safe about publishing by contract (§ Principles above), and a
|
|
153
|
+
// transport without the method would otherwise turn a monitoring event into a caller's
|
|
154
|
+
// exception.
|
|
155
|
+
if (typeof mqClient.publishTelemetry !== 'function') {
|
|
156
|
+
logger.warn(
|
|
157
|
+
`Monitoring publish skipped: the client has no publishTelemetry() (queue: ${queueName}) - `
|
|
158
|
+
+ 'Expected: a client whose telemetry has a channel of its own, so a refused event cannot '
|
|
159
|
+
+ 'close the channel a business message is waiting on. '
|
|
160
|
+
+ 'Fix: pass a @onlineapps/mq-client-core BaseClient, or a client that forwards '
|
|
161
|
+
+ 'publishTelemetry() to one.',
|
|
162
|
+
context
|
|
163
|
+
);
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
118
167
|
if (!message || !message.event_type) {
|
|
119
168
|
logger.warn(`Monitoring publish skipped: message missing event_type (queue: ${queueName})`, {
|
|
120
169
|
...context,
|
|
@@ -140,7 +189,7 @@ async function publishToMonitoringResilient(mqClient, queueName, message, logger
|
|
|
140
189
|
const publishOptions = queueName === 'monitoring.workflow'
|
|
141
190
|
? { exchange: WORKFLOW_FANOUT, routingKey: '' }
|
|
142
191
|
: {};
|
|
143
|
-
await mqClient.
|
|
192
|
+
await mqClient.publishTelemetry(queueName, message, publishOptions);
|
|
144
193
|
|
|
145
194
|
logger.debug(`Published to ${queueName}`, {
|
|
146
195
|
...context,
|
package/src/scheduler-request.js
CHANGED
|
@@ -81,7 +81,7 @@ const describeErrors = (errors) => (errors || [])
|
|
|
81
81
|
*/
|
|
82
82
|
function validateRecurrences(document, source) {
|
|
83
83
|
if (typeof source !== 'string' || source.trim() === '') {
|
|
84
|
-
throw
|
|
84
|
+
throw invalidRequest(
|
|
85
85
|
'[mq-client-core/scheduler-request] Missing source - Expected: a name for what is being '
|
|
86
86
|
+ 'checked, because every refusal below quotes it and a wrong name sends the reader to the '
|
|
87
87
|
+ 'wrong place. Fix: pass what the caller calls this document (e.g. "the declaration").'
|
|
@@ -89,7 +89,7 @@ function validateRecurrences(document, source) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
if (!document || typeof document !== 'object' || Array.isArray(document)) {
|
|
92
|
-
throw
|
|
92
|
+
throw invalidRequest(
|
|
93
93
|
`[mq-client-core/scheduler-request] ${source} is not a declaration - Expected: an object with a "recurrences" list, `
|
|
94
94
|
+ `got ${Array.isArray(document) ? 'an array' : typeof document}. `
|
|
95
95
|
+ 'Fix: write { "recurrences": [ … ] }, or remove the file if the service declares none.'
|
|
@@ -97,7 +97,7 @@ function validateRecurrences(document, source) {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
if (!validateDeclaration(document)) {
|
|
100
|
-
throw
|
|
100
|
+
throw invalidRequest(
|
|
101
101
|
`[mq-client-core/scheduler-request] ${source} does not hold: ${describeErrors(validateDeclaration.errors)} - `
|
|
102
102
|
+ 'Expected: every record carries id, cookbook_ref, cron (five fields) and an explicit '
|
|
103
103
|
+ 'IANA timezone, and nothing this shape does not declare. '
|
|
@@ -110,7 +110,7 @@ function validateRecurrences(document, source) {
|
|
|
110
110
|
const seen = new Set();
|
|
111
111
|
for (const recurrence of document.recurrences) {
|
|
112
112
|
if (seen.has(recurrence.id)) {
|
|
113
|
-
throw
|
|
113
|
+
throw invalidRequest(
|
|
114
114
|
`[mq-client-core/scheduler-request] ${source} declares the id "${recurrence.id}" twice - Expected: one record per `
|
|
115
115
|
+ 'id, because the id travels as the scheduler\'s idempotency_key and a second record '
|
|
116
116
|
+ 'would silently overwrite the first at every boot. '
|
|
@@ -123,6 +123,26 @@ function validateRecurrences(document, source) {
|
|
|
123
123
|
return document.recurrences;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The code every refusal of a builder carries — and only those (`SCHEDULER_REQUEST_INVALID`).
|
|
128
|
+
*
|
|
129
|
+
* A caller has two kinds of failure to tell apart and must never do it by the wording of a
|
|
130
|
+
* message: a request that cannot be built (a record that breaks the declaration rules, tenancy,
|
|
131
|
+
* a missing key or requester, a key the port does not declare) and a request that could not be
|
|
132
|
+
* SENT (no connection, a publish the broker refused). The wrapper's boot phase 0.7b isolates the
|
|
133
|
+
* first as a failed source and stops the boot on the second (confirmation `scheduler` 020,
|
|
134
|
+
* operational reading 1). So the builders mark their refusals with this code, and
|
|
135
|
+
* `publishSchedulerRequest()` and the port's construction never do.
|
|
136
|
+
*/
|
|
137
|
+
const SCHEDULER_REQUEST_INVALID = 'SCHEDULER_REQUEST_INVALID';
|
|
138
|
+
|
|
139
|
+
/** A builder's refusal: the message as written, plus the code that names its kind. */
|
|
140
|
+
function invalidRequest(message) {
|
|
141
|
+
const error = new Error(message);
|
|
142
|
+
error.code = SCHEDULER_REQUEST_INVALID;
|
|
143
|
+
return error;
|
|
144
|
+
}
|
|
145
|
+
|
|
126
146
|
/** A whole number a tenant or workspace id may be: ≥ 1, never 0 (confirmation scheduler 002). */
|
|
127
147
|
const isContextId = (value) => Number.isInteger(value) && value >= 1;
|
|
128
148
|
|
|
@@ -169,7 +189,7 @@ const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
|
169
189
|
/** The key that identifies the item, whatever kind it is - required by the contract's root. */
|
|
170
190
|
function requireIdempotencyKey(idempotencyKey) {
|
|
171
191
|
if (typeof idempotencyKey !== 'string' || idempotencyKey.trim() === '') {
|
|
172
|
-
throw
|
|
192
|
+
throw invalidRequest(
|
|
173
193
|
'[mq-client-core/scheduler-request] Missing idempotency_key - Expected: the stable identity '
|
|
174
194
|
+ 'of this item, chosen by the caller. The scheduler decides a one-shot item ONCE by this '
|
|
175
195
|
+ 'key, so a key invented here would turn every retry of one fact into a second run. '
|
|
@@ -183,7 +203,7 @@ function requireIdempotencyKey(idempotencyKey) {
|
|
|
183
203
|
/** The service registering, cancelling or reporting - checked by the scheduler against services.json. */
|
|
184
204
|
function requireRequestedBy(requestedBy) {
|
|
185
205
|
if (typeof requestedBy !== 'string' || requestedBy.trim() === '') {
|
|
186
|
-
throw
|
|
206
|
+
throw invalidRequest(
|
|
187
207
|
'[mq-client-core/scheduler-request] Missing requested_by - Expected: the platform name of the '
|
|
188
208
|
+ 'service sending this request; the scheduler checks it against api/config/services.json '
|
|
189
209
|
+ "and against the cookbook's own trigger declaration. "
|
|
@@ -197,7 +217,7 @@ function requireRequestedBy(requestedBy) {
|
|
|
197
217
|
/** Both context ids, under the one rule confirmation `scheduler` 002 fixed. */
|
|
198
218
|
function requireTenancy(tenantId, workspaceId) {
|
|
199
219
|
if (!isContextId(tenantId)) {
|
|
200
|
-
throw
|
|
220
|
+
throw invalidRequest(
|
|
201
221
|
`[mq-client-core/scheduler-request] Invalid tenant_id ${JSON.stringify(tenantId)} - Expected: `
|
|
202
222
|
+ 'a whole number of at least 1. The owner rejected a reserved value: 0 is not a platform '
|
|
203
223
|
+ 'scope, and an absent tenant is an error rather than a default (confirmation scheduler 002). '
|
|
@@ -208,7 +228,7 @@ function requireTenancy(tenantId, workspaceId) {
|
|
|
208
228
|
}
|
|
209
229
|
|
|
210
230
|
if (!isContextId(workspaceId)) {
|
|
211
|
-
throw
|
|
231
|
+
throw invalidRequest(
|
|
212
232
|
`[mq-client-core/scheduler-request] Invalid workspace_id ${JSON.stringify(workspaceId)} - `
|
|
213
233
|
+ 'Expected: a whole number of at least 1, under the same rule as the tenant (confirmation '
|
|
214
234
|
+ 'scheduler 002). Fix: pass workspaceId with every request - a recurrence carries it in the '
|
|
@@ -242,7 +262,7 @@ function buildEventRequest({ event, tenantId, workspaceId, requestedBy, idempote
|
|
|
242
262
|
requireTenancy(tenantId, workspaceId);
|
|
243
263
|
|
|
244
264
|
if (!event || typeof event !== 'object' || Array.isArray(event)) {
|
|
245
|
-
throw
|
|
265
|
+
throw invalidRequest(
|
|
246
266
|
`[mq-client-core/scheduler-request] Missing event - Expected: { service, name } - who it `
|
|
247
267
|
+ 'happened to and what happened, both of them. '
|
|
248
268
|
+ 'Fix: pass the fact this service publishes, never a cookbook name.'
|
|
@@ -251,7 +271,7 @@ function buildEventRequest({ event, tenantId, workspaceId, requestedBy, idempote
|
|
|
251
271
|
|
|
252
272
|
for (const [field, value] of [['service', event.service], ['name', event.name]]) {
|
|
253
273
|
if (typeof value !== 'string' || !NAME_PATTERN.test(value)) {
|
|
254
|
-
throw
|
|
274
|
+
throw invalidRequest(
|
|
255
275
|
`[mq-client-core/scheduler-request] Invalid event.${field} ${JSON.stringify(value)} - `
|
|
256
276
|
+ 'Expected: the platform spelling, lower case letters, digits and dashes '
|
|
257
277
|
+ '(the pattern the scheduler\'s own contract declares). '
|
|
@@ -414,7 +434,7 @@ function createSchedulerPublisher({ mqClient, requestedBy } = {}) {
|
|
|
414
434
|
// something, and dropping it in silence hides both (`architecture-principles.md` §3).
|
|
415
435
|
for (const key of Object.keys(record)) {
|
|
416
436
|
if (!RECURRING_PORT_KEYS.includes(key)) {
|
|
417
|
-
throw
|
|
437
|
+
throw invalidRequest(
|
|
418
438
|
`[mq-client-core/scheduler-request] registerRecurring got a key the request does not declare ("${key}") - `
|
|
419
439
|
+ 'Expected: exactly { id, cookbookRef, cron, timezone, tenantId, workspaceId, input?, catchUp? }. '
|
|
420
440
|
+ 'Fix: remove the key from the record the service returns / the ctx.scheduler call.'
|
|
@@ -472,6 +492,7 @@ function createSchedulerPublisher({ mqClient, requestedBy } = {}) {
|
|
|
472
492
|
module.exports = {
|
|
473
493
|
schedulerRequestQueue,
|
|
474
494
|
SENDABLE_KINDS,
|
|
495
|
+
SCHEDULER_REQUEST_INVALID,
|
|
475
496
|
validateRecurrences,
|
|
476
497
|
buildRecurringRequest,
|
|
477
498
|
buildEventRequest,
|