@onlineapps/mq-client-core 2.0.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/config.js
CHANGED
|
@@ -21,6 +21,35 @@ const runtimeCfg = createRuntimeConfig({
|
|
|
21
21
|
// Client behavior
|
|
22
22
|
heartbeat: { env: 'RABBITMQ_HEARTBEAT', defaultKey: 'heartbeatSeconds', type: 'number' },
|
|
23
23
|
serviceName: { env: 'SERVICE_NAME', defaultKey: 'serviceName' },
|
|
24
|
+
|
|
25
|
+
// Connection-level recovery budget. Resolved here (explicit → env →
|
|
26
|
+
// module default) instead of an inline `|| 10` in the transport, so the
|
|
27
|
+
// value has one owner and one documented override path.
|
|
28
|
+
maxReconnectAttempts: {
|
|
29
|
+
env: 'RABBITMQ_MAX_RECONNECT_ATTEMPTS',
|
|
30
|
+
defaultKey: 'maxReconnectAttempts',
|
|
31
|
+
type: 'number',
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
// How many of those budgets the client spends before it declares the
|
|
35
|
+
// connection permanently lost. Same class, same three legs, same reason —
|
|
36
|
+
// one owner and one documented override path (`./defaults.js`).
|
|
37
|
+
maxReconnectCycles: {
|
|
38
|
+
env: 'RABBITMQ_MAX_RECONNECT_CYCLES',
|
|
39
|
+
defaultKey: 'maxReconnectCycles',
|
|
40
|
+
type: 'number',
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
// Per-message delivery budget of `consume()`: how many times a handler may
|
|
44
|
+
// run for one message before it is rejected into `<svc>.dlq`. Same class as
|
|
45
|
+
// the reconnect budget — resolved here (explicit → env → module default) so
|
|
46
|
+
// the value has one owner and one documented override path, instead of a
|
|
47
|
+
// literal in the consume path. See ./config/deliveryPolicy.js.
|
|
48
|
+
maxDeliveryAttempts: {
|
|
49
|
+
env: 'RABBITMQ_MAX_DELIVERY_ATTEMPTS',
|
|
50
|
+
defaultKey: 'maxDeliveryAttempts',
|
|
51
|
+
type: 'number',
|
|
52
|
+
},
|
|
24
53
|
}
|
|
25
54
|
});
|
|
26
55
|
|
package/src/defaults.js
CHANGED
|
@@ -12,6 +12,49 @@
|
|
|
12
12
|
module.exports = {
|
|
13
13
|
heartbeatSeconds: 30,
|
|
14
14
|
serviceName: 'oa-service',
|
|
15
|
+
|
|
16
|
+
// How many times connection-level recovery retries before the connection is
|
|
17
|
+
// declared permanently lost. Client BEHAVIOUR, so it is owned here (the same
|
|
18
|
+
// class as heartbeat) and overridable by RABBITMQ_MAX_RECONNECT_ATTEMPTS or
|
|
19
|
+
// explicit config — unlike topology, which is never defaulted (see above).
|
|
20
|
+
// The budget is finite on purpose: an unbounded retry loop keeps a process
|
|
21
|
+
// alive that can no longer do its job, and a restart re-verifies every
|
|
22
|
+
// dependency at boot.
|
|
23
|
+
maxReconnectAttempts: 10,
|
|
24
|
+
|
|
25
|
+
// How many RECOVERY CYCLES the client spends before the connection is declared
|
|
26
|
+
// permanently lost. One cycle is the whole `maxReconnectAttempts` budget above;
|
|
27
|
+
// the first is started by the death of the connection, every further one by the
|
|
28
|
+
// next USE of the client (publish, consume, health check) — nothing polls in
|
|
29
|
+
// between, so a client nobody uses costs nothing while the broker is away.
|
|
30
|
+
//
|
|
31
|
+
// The cap exists because the owner forbade both ends of the scale
|
|
32
|
+
// (`docs/governance/confirmations/mq-client-lifecycle-contract.md` 001 point 3):
|
|
33
|
+
// "give up forever" after one spent budget is forbidden, and so is retrying
|
|
34
|
+
// without end, which keeps a process alive that can no longer do its job. This
|
|
35
|
+
// number is where the two meet.
|
|
36
|
+
//
|
|
37
|
+
// The value 3 is not new: it is the same decision `maxDeliveryAttempts` below
|
|
38
|
+
// already answers with — how many whole retries ONE thing gets before it is
|
|
39
|
+
// given up on — and two answers to one question must not differ.
|
|
40
|
+
maxReconnectCycles: 3,
|
|
41
|
+
|
|
42
|
+
// How many times a consumer's handler may run for ONE message before the
|
|
43
|
+
// message is rejected into `<svc>.dlq` (`config/deliveryPolicy.js`). Client
|
|
44
|
+
// BEHAVIOUR, the same class as the reconnect budget above, so it is owned
|
|
45
|
+
// here and overridable on all THREE legs the resolver declares — the explicit
|
|
46
|
+
// `maxDeliveryAttempts` of the client configuration (d.292 §6), the environment
|
|
47
|
+
// variable RABBITMQ_MAX_DELIVERY_ATTEMPTS, and the narrowest of them, the
|
|
48
|
+
// per-call `consume(queue, handler, { maxAttempts })`. Never a literal in the
|
|
49
|
+
// consume path, which is how the value used to be absent altogether: the
|
|
50
|
+
// transport requeued every failure unconditionally and no message ever reached
|
|
51
|
+
// the DLQ.
|
|
52
|
+
//
|
|
53
|
+
// The value 3 is not new: it is the budget `@onlineapps/error-handler-core`
|
|
54
|
+
// already declares for the same decision (`src/RetryHandler.js`, `maxRetries = 3`,
|
|
55
|
+
// "Max attempts; integer >= 1"). Two libraries answering one question must not
|
|
56
|
+
// answer it differently.
|
|
57
|
+
maxDeliveryAttempts: 3,
|
|
15
58
|
};
|
|
16
59
|
|
|
17
60
|
|
package/src/index.js
CHANGED
|
@@ -14,13 +14,15 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const BaseClient = require('./BaseClient');
|
|
17
|
-
const RabbitMQClient = require('./transports/rabbitmqClient');
|
|
18
17
|
const {
|
|
19
18
|
ValidationError,
|
|
20
19
|
ConnectionError,
|
|
21
20
|
PublishError,
|
|
22
21
|
ConsumeError,
|
|
23
22
|
SerializationError,
|
|
23
|
+
CONNECTION_CLOSED_UNEXPECTEDLY,
|
|
24
|
+
CONSUMER_QUEUE_MISSING,
|
|
25
|
+
CONSUMER_DEAD_LETTER_ROUTE_MISSING,
|
|
24
26
|
} = require('./utils/errorHandler');
|
|
25
27
|
const {
|
|
26
28
|
TransientPublishError,
|
|
@@ -34,6 +36,9 @@ const {
|
|
|
34
36
|
publishToMonitoringServices,
|
|
35
37
|
isQueueUnavailableError,
|
|
36
38
|
} = require('./monitoring-publish');
|
|
39
|
+
const queueConfig = require('./config/queueConfig');
|
|
40
|
+
const deliveryPolicy = require('./config/deliveryPolicy');
|
|
41
|
+
const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('./utils/redactCredentials');
|
|
37
42
|
|
|
38
43
|
// Export BaseClient as default (constructor), with additional named exports
|
|
39
44
|
// NOTE: When destructuring, use: const { BaseClient } = require('@onlineapps/mq-client-core');
|
|
@@ -41,7 +46,12 @@ const {
|
|
|
41
46
|
// BaseClient must be a constructor, so we export it directly and attach other exports as properties
|
|
42
47
|
module.exports = BaseClient;
|
|
43
48
|
module.exports.BaseClient = BaseClient;
|
|
44
|
-
|
|
49
|
+
// NO `RabbitMQClient` export. The transport is chosen by `type` and built by
|
|
50
|
+
// `transports/transportFactory.js` — the only place in the whole workspace that
|
|
51
|
+
// calls `new RabbitMQClient` (measured 2026-09-14). A caller constructing it
|
|
52
|
+
// directly would skip config composition, schema validation and the instance
|
|
53
|
+
// registry `disconnectAll()` is built on, so the export was a door nobody walked
|
|
54
|
+
// through and one that led past the front desk (d.341).
|
|
45
55
|
module.exports.errors = {
|
|
46
56
|
ValidationError,
|
|
47
57
|
ConnectionError,
|
|
@@ -49,6 +59,20 @@ module.exports.errors = {
|
|
|
49
59
|
ConsumeError,
|
|
50
60
|
SerializationError,
|
|
51
61
|
};
|
|
62
|
+
/**
|
|
63
|
+
* The machine-readable classification of the errors this package emits, for the
|
|
64
|
+
* listeners that must decide what one MEANS. A decision taken on the message text
|
|
65
|
+
* breaks the moment somebody rewords the sentence, and nothing reports the break
|
|
66
|
+
* (`architecture-principles.md` §3) — which is exactly how the client's own reconnect
|
|
67
|
+
* wait ignored nothing at all for as long as it compared strings (d.164).
|
|
68
|
+
*
|
|
69
|
+
* The values are owned by `./utils/errorHandler`; this is a re-export, never a copy.
|
|
70
|
+
*/
|
|
71
|
+
module.exports.errorCodes = Object.freeze({
|
|
72
|
+
CONNECTION_CLOSED_UNEXPECTEDLY,
|
|
73
|
+
CONSUMER_QUEUE_MISSING,
|
|
74
|
+
CONSUMER_DEAD_LETTER_ROUTE_MISSING,
|
|
75
|
+
});
|
|
52
76
|
module.exports.publishErrors = {
|
|
53
77
|
TransientPublishError,
|
|
54
78
|
PermanentPublishError,
|
|
@@ -61,4 +85,69 @@ module.exports.monitoring = {
|
|
|
61
85
|
publishToMonitoringServices,
|
|
62
86
|
isQueueUnavailableError,
|
|
63
87
|
};
|
|
88
|
+
/** The only public way to reach the central queue classification; `src/config/queueConfig` is an internal path, not a contract. */
|
|
89
|
+
module.exports.queueConfig = queueConfig;
|
|
90
|
+
/**
|
|
91
|
+
* Platform topology names that travel ON THE WIRE between services.
|
|
92
|
+
*
|
|
93
|
+
* A name three services must agree on has to have ONE owner, or it has three: batch
|
|
94
|
+
* 243g-C correctly removed the per-service env override for the health-events
|
|
95
|
+
* exchange — a name overridden on one side of a fanout loses the events silently —
|
|
96
|
+
* and then left the literal as a private constant in the registry, the delivery
|
|
97
|
+
* endpoint and the monitoring consumer. Nothing compared the three (INFRA request
|
|
98
|
+
* 2026-09-12, `api/shared/TODO.md`).
|
|
99
|
+
*
|
|
100
|
+
* Every value is composed from the entry that DECLARES it (`queueConfig.queueName()`),
|
|
101
|
+
* so it is the same fact as the queue configuration rather than a copy alongside it;
|
|
102
|
+
* a rename in `queueConfig` moves the export with it and fails fast if the entry is
|
|
103
|
+
* gone. Frozen, because an importer must not be able to rewrite platform topology.
|
|
104
|
+
*/
|
|
105
|
+
module.exports.topology = Object.freeze({
|
|
106
|
+
/** Fanout exchange: registry publishes infrastructure health events here. */
|
|
107
|
+
infrastructureHealthEventsExchange: queueConfig.queueName('infrastructure', 'health.events'),
|
|
108
|
+
/** The monitoring consumer's queue bound to that exchange. */
|
|
109
|
+
monitoringInfrastructureHealthEventsQueue: queueConfig.queueName('monitoring', 'infrastructure.health.events'),
|
|
110
|
+
/** The delivery endpoint's queue bound to that exchange, for WS push. */
|
|
111
|
+
deliveryHealthEventsQueue: queueConfig.queueName('deliveryEvents', 'health.events'),
|
|
112
|
+
/**
|
|
113
|
+
* The delivery endpoint's queue for resource-changed events, bound to the
|
|
114
|
+
* `monitoring.resource` exchange by the routing pattern `resource.changed.#`.
|
|
115
|
+
* Declared in `queueConfig` since d.278; the endpoint carries the same literal as
|
|
116
|
+
* a private default, which is the shape d.270 gave an owner for the health-events
|
|
117
|
+
* names (`api/docs/governance/confirmations/mq-consumer-contract.md` 003 — the
|
|
118
|
+
* endpoint declaring its queues from `queueConfig` is INFRA's step).
|
|
119
|
+
*/
|
|
120
|
+
deliveryResourceEventsQueue: queueConfig.queueName('deliveryEvents', 'resource.events')
|
|
121
|
+
});
|
|
122
|
+
/**
|
|
123
|
+
* The dead-letter policy of `consume()` as a contract, not as an implementation
|
|
124
|
+
* detail: `ATTEMPTS_HEADER` is a name that travels ON THE WIRE, so every reader
|
|
125
|
+
* of a dead-lettered message — the DLQ dashboard, an operator's script, the
|
|
126
|
+
* monitoring peek — reads it from here instead of retyping the literal. The
|
|
127
|
+
* classification vocabulary travels with it, because a caller's `classify` must
|
|
128
|
+
* return one of those two words.
|
|
129
|
+
*/
|
|
130
|
+
module.exports.deliveryPolicy = deliveryPolicy;
|
|
131
|
+
/**
|
|
132
|
+
* Redaction of the credential a connection URL carries, as a DECLARED export.
|
|
133
|
+
*
|
|
134
|
+
* `RABBITMQ_URL` is the single rail for the broker account, so every place that
|
|
135
|
+
* renders it verbatim leaks that account into the service log and from there into
|
|
136
|
+
* Loki, where it is durable and searchable. The platform answer is one function:
|
|
137
|
+
* userinfo out, scheme/host/port/vhost in, and a fixed placeholder for a value that
|
|
138
|
+
* is not a parseable URL.
|
|
139
|
+
*
|
|
140
|
+
* It is exported because it was already written three times — here, in
|
|
141
|
+
* `shared/service-common/src/redactUrl.js` (d.245/d.245b) and in
|
|
142
|
+
* `shared/connector/conn-orch-registry/src/redactUrl.js` (d.446), whose header says
|
|
143
|
+
* plainly that it stayed local only because this package declared no redactor and
|
|
144
|
+
* masked the password alone. Two semantics for one concern are two rails
|
|
145
|
+
* (`change-discipline.md` § One rail per concern); d.448 made them one and put it
|
|
146
|
+
* where a dependant can import it instead of copying it.
|
|
147
|
+
*
|
|
148
|
+
* The placeholder travels with the function: a caller comparing against it must
|
|
149
|
+
* read the same literal, not retype it.
|
|
150
|
+
*/
|
|
151
|
+
module.exports.redactUrl = redactUrl;
|
|
152
|
+
module.exports.UNPARSEABLE_PLACEHOLDER = UNPARSEABLE_PLACEHOLDER;
|
|
64
153
|
|
|
@@ -5,7 +5,9 @@ const {
|
|
|
5
5
|
PermanentPublishError,
|
|
6
6
|
QueueNotFoundError,
|
|
7
7
|
} = require('../utils/publishErrors');
|
|
8
|
+
const { ValidationError, PublishError } = require('../utils/errorHandler');
|
|
8
9
|
const MessageBuffer = require('../buffer/MessageBuffer');
|
|
10
|
+
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* PublishLayer - čistá vrstva pro publish s retry + buffer
|
|
@@ -19,7 +21,7 @@ class PublishLayer {
|
|
|
19
21
|
/**
|
|
20
22
|
* @param {Object} options
|
|
21
23
|
* @param {Object} options.client - Instance RabbitMQClient
|
|
22
|
-
* @param {Object}
|
|
24
|
+
* @param {Object} options.logger - Logger s info/warn/error/debug (povinný)
|
|
23
25
|
* @param {Object} [options.bufferConfig] - Konfigurace pro MessageBuffer
|
|
24
26
|
* @param {boolean} [options.retryEnabled=true] - Zapnout retry
|
|
25
27
|
* @param {number} [options.maxRetries=3] - Max počet pokusů
|
|
@@ -29,25 +31,49 @@ class PublishLayer {
|
|
|
29
31
|
*/
|
|
30
32
|
constructor(options = {}) {
|
|
31
33
|
if (!options.client) {
|
|
32
|
-
throw new
|
|
34
|
+
throw new ValidationError(
|
|
35
|
+
'[PublishLayer] options.client is required - Expected: the RabbitMQClient this layer publishes through. '
|
|
36
|
+
+ 'Fix: construct it as new PublishLayer({ client, logger }).'
|
|
37
|
+
);
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
this._client = options.client;
|
|
36
|
-
this._logger =
|
|
41
|
+
this._logger = assertLogger(
|
|
42
|
+
'PublishLayer',
|
|
43
|
+
options.logger,
|
|
44
|
+
'the layer reports every publish retry, every buffered message and every flush',
|
|
45
|
+
'pass options.logger (the transport forwards the one your service built)'
|
|
46
|
+
);
|
|
37
47
|
|
|
38
|
-
// Retry
|
|
48
|
+
// Retry configuration: what the caller wrote, else what the CLIENT resolved.
|
|
49
|
+
// No literal of this layer's own — every one of these numbers is declared and
|
|
50
|
+
// defaulted in `config/defaultConfig.js`, composed before validation, and the
|
|
51
|
+
// transport constructor reads it from there (d.292 §5).
|
|
52
|
+
//
|
|
53
|
+
// Asked with `=== undefined`, never with `||`. `||` means "if falsy", so the
|
|
54
|
+
// three numbers below lost a legally written `0` — `publishMaxRetries: 0`
|
|
55
|
+
// means "do not retry a publish", and it arrived here as 3. d.292 removed
|
|
56
|
+
// exactly that shape from the transport and said in as many words that this
|
|
57
|
+
// layer still carried its own copies; this is that floor (d.311).
|
|
39
58
|
this._retryEnabled = options.retryEnabled !== undefined
|
|
40
59
|
? options.retryEnabled
|
|
41
60
|
: (this._client._publishRetryEnabled !== false);
|
|
42
|
-
this._maxRetries = options.maxRetries
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
this.
|
|
61
|
+
this._maxRetries = options.maxRetries !== undefined
|
|
62
|
+
? options.maxRetries
|
|
63
|
+
: this._client._publishMaxRetries;
|
|
64
|
+
this._retryBaseDelay = options.retryBaseDelay !== undefined
|
|
65
|
+
? options.retryBaseDelay
|
|
66
|
+
: this._client._publishRetryBaseDelay;
|
|
67
|
+
this._retryMaxDelay = options.retryMaxDelay !== undefined
|
|
68
|
+
? options.retryMaxDelay
|
|
69
|
+
: this._client._publishRetryMaxDelay;
|
|
70
|
+
this._retryBackoffMultiplier = options.retryBackoffMultiplier !== undefined
|
|
71
|
+
? options.retryBackoffMultiplier
|
|
72
|
+
: this._client._publishRetryBackoffMultiplier;
|
|
46
73
|
|
|
47
74
|
// Buffer
|
|
48
75
|
this._buffer = new MessageBuffer({
|
|
49
76
|
inMemory: options.bufferConfig?.inMemory,
|
|
50
|
-
persistent: options.bufferConfig?.persistent,
|
|
51
77
|
logger: this._logger,
|
|
52
78
|
});
|
|
53
79
|
}
|
|
@@ -59,14 +85,16 @@ class PublishLayer {
|
|
|
59
85
|
* @param {Object} [options]
|
|
60
86
|
*/
|
|
61
87
|
async publish(queue, buffer, options = {}) {
|
|
88
|
+
// `priority` still orders the buffer's replay (critical first). What it no
|
|
89
|
+
// longer does is choose a DIFFERENT buffer: the persistent one was a stub
|
|
90
|
+
// that stored nothing, so routing critical messages to it dropped them
|
|
91
|
+
// (d.343).
|
|
62
92
|
const priority = options.priority || 'normal';
|
|
63
|
-
const usePersistentBuffer =
|
|
64
|
-
priority === 'critical' && !!this._client._config?.persistentBufferEnabled;
|
|
65
93
|
|
|
66
94
|
if (this._retryEnabled) {
|
|
67
|
-
return await this._publishWithRetry(queue, buffer, options, priority
|
|
95
|
+
return await this._publishWithRetry(queue, buffer, options, priority);
|
|
68
96
|
} else {
|
|
69
|
-
return await this._publishOnce(queue, buffer, options, priority
|
|
97
|
+
return await this._publishOnce(queue, buffer, options, priority);
|
|
70
98
|
}
|
|
71
99
|
}
|
|
72
100
|
|
|
@@ -74,11 +102,11 @@ class PublishLayer {
|
|
|
74
102
|
* Single publish attempt - deleguje na RabbitMQClient._publishOnce()
|
|
75
103
|
* @private
|
|
76
104
|
*/
|
|
77
|
-
async _publishOnce(queue, buffer, options, priority
|
|
105
|
+
async _publishOnce(queue, buffer, options, priority) {
|
|
78
106
|
try {
|
|
79
107
|
await this._client._publishOnce(queue, buffer, options);
|
|
80
108
|
} catch (err) {
|
|
81
|
-
return await this._handlePublishError(err, queue, buffer, options, priority
|
|
109
|
+
return await this._handlePublishError(err, queue, buffer, options, priority);
|
|
82
110
|
}
|
|
83
111
|
}
|
|
84
112
|
|
|
@@ -86,7 +114,7 @@ class PublishLayer {
|
|
|
86
114
|
* Publish s retry logikou
|
|
87
115
|
* @private
|
|
88
116
|
*/
|
|
89
|
-
async _publishWithRetry(queue, buffer, options, priority
|
|
117
|
+
async _publishWithRetry(queue, buffer, options, priority) {
|
|
90
118
|
let lastError = null;
|
|
91
119
|
let attempt = 0;
|
|
92
120
|
|
|
@@ -102,7 +130,7 @@ class PublishLayer {
|
|
|
102
130
|
lastError: lastError?.message
|
|
103
131
|
});
|
|
104
132
|
if (attempt > 1) {
|
|
105
|
-
this._logger
|
|
133
|
+
this._logger.debug(`[PublishLayer] Retry attempt ${attempt}/${this._maxRetries} for queue "${queue}"`);
|
|
106
134
|
}
|
|
107
135
|
|
|
108
136
|
await this._client._publishOnce(queue, buffer, options);
|
|
@@ -114,7 +142,7 @@ class PublishLayer {
|
|
|
114
142
|
totalAttempts: attempt
|
|
115
143
|
});
|
|
116
144
|
if (attempt > 1) {
|
|
117
|
-
this._logger
|
|
145
|
+
this._logger.info(`[PublishLayer] ✓ Published successfully after ${attempt} attempts for queue "${queue}"`);
|
|
118
146
|
}
|
|
119
147
|
|
|
120
148
|
return; // Success
|
|
@@ -139,14 +167,14 @@ class PublishLayer {
|
|
|
139
167
|
if (attempt < this._maxRetries) {
|
|
140
168
|
// Wait for reconnection if in progress
|
|
141
169
|
if (this._client._reconnecting) {
|
|
142
|
-
this._logger
|
|
170
|
+
this._logger.debug(`[PublishLayer] Connection reconnecting, waiting before retry ${attempt + 1}/${this._maxRetries}...`);
|
|
143
171
|
try {
|
|
144
172
|
await this._client._waitForReconnection();
|
|
145
|
-
this._logger
|
|
173
|
+
this._logger.debug(`[PublishLayer] ✓ Reconnection completed, proceeding with retry ${attempt + 1}/${this._maxRetries}`);
|
|
146
174
|
continue; // Retry immediately after reconnect
|
|
147
175
|
} catch (reconnectErr) {
|
|
148
176
|
// Reconnection failed - buffer and fail
|
|
149
|
-
await this._bufferMessage(queue, buffer, options, priority,
|
|
177
|
+
await this._bufferMessage(queue, buffer, options, priority, err);
|
|
150
178
|
this._client.emit('publish:failed', {
|
|
151
179
|
queue,
|
|
152
180
|
attempt,
|
|
@@ -154,7 +182,13 @@ class PublishLayer {
|
|
|
154
182
|
retryable: false,
|
|
155
183
|
reconnectFailed: true
|
|
156
184
|
});
|
|
157
|
-
throw new
|
|
185
|
+
throw new PublishError(
|
|
186
|
+
`[PublishLayer] Publish failed: reconnection failed after ${attempt} attempts for queue "${queue}" - `
|
|
187
|
+
+ 'Expected: the transport to re-establish the connection before the retry. '
|
|
188
|
+
+ 'Fix: read error.cause for the reconnect reason; the message itself is buffered, so it is not lost.',
|
|
189
|
+
queue,
|
|
190
|
+
reconnectErr
|
|
191
|
+
);
|
|
158
192
|
}
|
|
159
193
|
}
|
|
160
194
|
|
|
@@ -163,13 +197,13 @@ class PublishLayer {
|
|
|
163
197
|
this._retryBaseDelay * Math.pow(this._retryBackoffMultiplier, attempt - 1),
|
|
164
198
|
this._retryMaxDelay
|
|
165
199
|
);
|
|
166
|
-
this._logger
|
|
200
|
+
this._logger.warn(`[PublishLayer] Retryable error for queue "${queue}" (attempt ${attempt}/${this._maxRetries}), waiting ${delay}ms before retry: ${err.message}`);
|
|
167
201
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
168
202
|
continue;
|
|
169
203
|
}
|
|
170
204
|
|
|
171
205
|
// Max retries exceeded - buffer and fail
|
|
172
|
-
await this._bufferMessage(queue, buffer, options, priority,
|
|
206
|
+
await this._bufferMessage(queue, buffer, options, priority, err);
|
|
173
207
|
this._client.emit('publish:failed', {
|
|
174
208
|
queue,
|
|
175
209
|
attempt,
|
|
@@ -177,7 +211,13 @@ class PublishLayer {
|
|
|
177
211
|
retryable: true,
|
|
178
212
|
maxRetriesExceeded: true
|
|
179
213
|
});
|
|
180
|
-
throw new
|
|
214
|
+
throw new PublishError(
|
|
215
|
+
`[PublishLayer] Publish failed after ${attempt} attempts for queue "${queue}" - `
|
|
216
|
+
+ 'Expected: a retryable broker error to clear within maxRetries. '
|
|
217
|
+
+ 'Fix: read error.cause for the last broker reason; the message is buffered, so it is not lost.',
|
|
218
|
+
queue,
|
|
219
|
+
err
|
|
220
|
+
);
|
|
181
221
|
}
|
|
182
222
|
|
|
183
223
|
// Unknown error - fail immediately
|
|
@@ -185,21 +225,30 @@ class PublishLayer {
|
|
|
185
225
|
}
|
|
186
226
|
}
|
|
187
227
|
|
|
188
|
-
throw
|
|
228
|
+
throw (
|
|
229
|
+
lastError
|
|
230
|
+
|| new PublishError(
|
|
231
|
+
`[PublishLayer] Publish failed for queue "${queue}" after ${attempt} attempts - `
|
|
232
|
+
+ 'Expected: the retry loop to end with either a success or the error of the last attempt. '
|
|
233
|
+
+ 'Fix: this is a defect in PublishLayer, not in the caller — report it with the queue name and attempt count.',
|
|
234
|
+
queue,
|
|
235
|
+
null
|
|
236
|
+
)
|
|
237
|
+
);
|
|
189
238
|
}
|
|
190
239
|
|
|
191
240
|
/**
|
|
192
241
|
* Handle publish error - buffer transient errors
|
|
193
242
|
* @private
|
|
194
243
|
*/
|
|
195
|
-
async _handlePublishError(err, queue, buffer, options, priority
|
|
244
|
+
async _handlePublishError(err, queue, buffer, options, priority) {
|
|
196
245
|
if (err instanceof QueueNotFoundError) {
|
|
197
|
-
this._logger
|
|
246
|
+
this._logger.error(`[PublishLayer] QueueNotFoundError for '${queue}' (infra=${err.isInfrastructure}): ${err.message}`);
|
|
198
247
|
throw err;
|
|
199
248
|
}
|
|
200
249
|
|
|
201
250
|
if (err instanceof TransientPublishError) {
|
|
202
|
-
await this._bufferMessage(queue, buffer, options, priority,
|
|
251
|
+
await this._bufferMessage(queue, buffer, options, priority, err);
|
|
203
252
|
throw err;
|
|
204
253
|
}
|
|
205
254
|
|
|
@@ -210,16 +259,13 @@ class PublishLayer {
|
|
|
210
259
|
* Buffer message při transient error
|
|
211
260
|
* @private
|
|
212
261
|
*/
|
|
213
|
-
async _bufferMessage(queue, buffer, options, priority,
|
|
262
|
+
async _bufferMessage(queue, buffer, options, priority, err) {
|
|
214
263
|
try {
|
|
215
|
-
await this._buffer.add(queue, buffer, options, {
|
|
216
|
-
|
|
217
|
-
persistent: usePersistentBuffer,
|
|
218
|
-
});
|
|
219
|
-
this._logger?.warn?.(`[PublishLayer] Buffered message for queue '${queue}' due to transient error: ${err.message}`);
|
|
264
|
+
await this._buffer.add(queue, buffer, options, { priority });
|
|
265
|
+
this._logger.warn(`[PublishLayer] Buffered message for queue '${queue}' due to transient error: ${err.message}`);
|
|
220
266
|
this._client.emit('publish:buffered', { queue });
|
|
221
267
|
} catch (bufferErr) {
|
|
222
|
-
this._logger
|
|
268
|
+
this._logger.error(`[PublishLayer] Failed to buffer message for queue '${queue}': ${bufferErr.message}`);
|
|
223
269
|
}
|
|
224
270
|
}
|
|
225
271
|
|
|
@@ -232,7 +278,7 @@ class PublishLayer {
|
|
|
232
278
|
await this._client._publishOnce(queue, buf, mergedOptions);
|
|
233
279
|
});
|
|
234
280
|
|
|
235
|
-
this._logger
|
|
281
|
+
this._logger.info(`[PublishLayer] Flushed ${result} buffered message(s)`);
|
|
236
282
|
return result;
|
|
237
283
|
}
|
|
238
284
|
}
|
|
@@ -2,15 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* PublishMonitor - tracking a metriky pro publish operace
|
|
5
|
+
*
|
|
6
|
+
* NO LOGGER. This component counts and answers with return values
|
|
7
|
+
* (`getMetrics()`, `getPrometheusMetrics()`); it has nothing to report on its
|
|
8
|
+
* own, and it wrote nothing anywhere. Until 2026-09-07 it took an optional
|
|
9
|
+
* `options.logger` defaulted to `console` and never called it once — a required
|
|
10
|
+
* dependency with no consumer, which is the defect owner confirmation
|
|
11
|
+
* `docs/governance/confirmations/connector-logger-contract.md` 002 named: give
|
|
12
|
+
* such a logger a real consumer, or drop the declaration. A counter has nothing
|
|
13
|
+
* to say, so the declaration goes (`change-discipline.md` § Removing something
|
|
14
|
+
* removes its declaration).
|
|
5
15
|
*/
|
|
6
16
|
class PublishMonitor {
|
|
7
17
|
/**
|
|
8
|
-
* @param {Object} options
|
|
9
|
-
* @param {Object} [options.logger] - Logger
|
|
18
|
+
* @param {Object} [options] - Reserved; this monitor takes no dependencies.
|
|
10
19
|
*/
|
|
11
20
|
constructor(options = {}) {
|
|
12
|
-
this._logger = options.logger || console;
|
|
13
|
-
|
|
14
21
|
// Metrics
|
|
15
22
|
this._metrics = {
|
|
16
23
|
attempts: 0,
|