@onlineapps/mq-client-core 5.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,19 @@ const { assertLogger } = require('@onlineapps/logger-contract');
10
10
  * Primárně určený pro transient scénáře (health checky apod.).
11
11
  */
12
12
 
13
+ /**
14
+ * How a log line names where a buffered message was going: its exchange when it was published
15
+ * to one, its queue otherwise.
16
+ *
17
+ * @param {{queue: string, options: Object}} item
18
+ * @returns {string}
19
+ */
20
+ function describeTarget(item) {
21
+ return item.options && typeof item.options.exchange === 'string' && item.options.exchange !== ''
22
+ ? `exchange "${item.options.exchange}"`
23
+ : `queue "${item.queue}"`;
24
+ }
25
+
13
26
  class InMemoryBuffer {
14
27
  /**
15
28
  * Both numbers are REQUIRED and have no stand-in here.
@@ -30,6 +43,10 @@ class InMemoryBuffer {
30
43
  * @param {number} options.maxSize - Max messages held; 0 means hold none
31
44
  * @param {number} options.ttlMs - How long one message may wait, in ms
32
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.
33
50
  */
34
51
  constructor(options = {}) {
35
52
  // The logger first, so a caller who forgot it gets the contract message
@@ -58,10 +75,23 @@ class InMemoryBuffer {
58
75
  + 'or pass ttlMs explicitly.'
59
76
  );
60
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
+ }
61
88
  this._maxSize = options.maxSize;
62
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;
63
93
 
64
- /** @type {Array<{queue:string, buffer:Buffer, options:Object, createdAt:number, priority:string}>} */
94
+ /** @type {Array<{queue:string, buffer:Buffer, options:Object, createdAt:number, priority:string, sent:boolean}>} */
65
95
  this._items = [];
66
96
  }
67
97
 
@@ -69,7 +99,18 @@ class InMemoryBuffer {
69
99
  * Přidá zprávu do bufferu.
70
100
  * Staré/expirující zprávy jsou průběžně čištěny, při překročení kapacity odhazujeme nejstarší.
71
101
  */
72
- async add(queue, buffer, options = {}, priority = 'normal') {
102
+ async add(queue, buffer, options = {}, priority = 'normal', sent) {
103
+ // Whether the frame of this message already LEFT the client (d.1145, conf
104
+ // `mq-publish-buffer` 001): the flush after `unblocked` re-sends only the entries that
105
+ // never did, because the broker delivers the frames it was holding by itself. Required —
106
+ // an entry that cannot say would be re-sent or dropped by a guess.
107
+ if (typeof sent !== 'boolean') {
108
+ throw new Error(
109
+ `[InMemoryBuffer] Cannot hold a message for queue '${queue}' without knowing whether its frame was sent - `
110
+ + `got sent=${JSON.stringify(sent)}. Expected: a boolean from the failed publish (error.sent). `
111
+ + 'Fix: pass it from PublishLayer, which reads it off the transport\'s error.'
112
+ );
113
+ }
73
114
  const now = Date.now();
74
115
  this._cleanupExpired(now);
75
116
 
@@ -86,7 +127,9 @@ class InMemoryBuffer {
86
127
  options,
87
128
  createdAt: now,
88
129
  priority,
130
+ sent,
89
131
  });
132
+ this._armExpiry(now);
90
133
  }
91
134
 
92
135
  /**
@@ -94,7 +137,20 @@ class InMemoryBuffer {
94
137
  * @param {Function} flushFn - async (queue, buffer, options) => void
95
138
  * @returns {Promise<number>} - počet úspěšně flushnutých zpráv
96
139
  */
97
- async flush(flushFn) {
140
+ async flush(flushFn, { sentEntries } = {}) {
141
+ // What happens to an entry whose frame already LEFT the client (d.1145, conf
142
+ // `mq-publish-buffer` 001): 'resend' after a reconnect — the old socket and whatever the
143
+ // broker had not read from it are gone; 'drop' after the broker unblocked the connection —
144
+ // it reads what was waiting in the socket and delivers those frames by itself, so a
145
+ // re-send would be one more copy. Required: a flush that guessed would do one or the other
146
+ // wrong.
147
+ if (sentEntries !== 'resend' && sentEntries !== 'drop') {
148
+ throw new Error(
149
+ `[InMemoryBuffer] flush() needs to know what to do with a sent entry - got sentEntries=${JSON.stringify(sentEntries)}. `
150
+ + "Expected: 'resend' (after a reconnect) or 'drop' (after the broker unblocked the connection). "
151
+ + 'Fix: flush through PublishLayer.flushBuffered(trigger), which maps its trigger to one of them.'
152
+ );
153
+ }
98
154
  const now = Date.now();
99
155
  this._cleanupExpired(now);
100
156
 
@@ -103,8 +159,24 @@ class InMemoryBuffer {
103
159
  }
104
160
 
105
161
  // Zkopírujeme a vyprázdníme interní pole, abychom se vyhnuli reentrancy problémům
106
- const items = this._items.slice();
162
+ let items = this._items.slice();
107
163
  this._items = [];
164
+ // Nothing is held while the flush runs; a replay that fails re-arms through `add()`.
165
+ this._armExpiry(now);
166
+
167
+ if (sentEntries === 'drop') {
168
+ for (const item of items.filter((entry) => entry.sent)) {
169
+ this._logger.warn(
170
+ `[InMemoryBuffer] A buffered message for ${describeTarget(item)} is not re-sent after the broker unblocked `
171
+ + `the connection: its frame had been sent, and it was buffered ${now - item.createdAt} ms ago - Expected: the broker delivers `
172
+ + 'the frames it held during the alarm by itself once it reads the socket again, so a re-send would '
173
+ + 'be one more copy. Fix: nothing to do here; a consumer that must not see a message twice '
174
+ + 'deduplicates it (conf mq-consumer-contract 009), and a message the broker did NOT keep is '
175
+ + 'a broker-side loss this client cannot see.'
176
+ );
177
+ }
178
+ items = items.filter((entry) => !entry.sent);
179
+ }
108
180
 
109
181
  // Kritické zprávy (priority === 'critical') házíme dopředu
110
182
  items.sort((a, b) => {
@@ -125,7 +197,9 @@ class InMemoryBuffer {
125
197
  this._logger.warn(
126
198
  `[InMemoryBuffer] Failed to flush message for queue '${item.queue}': ${err.message}`
127
199
  );
128
- await this.add(item.queue, item.buffer, item.options, item.priority);
200
+ // The replay is one more attempt: if IT wrote the frame, the entry says so from now on.
201
+ const sent = item.sent || (err !== null && typeof err === 'object' && err.sent === true);
202
+ await this.add(item.queue, item.buffer, item.options, item.priority, sent);
129
203
  }
130
204
  }
131
205
 
@@ -140,15 +214,89 @@ class InMemoryBuffer {
140
214
  return this._items.length;
141
215
  }
142
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
+
255
+ /**
256
+ * Drop the entries older than the TTL — and say, as an ERROR, what was dropped (d.1145, conf
257
+ * `mq-publish-buffer` 001).
258
+ *
259
+ * Until d.1145 this was one `info` line with a count and nothing else, the only trace of a
260
+ * message nobody would ever deliver (a memory alarm keeps the connection alive, so no
261
+ * reconnect flush comes — d.1143). One error per destination and `sent` value, with the
262
+ * count and the age of the oldest: a message whose frame never left is LOST, one whose frame
263
+ * had been sent may have been delivered by the broker on its own, and the two are never
264
+ * folded into one sentence.
265
+ *
266
+ * @param {number} now - the clock reading the caller took
267
+ * @private
268
+ */
143
269
  _cleanupExpired(now) {
144
- const before = this._items.length;
145
- this._items = this._items.filter(
146
- (item) => now - item.createdAt <= this._ttlMs
147
- );
148
- const removed = before - this._items.length;
149
- if (removed > 0) {
150
- this._logger.info(
151
- `[InMemoryBuffer] Removed ${removed} expired buffered messages`
270
+ const expired = this._items.filter((item) => now - item.createdAt > this._ttlMs);
271
+ if (expired.length === 0) {
272
+ return;
273
+ }
274
+ this._items = this._items.filter((item) => now - item.createdAt <= this._ttlMs);
275
+
276
+ const groups = new Map();
277
+ for (const item of expired) {
278
+ const key = `${describeTarget(item)}|${item.sent}`;
279
+ const group = groups.get(key) || { target: describeTarget(item), sent: item.sent, count: 0, oldestMs: 0 };
280
+ group.count += 1;
281
+ group.oldestMs = Math.max(group.oldestMs, now - item.createdAt);
282
+ groups.set(key, group);
283
+ }
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).
288
+ this._logger.error(
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} - `
292
+ + (sent
293
+ ? 'the frame had been sent, so the broker may have delivered it on its own; nothing re-sends it now. '
294
+ : 'the frame never left this client, so the message is LOST; nothing re-sends it now. ')
295
+ + 'Expected: a reconnect, or the broker unblocking the connection, within publishBufferTtlMs '
296
+ + '(a recovered publisher channel alone replays nothing). '
297
+ + 'Fix: find why the broker stayed away that long (an alarm: management API /api/health/checks/alarms; '
298
+ + 'an outage: the connection log); a message that must survive longer belongs to a caller that keeps '
299
+ + 'it durably and publishes with bufferOnFailure: false.'
152
300
  );
153
301
  }
154
302
  }
@@ -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
  }
@@ -50,7 +53,9 @@ class MessageBuffer {
50
53
  async add(queue, buffer, options = {}, meta = {}) {
51
54
  const priority = meta.priority || 'normal';
52
55
 
53
- await this._inMemory.add(queue, buffer, options, priority);
56
+ // `sent` is passed through unread: the in-memory buffer owns the rule that it is required
57
+ // (d.1145, conf `mq-publish-buffer` 001).
58
+ await this._inMemory.add(queue, buffer, options, priority, meta.sent);
54
59
  }
55
60
 
56
61
  /**
@@ -58,8 +63,8 @@ class MessageBuffer {
58
63
  * @param {Function} flushFn - async (queue, buffer, options) => void
59
64
  * @returns {Promise<number>} how many messages were replayed
60
65
  */
61
- async flush(flushFn) {
62
- return await this._inMemory.flush(flushFn);
66
+ async flush(flushFn, flushPolicy) {
67
+ return await this._inMemory.flush(flushFn, flushPolicy);
63
68
  }
64
69
 
65
70
  /**
@@ -136,8 +136,12 @@ module.exports = {
136
136
  type: 'integer',
137
137
  minimum: 0,
138
138
  description:
139
- 'AMQP heartbeat in SECONDS, handed to amqplib at connect. Explicit value, else ENV '
140
- + 'RABBITMQ_HEARTBEAT, else the module default (../defaults.js heartbeatSeconds).',
139
+ 'AMQP heartbeat in SECONDS, proposed to the broker in the connection URL (?heartbeat=) at '
140
+ + 'connect. Explicit value, else ENV RABBITMQ_HEARTBEAT, else the module default '
141
+ + '(../defaults.js heartbeatSeconds). 0 does NOT switch the heartbeat off: amqplib\'s '
142
+ + 'negotiation takes whichever side sets a limit, so the broker\'s own value applies '
143
+ + '(RabbitMQ 60 s unless its configuration says otherwise; measured d.1126: a paused broker '
144
+ + 'detected after 179 s). Only a broker that also offers 0 disables it.',
141
145
  },
142
146
  serviceName: {
143
147
  type: 'string',
@@ -287,10 +291,12 @@ module.exports = {
287
291
  type: 'integer',
288
292
  minimum: 0,
289
293
  description:
290
- 'How long this client waits for an ANSWER from the broker, in milliseconds: the confirm of '
291
- + 'one publish attempt, and the close-ok of each handle disconnect() closes. One number, two '
292
- + 'consumers — it was named publishConfirmationTimeout until d.299, after the teardown had '
293
- + 'been spending it since d.283.',
294
+ 'How long this client waits for an ANSWER from the broker, in milliseconds — every wait: the '
295
+ + 'confirm of one publish attempt, the close-ok of each handle disconnect() closes, and every '
296
+ + 'request a channel sends and waits on (queue/exchange declarations, checkQueue, bindQueue, '
297
+ + 'purge, delete, consume, cancel, prefetch, channel open; conf mq-broker-answer-budget 001, '
298
+ + 'd.1135). One number for one question — it was named publishConfirmationTimeout until d.299, '
299
+ + 'after the teardown had been spending it since d.283.',
294
300
  },
295
301
  connectTimeout: {
296
302
  type: 'integer',
@@ -389,19 +395,9 @@ module.exports = {
389
395
  healthCriticalCallback: callbackOrNull(
390
396
  'Async function (health) => void, or null. Called when a health result is critical.'
391
397
  ),
392
- criticalHealthShutdown: {
393
- type: 'boolean',
394
- description:
395
- 'Whether a sustained critical health result makes the transport emit `health:shutdown`. '
396
- + 'Anything other than `false` means enabled. It only arms the event; ending the '
397
- + 'process remains the owner\'s decision.',
398
- },
399
- criticalHealthShutdownDelay: {
400
- type: 'integer',
401
- minimum: 0,
402
- description:
403
- 'How long health must stay critical before that event is emitted, in milliseconds.',
404
- },
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`).
405
401
 
406
402
  // ---------------------------------------------------------------------
407
403
  // `queueCreationFilter` and `queueCreationCallback` stood here until d.419,
@@ -120,12 +120,13 @@ module.exports = {
120
120
  publishRetryMaxDelay: 5000,
121
121
  publishRetryBackoffMultiplier: 2,
122
122
 
123
- // How long this client waits for an ANSWER from the broker, in ms: the confirm
124
- // of one publish attempt, and the `close-ok` of each handle `disconnect()`
125
- // closes — one number for two consumers, which is why it carries the name of
126
- // neither. It was `publishConfirmationTimeout` until d.299, a name that was
127
- // true of the publish path and false of the teardown that had used it since
128
- // d.283.
123
+ // How long this client waits for an ANSWER from the broker, in ms — every wait:
124
+ // the confirm of one publish attempt, the `close-ok` of each handle
125
+ // `disconnect()` closes, and every request a channel sends and waits on
126
+ // (conf `mq-broker-answer-budget` 001, d.1135) — one number for all of them,
127
+ // which is why it carries the name of none. It was `publishConfirmationTimeout`
128
+ // until d.299, a name that was true of the publish path and false of the
129
+ // teardown that had used it since d.283.
129
130
  brokerAnswerTimeout: 5000,
130
131
 
131
132
  // How long the connect handshake may take before the attempt is abandoned, in
@@ -190,11 +191,9 @@ module.exports = {
190
191
  healthReportCallback: null,
191
192
  healthCriticalCallback: null,
192
193
 
193
- // Does critical health end this client, and how long it stays critical first
194
- // (ms)? The library only reports; the shutdown it asks for is the owner's
195
- // `onFatal` path.
196
- criticalHealthShutdown: true,
197
- 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.
198
197
 
199
198
  // `recoveryScope` stood here until d.514, with the two callbacks it filtered
200
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; monitoring.workflow + delivery.workflow.events both receive copies.
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. */