@onlineapps/mq-client-core 5.1.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.
- package/CHANGELOG.md +329 -0
- package/README.md +74 -12
- package/package.json +2 -2
- package/src/BaseClient.js +80 -66
- package/src/buffer/InMemoryBuffer.js +66 -3
- package/src/buffer/MessageBuffer.js +3 -0
- package/src/config/configSchema.js +3 -13
- package/src/config/defaultConfig.js +3 -5
- package/src/config/queueConfig.js +35 -1
- package/src/index.js +22 -0
- package/src/layers/PublishLayer.js +95 -18
- package/src/transports/rabbitmqClient.js +366 -76
- package/src/utils/errorHandler.js +25 -0
- package/src/utils/publishErrors.js +49 -0
package/src/BaseClient.js
CHANGED
|
@@ -28,6 +28,7 @@ const {
|
|
|
28
28
|
SerializationError,
|
|
29
29
|
CONSUMER_QUEUE_MISSING,
|
|
30
30
|
CONSUMER_DEAD_LETTER_ROUTE_MISSING,
|
|
31
|
+
carryVerdict,
|
|
31
32
|
} = require('./utils/errorHandler');
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -110,6 +111,32 @@ const NOT_A_KEY_OF_THIS_CLIENT = Object.freeze({
|
|
|
110
111
|
'The persistent buffer it fed is gone (see persistentBufferEnabled): it never wrote to '
|
|
111
112
|
+ 'Redis at all, so the client injected here was held and never used.',
|
|
112
113
|
}),
|
|
114
|
+
/**
|
|
115
|
+
* Removed in d.1275 with the `health:shutdown` event they armed, on the owner's word
|
|
116
|
+
* (2026-10-06): "volbu i událost ZRUŠIT, žádný slib". After `criticalHealthShutdownDelay`
|
|
117
|
+
* of critical health the transport emitted `health:shutdown` and logged that it was
|
|
118
|
+
* "triggering shutdown" — but the library never ends the process (`_failFatally()` says
|
|
119
|
+
* why), and nothing in the repository listened: `@onlineapps/service-wrapper` dropped its
|
|
120
|
+
* listener on 2026-09-07, no infra service ever had one (measured 2026-10-06). The four
|
|
121
|
+
* questions of `change-discipline.md` § Removing are answered in CHANGELOG.md, in the release that removed them.
|
|
122
|
+
* Refused by name, not ignored: a caller writing `false` to silence the promise needs to
|
|
123
|
+
* hear that there is no promise left to silence.
|
|
124
|
+
*/
|
|
125
|
+
criticalHealthShutdown: Object.freeze({
|
|
126
|
+
key: null,
|
|
127
|
+
what: 'a shutdown on sustained critical health',
|
|
128
|
+
consequence:
|
|
129
|
+
'The client reports critical health (health:critical, healthCriticalCallback) and decides '
|
|
130
|
+
+ 'nothing about the process; the health:shutdown event this key armed reached no listener, '
|
|
131
|
+
+ 'so it was removed. Ending the process is the lifecycle owner\'s decision (onFatal).',
|
|
132
|
+
}),
|
|
133
|
+
criticalHealthShutdownDelay: Object.freeze({
|
|
134
|
+
key: null,
|
|
135
|
+
what: 'the delay before a shutdown on sustained critical health',
|
|
136
|
+
consequence:
|
|
137
|
+
'It timed the health:shutdown event, which is gone (see criticalHealthShutdown): the client '
|
|
138
|
+
+ 'reports critical health on every failing check and decides nothing about the process.',
|
|
139
|
+
}),
|
|
113
140
|
});
|
|
114
141
|
|
|
115
142
|
/**
|
|
@@ -370,6 +397,9 @@ class BaseClient {
|
|
|
370
397
|
*/
|
|
371
398
|
async connect(options = {}) {
|
|
372
399
|
if (this._connected) return;
|
|
400
|
+
// An explicit `connect()` builds a NEW transport below, so the verdict of the old one
|
|
401
|
+
// ends here — like `_closedByCaller` on the transport, it is undone by nothing else (d.1301).
|
|
402
|
+
this._fatalError = null;
|
|
373
403
|
|
|
374
404
|
// `host` is the broker URL and carries the account password. It is redacted
|
|
375
405
|
// on the way to the log — the only rail for that is utils/redactCredentials.
|
|
@@ -485,6 +515,30 @@ class BaseClient {
|
|
|
485
515
|
}
|
|
486
516
|
}
|
|
487
517
|
|
|
518
|
+
/**
|
|
519
|
+
* The one guard every operation on the broker runs first (d.1314; the predicate is
|
|
520
|
+
* d.1301's). It refuses only the two states where "connect()" is true advice — the client
|
|
521
|
+
* never connected, or the caller closed it with `disconnect()`. After `connection:fatal`
|
|
522
|
+
* it lets the call through to the transport, which knows that state and answers it with
|
|
523
|
+
* the verdict on top (`code: 'MQ_CONNECTION_FATAL'`, `reason`, the verdict as `cause`), so
|
|
524
|
+
* no second sentence about a lost connection is written here. Until d.1314 each of the
|
|
525
|
+
* eleven operations carried its own copy of this guard, nine of them still advising
|
|
526
|
+
* `connect()` after the verdict.
|
|
527
|
+
*
|
|
528
|
+
* @param {string} op - the public method asking, used in the sentence
|
|
529
|
+
* @param {string} [tail='.'] - what ends the Fix sentence
|
|
530
|
+
* @throws {ConnectionError}
|
|
531
|
+
* @private
|
|
532
|
+
*/
|
|
533
|
+
_requireUsable(op, tail = '.') {
|
|
534
|
+
if (!this._transport || (!this._connected && !this._fatalError)) {
|
|
535
|
+
throw new ConnectionError(
|
|
536
|
+
`[BaseClient] Cannot ${op}: client is not connected - Expected: connect() to have completed. `
|
|
537
|
+
+ `Fix: await client.connect() before ${op}()${tail}`
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
488
542
|
/**
|
|
489
543
|
* Publishes a message to the specified queue.
|
|
490
544
|
* @param {string} queue - Target queue name.
|
|
@@ -504,12 +558,13 @@ class BaseClient {
|
|
|
504
558
|
* only when the buffer holds the message for the next reconnect.
|
|
505
559
|
*/
|
|
506
560
|
async publish(queue, message, options = {}) {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
561
|
+
// A client whose connection was declared LOST is not "not connected": `connect()` is
|
|
562
|
+
// exactly what its lifecycle owner must not be told to do (d.1301). The transport still
|
|
563
|
+
// exists and answers that state itself — a `RecoveryEndedPublishError` with the verdict
|
|
564
|
+
// on top and `buffered: false` (`_ensurePublisherChannel()`, d.1300/d.1301) — so the call
|
|
565
|
+
// is let through to it; this guard keeps only the two states where the advice is true:
|
|
566
|
+
// never connected, and closed by `disconnect()`.
|
|
567
|
+
this._requireUsable('publish');
|
|
513
568
|
|
|
514
569
|
let buffer;
|
|
515
570
|
try {
|
|
@@ -566,6 +621,11 @@ class BaseClient {
|
|
|
566
621
|
if (err && typeof err.sent === 'boolean') {
|
|
567
622
|
failure.sent = err.sent;
|
|
568
623
|
}
|
|
624
|
+
// The verdict of a lost connection, carried like `buffered` and `sent` (d.1300, the
|
|
625
|
+
// publish half of d.1297): a publish whose recovery ended fatally reaches the caller
|
|
626
|
+
// with `code: 'MQ_CONNECTION_FATAL'` and `reason` on the error it catches — where the
|
|
627
|
+
// lifecycle owner reads them — not two `cause` down.
|
|
628
|
+
carryVerdict(failure, err);
|
|
569
629
|
throw failure;
|
|
570
630
|
}
|
|
571
631
|
}
|
|
@@ -616,12 +676,11 @@ class BaseClient {
|
|
|
616
676
|
* @throws {ConsumeError} If consumer setup fails.
|
|
617
677
|
*/
|
|
618
678
|
async consume(queue, messageHandler, options = {}) {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
}
|
|
679
|
+
// Same rule as `publish()` (d.1301): after `connection:fatal` the transport answers with
|
|
680
|
+
// the verdict on top (`_consumerChannelWithoutRecovery()`, d.1281/d.1297) and
|
|
681
|
+
// `_consumerRefusal()` lets it through unchanged; the "not connected" advice is kept
|
|
682
|
+
// for the two states where it is true.
|
|
683
|
+
this._requireUsable('consume');
|
|
625
684
|
|
|
626
685
|
// Apply prefetch and noAck overrides if provided
|
|
627
686
|
const { prefetch, noAck } = options;
|
|
@@ -899,12 +958,7 @@ class BaseClient {
|
|
|
899
958
|
* @returns {Promise<Object>}
|
|
900
959
|
*/
|
|
901
960
|
async assertQueue(queue, options = {}) {
|
|
902
|
-
|
|
903
|
-
throw new ConnectionError(
|
|
904
|
-
'[BaseClient] Cannot assertQueue: client is not connected - Expected: connect() to have completed. '
|
|
905
|
-
+ 'Fix: await client.connect() before assertQueue().'
|
|
906
|
-
);
|
|
907
|
-
}
|
|
961
|
+
this._requireUsable('assertQueue');
|
|
908
962
|
if (typeof this._transport.assertQueue !== 'function') {
|
|
909
963
|
throw new Error(
|
|
910
964
|
'[BaseClient] Transport does not support assertQueue(queue, options) - Expected: a transport implementing assertQueue. '
|
|
@@ -932,12 +986,7 @@ class BaseClient {
|
|
|
932
986
|
* @throws {ValidationError} If the name or type cannot declare an exchange.
|
|
933
987
|
*/
|
|
934
988
|
async assertExchange(exchange, type, options = {}) {
|
|
935
|
-
|
|
936
|
-
throw new ConnectionError(
|
|
937
|
-
'[BaseClient] Cannot assertExchange: client is not connected - Expected: connect() to have completed. '
|
|
938
|
-
+ 'Fix: await client.connect() before assertExchange().'
|
|
939
|
-
);
|
|
940
|
-
}
|
|
989
|
+
this._requireUsable('assertExchange');
|
|
941
990
|
return await this._transport.assertExchange(exchange, type, options);
|
|
942
991
|
}
|
|
943
992
|
|
|
@@ -953,12 +1002,7 @@ class BaseClient {
|
|
|
953
1002
|
* @throws {ValidationError} If a name or the pattern cannot form a binding.
|
|
954
1003
|
*/
|
|
955
1004
|
async bindQueue(queue, exchange, pattern) {
|
|
956
|
-
|
|
957
|
-
throw new ConnectionError(
|
|
958
|
-
'[BaseClient] Cannot bindQueue: client is not connected - Expected: connect() to have completed. '
|
|
959
|
-
+ 'Fix: await client.connect() before bindQueue().'
|
|
960
|
-
);
|
|
961
|
-
}
|
|
1005
|
+
this._requireUsable('bindQueue');
|
|
962
1006
|
return await this._transport.bindQueue(queue, exchange, pattern);
|
|
963
1007
|
}
|
|
964
1008
|
|
|
@@ -975,12 +1019,7 @@ class BaseClient {
|
|
|
975
1019
|
* @throws {ValidationError} If this client holds no such consumer.
|
|
976
1020
|
*/
|
|
977
1021
|
async cancelConsumer(consumerTagOrQueue) {
|
|
978
|
-
|
|
979
|
-
throw new ConnectionError(
|
|
980
|
-
'[BaseClient] Cannot cancelConsumer: client is not connected - Expected: connect() to have completed. '
|
|
981
|
-
+ 'Fix: await client.connect() before cancelConsumer().'
|
|
982
|
-
);
|
|
983
|
-
}
|
|
1022
|
+
this._requireUsable('cancelConsumer');
|
|
984
1023
|
return await this._transport.cancelConsumer(consumerTagOrQueue);
|
|
985
1024
|
}
|
|
986
1025
|
|
|
@@ -990,12 +1029,7 @@ class BaseClient {
|
|
|
990
1029
|
* @returns {Promise<Object>}
|
|
991
1030
|
*/
|
|
992
1031
|
async checkQueue(queue) {
|
|
993
|
-
|
|
994
|
-
throw new ConnectionError(
|
|
995
|
-
'[BaseClient] Cannot checkQueue: client is not connected - Expected: connect() to have completed. '
|
|
996
|
-
+ 'Fix: await client.connect() before checkQueue().'
|
|
997
|
-
);
|
|
998
|
-
}
|
|
1032
|
+
this._requireUsable('checkQueue');
|
|
999
1033
|
if (typeof this._transport.checkQueue !== 'function') {
|
|
1000
1034
|
throw new Error(
|
|
1001
1035
|
'[BaseClient] Transport does not support checkQueue(queue) - Expected: a transport implementing checkQueue. '
|
|
@@ -1011,12 +1045,7 @@ class BaseClient {
|
|
|
1011
1045
|
* @returns {Promise<Object>}
|
|
1012
1046
|
*/
|
|
1013
1047
|
async purgeQueue(queue) {
|
|
1014
|
-
|
|
1015
|
-
throw new ConnectionError(
|
|
1016
|
-
'[BaseClient] Cannot purgeQueue: client is not connected - Expected: connect() to have completed. '
|
|
1017
|
-
+ 'Fix: await client.connect() before purgeQueue().'
|
|
1018
|
-
);
|
|
1019
|
-
}
|
|
1048
|
+
this._requireUsable('purgeQueue');
|
|
1020
1049
|
if (typeof this._transport.purgeQueue !== 'function') {
|
|
1021
1050
|
throw new Error(
|
|
1022
1051
|
'[BaseClient] Transport does not support purgeQueue(queue) - Expected: a transport implementing purgeQueue. '
|
|
@@ -1033,12 +1062,7 @@ class BaseClient {
|
|
|
1033
1062
|
* @returns {Promise<Object>}
|
|
1034
1063
|
*/
|
|
1035
1064
|
async deleteQueue(queue, options = {}) {
|
|
1036
|
-
|
|
1037
|
-
throw new ConnectionError(
|
|
1038
|
-
'[BaseClient] Cannot deleteQueue: client is not connected - Expected: connect() to have completed. '
|
|
1039
|
-
+ 'Fix: await client.connect() before deleteQueue().'
|
|
1040
|
-
);
|
|
1041
|
-
}
|
|
1065
|
+
this._requireUsable('deleteQueue');
|
|
1042
1066
|
if (typeof this._transport.deleteQueue !== 'function') {
|
|
1043
1067
|
throw new Error(
|
|
1044
1068
|
'[BaseClient] Transport does not support deleteQueue(queue, options) - Expected: a transport implementing deleteQueue. '
|
|
@@ -1068,12 +1092,7 @@ class BaseClient {
|
|
|
1068
1092
|
* @returns {Promise<void>}
|
|
1069
1093
|
*/
|
|
1070
1094
|
async ack(msg) {
|
|
1071
|
-
|
|
1072
|
-
throw new ConnectionError(
|
|
1073
|
-
'[BaseClient] Cannot ack: client is not connected - Expected: connect() to have completed. '
|
|
1074
|
-
+ 'Fix: await client.connect() before ack(); a message received before a reconnect can no longer be acked.'
|
|
1075
|
-
);
|
|
1076
|
-
}
|
|
1095
|
+
this._requireUsable('ack', '; a message received before a reconnect can no longer be acked.');
|
|
1077
1096
|
if (typeof this._transport.ack !== 'function') {
|
|
1078
1097
|
throw new Error(
|
|
1079
1098
|
'[BaseClient] Transport does not support ack(msg) - Expected: a transport implementing ack. '
|
|
@@ -1095,12 +1114,7 @@ class BaseClient {
|
|
|
1095
1114
|
* @returns {Promise<void>}
|
|
1096
1115
|
*/
|
|
1097
1116
|
async nack(msg, options = {}) {
|
|
1098
|
-
|
|
1099
|
-
throw new ConnectionError(
|
|
1100
|
-
'[BaseClient] Cannot nack: client is not connected - Expected: connect() to have completed. '
|
|
1101
|
-
+ 'Fix: await client.connect() before nack(); a message received before a reconnect can no longer be nacked.'
|
|
1102
|
-
);
|
|
1103
|
-
}
|
|
1117
|
+
this._requireUsable('nack', '; a message received before a reconnect can no longer be nacked.');
|
|
1104
1118
|
if (typeof this._transport.nack !== 'function') {
|
|
1105
1119
|
throw new Error(
|
|
1106
1120
|
'[BaseClient] Transport does not support nack(msg, options) - Expected: a transport implementing nack. '
|
|
@@ -43,6 +43,10 @@ class InMemoryBuffer {
|
|
|
43
43
|
* @param {number} options.maxSize - Max messages held; 0 means hold none
|
|
44
44
|
* @param {number} options.ttlMs - How long one message may wait, in ms
|
|
45
45
|
* @param {Object} options.logger - Logger s info/warn/error/debug (povinný)
|
|
46
|
+
* @param {{setTimeout: Function, clearTimeout: Function}} options.timers - The clock the
|
|
47
|
+
* expiry runs on (required, d.1275): `setTimeout(callback, ms)` returns a handle,
|
|
48
|
+
* `clearTimeout(handle)` disarms it. The transport hands its own timer rail
|
|
49
|
+
* (`_setTimeout()` / `_clearTimeout()`), so `disconnect()` ends this timer too.
|
|
46
50
|
*/
|
|
47
51
|
constructor(options = {}) {
|
|
48
52
|
// The logger first, so a caller who forgot it gets the contract message
|
|
@@ -71,8 +75,21 @@ class InMemoryBuffer {
|
|
|
71
75
|
+ 'or pass ttlMs explicitly.'
|
|
72
76
|
);
|
|
73
77
|
}
|
|
78
|
+
if (!options.timers || typeof options.timers.setTimeout !== 'function'
|
|
79
|
+
|| typeof options.timers.clearTimeout !== 'function') {
|
|
80
|
+
throw new Error(
|
|
81
|
+
'[InMemoryBuffer] Missing required option - timers is required and has no default here. '
|
|
82
|
+
+ 'Expected: { setTimeout(callback, ms), clearTimeout(handle) } — the clock a held message '
|
|
83
|
+
+ 'expires on at its TTL, rather than whenever the buffer is next touched. '
|
|
84
|
+
+ 'Fix: build this buffer through PublishLayer, which hands it the transport\'s timer rail, '
|
|
85
|
+
+ 'or pass options.timers explicitly.'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
74
88
|
this._maxSize = options.maxSize;
|
|
75
89
|
this._ttlMs = options.ttlMs;
|
|
90
|
+
this._timers = options.timers;
|
|
91
|
+
/** The one armed expiry timer, aimed at the oldest entry's deadline; null while empty. */
|
|
92
|
+
this._expiryTimer = null;
|
|
76
93
|
|
|
77
94
|
/** @type {Array<{queue:string, buffer:Buffer, options:Object, createdAt:number, priority:string, sent:boolean}>} */
|
|
78
95
|
this._items = [];
|
|
@@ -112,6 +129,7 @@ class InMemoryBuffer {
|
|
|
112
129
|
priority,
|
|
113
130
|
sent,
|
|
114
131
|
});
|
|
132
|
+
this._armExpiry(now);
|
|
115
133
|
}
|
|
116
134
|
|
|
117
135
|
/**
|
|
@@ -143,6 +161,8 @@ class InMemoryBuffer {
|
|
|
143
161
|
// Zkopírujeme a vyprázdníme interní pole, abychom se vyhnuli reentrancy problémům
|
|
144
162
|
let items = this._items.slice();
|
|
145
163
|
this._items = [];
|
|
164
|
+
// Nothing is held while the flush runs; a replay that fails re-arms through `add()`.
|
|
165
|
+
this._armExpiry(now);
|
|
146
166
|
|
|
147
167
|
if (sentEntries === 'drop') {
|
|
148
168
|
for (const item of items.filter((entry) => entry.sent)) {
|
|
@@ -194,6 +214,44 @@ class InMemoryBuffer {
|
|
|
194
214
|
return this._items.length;
|
|
195
215
|
}
|
|
196
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Aim the one expiry timer at the oldest entry's deadline — or disarm it when nothing is held.
|
|
219
|
+
*
|
|
220
|
+
* WHY A TIMER (d.1275, INFRA 531). Until d.1275 expiry ran only inside `add()`, `flush()` and
|
|
221
|
+
* `size()`. The buffer is emptied by a reconnect or by `unblocked`; an outage of a CHANNEL
|
|
222
|
+
* alone ends with neither, so an entry outlived its TTL until some unrelated publish touched
|
|
223
|
+
* the buffer — measured on the dev scheduler at 584 843 ms and 5 267 969 ms against a TTL of
|
|
224
|
+
* 300 000, and the ERROR line saying the message was lost came that late. The deadline is
|
|
225
|
+
* the TTL; the line now comes at the deadline.
|
|
226
|
+
*
|
|
227
|
+
* One timer, re-aimed on every change, rather than one per entry: entries are FIFO by
|
|
228
|
+
* `createdAt`, so the oldest one's deadline is the next one that matters. Re-aiming always
|
|
229
|
+
* clears the previous handle first, so a handle the transport's teardown already swept
|
|
230
|
+
* (`_clearAllTimers()`) is never trusted to still be armed.
|
|
231
|
+
*
|
|
232
|
+
* @param {number} now - the clock reading the caller took
|
|
233
|
+
* @private
|
|
234
|
+
*/
|
|
235
|
+
_armExpiry(now) {
|
|
236
|
+
if (this._expiryTimer !== null) {
|
|
237
|
+
this._timers.clearTimeout(this._expiryTimer);
|
|
238
|
+
this._expiryTimer = null;
|
|
239
|
+
}
|
|
240
|
+
if (this._items.length === 0) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const oldest = this._items.reduce((min, item) => Math.min(min, item.createdAt), Infinity);
|
|
244
|
+
// `_cleanupExpired()` drops an entry once its age is MORE than the TTL, so the timer
|
|
245
|
+
// fires one millisecond past the deadline.
|
|
246
|
+
const delay = Math.max(0, oldest + this._ttlMs + 1 - now);
|
|
247
|
+
this._expiryTimer = this._timers.setTimeout(() => {
|
|
248
|
+
this._expiryTimer = null;
|
|
249
|
+
const firedAt = Date.now();
|
|
250
|
+
this._cleanupExpired(firedAt);
|
|
251
|
+
this._armExpiry(firedAt);
|
|
252
|
+
}, delay);
|
|
253
|
+
}
|
|
254
|
+
|
|
197
255
|
/**
|
|
198
256
|
* Drop the entries older than the TTL — and say, as an ERROR, what was dropped (d.1145, conf
|
|
199
257
|
* `mq-publish-buffer` 001).
|
|
@@ -224,13 +282,18 @@ class InMemoryBuffer {
|
|
|
224
282
|
groups.set(key, group);
|
|
225
283
|
}
|
|
226
284
|
for (const { target, sent, count, oldestMs } of groups.values()) {
|
|
285
|
+
// `LOST after <ms>` is the phrase an operator greps for, and only a never-sent message
|
|
286
|
+
// carries it: a sent one may have been delivered by the broker (conf `mq-publish-buffer`
|
|
287
|
+
// 001 — the two are never folded into one sentence).
|
|
227
288
|
this._logger.error(
|
|
228
|
-
`[InMemoryBuffer] ${count} buffered message(s) for ${target}
|
|
229
|
-
+ `
|
|
289
|
+
`[InMemoryBuffer] ${count} buffered message(s) for ${target} `
|
|
290
|
+
+ (sent ? `expired after ${oldestMs} ms` : `LOST after ${oldestMs} ms`)
|
|
291
|
+
+ ` (buffer TTL ${this._ttlMs} ms; oldest ${oldestMs} ms), sent=${sent} - `
|
|
230
292
|
+ (sent
|
|
231
293
|
? 'the frame had been sent, so the broker may have delivered it on its own; nothing re-sends it now. '
|
|
232
294
|
: 'the frame never left this client, so the message is LOST; nothing re-sends it now. ')
|
|
233
|
-
+ 'Expected: a reconnect, or the broker unblocking the connection, within publishBufferTtlMs
|
|
295
|
+
+ 'Expected: a reconnect, or the broker unblocking the connection, within publishBufferTtlMs '
|
|
296
|
+
+ '(a recovered publisher channel alone replays nothing). '
|
|
234
297
|
+ 'Fix: find why the broker stayed away that long (an alarm: management API /api/health/checks/alarms; '
|
|
235
298
|
+ 'an outage: the connection log); a message that must survive longer belongs to a caller that keeps '
|
|
236
299
|
+ 'it durably and publishes with bufferOnFailure: false.'
|
|
@@ -22,6 +22,8 @@ class MessageBuffer {
|
|
|
22
22
|
* @param {Object} options
|
|
23
23
|
* @param {Object} [options.inMemory] - In-memory buffer options
|
|
24
24
|
* @param {Object} options.logger - Logger s info/warn/error/debug (povinny)
|
|
25
|
+
* @param {{setTimeout: Function, clearTimeout: Function}} options.timers - The expiry clock,
|
|
26
|
+
* passed through unread: `InMemoryBuffer` owns the rule that it is required (d.1275).
|
|
25
27
|
*/
|
|
26
28
|
constructor(options = {}) {
|
|
27
29
|
this._logger = assertLogger(
|
|
@@ -35,6 +37,7 @@ class MessageBuffer {
|
|
|
35
37
|
maxSize: options.inMemory?.maxSize,
|
|
36
38
|
ttlMs: options.inMemory?.ttlMs,
|
|
37
39
|
logger: this._logger,
|
|
40
|
+
timers: options.timers,
|
|
38
41
|
});
|
|
39
42
|
|
|
40
43
|
}
|
|
@@ -395,19 +395,9 @@ module.exports = {
|
|
|
395
395
|
healthCriticalCallback: callbackOrNull(
|
|
396
396
|
'Async function (health) => void, or null. Called when a health result is critical.'
|
|
397
397
|
),
|
|
398
|
-
criticalHealthShutdown:
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
'Whether a sustained critical health result makes the transport emit `health:shutdown`. '
|
|
402
|
-
+ 'Anything other than `false` means enabled. It only arms the event; ending the '
|
|
403
|
-
+ 'process remains the owner\'s decision.',
|
|
404
|
-
},
|
|
405
|
-
criticalHealthShutdownDelay: {
|
|
406
|
-
type: 'integer',
|
|
407
|
-
minimum: 0,
|
|
408
|
-
description:
|
|
409
|
-
'How long health must stay critical before that event is emitted, in milliseconds.',
|
|
410
|
-
},
|
|
398
|
+
// NO `criticalHealthShutdown` / `criticalHealthShutdownDelay` since d.1275: the
|
|
399
|
+
// `health:shutdown` event they armed reached no listener. `BaseClient` refuses both
|
|
400
|
+
// by name before this schema is applied (`NOT_A_KEY_OF_THIS_CLIENT`).
|
|
411
401
|
|
|
412
402
|
// ---------------------------------------------------------------------
|
|
413
403
|
// `queueCreationFilter` and `queueCreationCallback` stood here until d.419,
|
|
@@ -191,11 +191,9 @@ module.exports = {
|
|
|
191
191
|
healthReportCallback: null,
|
|
192
192
|
healthCriticalCallback: null,
|
|
193
193
|
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
criticalHealthShutdown: true,
|
|
198
|
-
criticalHealthShutdownDelay: 60000,
|
|
194
|
+
// NO `criticalHealthShutdown` / `criticalHealthShutdownDelay` (removed in d.1275,
|
|
195
|
+
// refused by name in `BaseClient`): they armed a `health:shutdown` event no one
|
|
196
|
+
// listened to. Critical health is reported; the process is the owner's.
|
|
199
197
|
|
|
200
198
|
// `recoveryScope` stood here until d.514, with the two callbacks it filtered
|
|
201
199
|
// (`queueCreationFilter`, `queueCreationCallback`, d.419) one batch ahead of
|
|
@@ -93,6 +93,28 @@ const INFRASTRUCTURE_PREFIX_LOOKUPS = Object.freeze({
|
|
|
93
93
|
|
|
94
94
|
const INFRASTRUCTURE_PREFIXES = Object.freeze(Object.keys(INFRASTRUCTURE_PREFIX_LOOKUPS));
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The OBSERVERS' COPIES of a run (d.1336): the queues `monitoring.workflow.fanout` copies a
|
|
98
|
+
* run's lifecycle events into for a reader that watches the run and is not part of it — the
|
|
99
|
+
* monitoring consumer's intake, the delivery endpoint's WS feed, the scheduler's end-of-run
|
|
100
|
+
* watch. A copy whose reader is away dies on its TTL and dead-letters while the run goes on,
|
|
101
|
+
* so a `message_dlq` from one of them is a lost copy of the run's telemetry, not a lost run;
|
|
102
|
+
* the monitoring consumer and the scheduler both decide on that (INFRA 559).
|
|
103
|
+
*
|
|
104
|
+
* DECLARED, NOT DERIVED — measured on this file: membership cannot be read off an entry. The
|
|
105
|
+
* binding to the fanout is each consumer's own act at boot (`bindQueueToFanout()`), written on
|
|
106
|
+
* the entries only as a comment; TTL plus a dead-letter route is what every family here
|
|
107
|
+
* carries. So the list is written ONCE, here, as `[section, key]` pairs, and every name is
|
|
108
|
+
* composed by `queueName()` — which refuses an entry this file does not declare, so a release
|
|
109
|
+
* that dropped one stops the importer instead of shortening the list. A new observer of a run
|
|
110
|
+
* joins by one line here, beside its declaration below.
|
|
111
|
+
*/
|
|
112
|
+
const WORKFLOW_OBSERVER_COPIES = Object.freeze([
|
|
113
|
+
['monitoring', 'workflow'],
|
|
114
|
+
['deliveryEvents', 'workflow.events'],
|
|
115
|
+
['scheduler', 'workflow.events']
|
|
116
|
+
]);
|
|
117
|
+
|
|
96
118
|
/** Every section declaring names under one prefix — derived, never a second list. */
|
|
97
119
|
const sectionsForPrefix = (prefix) =>
|
|
98
120
|
Object.keys(SECTION_PREFIXES).filter((section) => SECTION_PREFIXES[section] === prefix);
|
|
@@ -725,7 +747,8 @@ module.exports = {
|
|
|
725
747
|
|
|
726
748
|
/**
|
|
727
749
|
* monitoring.workflow.fanout - Fanout exchange for workflow events
|
|
728
|
-
* Publishers send to this exchange;
|
|
750
|
+
* Publishers send to this exchange; the queues bound to it — the observers' copies of a
|
|
751
|
+
* run — are listed once, in `WORKFLOW_OBSERVER_COPIES` at the top of this file.
|
|
729
752
|
*/
|
|
730
753
|
'workflow.fanout': {
|
|
731
754
|
type: 'exchange',
|
|
@@ -1302,6 +1325,17 @@ module.exports = {
|
|
|
1302
1325
|
return INFRASTRUCTURE_PREFIXES;
|
|
1303
1326
|
},
|
|
1304
1327
|
|
|
1328
|
+
/**
|
|
1329
|
+
* The names of the observers' copies of a run — `WORKFLOW_OBSERVER_COPIES` above, each
|
|
1330
|
+
* composed by `queueName()`. Published as `topology.workflowObserverCopyQueues`; this is
|
|
1331
|
+
* the one place the list is read from its declaration (d.1336).
|
|
1332
|
+
* @returns {ReadonlyArray<string>} Frozen queue names, in declaration order.
|
|
1333
|
+
* @throws {ValidationError} when a listed entry is not declared in this file.
|
|
1334
|
+
*/
|
|
1335
|
+
workflowObserverCopyQueues() {
|
|
1336
|
+
return Object.freeze(WORKFLOW_OBSERVER_COPIES.map(([section, key]) => this.queueName(section, key)));
|
|
1337
|
+
},
|
|
1338
|
+
|
|
1305
1339
|
/**
|
|
1306
1340
|
* Check if queue name is an infrastructure queue.
|
|
1307
1341
|
*
|
package/src/index.js
CHANGED
|
@@ -23,6 +23,7 @@ const {
|
|
|
23
23
|
} = require('./utils/errorHandler');
|
|
24
24
|
const {
|
|
25
25
|
TransientPublishError,
|
|
26
|
+
RecoveryEndedPublishError,
|
|
26
27
|
PermanentPublishError,
|
|
27
28
|
QueueNotFoundError,
|
|
28
29
|
classifyPublishError,
|
|
@@ -87,6 +88,7 @@ module.exports.errorCodes = Object.freeze({
|
|
|
87
88
|
});
|
|
88
89
|
module.exports.publishErrors = {
|
|
89
90
|
TransientPublishError,
|
|
91
|
+
RecoveryEndedPublishError,
|
|
90
92
|
PermanentPublishError,
|
|
91
93
|
QueueNotFoundError,
|
|
92
94
|
classifyPublishError,
|
|
@@ -139,6 +141,26 @@ module.exports.queueConfig = queueConfig;
|
|
|
139
141
|
* gone. Frozen, because an importer must not be able to rewrite platform topology.
|
|
140
142
|
*/
|
|
141
143
|
module.exports.topology = Object.freeze({
|
|
144
|
+
/**
|
|
145
|
+
* The queue every infrastructure service publishes its health checks to and the
|
|
146
|
+
* registry drains (`docs/standards/queue-ownership.md` § Infrastructure Queue Creation
|
|
147
|
+
* Order). Declared in `queueConfig` all along; composed here since d.1307, because the
|
|
148
|
+
* name was carried as a literal one floor up — `@onlineapps/service-common`
|
|
149
|
+
* `src/defaults.js` `infrastructureHealthQueueName`, which the registry, the health
|
|
150
|
+
* publisher and the wrapper read and call "the one owner" — so the platform had two
|
|
151
|
+
* declaring owners of one wire name (finding d.1306). Which side cites this export is
|
|
152
|
+
* the owner's decision (d.1306 A/B); the export is the step both ways share.
|
|
153
|
+
*/
|
|
154
|
+
infrastructureHealthChecksQueue: queueConfig.queueName('infrastructure', 'health.checks'),
|
|
155
|
+
/**
|
|
156
|
+
* The observers' copies of a run (d.1336, INFRA 559): the queues bound to
|
|
157
|
+
* `monitoring.workflow.fanout` for a reader that watches the run and is not part of it.
|
|
158
|
+
* A dead letter from one of them is a lost copy, not a lost run — the monitoring consumer
|
|
159
|
+
* (`observer_copy`) and the scheduler both decide on that and selected the queues by hand,
|
|
160
|
+
* twice. Declared once in `queueConfig` (`WORKFLOW_OBSERVER_COPIES`), names composed by
|
|
161
|
+
* `queueName()`; a frozen array.
|
|
162
|
+
*/
|
|
163
|
+
workflowObserverCopyQueues: queueConfig.workflowObserverCopyQueues(),
|
|
142
164
|
/** Fanout exchange: registry publishes infrastructure health events here. */
|
|
143
165
|
infrastructureHealthEventsExchange: queueConfig.queueName('infrastructure', 'health.events'),
|
|
144
166
|
/** The monitoring consumer's queue bound to that exchange. */
|