@onlineapps/mq-client-core 4.1.0 → 5.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 +766 -0
- package/README.md +226 -11
- package/package.json +1 -1
- package/src/BaseClient.js +105 -29
- package/src/config/configSchema.js +21 -4
- package/src/config/defaultConfig.js +14 -4
- package/src/config/deliveryPolicy.js +86 -6
- package/src/config/queueConfig.js +141 -60
- package/src/config/recurrencesSchema.js +121 -0
- package/src/config.js +5 -1
- package/src/defaults.js +7 -1
- package/src/index.js +67 -4
- package/src/layers/PublishLayer.js +113 -5
- package/src/monitoring-publish.js +89 -40
- package/src/scheduler-request.js +502 -0
- package/src/transports/rabbitmqClient.js +1303 -318
- package/src/utils/decodeMessage.js +123 -0
- package/src/utils/publishErrors.js +256 -17
- package/jest.config.js +0 -15
- package/jest.integration.config.js +0 -16
- package/src/workers/RecoveryWorker.js +0 -130
|
@@ -51,16 +51,22 @@
|
|
|
51
51
|
* environment form goes through numeric coercion, because an environment
|
|
52
52
|
* variable has no other shape (`../config.js`, `../defaults.js`).
|
|
53
53
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* (`
|
|
58
|
-
*
|
|
59
|
-
*
|
|
54
|
+
* The option rules — `requeueOnError` a boolean, `requeueOnError: false` meaning
|
|
55
|
+
* one attempt and contradicting any other `maxAttempts`, the budget an integer
|
|
56
|
+
* >= 1, `classify` a function — live in `validateConsumeOptions` below, the one
|
|
57
|
+
* owner the transport's `consume()` calls and the package exports (d.1005), so a
|
|
58
|
+
* consumer's test double asks the same function instead of copying the rules.
|
|
59
|
+
* Every refusal opens with the literal context of the call it guards,
|
|
60
|
+
* `[RabbitMQClient]`, written at the throw site: a context passed in as a
|
|
61
|
+
* parameter would be invisible to the guard that checks it
|
|
62
|
+
* (`tests/unit/error-message-contract.test.js`), and the texts are the ones
|
|
63
|
+
* `consume()` has always thrown.
|
|
60
64
|
*
|
|
61
65
|
* @see ../../README.md § consume() — the delivery contract
|
|
62
66
|
*/
|
|
63
67
|
|
|
68
|
+
const { ValidationError } = require('../utils/errorHandler');
|
|
69
|
+
|
|
64
70
|
/** Header carrying how many attempts a message has already cost. Never a literal elsewhere. */
|
|
65
71
|
const ATTEMPTS_HEADER = 'x-oa-delivery-attempts';
|
|
66
72
|
|
|
@@ -153,6 +159,79 @@ function classifyError(classify, error) {
|
|
|
153
159
|
return { classification: answer, failure: null };
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Validate the options of `consume()` and resolve the delivery policy they ask for.
|
|
164
|
+
*
|
|
165
|
+
* `requeueOnError` is the SAME policy as `maxAttempts` said in one word, never a
|
|
166
|
+
* second one: `false` means a budget of one attempt, `true` or absent leaves the
|
|
167
|
+
* budget to `maxAttempts` or the default. `requeueOnError: false` together with
|
|
168
|
+
* any `maxAttempts` other than 1 is refused rather than resolved by preferring
|
|
169
|
+
* one of the two. An explicit `maxAttempts` is taken exactly as written — never
|
|
170
|
+
* coerced. `classify` defaults to `defaultClassification`.
|
|
171
|
+
*
|
|
172
|
+
* @param {Object} options the options passed to `consume()`
|
|
173
|
+
* @param {function(): number} resolveDefaultMaxAttempts returns the budget when
|
|
174
|
+
* the caller names none — the transport resolves its configuration there
|
|
175
|
+
* (explicit → env RABBITMQ_MAX_DELIVERY_ATTEMPTS → defaults.js). Called ONLY
|
|
176
|
+
* when the options leave the budget open: resolving it reads configuration,
|
|
177
|
+
* which can refuse on its own, and an explicit `maxAttempts` or
|
|
178
|
+
* `requeueOnError: false` never needed it. Its value is validated like an
|
|
179
|
+
* explicit one.
|
|
180
|
+
* @returns {{maxAttempts: number, classify: function(Error): string}}
|
|
181
|
+
* @throws {ValidationError} the first rule the options break, in the words
|
|
182
|
+
* `consume()` refuses them with
|
|
183
|
+
*/
|
|
184
|
+
function validateConsumeOptions(options, resolveDefaultMaxAttempts) {
|
|
185
|
+
if (options.requeueOnError !== undefined && typeof options.requeueOnError !== 'boolean') {
|
|
186
|
+
throw new ValidationError(
|
|
187
|
+
'[RabbitMQClient] Invalid requeueOnError - Expected: true or false, got '
|
|
188
|
+
+ `${JSON.stringify(options.requeueOnError)}. Fix: pass consume(queue, handler, `
|
|
189
|
+
+ '{ requeueOnError: false }) to dead-letter on the first failure, or leave it out and '
|
|
190
|
+
+ 'let the delivery budget decide.'
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const noRequeue = options.requeueOnError === false;
|
|
195
|
+
|
|
196
|
+
if (noRequeue && options.maxAttempts !== undefined && options.maxAttempts !== 1) {
|
|
197
|
+
throw new ValidationError(
|
|
198
|
+
'[RabbitMQClient] Contradictory delivery policy - requeueOnError: false means one attempt, '
|
|
199
|
+
+ `but maxAttempts says ${JSON.stringify(options.maxAttempts)}. Expected: one statement about `
|
|
200
|
+
+ 'how many times a handler may run. Fix: drop requeueOnError to keep the budget, or drop '
|
|
201
|
+
+ 'maxAttempts to dead-letter on the first failure.'
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const maxAttempts = noRequeue
|
|
206
|
+
? 1
|
|
207
|
+
: options.maxAttempts === undefined
|
|
208
|
+
? resolveDefaultMaxAttempts()
|
|
209
|
+
: options.maxAttempts;
|
|
210
|
+
|
|
211
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
212
|
+
throw new ValidationError(
|
|
213
|
+
'[RabbitMQClient] Invalid delivery budget - maxAttempts must be an integer >= 1, got '
|
|
214
|
+
+ `${JSON.stringify(maxAttempts)}. Fix: pass consume(queue, handler, { maxAttempts: <integer >= 1> }), `
|
|
215
|
+
+ 'or leave it out and let the module default (defaults.js maxDeliveryAttempts, '
|
|
216
|
+
+ 'env RABBITMQ_MAX_DELIVERY_ATTEMPTS) decide.'
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const classify = options.classify === undefined
|
|
221
|
+
? defaultClassification
|
|
222
|
+
: options.classify;
|
|
223
|
+
|
|
224
|
+
if (typeof classify !== 'function') {
|
|
225
|
+
throw new ValidationError(
|
|
226
|
+
'[RabbitMQClient] Invalid error classifier - classify must be a function returning '
|
|
227
|
+
+ `'transient' or 'permanent', got ${typeof options.classify}. Fix: pass consume(queue, handler, `
|
|
228
|
+
+ '{ classify: (error) => … }), or leave it out and every error is treated as transient.'
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { maxAttempts, classify };
|
|
233
|
+
}
|
|
234
|
+
|
|
156
235
|
module.exports = {
|
|
157
236
|
ATTEMPTS_HEADER,
|
|
158
237
|
TRANSIENT,
|
|
@@ -162,4 +241,5 @@ module.exports = {
|
|
|
162
241
|
defaultClassification,
|
|
163
242
|
readAttemptsMade,
|
|
164
243
|
classifyError,
|
|
244
|
+
validateConsumeOptions,
|
|
165
245
|
};
|
|
@@ -61,7 +61,12 @@ const SECTION_PREFIXES = Object.freeze({
|
|
|
61
61
|
telemetry: 'telemetry',
|
|
62
62
|
alerts: 'alert',
|
|
63
63
|
registry: 'registry',
|
|
64
|
-
scheduler: 'scheduler'
|
|
64
|
+
scheduler: 'scheduler',
|
|
65
|
+
// The one section whose names travel WITHOUT a prefix: `dlx` is the wire name every
|
|
66
|
+
// business queue dead-letters to (`x-dead-letter-exchange`), and renaming it would
|
|
67
|
+
// re-declare live topology. The empty prefix is a declared case, read explicitly by
|
|
68
|
+
// `queueName()`; a section absent from this map is still refused (d.902).
|
|
69
|
+
deadLetter: ''
|
|
65
70
|
});
|
|
66
71
|
|
|
67
72
|
/**
|
|
@@ -124,8 +129,11 @@ const templateShape = (template) =>
|
|
|
124
129
|
* @returns {{serviceName: string, queueType: string}|null} The service and the
|
|
125
130
|
* template key that declares the queue's arguments, or `null` for a name no
|
|
126
131
|
* template matches.
|
|
127
|
-
* @throws {ValidationError} If two templates match one name - a defect in
|
|
128
|
-
* never a runtime condition, and one that would otherwise be resolved by key
|
|
132
|
+
* @throws {ValidationError} If two templates of the same kind match one name - a defect in
|
|
133
|
+
* this file, never a runtime condition, and one that would otherwise be resolved by key
|
|
134
|
+
* order. A literal shape and a placeholder shape matching the same name is NOT that case:
|
|
135
|
+
* the literal wins, because it declares the name while the placeholder only fits it
|
|
136
|
+
* (d.817b, `registry.events` against `registry.{instance_id}`).
|
|
129
137
|
*/
|
|
130
138
|
const matchBusinessTemplate = (business, queueName) => {
|
|
131
139
|
const parts = queueName.split('.');
|
|
@@ -140,15 +148,24 @@ const matchBusinessTemplate = (business, queueName) => {
|
|
|
140
148
|
);
|
|
141
149
|
});
|
|
142
150
|
|
|
143
|
-
|
|
151
|
+
// A LITERAL BEATS A PLACEHOLDER (d.817b). `registry.events` names a queue;
|
|
152
|
+
// `registry.{instance_id}` names a shape — so `<svc>.registry.events` fits both, and a
|
|
153
|
+
// wildcard must not claim a name another template DECLARES. It is the same tie-break this
|
|
154
|
+
// file makes one level up, where a name an infrastructure section declares is never a
|
|
155
|
+
// business queue however well it fits a template (d.279). Ambiguity between two templates
|
|
156
|
+
// of the SAME kind is still a defect and still refused below.
|
|
157
|
+
const literals = matched.filter((template) => !templateShape(template).includes(null));
|
|
158
|
+
const decided = literals.length > 0 ? literals : matched;
|
|
159
|
+
|
|
160
|
+
if (decided.length > 1) {
|
|
144
161
|
throw new ValidationError(
|
|
145
|
-
`[queueConfig] Business queue ${queueName} matches more than one template: ${
|
|
162
|
+
`[queueConfig] Business queue ${queueName} matches more than one template: ${decided.join(', ')} - `
|
|
146
163
|
+ 'Expected: exactly one template declares a name, so one queue has one definition. '
|
|
147
164
|
+ 'Fix: make the overlapping keys in the `business` section of src/config/queueConfig.js distinct.'
|
|
148
165
|
);
|
|
149
166
|
}
|
|
150
167
|
|
|
151
|
-
return
|
|
168
|
+
return decided.length === 1 ? { serviceName: parts[0], queueType: decided[0] } : null;
|
|
152
169
|
};
|
|
153
170
|
|
|
154
171
|
/**
|
|
@@ -272,33 +289,11 @@ module.exports = {
|
|
|
272
289
|
}
|
|
273
290
|
},
|
|
274
291
|
|
|
275
|
-
/**
|
|
276
|
-
* workflow.control - Shared queue for service-agnostic control-flow steps
|
|
277
|
-
* All business services listen as competing consumers (same pattern as workflow.init).
|
|
278
|
-
*
|
|
279
|
-
* Purpose:
|
|
280
|
-
* - Execute control-flow steps that are not pinned to a specific service
|
|
281
|
-
* - Keep workflow.init as a clear "gateway entrypoint" queue (no semantic overload)
|
|
282
|
-
*
|
|
283
|
-
* TTL: 30 seconds - if no business service processes message within 30s, it expires
|
|
284
|
-
* DLQ routing: Expired messages go to workflow.failed
|
|
285
|
-
*/
|
|
286
|
-
control: {
|
|
287
|
-
durable: true,
|
|
288
|
-
arguments: {
|
|
289
|
-
'x-message-ttl': 30000,
|
|
290
|
-
'x-max-length': 10000,
|
|
291
|
-
'x-dead-letter-exchange': '',
|
|
292
|
-
'x-dead-letter-routing-key': 'workflow.failed'
|
|
293
|
-
}
|
|
294
|
-
},
|
|
295
|
-
|
|
296
292
|
/**
|
|
297
293
|
* workflow.completed - Completed workflows
|
|
298
294
|
*
|
|
299
295
|
* Dead-letters to workflow.dlq over the DEFAULT exchange, where the routing key
|
|
300
|
-
* is the destination queue name — the shape workflow.init
|
|
301
|
-
* already use. The dispatcher rejects a message here on an invalid payload, a
|
|
296
|
+
* is the destination queue name — the shape workflow.init already uses. The dispatcher rejects a message here on an invalid payload, a
|
|
302
297
|
* schema violation and a delivery failure; until d.278 the broker dropped each
|
|
303
298
|
* one, because the queue declared nowhere to put it.
|
|
304
299
|
*
|
|
@@ -487,6 +482,41 @@ module.exports = {
|
|
|
487
482
|
'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
|
|
488
483
|
'x-dead-letter-routing-key': '{service}.dlq'
|
|
489
484
|
}
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* {service}.registry.{instance_id} - the queue ONE INSTANCE gets its registry answers
|
|
489
|
+
* on, beside the shared `{service}.registry` the registry uses for its own prompts.
|
|
490
|
+
*
|
|
491
|
+
* The owner settled the name and the split (`registry-instance-identity` 001,
|
|
492
|
+
* Conditions): answers travel to `<svc>.registry.<instanceId>` through the existing
|
|
493
|
+
* `responseQueue`, the shared queue carries the registry's prompts. Measured by the
|
|
494
|
+
* registry thread in d.817: without a template here `getDeadLetterRoute()` answered
|
|
495
|
+
* null, `consume()` refuses a queue with no declared route (`mq-consumer-contract`
|
|
496
|
+
* 002), and the client had to go on answering on the shared queue — the state the
|
|
497
|
+
* confirmation moved away from.
|
|
498
|
+
*
|
|
499
|
+
* ITS LIFETIME IS `x-expires` (question 14, variant A; `registry-instance-identity`
|
|
500
|
+
* 003). Every boot of a copy names a new queue (the
|
|
501
|
+
* instance id is a UUID per process unless `SERVICE_INSTANCE_ID` names one), and
|
|
502
|
+
* nothing deletes the old one — not `deregister()`, not the wrapper's shutdown, not the
|
|
503
|
+
* registry, which only checks the queue exists before it answers. A durable queue with
|
|
504
|
+
* no lifetime would therefore stay behind after every restart, crash or clean stop
|
|
505
|
+
* alike. The broker deletes a queue with `x-expires` once it has gone that long with no
|
|
506
|
+
* consumer, so a live copy (its consumer attached) keeps it and a gone copy loses it.
|
|
507
|
+
* 60 s: longer than a reconnect of a live copy, shorter than the monitoring orphan
|
|
508
|
+
* window (`queue_orphan_alert.min_orphan_minutes`, seed 5), so a dead copy's queue
|
|
509
|
+
* never opens a `no_owner` episode. One rail: the lifetime lives in this declaration
|
|
510
|
+
* only — the transport refuses a caller's `autoDelete`/`exclusive` on a classified
|
|
511
|
+
* queue, and no caller deletes it.
|
|
512
|
+
*/
|
|
513
|
+
'registry.{instance_id}': {
|
|
514
|
+
durable: true,
|
|
515
|
+
arguments: {
|
|
516
|
+
'x-expires': 60000, // 60 s with no consumer, then the broker deletes it (question 14, A)
|
|
517
|
+
'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
|
|
518
|
+
'x-dead-letter-routing-key': '{service}.dlq' // Placeholder, replaced with actual service name
|
|
519
|
+
}
|
|
490
520
|
}
|
|
491
521
|
},
|
|
492
522
|
|
|
@@ -629,10 +659,17 @@ module.exports = {
|
|
|
629
659
|
/**
|
|
630
660
|
* monitoring.workflow - Unified workflow lifecycle monitoring
|
|
631
661
|
* Bound to monitoring.workflow.fanout exchange (fanout).
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
662
|
+
*
|
|
663
|
+
* WHO publishes onto that fanout: the Delivery Dispatcher after it processes a terminal
|
|
664
|
+
* workflow message, the in-process orchestrator during a run, and this client's own
|
|
665
|
+
* dead-letter hook. WHICH event_type each of them sends is not written here - the
|
|
666
|
+
* vocabulary has one owner and it grows, so a list copied into this comment would be a
|
|
667
|
+
* hand-written descriptive fact that ages in silence
|
|
668
|
+
* (`.claude/rules/doc-code-binding.md` §1).
|
|
669
|
+
*
|
|
670
|
+
* The message carries the cookbook and the context snapshot the trace store keeps.
|
|
671
|
+
*
|
|
672
|
+
* @see api/docs/biz/70-contracts/monitoring-events.md
|
|
636
673
|
*/
|
|
637
674
|
'workflow': {
|
|
638
675
|
durable: true,
|
|
@@ -646,15 +683,20 @@ module.exports = {
|
|
|
646
683
|
|
|
647
684
|
/**
|
|
648
685
|
* monitoring.services - Service lifecycle events monitoring
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
686
|
+
*
|
|
687
|
+
* WHO publishes here: the Registry, which owns the service lifecycle, and the
|
|
688
|
+
* InfrastructureHealthTracker with service status changes. WHICH event_type each of them
|
|
689
|
+
* sends is not written here — that vocabulary has one owner and it grows, and a list
|
|
690
|
+
* copied into this comment would be a hand-written descriptive fact that ages in silence
|
|
691
|
+
* (`.claude/rules/doc-code-binding.md` §1).
|
|
692
|
+
*
|
|
653
693
|
* NOTE: Heartbeats are NOT published here - they are too frequent. The cadence
|
|
654
694
|
* is not a number this file owns: see the `heartbeatInterval` constructor option
|
|
655
695
|
* of RegistryClient (@onlineapps/conn-orch-registry), which ServiceWrapper feeds
|
|
656
696
|
* from BIZ_HEARTBEAT_INTERVAL_MS.
|
|
657
697
|
* NOTE: Healthcheck messages go to infrastructure.health.events exchange
|
|
698
|
+
*
|
|
699
|
+
* @see api/docs/biz/70-contracts/monitoring-events.md
|
|
658
700
|
*/
|
|
659
701
|
'services': {
|
|
660
702
|
durable: true,
|
|
@@ -749,11 +791,12 @@ module.exports = {
|
|
|
749
791
|
* `mq-consumer-contract` 002 point 2, 003 point 1).
|
|
750
792
|
*
|
|
751
793
|
* **A declaration is not a creation instruction. The terminal is brought into being
|
|
752
|
-
* by the owner of the prefix that NAMES it.** `initInfrastructureQueues()`
|
|
753
|
-
* the
|
|
754
|
-
*
|
|
755
|
-
* queues it owns (`infra/api_gateway/index.js`).
|
|
756
|
-
* to the monitoring service's own call, beside
|
|
794
|
+
* by the owner of the prefix that NAMES it.** `initInfrastructureQueues()` creates
|
|
795
|
+
* exactly the names its caller passes in `queues`, a list that is required since
|
|
796
|
+
* d.668 — it derives nothing from these sections, and the gateway's list names
|
|
797
|
+
* exactly the five `workflow.*` queues it owns (`infra/api_gateway/index.js`).
|
|
798
|
+
* `monitoring.dlq` therefore belongs to the monitoring service's own call, beside
|
|
799
|
+
* the queues it consumes. Until that
|
|
757
800
|
* call names it, a `nack(requeue=false)` on a queue of this family dead-letters onto
|
|
758
801
|
* a routing key no queue carries and the broker DROPS the message — measured
|
|
759
802
|
* 2026-09-16 on the dev broker: 78 queues, and the only `.dlq` among them
|
|
@@ -830,7 +873,7 @@ module.exports = {
|
|
|
830
873
|
'logs.queue': {
|
|
831
874
|
durable: true,
|
|
832
875
|
arguments: {
|
|
833
|
-
'x-message-ttl':
|
|
876
|
+
'x-message-ttl': 300000, // 5 min — okno deploye bez konzumenta; conf telemetry-queue-ttl 001
|
|
834
877
|
'x-max-length': 100000, // Max 100k messages in queue
|
|
835
878
|
'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
|
|
836
879
|
'x-dead-letter-routing-key': 'telemetry.dlq'
|
|
@@ -841,7 +884,7 @@ module.exports = {
|
|
|
841
884
|
'metrics.queue': {
|
|
842
885
|
durable: true,
|
|
843
886
|
arguments: {
|
|
844
|
-
'x-message-ttl':
|
|
887
|
+
'x-message-ttl': 300000, // 5 min — okno deploye bez konzumenta; conf telemetry-queue-ttl 001
|
|
845
888
|
'x-max-length': 100000,
|
|
846
889
|
'x-dead-letter-exchange': '',
|
|
847
890
|
'x-dead-letter-routing-key': 'telemetry.dlq'
|
|
@@ -852,7 +895,7 @@ module.exports = {
|
|
|
852
895
|
'traces.queue': {
|
|
853
896
|
durable: true,
|
|
854
897
|
arguments: {
|
|
855
|
-
'x-message-ttl':
|
|
898
|
+
'x-message-ttl': 300000, // 5 min — okno deploye bez konzumenta; conf telemetry-queue-ttl 001
|
|
856
899
|
'x-max-length': 100000,
|
|
857
900
|
'x-dead-letter-exchange': '',
|
|
858
901
|
'x-dead-letter-routing-key': 'telemetry.dlq'
|
|
@@ -1075,12 +1118,17 @@ module.exports = {
|
|
|
1075
1118
|
*
|
|
1076
1119
|
* The third queue bound to `monitoring.workflow.fanout`, beside `monitoring.workflow`
|
|
1077
1120
|
* and `delivery.workflow.events`. The owner settled it in
|
|
1078
|
-
* `docs/governance/confirmations/scheduler.md` 007: the terminal event of a run
|
|
1079
|
-
*
|
|
1080
|
-
*
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
* and this file only
|
|
1121
|
+
* `docs/governance/confirmations/scheduler.md` 007: the terminal event of a run already
|
|
1122
|
+
* travels on that exchange, so the scheduler reads it there rather than asking the
|
|
1123
|
+
* observer over HTTP or reading a database that is not its own. No producer changes; the
|
|
1124
|
+
* binding is the scheduler's own act at boot (the pattern
|
|
1125
|
+
* `infra/api_delivery_endpoint/src/index.js` already uses with its
|
|
1126
|
+
* `mq.bindQueueToFanout(…)` calls), and this file only
|
|
1127
|
+
* DECLARES what that queue is.
|
|
1128
|
+
*
|
|
1129
|
+
* WHICH event_type values those are is NOT written here: the vocabulary has one owner and
|
|
1130
|
+
* it grows. A list copied into this comment would be a hand-written descriptive fact that
|
|
1131
|
+
* ages in silence (`.claude/rules/doc-code-binding.md` §1).
|
|
1084
1132
|
*
|
|
1085
1133
|
* THE TTL IS STATED, NOT INHERITED, and it is a property of that decision rather than a
|
|
1086
1134
|
* default: a fanout cannot filter, so this queue receives EVERY workflow event of the
|
|
@@ -1096,6 +1144,7 @@ module.exports = {
|
|
|
1096
1144
|
* `CONSUMER_DEAD_LETTER_ROUTE_MISSING`; `mq-consumer-contract` 002).
|
|
1097
1145
|
*
|
|
1098
1146
|
* @see api/docs/governance/confirmations/scheduler.md 007
|
|
1147
|
+
* @see api/docs/biz/70-contracts/monitoring-events.md
|
|
1099
1148
|
*/
|
|
1100
1149
|
'workflow.events': {
|
|
1101
1150
|
durable: true,
|
|
@@ -1123,6 +1172,31 @@ module.exports = {
|
|
|
1123
1172
|
}
|
|
1124
1173
|
},
|
|
1125
1174
|
|
|
1175
|
+
/**
|
|
1176
|
+
* The platform's dead-letter exchange, DECLARED — name, type and durability.
|
|
1177
|
+
*
|
|
1178
|
+
* Every business template dead-letters here (`x-dead-letter-exchange`), and
|
|
1179
|
+
* `@onlineapps/conn-infra-mq` `QueueManager.setupServiceQueues()` brings it into
|
|
1180
|
+
* being as a durable `direct` exchange before binding `{service}.dlq` to it. The
|
|
1181
|
+
* broker compares type and durability at EVERY `assertExchange`, so they belong to
|
|
1182
|
+
* the declaration, where a second reader — `api/scripts/ci/declare-broker-exchanges.mjs`,
|
|
1183
|
+
* which declares exchanges on a CI broker nobody's services booted against — finds
|
|
1184
|
+
* them through the same loop as every other exchange of this file, instead of keeping
|
|
1185
|
+
* a copy of the founder's line (TODO INFRA 349, d.902).
|
|
1186
|
+
*
|
|
1187
|
+
* Its wire name carries no prefix (`SECTION_PREFIXES.deadLetter` is `''`), and the key
|
|
1188
|
+
* is the one constant at the top of this file, so the name has one spelling.
|
|
1189
|
+
* An exchange holds nothing to dead-letter, so — like every other exchange here — it
|
|
1190
|
+
* carries no route.
|
|
1191
|
+
*/
|
|
1192
|
+
deadLetter: {
|
|
1193
|
+
[DEAD_LETTER_EXCHANGE]: {
|
|
1194
|
+
type: 'exchange',
|
|
1195
|
+
exchangeType: 'direct',
|
|
1196
|
+
durable: true
|
|
1197
|
+
}
|
|
1198
|
+
},
|
|
1199
|
+
|
|
1126
1200
|
/**
|
|
1127
1201
|
* The full wire name of a declared entry: the section's prefix + the entry's key.
|
|
1128
1202
|
*
|
|
@@ -1132,9 +1206,13 @@ module.exports = {
|
|
|
1132
1206
|
* a name this configuration does not declare would be asserted against the broker
|
|
1133
1207
|
* by whoever imported it, which is how a typo becomes a second queue.
|
|
1134
1208
|
*
|
|
1209
|
+
* A section whose declared prefix is `''` (`deadLetter`) composes the bare key: its
|
|
1210
|
+
* names travel without a prefix. That is a value of the map, not a default — a
|
|
1211
|
+
* section missing from it is refused below.
|
|
1212
|
+
*
|
|
1135
1213
|
* @param {string} section - Section holding the entry (e.g. 'deliveryEvents').
|
|
1136
1214
|
* @param {string} key - The entry's key within that section (e.g. 'health.events').
|
|
1137
|
-
* @returns {string} The full name (e.g. 'delivery.health.events').
|
|
1215
|
+
* @returns {string} The full name (e.g. 'delivery.health.events'; the bare key for 'deadLetter').
|
|
1138
1216
|
* @throws {ValidationError} If the section composes no wire name, or the entry is undeclared.
|
|
1139
1217
|
*/
|
|
1140
1218
|
queueName(section, key) {
|
|
@@ -1156,7 +1234,7 @@ module.exports = {
|
|
|
1156
1234
|
);
|
|
1157
1235
|
}
|
|
1158
1236
|
|
|
1159
|
-
return `${prefix}.${key}`;
|
|
1237
|
+
return prefix === '' ? key : `${prefix}.${key}`;
|
|
1160
1238
|
},
|
|
1161
1239
|
|
|
1162
1240
|
/**
|
|
@@ -1213,8 +1291,11 @@ module.exports = {
|
|
|
1213
1291
|
|
|
1214
1292
|
/**
|
|
1215
1293
|
* The infrastructure prefixes this configuration classifies by, in declaration
|
|
1216
|
-
* order. Published so a caller
|
|
1217
|
-
*
|
|
1294
|
+
* order. Published so a caller reads the list instead of keeping a copy of it.
|
|
1295
|
+
* It named `initInfrastructureQueues()` as such a caller until d.668, when that
|
|
1296
|
+
* function stopped deriving a queue list at all and started requiring `queues`;
|
|
1297
|
+
* what reads it today is this package's own coverage of the classification
|
|
1298
|
+
* (`tests/unit/queue-name-collision.test.js`, `queueConfig-telemetry.test.js`).
|
|
1218
1299
|
* @returns {ReadonlyArray<string>} Prefixes WITHOUT the trailing dot.
|
|
1219
1300
|
*/
|
|
1220
1301
|
infrastructurePrefixes() {
|
|
@@ -1456,8 +1537,7 @@ module.exports = {
|
|
|
1456
1537
|
*
|
|
1457
1538
|
* ONE owner of the mapping name → options. Every path that declares a queue
|
|
1458
1539
|
* reads it here and nowhere else: the public `assertQueue()` of the transport
|
|
1459
|
-
* (and through it
|
|
1460
|
-
* QueueManager and the publish path's 404 branch), and the consumer's
|
|
1540
|
+
* (and through it the cookbook-router's QueueManager), and the consumer's
|
|
1461
1541
|
* `_prepareQueueForConsume()`.
|
|
1462
1542
|
*
|
|
1463
1543
|
* Until d.410 those two worked the same three lines out separately —
|
|
@@ -1558,7 +1638,7 @@ module.exports = {
|
|
|
1558
1638
|
*
|
|
1559
1639
|
* The counterpart of `getDeadLetterRoute()`, and the reason a queue may have no
|
|
1560
1640
|
* route without that being a defect. `workflow.failed` is named in
|
|
1561
|
-
* `x-dead-letter-routing-key` by `workflow.init
|
|
1641
|
+
* `x-dead-letter-routing-key` by `workflow.init`;
|
|
1562
1642
|
* `{service}.dlq` by every business template; `workflow.dlq` and `delivery.dlq`
|
|
1563
1643
|
* by their own families. Giving such a queue a route of its own would either
|
|
1564
1644
|
* close a loop or start an endless chain, which is why the confirmed topology
|
|
@@ -1627,11 +1707,12 @@ module.exports = {
|
|
|
1627
1707
|
* Every business template routes here (`x-dead-letter-exchange`), and so does
|
|
1628
1708
|
* `getDeadLetterRoute()` for a business queue. `@onlineapps/conn-infra-mq` binds
|
|
1629
1709
|
* `<service>.dlq` to it and had to read the name back out of a queue's arguments
|
|
1630
|
-
* for want of an export.
|
|
1710
|
+
* for want of an export. Composed from the `deadLetter` entry, which also declares
|
|
1711
|
+
* the exchange's type and durability — one declaration, not a name beside it.
|
|
1631
1712
|
*
|
|
1632
1713
|
* @returns {string}
|
|
1633
1714
|
*/
|
|
1634
1715
|
deadLetterExchange() {
|
|
1635
|
-
return DEAD_LETTER_EXCHANGE;
|
|
1716
|
+
return this.queueName('deadLetter', DEAD_LETTER_EXCHANGE);
|
|
1636
1717
|
}
|
|
1637
1718
|
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* recurrencesSchema.js
|
|
5
|
+
*
|
|
6
|
+
* JSON Schema used by Ajv (in `src/scheduler-request.js`) to validate the recurrences a service
|
|
7
|
+
* declares about itself.
|
|
8
|
+
*
|
|
9
|
+
* WHY THE SHAPE LIVES HERE. `docs/architecture/scheduler.md` §7 has the wrapper register a
|
|
10
|
+
* service's recurrences at boot — "idempotent, so every boot re-asserts the declared state" —
|
|
11
|
+
* and the service answers with them through ONE hook, platform ones as a constant list and
|
|
12
|
+
* tenant ones derived from its own data (confirmation `scheduler` 011). The shape of a record
|
|
13
|
+
* is the library's; where the record comes from is not. One owner of the shape means every service's file is the
|
|
14
|
+
* same file, and the wrapper reading it never has to guess.
|
|
15
|
+
*
|
|
16
|
+
* WHAT THE FILE DOES NOT CARRY, and why that is a rule rather than an omission:
|
|
17
|
+
*
|
|
18
|
+
* - **`tenant_id` and `workspace_id`.** They travel beside the record, not in it: the
|
|
19
|
+
* builder takes them as `tenantId` and `workspaceId`, and the caller supplies them with
|
|
20
|
+
* every recurrence — the service knows them from its own data, and nothing reads them from
|
|
21
|
+
* the environment or defaults them.
|
|
22
|
+
* @see api/docs/governance/confirmations/scheduler.md 002, 010
|
|
23
|
+
* - **`requested_by`.** It is the service that declared the record; the caller passes the name
|
|
24
|
+
* it already knows, so a record cannot disagree with the service that answered it.
|
|
25
|
+
*
|
|
26
|
+
* `additionalProperties: false` at both levels, deliberately: the consumer's own contract
|
|
27
|
+
* (`api/infra/api_scheduler/contracts/scheduler.request.schema.json`) closes its keys the same
|
|
28
|
+
* way, so a key accepted here and refused there would turn a boot into a dead-lettered
|
|
29
|
+
* request nobody reads.
|
|
30
|
+
*
|
|
31
|
+
* @see ../scheduler-request.js — the shape check, the builders and the send
|
|
32
|
+
* @see api/docs/architecture/scheduler.md §4, §7
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Five whitespace-separated fields — the same expression the scheduler's contract takes
|
|
37
|
+
* (`recurrence.cron`, pattern `^\S+( \S+){4}$`). Whether those fields describe a reachable
|
|
38
|
+
* instant is the tick engine's answer, not this file's.
|
|
39
|
+
*/
|
|
40
|
+
const CRON_PATTERN = '^\\S+( \\S+){4}$';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The id of one schedule: it becomes `idempotency_key`, so it is a name, not a sentence.
|
|
44
|
+
*
|
|
45
|
+
* As WIDE as the consumer's own rule and no wider: the scheduler takes any string of 1–255
|
|
46
|
+
* characters for that key, so this file refuses only what would make the key a sentence —
|
|
47
|
+
* whitespace. The narrower spelling this schema first carried (lower-case letters and dashes)
|
|
48
|
+
* was the library being stricter than the contract for no reason, and it refused the very
|
|
49
|
+
* convention the first caller uses: `<source_uuid>:<cookbook_ref>` (BIZ-ingest, 2026-09-23).
|
|
50
|
+
*/
|
|
51
|
+
const ID_PATTERN = '^\\S+$';
|
|
52
|
+
|
|
53
|
+
const recurrencesSchema = {
|
|
54
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
55
|
+
$id: 'https://onlineapps.cz/schemas/mq-client-core/recurrences.v1.schema.json',
|
|
56
|
+
title: 'Service recurrence declaration',
|
|
57
|
+
description:
|
|
58
|
+
'What a service declares about its own recurring runs. The wrapper re-asserts every record '
|
|
59
|
+
+ 'at boot as a `scheduler.request` of kind `recurring`.',
|
|
60
|
+
type: 'object',
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
required: ['recurrences'],
|
|
63
|
+
properties: {
|
|
64
|
+
recurrences: {
|
|
65
|
+
description:
|
|
66
|
+
'Every recurrence this service owns. An empty list is a valid declaration of nothing, '
|
|
67
|
+
+ 'and a service with no recurrences may answer one.',
|
|
68
|
+
type: 'array',
|
|
69
|
+
items: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
required: ['id', 'cookbook_ref', 'cron', 'timezone'],
|
|
73
|
+
properties: {
|
|
74
|
+
id: {
|
|
75
|
+
description:
|
|
76
|
+
'Stable id of THIS schedule; it travels as `idempotency_key`, so re-registration '
|
|
77
|
+
+ 'upserts instead of creating a second schedule.',
|
|
78
|
+
type: 'string',
|
|
79
|
+
minLength: 1,
|
|
80
|
+
maxLength: 255,
|
|
81
|
+
pattern: ID_PATTERN
|
|
82
|
+
},
|
|
83
|
+
cookbook_ref: {
|
|
84
|
+
description:
|
|
85
|
+
'Id of the cookbook to run. Its `triggers` must name this service and the kind '
|
|
86
|
+
+ '`recurring`, or the scheduler refuses the request (confirmation scheduler 004).',
|
|
87
|
+
type: 'string',
|
|
88
|
+
minLength: 1,
|
|
89
|
+
maxLength: 255
|
|
90
|
+
},
|
|
91
|
+
cron: {
|
|
92
|
+
description: 'Cron expression, five fields.',
|
|
93
|
+
type: 'string',
|
|
94
|
+
pattern: CRON_PATTERN
|
|
95
|
+
},
|
|
96
|
+
timezone: {
|
|
97
|
+
description: 'IANA zone, explicit. No implicit zone exists (scheduler.md §4).',
|
|
98
|
+
type: 'string',
|
|
99
|
+
minLength: 1,
|
|
100
|
+
maxLength: 64
|
|
101
|
+
},
|
|
102
|
+
input: {
|
|
103
|
+
description:
|
|
104
|
+
'Input handed to the cookbook unchanged. Present only where the run needs one — '
|
|
105
|
+
+ 'a scheduled import that writes says so here, while the same cookbook run by hand '
|
|
106
|
+
+ 'can be given a different input.',
|
|
107
|
+
type: 'object'
|
|
108
|
+
},
|
|
109
|
+
catch_up: {
|
|
110
|
+
description:
|
|
111
|
+
'Whether a missed window runs once when the scheduler comes back. Absent means '
|
|
112
|
+
+ 'false, and the scheduler materialises that default — this file does not.',
|
|
113
|
+
type: 'boolean'
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
module.exports = { recurrencesSchema, CRON_PATTERN, ID_PATTERN };
|
package/src/config.js
CHANGED
|
@@ -20,7 +20,11 @@ const runtimeCfg = createRuntimeConfig({
|
|
|
20
20
|
|
|
21
21
|
// Client behavior
|
|
22
22
|
heartbeat: { env: 'RABBITMQ_HEARTBEAT', defaultKey: 'heartbeatSeconds', type: 'number' },
|
|
23
|
-
|
|
23
|
+
// The caller's own identity — required, never defaulted (d.770). Two legs, both the
|
|
24
|
+
// caller's: the explicit key, or ENV SERVICE_NAME. There is no third, because a
|
|
25
|
+
// library that invents a service name makes every row it writes name the wrong
|
|
26
|
+
// service (`architecture-principles.md` §3, and `./defaults.js` for the measurement).
|
|
27
|
+
serviceName: { env: 'SERVICE_NAME', required: true },
|
|
24
28
|
|
|
25
29
|
// Connection-level recovery budget. Resolved here (explicit → env →
|
|
26
30
|
// module default) instead of an inline `|| 10` in the transport, so the
|
package/src/defaults.js
CHANGED
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
* - This module owns defaults for MQ client behavior (heartbeat, connectionName prefix).
|
|
8
8
|
* - Infrastructure topology (RabbitMQ URL) is NOT defaulted; it must be provided via
|
|
9
9
|
* explicit config.host or ENV RABBITMQ_URL (fail-fast).
|
|
10
|
+
* - The CALLER's own identity is not defaulted either. `serviceName` used to be
|
|
11
|
+
* `'oa-service'` here until d.770: a name this module cannot know and must not invent
|
|
12
|
+
* (`architecture-principles.md` §3). What it bought was measured, and it was a lie —
|
|
13
|
+
* every client built without the key published its monitoring events, and named its
|
|
14
|
+
* queues, under a service that does not exist. It is now required, with the same two
|
|
15
|
+
* legs every caller already uses: explicit `serviceName`, or ENV `SERVICE_NAME`
|
|
16
|
+
* (`../config.js`).
|
|
10
17
|
*/
|
|
11
18
|
|
|
12
19
|
module.exports = {
|
|
13
20
|
heartbeatSeconds: 30,
|
|
14
|
-
serviceName: 'oa-service',
|
|
15
21
|
|
|
16
22
|
// How many times connection-level recovery retries before the connection is
|
|
17
23
|
// declared permanently lost. Client BEHAVIOUR, so it is owned here (the same
|