@onlineapps/mq-client-core 5.0.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.
@@ -122,8 +122,35 @@ class PublishLayer {
122
122
  * @param {string} queue
123
123
  * @param {Buffer} buffer
124
124
  * @param {Object} [options]
125
+ * @param {boolean} [options.bufferOnFailure=true] - Whether a message whose publish failed
126
+ * transiently is held in the buffer and replayed after the next reconnect. `false` is for
127
+ * a caller that keeps the message durably elsewhere (conf `step-publish-failure` 001):
128
+ * nothing is held, so nothing replays behind its back. On each of the three transient
129
+ * paths (retries exhausted, retry disabled, the awaited reconnect failed) the thrown
130
+ * error carries `buffered` (boolean) — `true` only when the buffer really holds the
131
+ * message. A non-boolean value is refused before any attempt.
132
+ *
133
+ * Every error this method throws also carries `sent` (boolean, d.1145, conf
134
+ * `mq-publish-buffer` 001): `true` when ANY attempt of this call wrote the frame to the
135
+ * channel, so the broker may hold — and after a memory alarm deliver — the message even
136
+ * though the call failed; `false` when no attempt got that far. The buffer entry records the
137
+ * same fact, and the flush after `unblocked` reads it.
138
+ * @throws {ValidationError} When `options.bufferOnFailure` is present and not a boolean.
125
139
  */
126
140
  async publish(queue, buffer, options = {}) {
141
+ // Refused HERE, before any attempt: a value that cannot say yes or no must not be read
142
+ // as either — `'false'` is truthy, and `0` would be read as "no" by accident
143
+ // (`architecture-principles.md` §3, §4). The one owner of the option is this layer;
144
+ // `BaseClient.publish()` only carries it.
145
+ if (options.bufferOnFailure !== undefined && typeof options.bufferOnFailure !== 'boolean') {
146
+ throw new ValidationError(
147
+ `[PublishLayer] options.bufferOnFailure must be a boolean - got ${JSON.stringify(options.bufferOnFailure)} `
148
+ + `for queue "${queue}". Expected: true (hold a transiently failed message and replay it after `
149
+ + 'the reconnect — the default) or false (hold nothing; the caller keeps the message). '
150
+ + 'Fix: pass bufferOnFailure: true or bufferOnFailure: false, or omit it.'
151
+ );
152
+ }
153
+
127
154
  // `priority` still orders the buffer's replay (critical first). What it no
128
155
  // longer does is choose a DIFFERENT buffer: the persistent one was a stub
129
156
  // that stored nothing, so routing critical messages to it dropped them
@@ -145,10 +172,40 @@ class PublishLayer {
145
172
  try {
146
173
  await this._client._publishOnce(queue, buffer, options);
147
174
  } catch (err) {
148
- return await this._handlePublishError(err, queue, buffer, options, priority);
175
+ const sent = this._sentOf(err, queue);
176
+ return await this._handlePublishError(err, queue, buffer, options, priority, sent);
149
177
  }
150
178
  }
151
179
 
180
+ /**
181
+ * Whether the transport says the failed attempt's frame left the client.
182
+ *
183
+ * The transport writes `sent` on every error of `_publishOnce()` (d.1145). An error without
184
+ * it is a defect of the neighbour, and it is refused here by name rather than read as
185
+ * `false`: a message whose frame did leave would then be re-sent after `unblocked`, and
186
+ * that is the duplicate conf `mq-publish-buffer` 001 exists to prevent.
187
+ *
188
+ * @param {*} err - what `client._publishOnce()` threw
189
+ * @param {string} queue
190
+ * @returns {boolean}
191
+ * @throws {Error} when the error carries no boolean `sent`
192
+ * @private
193
+ */
194
+ _sentOf(err, queue) {
195
+ if (err !== null && typeof err === 'object' && typeof err.sent === 'boolean') {
196
+ return err.sent;
197
+ }
198
+ throw new Error(
199
+ '[PublishLayer] The transport reported a failed attempt without saying whether its frame was sent - '
200
+ + `queue "${queue}", error: ${err && err.message ? err.message : String(err)}. `
201
+ + 'Expected: every error of client._publishOnce() carries sent (boolean) — RabbitMQClient writes it on '
202
+ + 'every attempt (conf mq-publish-buffer 001). Fix: a stand-in transport must write err.sent as the '
203
+ + 'real one does; the message is neither buffered nor retried, because nobody can say whether '
204
+ + 'the broker may already hold it.',
205
+ { cause: err }
206
+ );
207
+ }
208
+
152
209
  /**
153
210
  * Publish s retry logikou
154
211
  * @private
@@ -171,6 +228,14 @@ class PublishLayer {
171
228
  * (the transport's `channel._closeError` + `_closeFrameNamesThisSend()`), the event is
172
229
  * the one already emitted, and the subscription lives exactly as long as this publish.
173
230
  */
231
+ // Whether ANY attempt of this call wrote the frame to the channel (d.1145). Written on
232
+ // every error this loop throws — `said()` — and on the buffer entry.
233
+ let sentAny = false;
234
+ const said = (error) => {
235
+ error.sent = sentAny;
236
+ return error;
237
+ };
238
+
174
239
  let lateRefusal = null;
175
240
  const onRefusedLate = (event) => {
176
241
  if (event.queue === queue && event.verdict === 'PermanentPublishError' && event.error) {
@@ -196,7 +261,7 @@ class PublishLayer {
196
261
  retryable: false,
197
262
  refusedLate: true
198
263
  });
199
- throw lateRefusal;
264
+ throw said(lateRefusal);
200
265
  }
201
266
 
202
267
  attempt++;
@@ -234,6 +299,7 @@ class PublishLayer {
234
299
 
235
300
  } catch (err) {
236
301
  lastError = err;
302
+ sentAny = this._sentOf(err, queue) || sentAny;
237
303
 
238
304
  // Non-retryable errors - fail immediately
239
305
  if (err instanceof PermanentPublishError || err instanceof QueueNotFoundError) {
@@ -243,7 +309,7 @@ class PublishLayer {
243
309
  error: err.message,
244
310
  retryable: false
245
311
  });
246
- throw err;
312
+ throw said(err);
247
313
  }
248
314
 
249
315
  // Transient errors - check if we should retry or buffer
@@ -258,8 +324,8 @@ class PublishLayer {
258
324
  this._logger.debug(`[PublishLayer] ✓ Reconnection completed, proceeding with retry ${attempt + 1}/${this._maxRetries}`);
259
325
  continue; // Retry immediately after reconnect
260
326
  } catch (reconnectErr) {
261
- // Reconnection failed - buffer and fail
262
- await this._bufferMessage(queue, buffer, options, priority, err);
327
+ // Reconnection failed - buffer (unless the caller said not to) and fail
328
+ const held = await this._holdOnFailure(queue, buffer, options, priority, err, sentAny);
263
329
  this._client.emit('publish:failed', {
264
330
  queue,
265
331
  attempt,
@@ -267,13 +333,15 @@ class PublishLayer {
267
333
  retryable: false,
268
334
  reconnectFailed: true
269
335
  });
270
- throw new PublishError(
336
+ const failure = new PublishError(
271
337
  `[PublishLayer] Publish failed: reconnection failed after ${attempt} attempts for queue "${queue}" - `
272
338
  + 'Expected: the transport to re-establish the connection before the retry. '
273
- + 'Fix: read error.cause for the reconnect reason; the message itself is buffered, so it is not lost.',
339
+ + `Fix: read error.cause for the reconnect reason; ${held.sentence}`,
274
340
  queue,
275
341
  reconnectErr
276
342
  );
343
+ failure.buffered = held.buffered;
344
+ throw said(failure);
277
345
  }
278
346
  }
279
347
 
@@ -305,8 +373,8 @@ class PublishLayer {
305
373
  continue;
306
374
  }
307
375
 
308
- // Max retries exceeded - buffer and fail
309
- await this._bufferMessage(queue, buffer, options, priority, err);
376
+ // Max retries exceeded - buffer (unless the caller said not to) and fail
377
+ const held = await this._holdOnFailure(queue, buffer, options, priority, err, sentAny);
310
378
  this._client.emit('publish:failed', {
311
379
  queue,
312
380
  attempt,
@@ -314,21 +382,23 @@ class PublishLayer {
314
382
  retryable: true,
315
383
  maxRetriesExceeded: true
316
384
  });
317
- throw new PublishError(
385
+ const failure = new PublishError(
318
386
  `[PublishLayer] Publish failed after ${attempt} attempts for queue "${queue}" - `
319
387
  + 'Expected: a retryable broker error to clear within maxRetries. '
320
- + 'Fix: read error.cause for the last broker reason; the message is buffered, so it is not lost.',
388
+ + `Fix: read error.cause for the last broker reason; ${held.sentence}`,
321
389
  queue,
322
390
  err
323
391
  );
392
+ failure.buffered = held.buffered;
393
+ throw said(failure);
324
394
  }
325
395
 
326
396
  // Unknown error - fail immediately
327
- throw err;
397
+ throw said(err);
328
398
  }
329
399
  }
330
400
 
331
- throw (
401
+ throw said(
332
402
  lastError
333
403
  || new PublishError(
334
404
  `[PublishLayer] Publish failed for queue "${queue}" after ${attempt} attempts - `
@@ -349,44 +419,103 @@ class PublishLayer {
349
419
  * Handle publish error - buffer transient errors
350
420
  * @private
351
421
  */
352
- async _handlePublishError(err, queue, buffer, options, priority) {
422
+ async _handlePublishError(err, queue, buffer, options, priority, sent) {
353
423
  if (err instanceof QueueNotFoundError) {
354
424
  this._logger.error(`[PublishLayer] QueueNotFoundError for '${queue}' (infra=${err.isInfrastructure}): ${err.message}`);
355
425
  throw err;
356
426
  }
357
427
 
358
428
  if (err instanceof TransientPublishError) {
359
- await this._bufferMessage(queue, buffer, options, priority, err);
429
+ const held = await this._holdOnFailure(queue, buffer, options, priority, err, sent);
430
+ // The transport's own error, thrown as it came — this path has always done that —
431
+ // with the one fact only this layer knows written on it.
432
+ err.buffered = held.buffered;
360
433
  throw err;
361
434
  }
362
435
 
363
436
  throw err;
364
437
  }
365
438
 
439
+ /**
440
+ * Hold a transiently failed message in the buffer — or, when the caller passed
441
+ * `bufferOnFailure: false`, deliberately do not — and say which happened.
442
+ *
443
+ * The one decision point of the three transient paths, so the option cannot be honoured
444
+ * on one path and forgotten on another.
445
+ *
446
+ * @private
447
+ * @returns {Promise<{buffered: boolean, sentence: string}>} `buffered` is `true` only when
448
+ * the buffer really holds the message; `sentence` ends the thrown error's `Fix:` with
449
+ * what the caller can rely on.
450
+ */
451
+ async _holdOnFailure(queue, buffer, options, priority, err, sent) {
452
+ if (options.bufferOnFailure === false) {
453
+ this._logger.warn(
454
+ `[PublishLayer] Message for queue '${queue}' NOT buffered (bufferOnFailure: false) after a transient error: ${err.message}`
455
+ );
456
+ return {
457
+ buffered: false,
458
+ sentence: 'the message is NOT buffered (bufferOnFailure: false), so nothing replays it — '
459
+ + 'the caller holds the only copy (error.buffered === false).'
460
+ };
461
+ }
462
+ if (await this._bufferMessage(queue, buffer, options, priority, err, sent)) {
463
+ return { buffered: true, sentence: 'the message is buffered, so it is not lost.' };
464
+ }
465
+ return {
466
+ buffered: false,
467
+ sentence: 'the message is NOT buffered (the buffer refused it — see the error log line before this one), '
468
+ + 'so nothing replays it (error.buffered === false).'
469
+ };
470
+ }
471
+
366
472
  /**
367
473
  * Buffer message při transient error
368
474
  * @private
475
+ * @returns {Promise<boolean>} whether the buffer now holds the message
369
476
  */
370
- async _bufferMessage(queue, buffer, options, priority, err) {
477
+ async _bufferMessage(queue, buffer, options, priority, err, sent) {
371
478
  try {
372
- await this._buffer.add(queue, buffer, options, { priority });
479
+ await this._buffer.add(queue, buffer, options, { priority, sent });
373
480
  this._logger.warn(`[PublishLayer] Buffered message for queue '${queue}' due to transient error: ${err.message}`);
374
481
  this._client.emit('publish:buffered', { queue });
482
+ return true;
375
483
  } catch (bufferErr) {
376
484
  this._logger.error(`[PublishLayer] Failed to buffer message for queue '${queue}': ${bufferErr.message}`);
485
+ return false;
377
486
  }
378
487
  }
379
488
 
380
489
  /**
381
- * Flush buffered messages po reconnectu
490
+ * Re-send what the buffer holds — the one flush, with two triggers (d.1145, conf
491
+ * `mq-publish-buffer` 001):
492
+ *
493
+ * - `'reconnected'` — a new connection: every entry is re-sent, as it always was;
494
+ * - `'unblocked'` — the broker ended a memory alarm on the SAME connection: only the
495
+ * entries whose frame never left the client are re-sent; the sent ones are dropped with a
496
+ * warning, because the broker delivers those itself (d.1143 measured it).
497
+ *
498
+ * Every entry is re-sent with the publish options it was held with — nothing else is
499
+ * merged in (d.1145 removed the `flushOptions` argument nobody passed).
500
+ *
501
+ * @param {'reconnected'|'unblocked'} trigger - what ended the outage
502
+ * @returns {Promise<number>} how many entries were re-sent successfully
503
+ * @throws {Error} when the trigger is not one of the two
382
504
  */
383
- async flushBuffered(flushOptions = {}) {
505
+ async flushBuffered(trigger) {
506
+ const SENT_ENTRIES = { reconnected: 'resend', unblocked: 'drop' };
507
+ if (!Object.prototype.hasOwnProperty.call(SENT_ENTRIES, trigger)) {
508
+ throw new Error(
509
+ `[PublishLayer] flushBuffered() needs its trigger - got ${JSON.stringify(trigger)}. `
510
+ + "Expected: 'reconnected' or 'unblocked' — they differ in what happens to a message whose frame "
511
+ + 'was already sent (conf mq-publish-buffer 001). Fix: pass the event that ended the outage.'
512
+ );
513
+ }
384
514
  const result = await this._buffer.flush(async (queue, buf, opts) => {
385
- const mergedOptions = Object.assign({}, opts, flushOptions);
386
- await this._client._publishOnce(queue, buf, mergedOptions);
387
- });
515
+ await this._client._publishOnce(queue, buf, opts);
516
+ }, { sentEntries: SENT_ENTRIES[trigger] });
388
517
 
389
- this._logger.info(`[PublishLayer] Flushed ${result} buffered message(s)`);
518
+ this._logger.info(`[PublishLayer] Flushed ${result} buffered message(s) after ${trigger}`);
390
519
  return result;
391
520
  }
392
521
  }
@@ -8,15 +8,12 @@
8
8
  * run `active` on it (`api/infra/api_monitoring/src/consumer/index.js`). A service that
9
9
  * does not file it starts runs that are invisible for their whole life.
10
10
  *
11
- * Until now two services built that event, each in its own words: the gateway
12
- * (`infra/api_gateway/shared/monitoringInitiatedCopy.js`, with a redacted cookbook) and
13
- * the scheduler (`infra/api_scheduler/src/publisher/workflowPublisher.js`, inline, the
14
- * cookbook verbatim and no `tenant_uuid` at all). Two implementations of one concern are
15
- * a defect by default (`change-discipline.md` § One rail per concern), and the owner
16
- * settled which rail survives: this one, in the library that already owns the transport
17
- * half of the same wire (`docs/governance/confirmations/scheduler.md` 009). Both copies
18
- * are retired by their own services in the change that adopts this module — no period
19
- * with both (`architecture-principles.md` §11).
11
+ * One producer rail, by the owner's decision (`docs/governance/confirmations/scheduler.md`
12
+ * 009 and 024): the event is built and filed by this module, in the library that already
13
+ * owns the transport half of the same wire (`./monitoring-publish.js`). Every service that
14
+ * starts a run files it through here — the decision names the gateway and the scheduler.
15
+ * A service that builds the event in its own words is a second implementation of one
16
+ * concern, a defect by default (`change-discipline.md` § One rail per concern).
20
17
  *
21
18
  * WHAT THIS MODULE DOES NOT DO, deliberately:
22
19
  *