@onlineapps/mq-client-core 5.0.0 → 6.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.
@@ -2,10 +2,11 @@
2
2
 
3
3
  const {
4
4
  TransientPublishError,
5
+ RecoveryEndedPublishError,
5
6
  PermanentPublishError,
6
7
  QueueNotFoundError,
7
8
  } = require('../utils/publishErrors');
8
- const { ValidationError, PublishError } = require('../utils/errorHandler');
9
+ const { ValidationError, PublishError, carryVerdict } = require('../utils/errorHandler');
9
10
  const MessageBuffer = require('../buffer/MessageBuffer');
10
11
  const { assertLogger } = require('@onlineapps/logger-contract');
11
12
 
@@ -23,6 +24,8 @@ class PublishLayer {
23
24
  * @param {Object} options.client - Instance RabbitMQClient
24
25
  * @param {Object} options.logger - Logger s info/warn/error/debug (povinný)
25
26
  * @param {Object} [options.bufferConfig] - Konfigurace pro MessageBuffer
27
+ * @param {{setTimeout: Function, clearTimeout: Function}} options.timers - The clock the
28
+ * buffer expires its entries on (required, d.1275; the transport passes its own timer rail).
26
29
  * @param {boolean} [options.retryEnabled=true] - Zapnout retry
27
30
  * @param {number} [options.maxRetries=3] - Max počet pokusů
28
31
  * @param {number} [options.retryBaseDelay=100] - Base delay v ms
@@ -114,7 +117,11 @@ class PublishLayer {
114
117
  this._buffer = new MessageBuffer({
115
118
  inMemory: options.bufferConfig?.inMemory,
116
119
  logger: this._logger,
120
+ timers: options.timers,
117
121
  });
122
+ // Read by the one sentence that tells a caller how long the buffer holds its message
123
+ // (`_holdOnFailure()`); `InMemoryBuffer` has already refused a missing value above.
124
+ this._bufferTtlMs = options.bufferConfig.inMemory.ttlMs;
118
125
  }
119
126
 
120
127
  /**
@@ -122,8 +129,35 @@ class PublishLayer {
122
129
  * @param {string} queue
123
130
  * @param {Buffer} buffer
124
131
  * @param {Object} [options]
132
+ * @param {boolean} [options.bufferOnFailure=true] - Whether a message whose publish failed
133
+ * transiently is held in the buffer and replayed after the next reconnect. `false` is for
134
+ * a caller that keeps the message durably elsewhere (conf `step-publish-failure` 001):
135
+ * nothing is held, so nothing replays behind its back. On each of the three transient
136
+ * paths (retries exhausted, retry disabled, the awaited reconnect failed) the thrown
137
+ * error carries `buffered` (boolean) — `true` only when the buffer really holds the
138
+ * message. A non-boolean value is refused before any attempt.
139
+ *
140
+ * Every error this method throws also carries `sent` (boolean, d.1145, conf
141
+ * `mq-publish-buffer` 001): `true` when ANY attempt of this call wrote the frame to the
142
+ * channel, so the broker may hold — and after a memory alarm deliver — the message even
143
+ * though the call failed; `false` when no attempt got that far. The buffer entry records the
144
+ * same fact, and the flush after `unblocked` reads it.
145
+ * @throws {ValidationError} When `options.bufferOnFailure` is present and not a boolean.
125
146
  */
126
147
  async publish(queue, buffer, options = {}) {
148
+ // Refused HERE, before any attempt: a value that cannot say yes or no must not be read
149
+ // as either — `'false'` is truthy, and `0` would be read as "no" by accident
150
+ // (`architecture-principles.md` §3, §4). The one owner of the option is this layer;
151
+ // `BaseClient.publish()` only carries it.
152
+ if (options.bufferOnFailure !== undefined && typeof options.bufferOnFailure !== 'boolean') {
153
+ throw new ValidationError(
154
+ `[PublishLayer] options.bufferOnFailure must be a boolean - got ${JSON.stringify(options.bufferOnFailure)} `
155
+ + `for queue "${queue}". Expected: true (hold a transiently failed message and replay it after `
156
+ + 'the reconnect — the default) or false (hold nothing; the caller keeps the message). '
157
+ + 'Fix: pass bufferOnFailure: true or bufferOnFailure: false, or omit it.'
158
+ );
159
+ }
160
+
127
161
  // `priority` still orders the buffer's replay (critical first). What it no
128
162
  // longer does is choose a DIFFERENT buffer: the persistent one was a stub
129
163
  // that stored nothing, so routing critical messages to it dropped them
@@ -145,10 +179,40 @@ class PublishLayer {
145
179
  try {
146
180
  await this._client._publishOnce(queue, buffer, options);
147
181
  } catch (err) {
148
- return await this._handlePublishError(err, queue, buffer, options, priority);
182
+ const sent = this._sentOf(err, queue);
183
+ return await this._handlePublishError(err, queue, buffer, options, priority, sent);
149
184
  }
150
185
  }
151
186
 
187
+ /**
188
+ * Whether the transport says the failed attempt's frame left the client.
189
+ *
190
+ * The transport writes `sent` on every error of `_publishOnce()` (d.1145). An error without
191
+ * it is a defect of the neighbour, and it is refused here by name rather than read as
192
+ * `false`: a message whose frame did leave would then be re-sent after `unblocked`, and
193
+ * that is the duplicate conf `mq-publish-buffer` 001 exists to prevent.
194
+ *
195
+ * @param {*} err - what `client._publishOnce()` threw
196
+ * @param {string} queue
197
+ * @returns {boolean}
198
+ * @throws {Error} when the error carries no boolean `sent`
199
+ * @private
200
+ */
201
+ _sentOf(err, queue) {
202
+ if (err !== null && typeof err === 'object' && typeof err.sent === 'boolean') {
203
+ return err.sent;
204
+ }
205
+ throw new Error(
206
+ '[PublishLayer] The transport reported a failed attempt without saying whether its frame was sent - '
207
+ + `queue "${queue}", error: ${err && err.message ? err.message : String(err)}. `
208
+ + 'Expected: every error of client._publishOnce() carries sent (boolean) — RabbitMQClient writes it on '
209
+ + 'every attempt (conf mq-publish-buffer 001). Fix: a stand-in transport must write err.sent as the '
210
+ + 'real one does; the message is neither buffered nor retried, because nobody can say whether '
211
+ + 'the broker may already hold it.',
212
+ { cause: err }
213
+ );
214
+ }
215
+
152
216
  /**
153
217
  * Publish s retry logikou
154
218
  * @private
@@ -171,6 +235,14 @@ class PublishLayer {
171
235
  * (the transport's `channel._closeError` + `_closeFrameNamesThisSend()`), the event is
172
236
  * the one already emitted, and the subscription lives exactly as long as this publish.
173
237
  */
238
+ // Whether ANY attempt of this call wrote the frame to the channel (d.1145). Written on
239
+ // every error this loop throws — `said()` — and on the buffer entry.
240
+ let sentAny = false;
241
+ const said = (error) => {
242
+ error.sent = sentAny;
243
+ return error;
244
+ };
245
+
174
246
  let lateRefusal = null;
175
247
  const onRefusedLate = (event) => {
176
248
  if (event.queue === queue && event.verdict === 'PermanentPublishError' && event.error) {
@@ -196,7 +268,7 @@ class PublishLayer {
196
268
  retryable: false,
197
269
  refusedLate: true
198
270
  });
199
- throw lateRefusal;
271
+ throw said(lateRefusal);
200
272
  }
201
273
 
202
274
  attempt++;
@@ -234,6 +306,7 @@ class PublishLayer {
234
306
 
235
307
  } catch (err) {
236
308
  lastError = err;
309
+ sentAny = this._sentOf(err, queue) || sentAny;
237
310
 
238
311
  // Non-retryable errors - fail immediately
239
312
  if (err instanceof PermanentPublishError || err instanceof QueueNotFoundError) {
@@ -243,7 +316,22 @@ class PublishLayer {
243
316
  error: err.message,
244
317
  retryable: false
245
318
  });
246
- throw err;
319
+ throw said(err);
320
+ }
321
+
322
+ // The attempt could not get its send channel because the connection stayed down
323
+ // (B1, d.1300): the recovery it waited for ended in standby or fatal, or none may
324
+ // start. Retrying NOW is pointless — the next use of the client starts the next
325
+ // cycle — so the message is held at once, on the same rail as the retry below that
326
+ // waited for a reconnect and saw it fail. Asked BEFORE the generic transient branch,
327
+ // because the class is a transient one (the message is held and replayed).
328
+ if (err instanceof RecoveryEndedPublishError) {
329
+ throw said(await this._failedAfterRecovery({
330
+ queue, buffer, options, priority, attempt, transient: err, recoveryError: err.cause || err, sentAny,
331
+ headline: '[PublishLayer] Publish failed: the recovery this publish waited for ended without a connection '
332
+ + `(attempt ${attempt} for queue "${queue}") - ${err.message} `
333
+ + 'Expected: a live connection before the send. '
334
+ }));
247
335
  }
248
336
 
249
337
  // Transient errors - check if we should retry or buffer
@@ -258,22 +346,12 @@ class PublishLayer {
258
346
  this._logger.debug(`[PublishLayer] ✓ Reconnection completed, proceeding with retry ${attempt + 1}/${this._maxRetries}`);
259
347
  continue; // Retry immediately after reconnect
260
348
  } catch (reconnectErr) {
261
- // Reconnection failed - buffer and fail
262
- await this._bufferMessage(queue, buffer, options, priority, err);
263
- this._client.emit('publish:failed', {
264
- queue,
265
- attempt,
266
- error: `Reconnection failed: ${reconnectErr.message}`,
267
- retryable: false,
268
- reconnectFailed: true
269
- });
270
- throw new PublishError(
271
- `[PublishLayer] Publish failed: reconnection failed after ${attempt} attempts for queue "${queue}" - `
349
+ // Reconnection failed - buffer (unless the caller said not to) and fail
350
+ throw said(await this._failedAfterRecovery({
351
+ queue, buffer, options, priority, attempt, transient: err, recoveryError: reconnectErr, sentAny,
352
+ headline: `[PublishLayer] Publish failed: reconnection failed after ${attempt} attempts for queue "${queue}" - `
272
353
  + '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.',
274
- queue,
275
- reconnectErr
276
- );
354
+ }));
277
355
  }
278
356
  }
279
357
 
@@ -305,8 +383,8 @@ class PublishLayer {
305
383
  continue;
306
384
  }
307
385
 
308
- // Max retries exceeded - buffer and fail
309
- await this._bufferMessage(queue, buffer, options, priority, err);
386
+ // Max retries exceeded - buffer (unless the caller said not to) and fail
387
+ const held = await this._holdOnFailure(queue, buffer, options, priority, err, sentAny);
310
388
  this._client.emit('publish:failed', {
311
389
  queue,
312
390
  attempt,
@@ -314,21 +392,23 @@ class PublishLayer {
314
392
  retryable: true,
315
393
  maxRetriesExceeded: true
316
394
  });
317
- throw new PublishError(
395
+ const failure = new PublishError(
318
396
  `[PublishLayer] Publish failed after ${attempt} attempts for queue "${queue}" - `
319
397
  + '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.',
398
+ + `Fix: read error.cause for the last broker reason; ${held.sentence}`,
321
399
  queue,
322
400
  err
323
401
  );
402
+ failure.buffered = held.buffered;
403
+ throw said(failure);
324
404
  }
325
405
 
326
406
  // Unknown error - fail immediately
327
- throw err;
407
+ throw said(err);
328
408
  }
329
409
  }
330
410
 
331
- throw (
411
+ throw said(
332
412
  lastError
333
413
  || new PublishError(
334
414
  `[PublishLayer] Publish failed for queue "${queue}" after ${attempt} attempts - `
@@ -345,48 +425,174 @@ class PublishLayer {
345
425
  }
346
426
  }
347
427
 
428
+ /**
429
+ * The one ending for "the recovery this publish depended on did not arrive": hold the
430
+ * message (unless the caller said not to), report the failure, and build the error the
431
+ * caller gets — with `buffered` written on it and the verdict of a fatal ending carried on
432
+ * top (`carryVerdict()`, d.1297/d.1300).
433
+ *
434
+ * Two callers, one body (d.1300): the retry that waited for a reconnect in flight and saw
435
+ * it fail, and the attempt whose send channel could not be had because the connection
436
+ * stayed down (`RecoveryEndedPublishError`). Until d.1300 only the first held the message;
437
+ * the second threw the wait's ending raw, so two near-identical situations ended two ways
438
+ * (INFRA-DOCS ID254 point 1).
439
+ *
440
+ * @private
441
+ * @returns {Promise<PublishError>} the failure to throw; the caller writes `sent` on it
442
+ */
443
+ async _failedAfterRecovery({ queue, buffer, options, priority, attempt, transient, recoveryError, sentAny, headline }) {
444
+ const held = await this._holdOnFailure(queue, buffer, options, priority, transient, sentAny);
445
+ this._client.emit('publish:failed', {
446
+ queue,
447
+ attempt,
448
+ error: `Reconnection failed: ${recoveryError.message}`,
449
+ retryable: false,
450
+ reconnectFailed: true
451
+ });
452
+ const failure = new PublishError(
453
+ `${headline}Fix: read error.cause for the reconnect reason; ${held.sentence}`,
454
+ queue,
455
+ recoveryError
456
+ );
457
+ failure.buffered = held.buffered;
458
+ return carryVerdict(failure, recoveryError);
459
+ }
460
+
348
461
  /**
349
462
  * Handle publish error - buffer transient errors
350
463
  * @private
351
464
  */
352
- async _handlePublishError(err, queue, buffer, options, priority) {
465
+ async _handlePublishError(err, queue, buffer, options, priority, sent) {
353
466
  if (err instanceof QueueNotFoundError) {
354
467
  this._logger.error(`[PublishLayer] QueueNotFoundError for '${queue}' (infra=${err.isInfrastructure}): ${err.message}`);
355
468
  throw err;
356
469
  }
357
470
 
358
471
  if (err instanceof TransientPublishError) {
359
- await this._bufferMessage(queue, buffer, options, priority, err);
472
+ const held = await this._holdOnFailure(queue, buffer, options, priority, err, sent);
473
+ // The transport's own error, thrown as it came — this path has always done that —
474
+ // with the one fact only this layer knows written on it.
475
+ err.buffered = held.buffered;
360
476
  throw err;
361
477
  }
362
478
 
363
479
  throw err;
364
480
  }
365
481
 
482
+ /**
483
+ * Hold a transiently failed message in the buffer — or, when the caller passed
484
+ * `bufferOnFailure: false`, deliberately do not — and say which happened.
485
+ *
486
+ * The one decision point of the three transient paths, so the option cannot be honoured
487
+ * on one path and forgotten on another.
488
+ *
489
+ * @private
490
+ * @returns {Promise<{buffered: boolean, sentence: string}>} `buffered` is `true` only when
491
+ * the buffer really holds the message; `sentence` ends the thrown error's `Fix:` with
492
+ * what the caller can rely on.
493
+ */
494
+ async _holdOnFailure(queue, buffer, options, priority, err, sent) {
495
+ if (options.bufferOnFailure === false) {
496
+ this._logger.warn(
497
+ `[PublishLayer] Message for queue '${queue}' NOT buffered (bufferOnFailure: false) after a transient error: ${err.message}`
498
+ );
499
+ return {
500
+ buffered: false,
501
+ sentence: 'the message is NOT buffered (bufferOnFailure: false), so nothing replays it — '
502
+ + 'the caller holds the only copy (error.buffered === false).'
503
+ };
504
+ }
505
+ // Asked AFTER the caller's explicit choice above (`architecture-principles.md` §8: the
506
+ // option the caller passed is what its sentence names), and before any hold.
507
+ // A client that will never reconnect on its own cannot replay anything: the two flushes
508
+ // are `reconnected` and `unblocked`, and neither comes after the connection is declared
509
+ // lost, after `disconnect()`, or with `reconnectEnabled: false`. Holding there is a loss
510
+ // with a delay (reported LOST at TTL), not a safety — so nothing is held, and the error
511
+ // says so (d.1301, the lead's decision narrowing B1 of d.1300). The transport writes the
512
+ // fact (`replayable`) where it knows the state; this layer only reads it.
513
+ if (err instanceof RecoveryEndedPublishError && err.replayable === false) {
514
+ this._logger.warn(
515
+ `[PublishLayer] Message for queue '${queue}' NOT buffered: this client will not reconnect, so no replay could come - ${err.message}`
516
+ );
517
+ return {
518
+ buffered: false,
519
+ sentence: 'the message is NOT buffered: this client will not reconnect on its own (the connection is '
520
+ + 'permanently lost, the caller closed it, or reconnectEnabled is false), so no reconnect could replay '
521
+ + 'it — the caller holds the only copy (error.buffered === false).'
522
+ };
523
+ }
524
+ if (await this._bufferMessage(queue, buffer, options, priority, err, sent)) {
525
+ // What the buffer WILL do, and nothing more (d.1275). It used to say "the message is
526
+ // buffered, so it is not lost" — while the only two flushes are the reconnect and
527
+ // `unblocked`, so an outage of the publisher channel alone ended with the message
528
+ // expiring (INFRA 531, W224). Whether a channel recovery should flush too is an open
529
+ // decision (a sent entry re-sent after it may be a duplicate); until it is made, the
530
+ // sentence states the gap instead of papering over it.
531
+ return {
532
+ buffered: true,
533
+ sentence: `the message is buffered for up to ${this._bufferTtlMs} ms (publishBufferTtlMs) and `
534
+ + 'replayed only after a reconnect or after the broker unblocks the connection — a recovered '
535
+ + 'publisher channel alone does not replay it; if neither comes within that time it expires and '
536
+ + (sent
537
+ ? 'is reported as an expired message whose frame had been sent (the broker may hold a copy) '
538
+ : 'is reported as LOST ')
539
+ + '(an error line naming the queue or exchange), and nothing replays it then.'
540
+ };
541
+ }
542
+ return {
543
+ buffered: false,
544
+ sentence: 'the message is NOT buffered (the buffer refused it — see the error log line before this one), '
545
+ + 'so nothing replays it (error.buffered === false).'
546
+ };
547
+ }
548
+
366
549
  /**
367
550
  * Buffer message při transient error
368
551
  * @private
552
+ * @returns {Promise<boolean>} whether the buffer now holds the message
369
553
  */
370
- async _bufferMessage(queue, buffer, options, priority, err) {
554
+ async _bufferMessage(queue, buffer, options, priority, err, sent) {
371
555
  try {
372
- await this._buffer.add(queue, buffer, options, { priority });
556
+ await this._buffer.add(queue, buffer, options, { priority, sent });
373
557
  this._logger.warn(`[PublishLayer] Buffered message for queue '${queue}' due to transient error: ${err.message}`);
374
558
  this._client.emit('publish:buffered', { queue });
559
+ return true;
375
560
  } catch (bufferErr) {
376
561
  this._logger.error(`[PublishLayer] Failed to buffer message for queue '${queue}': ${bufferErr.message}`);
562
+ return false;
377
563
  }
378
564
  }
379
565
 
380
566
  /**
381
- * Flush buffered messages po reconnectu
567
+ * Re-send what the buffer holds — the one flush, with two triggers (d.1145, conf
568
+ * `mq-publish-buffer` 001):
569
+ *
570
+ * - `'reconnected'` — a new connection: every entry is re-sent, as it always was;
571
+ * - `'unblocked'` — the broker ended a memory alarm on the SAME connection: only the
572
+ * entries whose frame never left the client are re-sent; the sent ones are dropped with a
573
+ * warning, because the broker delivers those itself (d.1143 measured it).
574
+ *
575
+ * Every entry is re-sent with the publish options it was held with — nothing else is
576
+ * merged in (d.1145 removed the `flushOptions` argument nobody passed).
577
+ *
578
+ * @param {'reconnected'|'unblocked'} trigger - what ended the outage
579
+ * @returns {Promise<number>} how many entries were re-sent successfully
580
+ * @throws {Error} when the trigger is not one of the two
382
581
  */
383
- async flushBuffered(flushOptions = {}) {
582
+ async flushBuffered(trigger) {
583
+ const SENT_ENTRIES = { reconnected: 'resend', unblocked: 'drop' };
584
+ if (!Object.prototype.hasOwnProperty.call(SENT_ENTRIES, trigger)) {
585
+ throw new Error(
586
+ `[PublishLayer] flushBuffered() needs its trigger - got ${JSON.stringify(trigger)}. `
587
+ + "Expected: 'reconnected' or 'unblocked' — they differ in what happens to a message whose frame "
588
+ + 'was already sent (conf mq-publish-buffer 001). Fix: pass the event that ended the outage.'
589
+ );
590
+ }
384
591
  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
- });
592
+ await this._client._publishOnce(queue, buf, opts);
593
+ }, { sentEntries: SENT_ENTRIES[trigger] });
388
594
 
389
- this._logger.info(`[PublishLayer] Flushed ${result} buffered message(s)`);
595
+ this._logger.info(`[PublishLayer] Flushed ${result} buffered message(s) after ${trigger}`);
390
596
  return result;
391
597
  }
392
598
  }
@@ -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
  *