@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 CHANGED
@@ -4,6 +4,335 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [6.0.0] — 2026-10-12
8
+
9
+ ### Added — `topology.workflowObserverCopyQueues`: pozorovatelské kopie běhu mají jeden seznam (d.1336, INFRA 559)
10
+
11
+ Které fronty jsou „pozorovatelské kopie běhu“ (fronty na `monitoring.workflow.fanout` pro čtenáře,
12
+ který běh sleduje a není jeho součástí; dead letter z nich = ztracená kopie telemetrie, ne ztracený
13
+ běh) se vybíralo ručně na dvou místech — monitoring `src/consumer/topology.js` `OBSERVER_COPY_QUEUES`
14
+ a plánovač `config/config.js` `observerCopyQueues`. Obsah se shoduje (změřeno hodnotami):
15
+ `monitoring.workflow`, `delivery.workflow.events`, `scheduler.workflow.events`.
16
+
17
+ - `queueConfig` deklaruje seznam jednou (`WORKFLOW_OBSERVER_COPIES`, páry `[section, key]`) — odvodit
18
+ ho z deklarace nejde: vazbu na fanout dělá každý konzument sám při bootu (`bindQueueToFanout()`), na
19
+ položce je jen komentářem, a TTL + dead-letter route nese každá rodina. Nový pozorovatel = jeden
20
+ řádek vedle své deklarace.
21
+ - `queueConfig.workflowObserverCopyQueues()` a `topology.workflowObserverCopyQueues` vrací zmrazené pole
22
+ jmen složených `queueName()` (žádný literál; neznámá položka odmítnuta `ValidationError`).
23
+ - Komentář `monitoring.workflow.fanout` („both receive copies“ — byly tři) ukazuje na seznam.
24
+ - Deklarace front beze změny.
25
+
26
+
27
+ ### Tests — management API má v IT tieru jeden čtenář: `createMgmtAsk` ze `scripts/tests/sweepOrphanQueues.js` (d.1330)
28
+
29
+ `tests/vhost.js § mgmt` byl po d.1324/d.1326 poslední vlastní čtenář management API v repu
30
+ (vlastní `fetch`, vlastní mez `MGMT_ASK_TIMEOUT_MS` 5 000 ms, vlastní věta `MGMT_NO_ANSWER`);
31
+ `tests/integration/setup.js` navíc bránu alarmu napojoval adaptérem `{ status, json }` nad ním.
32
+
33
+ - `mgmt(path, init)` je tenké volání sdíleného čtenáře: požadavek, oba rozpočty a věta pro
34
+ management API, které neodpoví (`[mq-client-core/integration] The management API did not
35
+ answer - …` / `… did not finish within 10000ms …`), jsou čtenáře; tento soubor přidává jen
36
+ bránu stavů (`allow`, `MGMT_REFUSED`) a tvar `{ status, body }`, který sady balíčku čtou
37
+ (devět IT sad + `mgmtRead`/`mgmtWrite` beze změny). `MGMT_ASK_TIMEOUT_MS`, `MGMT_NO_ANSWER`
38
+ a `config.rabbitmq.mgmtAuthHeader` jsou pryč (bez čtenáře).
39
+ - `tests/config.js` dodává `timeouts: { connection: 5000, operation: 10000 }` (hodnoty d.1324 /
40
+ registry d.744b, důvod u klíče) a `tier: 'mq-client-core/integration'`; `rabbitmq` nese
41
+ `user`/`password` pro čtenáře.
42
+ - `setup.js`: brána `refuseBrokerUnderAlarm` dostává `ask: createMgmtAsk(...)` přímo — adaptér
43
+ odešel.
44
+ - Sonda `tests/unit/mgmt-reader-has-one-rail.test.js`: žádný vlastní `fetch(` v `tests/**`,
45
+ `setup.js` bez adaptéru a s `createMgmtAsk(`, `vhost.js` requiruje sdíleného čtenáře; chování
46
+ `mgmt()` nad atrapou čtenáře (tvar, `allow`, `MGMT_REFUSED`, chyba čtenáře prochází netknutá).
47
+ `src/**` netknuto.
48
+
49
+ ### Tests — brána „broker pod alarmem“ IT tieru má jedno tělo v repu (d.1326)
50
+
51
+ - `tests/brokerAlarm.js` (`refuseBrokerUnderAlarm(ask)`, d.807 — „one concern, two
52
+ implementations, on purpose and on record“ do dne, kdy bude sdílené místo) odstraněn; sdílené
53
+ místo je `api/scripts/tests/brokerAlarm.js` vedle `sweepOrphanQueues.js` (d.1320). Čtenář
54
+ zůstává kolej tohoto balíčku `tests/vhost.js` § `mgmt` — `integration/setup.js` ji předává
55
+ s `allow: [503]` a v tvaru Response (`status`, `json()`), který sdílené tělo čte.
56
+ - `tests/unit/broker-alarm-gate.test.js` míří na sdílené tělo a tvrdí totéž (resource, node,
57
+ status, jedna otázka `/health/checks/alarms`, nedokumentovaný tvar = „unreported“, bez `ask`
58
+ odmítnuto jménem) + nově: mlčící čtenář = jeho vlastní chyba projde beze změny (žádný vymyšlený
59
+ alarm), HTTP mimo 200/503 = „did not answer the alarm check“. Tvrzení `init.allow === [503]`
60
+ odešlo z unit testu do adaptéru v `setup.js` (detail koleje, ne pravidla).
61
+
62
+ Příští MAJOR (vlna 5). Tři nálezy INFRA z diagnózy dev plánovače (W224, INFRA 531, 5. 10. 2026):
63
+ N3 je odebrání (BREAKING), N1 a N2 jsou opravy (d.1275).
64
+
65
+ ### Added — `topology.infrastructureHealthChecksQueue` = `infrastructure.health.checks` (d.1307)
66
+
67
+ Nejčtenější jméno platformy — fronta, na kterou každá infra služba publikuje health checky a
68
+ kterou odebírá registry (`docs/standards/queue-ownership.md` § Infrastructure Queue Creation
69
+ Order) — mělo deklaraci tady (`queueConfig.infrastructure['health.checks']`), ale žádný složený
70
+ export: `topology` (d.270) nesl `health.events`, monitoring a delivery, ne `health.checks`.
71
+ Jméno proto žije podruhé jako literál v `@onlineapps/service-common` `src/defaults.js`
72
+ (`infrastructureHealthQueueName`), který registry, health publisher i wrapper čtou a v hláškách
73
+ označují za „one owner“ — dva deklarující vlastníci jednoho jména na drátě (nález d.1306).
74
+
75
+ - `topology.infrastructureHealthChecksQueue` je složené z deklarace
76
+ (`queueConfig.queueName('infrastructure', 'health.checks')`), ne přepsané: přejmenování
77
+ v `queueConfig` export přesune, smazaný záznam padne rychle; objekt zůstává zmrazený.
78
+ - Čtenář zatím žádný — `@onlineapps/service-common` / `@onlineapps/infrastructure-tools` se
79
+ přepnou podle rozhodnutí vlastníka A/B (d.1306: service-common cituje export × čtenáři
80
+ citují export přímo a klíč service-common zaniká). Export je krok společný oběma cestám.
81
+ - Žádná změna chování transportu; test `tests/unit/queueConfig-public-export.test.js`
82
+ (hodnota, složení z deklarace a nepřítomnost literálu v `src/index.js`, argumenty fronty,
83
+ kontrola zmrazení a nezměněných jmen). README § topology — řádek tabulky.
84
+
85
+ ### Removed — BREAKING — `criticalHealthShutdown`, `criticalHealthShutdownDelay` a událost `health:shutdown` (d.1275, N3)
86
+
87
+ Slovo vlastníka 2026-10-06 (dialog 7 (4)): „volbu i událost ZRUŠIT, žádný slib — o životě
88
+ procesu rozhoduje wrapper/služba“.
89
+
90
+ Co to znamená pro volajícího — hodnotami:
91
+
92
+ - `new BaseClient({ …, criticalHealthShutdown: <cokoli> })` i `criticalHealthShutdownDelay`
93
+ hází `ValidationError` `[BaseClient] Configuration key "criticalHealthShutdown" does not exist
94
+ - Expected: no such key; a shutdown on sustained critical health is not something this client
95
+ has. Fix: remove "criticalHealthShutdown" from the configuration. …` — odmítnutí jménem
96
+ kolejí `NOT_A_KEY_OF_THIS_CLIENT`, jako `persistentBufferEnabled` (d.343); ani `false` se
97
+ nepřijme, protože už není co vypínat;
98
+ - `defaultConfig` ani schéma oba klíče nedeklarují;
99
+ - transport po žádné délce kritického zdraví neemituje `health:shutdown` a nepíše řádky
100
+ „Critical health will trigger shutdown in <ms> if not resolved“ ani „… - triggering shutdown“;
101
+ - beze změny: `health:critical` a `healthCriticalCallback` při každé selhané kontrole, řádky
102
+ „Critical health detected at …“ a „Health recovered after <ms> of critical state“.
103
+
104
+ Čtyři otázky `change-discipline.md` § Removing:
105
+
106
+ 1. **Proč vznikl a co měl dělat.** Volba přišla s monitorováním zdraví transportu: po
107
+ `criticalHealthShutdownDelay` (60 s) kritického zdraví emitovat `health:shutdown`, aby
108
+ aplikace proces ukončila a orchestrátor kontejneru ho nahodil znovu. Výchozí `true`.
109
+ 2. **Která část konceptu ji nesla a platí ještě.** Žádná živá: koncept životního cyklu říká
110
+ opak — knihovna proces neukončuje, ukončení je rozhodnutí vlastníka životního cyklu
111
+ (`_failFatally()` v `transports/rabbitmqClient.js`; trvalou ztrátu spojení hlásí
112
+ `connection:fatal` + injektovaný `onFatal`). `docs/architecture/rabbitmq-channel-lifecycle.md`
113
+ ji popisuje jako knob, ne jako rozhodnutí.
114
+ 3. **Proč ji dnes nic nečte.** Ustoupila rozhodnutí: `@onlineapps/service-wrapper` posluchače
115
+ `health:shutdown` odebral 2026-09-07 (jeho CHANGELOG § „Removed — the MQ `health:shutdown`
116
+ policy that could never apply“), žádná infra služba ho nikdy neměla — změřeno 2026-10-06
117
+ `git grep -n -e "health:shutdown" -e "criticalHealthShutdown"`: mimo tento balíček jen
118
+ komentáře a testy wrapperu, dvě věty v `docs/` a záznamy `TODO.md`, žádný kód. Výchozí `true`
119
+ tak každé kritické okno každého klienta zakončil slibem, který nikdo nesplnil (INFRA 531:
120
+ plánovač na devu hlásil „will trigger shutdown“, proces běžel dál — správně).
121
+ 4. **Je náhrada koncepčnější.** Ano: jedna kolej pro „proces končí“ (`onFatal` /
122
+ `connection:fatal`, vlastník životního cyklu rozhoduje), a pro trvalé kritické zdraví
123
+ hlášení bez rozhodnutí (`health:critical`, `healthCriticalCallback`). Slib bez vykonavatele
124
+ koncepčnější není nikdy.
125
+
126
+ ### Fixed — `consumers.active` se počítá z vlastního stavu klienta, ne z toho, zda doběhla sonda fronty (d.1275, N1)
127
+
128
+ Dosud se `active` přičítalo jen uvnitř smyčky `checkQueue` v `_performHealthCheck()`. Mrtvý
129
+ frontový (queue) kanál smyčku vůbec nespustil a `checkQueue` bez odpovědi v `brokerAnswerTimeout`
130
+ ji ukončil — v obou případech `active: 0` a verdikt „No consumer of this client is attached …
131
+ this client takes no message off any queue“ o klientovi, jehož consumer kanál žil a přijímal
132
+ (W224 na dev plánovači 5. 10.: spojení i consumer kanál žily, queue kanál thrashoval).
133
+
134
+ - „Připojený konzument“ má jednu definici `_isConsumerAttached()`: consumer kanál je živý
135
+ **a** záznam nese tag brokera. Čtou ji `performHealthCheck()` i `getConsumerState()` (d.613),
136
+ takže se nemění jen zdraví: **`getConsumerState().active` je při mrtvém consumer kanálu `[]`**
137
+ (dosud vypisoval frontu, dokud záznam držel tag kanálu, který už neexistuje).
138
+ - `consumers.active` / `consumers.inactive` se počítají z toho před sondou, nezávisle na ní.
139
+ Mrtvý queue kanál je nález kanálu (`Queue channel is closed or missing`), neúspěšná sonda
140
+ nález sondy (`Error checking queue <q>: …`); věta „No consumer …“ jen když opravdu žádný
141
+ konzument připojený není.
142
+ - Nové pole `queues.consumers`: `{ <fronta>: <consumerCount brokera> | 'unknown' }` — počet
143
+ konzumentů na frontě podle brokera (všech, ne jen tohoto klienta), `'unknown'` kde sonda
144
+ neběžela nebo nedostala odpověď. Nikdy 0 za „neptal jsem se“.
145
+ - Issue u fronty bez připojeného konzumenta jmenuje příčinu: `(consumerTag missing)` při živém
146
+ consumer kanálu, `(consumer channel is not alive)` při mrtvém.
147
+
148
+ ### Fixed — buffer publishe expiruje podle vlastních hodin a `PublishError` neslibuje „not lost“ (d.1275, N2)
149
+
150
+ - Záznam bufferu se zahodí **v okamžiku svého TTL** (`publishBufferTtlMs`) jedním časovačem
151
+ bufferu, ne až při příštím `add()`/`flush()`/`getBufferState()`. Změřeno na dev plánovači:
152
+ stáří při líné expiraci 584 843 ms a 5 267 969 ms místo 300 000. Časovač běží po koleji
153
+ časovačů klienta (`_setTimeout()`/`_clearTimeout()`, handle v `_activeTimers`), takže ho
154
+ `disconnect()` ukončí; prázdný buffer žádný časovač nedrží. `InMemoryBuffer`, `MessageBuffer`
155
+ a `PublishLayer` (interní, neexportované) proto vyžadují `timers: { setTimeout, clearTimeout }`
156
+ a bez nich odmítnou konstrukci jménem.
157
+ - ERROR řádek expirace: `sent=false` → `[InMemoryBuffer] <n> buffered message(s) for <cíl> LOST
158
+ after <ms> ms (buffer TTL <ttl> ms; …)`, `sent=true` → `… expired after <ms> ms …` (broker ji
159
+ mohl doručit sám; conf `mq-publish-buffer` 001 — obě věty se nikdy neslučují). Věta `Expected:`
160
+ nově říká, že samotná obnova publisher kanálu nic nepřehraje.
161
+ - `PublishError` z `PublishLayer` s `buffered: true` (vyčerpané pokusy, selhaný reconnect, na
162
+ který retry čekal) už ve `Fix:` neříká „the message is buffered, so it is not lost“, ale: „the message is buffered
163
+ for up to <ttl> ms (publishBufferTtlMs) and replayed only after a reconnect or after the broker
164
+ unblocks the connection — a recovered publisher channel alone does not replay it; if neither
165
+ comes within that time it expires and is reported as LOST …“ (u `sent: true` „reported as an
166
+ expired message whose frame had been sent (the broker may hold a copy)“).
167
+ - **Neudělané (čeká na rozhodnutí):** flush bufferu po obnově samotného publisher kanálu.
168
+ Obnova kanálu na živém spojení je třetí spouštěč, o kterém conf `mq-publish-buffer` 001 nemluví
169
+ (rozhodl jen `reconnected` a `unblocked`), a u záznamu s `sent: true` je volba mezi druhou
170
+ kopií a ztrátou — §1.2 `confirmation-triggers.md`.
171
+
172
+ ### Fixed — `consume()`, které čekalo na obnovu, jež nedorazila, dostane chybu toho čekání, ne „queue channel is not available“ (d.1278, INFRA-DOCS ID254)
173
+
174
+ Dosud `_ensureConsumerChannel()` chybu z čekání na obnovu (`_waitForReconnection()`) zapsalo
175
+ jako WARN a odpovědělo `{ reRegistered: 0, failed: n }`, jako by to byl výsledek; `consume()`
176
+ pokračovalo bez kanálu a volající dostal odmítnutí koleje frontového kanálu „Cannot checkQueue
177
+ <q>: queue channel is not available … Fix: await client.connect()“ — bez `cause`, bez jména
178
+ cyklu, a verdikt fatálního cyklu (`MQ_CONNECTION_FATAL`) dostal jen `onFatal`.
179
+
180
+ - `_ensureConsumerChannel()` po neúspěšném čekání **hází** `ConnectionError` `[RabbitMQClient]
181
+ Cannot recreate the consumer channel: the recovery it waited for did not arrive - <věta
182
+ chyby čekání> Expected: … Fix: read error.cause for the verdict of that recovery …` — stejně
183
+ jako publisher kolej (`_ensurePublisherChannel()`) chybu čekání propouští.
184
+ - `error.cause` je **tatáž** chyba, kterou cyklus vydal, ne kopie: po posledním cyklu objekt s
185
+ `code: 'MQ_CONNECTION_FATAL'`, který už dostal `onFatal` (volán právě jednou — hození není
186
+ druhé hlášení); po vyčerpaném cyklu `ConnectionError` jmenující cyklus (`recovery cycle 2/3
187
+ spent its 2 attempts …`) **bez `code`** — klient si `MQ_CONNECTION_STANDBY` nechává
188
+ (`docs/standards/rabbitmq-channel-best-practices.md` § Recovery).
189
+ - Přes `BaseClient.consume()` je to `ConsumeError` → `cause` = ta `ConnectionError` → `cause.cause`
190
+ = verdikt cyklu.
191
+ - Beze změny: `consume()` na živém kanálu; `consume()` po návratu brokeru spustí další cyklus a
192
+ konzumenta připojí (líná polovina obnovy, d.339); cesta `onFatal`.
193
+
194
+ ### Fixed — `consume()` na klientovi, který obnovu nespustí, řekne proč — ne „queue channel is not available“ (d.1281, nález d.1278)
195
+
196
+ Zbylé dvě větve `_ensureConsumerChannel()`, které po d.1278 ještě vracely `{ reRegistered: 0,
197
+ failed: n }` (volající pak dostal odmítnutí frontového kanálu „… queue channel is not available
198
+ … Fix: await client.connect()“ bez `cause`), **hází** `ConnectionError` jmenující stav:
199
+
200
+ - spojení trvale ztracené (`isConnectionFatal()`): `… the connection is permanently lost and no
201
+ recovery may start - <věta verdiktu> …`; `error.cause` je **tentýž objekt**, který dostal
202
+ `onFatal` / `connection:fatal` (`code: 'MQ_CONNECTION_FATAL'`) — klient si ho nově drží
203
+ (`_connectionFatalError`), `onFatal` se nevolá podruhé;
204
+ - zavřeno volajícím (`disconnect()`): `… the caller closed this client (disconnect()) and no
205
+ recovery may start - … Fix: await client.connect() before consume(); only an explicit connect()
206
+ re-arms recovery.` — bez `cause`;
207
+ - obnova vypnutá (`reconnectEnabled: false`): `… the connection is closed and recovery is disabled
208
+ by configuration (reconnectEnabled: false) - …` — bez `cause`;
209
+ - čekání skončilo `reconnected`, ale spojení už zase neživé: `… the recovery it waited for
210
+ completed, but the connection is not alive any more - … Fix: run consume() again …` — bez
211
+ `cause` (druhou smrt nic nedrží jako objekt).
212
+
213
+ Beze změny: `consume()` na živém kanálu, `onFatal` cesta, publish cesta.
214
+
215
+ ### Fixed — verdikt `MQ_CONNECTION_FATAL` je na chybě z `consume()` čitelný nahoře, ne o `cause` níž (d.1297, nález d.1294)
216
+
217
+ Chyby z d.1278 a d.1281 (`consume()` čekající na obnovu, která skončila fatálně; `consume()` na
218
+ trvale ztraceném klientovi) nesly verdikt cyklu jen jako `cause`. `BaseClient._consumerRefusal()`
219
+ propouští odmítnutí beze změny jen s `code === 'MQ_CONNECTION_FATAL'` na chybě samé (d.577:
220
+ značku nikdy nepohřbít o `cause` hlouběji) — obalilo je tedy `ConsumeError` a vlastník životního
221
+ cyklu, který čte `error.code`, verdikt neviděl.
222
+
223
+ - Transport verdikt **přenáší nahoru** (`_carryVerdict()`, jedna kolej pro obě místa): je-li
224
+ příčina fatální verdikt, chyba nese `code: 'MQ_CONNECTION_FATAL'` a `reason` verdiktu
225
+ (`'cycles-spent'` | `'broker-refused'`); `cause` zůstává tentýž objekt, který dostal `onFatal`.
226
+ - `BaseClient.consume()` takovou chybu hází **beze změny** (`ConnectionError`, ne `ConsumeError`) —
227
+ tak jako každé jiné odmítnutí s verdiktem.
228
+ - Nic nechodí řetězem `cause` (žádný fallback nad pohřbenou značkou): vyčerpaný cyklus (standby),
229
+ `disconnect()` a `reconnectEnabled: false` dál nenesou `code` ani `reason`.
230
+ - Věty `Fix:` obou chyb říkají „read error.code“ místo „read error.cause“.
231
+
232
+ ### Changed — BREAKING — `publish()`, jehož čekání na obnovu skončí standby/fatal, zprávu DRŽÍ v bufferu (`buffered: true`) a nese verdikt nahoře (d.1300, B1)
233
+
234
+ Slovo vlastníka 2026-10-06 (dialog 9): „B1 — držet v bufferu, `buffered: true`“ — jedna kolej
235
+ pro „čekání na reconnect selhalo“ se sousední cestou `PublishLayer` (přechodná chyba → čekání na
236
+ běžící reconnect → selhání → držení). Dosud (INFRA-DOCS ID254 bod 1, sonda d.1278): send kanál,
237
+ který pokus nedostal, protože spojení zůstalo dole, hodil chybu čekání syrově — volající dostal
238
+ `PublishError` se `sent: false` a **bez `buffered`**, zpráva držena nebyla.
239
+
240
+ Co se mění pro volajícího — hodnotami:
241
+
242
+ - `publish()` čekající na obnovu, která skončí **standby** (vyčerpaný cyklus) nebo **dokončením
243
+ do mrtvého spojení** — tedy tam, kde na tomto klientovi ještě může přijít `reconnected` —
244
+ odmítne `PublishError` s `buffered: true`, `sent: false`; zpráva je v bufferu a **přehraje se
245
+ po příštím `reconnected` / `unblocked`** (nic jiného ji nepřehrává), TTL `publishBufferTtlMs`
246
+ běží od držení, při expiraci ERROR řádek d.1275 (`LOST after <ms>`). Věta `Fix:` jmenuje stav
247
+ (`recovery cycle 2/3 spent its 2 attempts …`) a pravidla přehrání.
248
+ - Tam, kde `reconnected` už nikdy nepřijde — čekání skončilo **fatálem**, klient je **trvale
249
+ ztracený**, zavřený **`disconnect()`**, nebo má **`reconnectEnabled: false`** — se **nic
250
+ nedrží** (`buffered: false`, věta „NOT buffered: this client will not reconnect on its own …
251
+ the caller holds the only copy“; d.1301 zúžení B1 rozhodnutím leada: držení má smysl jen tam,
252
+ kde přijde `reconnected`; jinak je to ztráta s odkladem hlášená až při TTL). Třída to nese v
253
+ povinném `replayable` (boolean), které transport odvozuje ze stavu po skončení čekání.
254
+ - `bufferOnFailure: false` platí i tady: `buffered: false`, nic se nedrží (orchestrátor,
255
+ `publish_pending`).
256
+ - **Verdikt nahoře** (publishová polovina d.1297): skončí-li obnova fatálně, nese `PublishError`
257
+ z `BaseClient.publish()` i chyba `PublishLayer` `code: 'MQ_CONNECTION_FATAL'` a `reason`
258
+ (`'cycles-spent'` | `'broker-refused'`), `cause` řetěz končí tímtéž objektem, který dostal
259
+ `onFatal`. Standby, `disconnect()` a `reconnectEnabled: false` `code` nenesou (kontrakt).
260
+ - Nová třída `RecoveryEndedPublishError extends TransientPublishError` (export
261
+ `publishErrors.RecoveryEndedPublishError`): co transport hodí z `_ensurePublisherChannel()`,
262
+ když spojení zůstalo dole; `PublishLayer` ji drží okamžitě (žádný retry — další cyklus
263
+ spouští až příští použití klienta) týmž pomocníkem `_failedAfterRecovery()` jako selhané
264
+ čekání retry. Při `publishRetryEnabled: false` se hází tato chyba sama s `buffered`.
265
+ - Událost `publish:failed` nese `reconnectFailed: true`, `retryable: false` — jako u sousední
266
+ cesty; `publish:buffered` při držení.
267
+ - `carryVerdict()` z `utils/errorHandler.js` je jedno tělo přenosu verdiktu (transport,
268
+ `PublishLayer`, `BaseClient.publish()`).
269
+ - Beze změny: sousední cesta (přechodná chyba → čekání na běžící reconnect → selhání) — stejný
270
+ tvar jako dosud; publish na živém spojení; `onFatal` právě jednou.
271
+
272
+ ### Fixed — `BaseClient.publish()`/`consume()` po `connection:fatal` odmítá s verdiktem nahoře, ne radou „await client.connect()“ (d.1301, nález d.1294 §4/2)
273
+
274
+ Dosud po fatálu `_connected = false` → `ConnectionError` „Cannot publish/consume: client is not
275
+ connected … Fix: await client.connect()“ bez `code`, ačkoli klient verdikt držel (`_fatalError`) —
276
+ rada nepravdivá (po fatálu rozhoduje vlastník životního cyklu z `onFatal`, ne `connect()`) a
277
+ větve „trvale ztracený“ transportu (d.1281, d.1300) byly přes `BaseClient` nedosažitelné.
278
+
279
+ - `BaseClient` po fatálu volání **pouští do transportu**, který stav zná a odpoví sám (jedna
280
+ kolej, žádná druhá věta): `publish()` → `PublishError` s `code: 'MQ_CONNECTION_FATAL'`,
281
+ `reason`, `buffered: false`, `sent: false`, `cause` řetěz končí objektem z `onFatal`;
282
+ `consume()` → `ConnectionError` s týmž `code`/`reason`, `cause` = tentýž objekt
283
+ (`_consumerRefusal()` ji propouští beze změny). Nikde slovo `connect()` jako rada.
284
+ - Beze změny: před prvním `connect()` a po čistém `disconnect()` zůstává „not connected … Fix:
285
+ await client.connect()“ bez `code` — tam je rada pravdivá. Explicitní `connect()` verdikt
286
+ smaže (staví nový transport).
287
+
288
+ ### Fixed — každá operace na brokeru po `connection:fatal` odpovídá verdiktem nahoře, ne radou „await client.connect()“ (d.1314, nález d.1301 §6/2)
289
+
290
+ Rozšíření d.1301 na zbylé strážené metody `BaseClient`: `assertQueue`, `assertExchange`,
291
+ `bindQueue`, `checkQueue`, `purgeQueue`, `deleteQueue`, `cancelConsumer`, `ack`, `nack` (měřeno:
292
+ všech devět po fatálu hlásilo „Cannot <op>: client is not connected … Fix: await client.connect()“
293
+ bez `code`).
294
+
295
+ - `BaseClient`: jediná stráž `_requireUsable(op)` pro všech jedenáct operací (dosud jedenáct kopií);
296
+ odmítá jen před prvním `connect()` a po `disconnect()` — toutéž větou jako dosud; po fatálu
297
+ pouští volání do transportu.
298
+ - Transport: jeden stavitel odmítnutí `_refusalWithoutRecovery(headline, verb)` (dosud jen pro
299
+ consumer kanál) odpovídá i v koleji frontového kanálu (`_requireQueueChannel`: assert/check/
300
+ purge/delete/bind/assertExchange), v `cancelConsumer()` a v `ack()`/`nack()`, je-li spojení
301
+ trvale ztracené: `ConnectionError` s `code: 'MQ_CONNECTION_FATAL'`, `reason`, `cause` = verdikt z
302
+ `onFatal`; věta „… the connection is permanently lost and no recovery may start - …“ místo
303
+ „queue channel is not available … wait for the reconnect“ / „consumer channel is not initialized
304
+ … await client.connect()“.
305
+ - Stavové metody po fatálu nehází a říkají pravdu (beze změny): `isConnected()` `false`,
306
+ `isConnectionFatal()` `true`, `getConsumerState()` `{ tracked, active: [] }`,
307
+ `performHealthCheck()` `healthy: false` s `connection.closed: true`, metriky a stav bufferu.
308
+
309
+ ### Fixed — transport: „spojení dole a obnova nemůže začít“ odpovídá na každé koleji touž větou stavu; predikát „obnova může začít“ má jedno tělo (d.1319, nálezy d.1314)
310
+
311
+ - (A) Po `disconnect()` a při `reconnectEnabled: false` (spojení dole) radily přímé koleje transportu
312
+ podle staré věty — frontový kanál „queue channel is not available … wait for the reconnect“,
313
+ `ack`/`nack` „consumer channel is not initialized … await client.connect() and start the consumer“,
314
+ `cancelConsumer` „call cancelConsumer() again once the connection is back“ — ačkoli žádný reconnect
315
+ nepřijde. Teď všechny (assert/check/purge/delete/bind/assertExchange, `cancelConsumer`, `ack`,
316
+ `nack`) odpovídají `_refusalWithoutRecovery()` (tři stavy jako consume d.1281): „… the caller closed
317
+ this client (disconnect()) and no recovery may start - …“ resp. „… recovery is disabled by
318
+ configuration (reconnectEnabled: false) - …“, bez `code` (není fatál). Beze změny: standby
319
+ (cyklus může přijít) dál „wait for the reconnect“; po fatálu verdikt (d.1314). Přes `BaseClient`
320
+ tyto dva stavy dál chytá jeho stráž („not connected … Fix: await client.connect()“).
321
+ - (B) `_recoveryMayStart()` je jediné tělo predikátu „obnova může začít“ (ne fatál, ne `disconnect()`,
322
+ `reconnectEnabled: true`); čtou ho `_resumeRecovery()`, `replayable` v `_recoveryEndedForPublish()`
323
+ a koleje z (A). Chování beze změny.
324
+
325
+ ### Fixed — zavření spojení z opuštěného handshaku čeká na `close-ok` nejvýš `brokerAnswerTimeout` (d.1322, položka vlny 5)
326
+
327
+ `_closeAbandonedHandshake()` (handshake dokončený po `connectTimeout`, který klientovi nepatří)
328
+ čekalo na `late.close()` bez rozpočtu: broker, který na close neodpověděl, nechal pozdní spojení,
329
+ jeho socket i heartbeat žít do konce procesu (únik handshaků). Teď jde zavření toutéž kolejí jako
330
+ teardown (`_closeWithinTeardownBudget()` → `_awaitBrokerAnswer()`, jediný rozpočet čekání na broker —
331
+ conf `mq-broker-answer-budget` 001): po `brokerAnswerTimeout` WARN „The broker did not answer the
332
+ abandoned <connect|reconnect> handshake close within the teardown budget (<ms>ms) - …“ a socket s
333
+ heartbeatem se zahodí (`_dropUnansweredConnection()`, jako u napůl otevřeného spojení v `connect()`).
334
+ Časovač rozpočtu je na koleji klienta a s odpovědí se ruší; odmítnuté close hlásí dosavadní WARN.
335
+
7
336
  ## [5.1.0] — 2026-10-03
8
337
 
9
338
  ### Added
package/README.md CHANGED
@@ -6,7 +6,7 @@ Uniform: [library/connector](../connector/conn-orch-validator/manifests/library.
6
6
 
7
7
  Duty sections that apply:
8
8
 
9
- - `all`: L-MAIN, L-ENGINES, L-TESTS, L-TEST-SCRIPT, L-PACK-TESTS, L-PINS, L-NO-FILE-RANGE, L-CHANGELOG, L-README, L-README-REGION, L-CONSUMER
9
+ - `all`: L-MAIN, L-ENGINES, L-TESTS, L-TEST-SCRIPT, L-PACK-TESTS, L-PINS, L-NO-FILE-RANGE, L-CHANGELOG, L-PACK-CHANGELOG, L-README, L-README-REGION, L-CONSUMER
10
10
  - `connector`: L-CONNECTOR-ENV
11
11
  <!-- END GENERATED: library-uniform -->
12
12
 
@@ -107,6 +107,14 @@ config → env (`RABBITMQ_MAX_RECONNECT_ATTEMPTS`, `RABBITMQ_MAX_RECONNECT_CYCLE
107
107
  attempts inside ONE recovery cycle, the second how many cycles there may be — see
108
108
  § Connection loss.
109
109
 
110
+ **`criticalHealthShutdown` and `criticalHealthShutdownDelay` are refused by name** (removed
111
+ 2026-10-06, d.1275, on the owner's word). They made the transport, after a minute
112
+ of critical health, emit `health:shutdown` and log that it was "triggering shutdown" — but this
113
+ library never ends the process (see § Connection loss), and nothing on the platform listened
114
+ for the event, so the line promised an action that never came. The event went with the keys.
115
+ Critical health is reported — `health:critical` and `healthCriticalCallback` on every failing
116
+ check — and what to do about the process is the lifecycle owner's decision (`onFatal`).
117
+
110
118
  ### A subclass declares its own keys
111
119
 
112
120
  `new BaseClient(config, extraProperties?)`. The second argument is the JSON Schema
@@ -172,7 +180,17 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
172
180
  healthy** — `healthy: false`, with an issue naming how many it tracks — because
173
181
  such a client takes no message off any queue. A client that registered no
174
182
  consumer at all is a publisher and stays healthy; the check's own log line says
175
- which of the two it is.
183
+ which of the two it is. `consumers.active` / `consumers.inactive` are this
184
+ client's own facts — a consumer is attached while the consumer channel is alive
185
+ and its entry holds the broker's tag (the definition `getConsumerState()` reads)
186
+ — so a dead QUEUE channel or an unanswered queue probe is reported as what it is,
187
+ a channel or probe issue, never as "no consumer" (d.1275). The probe adds what
188
+ only the broker knows: `queues.consumers` maps each tracked queue to the broker's
189
+ `consumerCount` (every consumer on the queue, not only this client's), or to
190
+ `'unknown'` where the probe did not run or got no answer — never `0` for "not
191
+ asked". A failing check emits `health:critical` and calls
192
+ `healthCriticalCallback`; it decides nothing about the process (there is no
193
+ `health:shutdown`, see § Configuration).
176
194
  - `assertQueue(queue, options?)` / `checkQueue(queue)` / `purgeQueue(queue)` /
177
195
  `deleteQueue(queue, options?)` - Queue operations, on the queue channel.
178
196
  `assertQueue()` reads exactly four options — `durable`, `arguments`,
@@ -458,8 +476,21 @@ Permanently lost means all of this, together:
458
476
  built on them;
459
477
  - no further reconnect attempts are made, by a use or otherwise — the client is
460
478
  dead until the process restarts, or until an explicit `connect()`;
461
- - `publish()`/`consume()` refuse immediately instead of blocking on a recovery
462
- that is not coming.
479
+ - `publish()`/`consume()` — and every other operation on the broker:
480
+ `assertQueue`, `checkQueue`, `purgeQueue`, `deleteQueue`, `assertExchange`,
481
+ `bindQueue`, `cancelConsumer`, `ack`, `nack` — refuse immediately instead of
482
+ blocking on a recovery that is not coming, with the verdict on the error
483
+ (`code: 'MQ_CONNECTION_FATAL'`, `reason`, the verdict as `cause`); through
484
+ `BaseClient` too, which after the verdict lets the lost client answer for
485
+ itself instead of advising `connect()`. The state readers (`isConnected()`,
486
+ `isConnectionFatal()`, `getConsumerState()`, `getChannelState()`,
487
+ `performHealthCheck()`, the metrics) do not throw: they describe the state. A `consume()`
488
+ that was waiting when the verdict fell, or that is asked of the lost client
489
+ afterwards, is refused with that verdict readable on the error (`code`,
490
+ `reason`) and the verdict itself in `cause`. A `publish()` in the same position
491
+ is refused with a `PublishError` that carries the same `code` and `reason` and
492
+ `buffered: false`: nothing is held, because no `reconnected` will ever come to
493
+ replay it (§ *The publish buffer*) — the caller holds the only copy.
463
494
 
464
495
  **The library never ends the process, and never listens for a signal.** Ending
465
496
  it is the lifecycle owner's decision: a service typically logs and exits
@@ -590,14 +621,27 @@ is permanent (d.1134).
590
621
  **One call can leave the buffer: `publish(queue, message, { bufferOnFailure: false })`.**
591
622
  It is for a caller that keeps the message durably elsewhere and must not have a second
592
623
  copy replayed behind its back — the orchestrator writing a `publish_pending` record
593
- (`docs/governance/confirmations/step-publish-failure.md` 001). On each of the three
624
+ (`docs/governance/confirmations/step-publish-failure.md` 001). On each of the four
594
625
  transient paths (retries exhausted, `publishRetryEnabled: false`, the reconnect a retry
595
- waited for failed) the error `publish()` throws carries `buffered` (boolean): `true` only
596
- when the buffer holds the message for the next reconnect, `false` when nothing will
597
- replay it. A permanent refusal never reaches the buffer and carries no `buffered`. A
626
+ waited for failed, and a send channel the attempt could not get because the connection stayed
627
+ down — the recovery it waited for ended in standby or fatal, or none may start: the client is
628
+ permanently lost, closed by `disconnect()`, or has `reconnectEnabled: false`; B1, the owner's
629
+ decision of 2026-10-06) the error `publish()` throws carries `buffered` (boolean): `true` only
630
+ when the buffer holds the message for the next reconnect or `unblocked`, `false` when
631
+ nothing will replay it. A permanent refusal never reaches the buffer and carries no `buffered`. A
598
632
  value that is not a boolean is refused before any attempt (`ValidationError`). Retry
599
633
  stays the library's either way — the option decides only what happens after it.
600
634
 
635
+ The fourth path is its own class on the transport, `RecoveryEndedPublishError` (a
636
+ `TransientPublishError`, exported under `publishErrors`): `PublishLayer` holds the message at
637
+ once instead of retrying, because the next use of the client is what starts the next recovery
638
+ cycle. It holds it **only where a `reconnected` can still come** — the class says so in
639
+ `replayable`: after a spent cycle (standby) the message is held; once the client is permanently
640
+ lost, closed by `disconnect()`, or has `reconnectEnabled: false`, nothing will ever replay it, so
641
+ nothing is held and the error says `buffered: false` with the reason. When the recovery ended
642
+ fatally the `PublishError` carries the verdict on top — `code: 'MQ_CONNECTION_FATAL'`, `reason` —
643
+ and the verdict itself at the end of `cause`; a spent cycle carries no code.
644
+
601
645
  **Every failed publish says whether its frame left the client: `error.sent`** (boolean, on
602
646
  the `PublishError` from `publish()` and on its cause; conf
603
647
  `docs/governance/confirmations/mq-publish-buffer.md` 001). `true` when any attempt wrote the
@@ -611,9 +655,21 @@ happens when the outage ends:
611
655
  | a reconnect (`reconnected`) | re-sent | re-sent — the old socket and what the broker had not read are gone |
612
656
  | the broker unblocking the connection (`connection.unblocked`, end of a memory or disk alarm) | re-sent | dropped with a `warn` — the broker delivers the frames it held by itself |
613
657
 
614
- `connection.blocked` is logged as a `warn` with the broker's reason. A held message that
615
- outlives `publishBufferTtlMs` is logged as an `error` naming its queue or exchange, the
616
- count, the age and `sent` — `sent=false` is a message lost for good.
658
+ `connection.blocked` is logged as a `warn` with the broker's reason. **Nothing else flushes
659
+ the buffer**: an outage of the publisher CHANNEL alone, on a connection that stays up and is
660
+ not blocked, ends with neither event, so a message held during it is not replayed when the
661
+ next publish recreates the channel. Whether a channel recovery should flush too is an open
662
+ decision (a sent entry re-sent then may be a second copy), and the error `publish()` throws
663
+ says the gap in as many words: buffered for up to `publishBufferTtlMs`, replayed only after a
664
+ reconnect or `unblocked`, reported as LOST at expiry.
665
+
666
+ A held message that outlives `publishBufferTtlMs` is dropped **at its TTL, on the buffer's own
667
+ timer** — not whenever the buffer is next touched (until d.1275 the expiry ran only inside the
668
+ next `add()`/`flush()`/`getBufferState()`, and was measured on the dev scheduler up to 88
669
+ minutes late; d.1275). The timer runs on the client's timer rail, so `disconnect()` ends it. The
670
+ drop is logged as an `error` naming its queue or exchange, the count, the age and `sent` —
671
+ `sent=false` reads `LOST after <ms>` and is a message lost for good; `sent=true` reads `expired
672
+ after <ms>`, because the broker may have delivered it on its own.
617
673
 
618
674
  **There is no persistent buffer, and `persistentBufferEnabled` /
619
675
  `persistentRedisClient` are refused by name.** They declared one until
@@ -658,11 +714,15 @@ error had never once run (fixed 2026-09-08, `errors.ConnectionError` now carries
658
714
  | `CONSUMER_QUEUE_MISSING` | the queue does not exist | start the service that owns the queue — a consumer never creates one |
659
715
  | `CONSUMER_DEAD_LETTER_ROUTE_MISSING` | the queue exists, `queueConfig` declares no dead-letter route for it | declare `x-dead-letter-exchange`/`-routing-key` for that queue and bind the destination; the queue itself is fine |
660
716
  | `MQ_CONNECTION_FATAL` (with `reason: 'broker-refused'`) | the BROKER refused this queue — 406 PRECONDITION-FAILED for a queue that exists with other arguments, 403/530 for one this account may not use | correct the topology or the permission the broker named in `error.cause`, then restart; retrying gets the same answer |
717
+ | `MQ_CONNECTION_FATAL` (with `reason: 'cycles-spent' \| 'broker-refused'`, and `isConnectionFatal()` now `true`) | the CONNECTION is permanently lost — the recovery this `consume()` waited for ended the client, or the client was already lost; `error.cause` is the very verdict `onFatal` received (§ *permanently lost*) | the lifecycle owner stops the process; the restart policy boots a new one against a verified broker |
661
718
 
662
719
  The third one is the SAME verdict a refused connection carries (§ *permanently lost*),
663
720
  because it asks the lifecycle owner for the same thing: stop retrying. It refuses the
664
721
  QUEUE, not the connection — the client stays connected and `isConnectionFatal()` stays
665
- `false`. `@onlineapps/service-wrapper` reads `code` to end FÁZE 0.7 permanently; before
722
+ `false`. The fourth is that connection verdict itself, carried onto the `consume()` that met
723
+ it (one rail, `_carryVerdict()`): the code is on the error, not only in its `cause`, so
724
+ `BaseClient` lets it through unchanged as it does every refusal with a verdict. A recovery
725
+ cycle that merely ran out (standby) is no verdict and carries no code. `@onlineapps/service-wrapper` reads `code` to end FÁZE 0.7 permanently; before
666
726
  2026-09-16 a 406 on a queue carried no code at all, so the service kept booting against a
667
727
  queue it will never be allowed to declare.
668
728
 
@@ -758,6 +818,7 @@ topology.telemetryLogsQueue = 'mine'; // TypeError — the object is froz
758
818
 
759
819
  | Key | Name on the wire | Who publishes / who drains it |
760
820
  |---|---|---|
821
+ | `infrastructureHealthChecksQueue` | `infrastructure.health.checks` | Every infrastructure service publishes its health checks there (`@onlineapps/infrastructure-tools` `healthPublisher.js`); the registry drains it (`api/infra/api_services_registry/src/listeners/infrastructureHealth.listener.js`). Composed here since d.1307; no reader cites it yet — `@onlineapps/service-common` still carries the literal (`src/defaults.js`), and which side switches is the owner's decision (d.1306). |
761
822
  | `infrastructureHealthEventsExchange` | `infrastructure.health.events` | Fanout. The registry publishes (`api/infra/api_services_registry/src/services/infrastructureEventPublisher.js`); the two queues below are bound to it. |
762
823
  | `monitoringInfrastructureHealthEventsQueue` | `monitoring.infrastructure.health.events` | The monitoring consumer's queue on that fanout (`api/infra/api_monitoring/src/consumer/topology.js`, `MONITORING_INFRA_EVENT_QUEUE`). |
763
824
  | `deliveryHealthEventsQueue` | `delivery.health.events` | The delivery endpoint binds it to the fanout and consumes it for WS push (`api/infra/api_delivery_endpoint/src/index.js`, `bindQueueToFanout(config.healthEventsQueue, …)`). |
@@ -766,6 +827,7 @@ topology.telemetryLogsQueue = 'mine'; // TypeError — the object is froz
766
827
  | `telemetryMetricsQueue` | `telemetry.metrics.queue` | The same exchange, the same consumer — metric samples. |
767
828
  | `telemetryTracesQueue` | `telemetry.traces.queue` | The same exchange, the same consumer — spans. |
768
829
  | `telemetryExchange` | `telemetry.exchange` | Topic exchange the three queues above are bound to; every telemetry producer publishes to it, and the monitoring consumer asserts it (d.708b). |
830
+ | `workflowObserverCopyQueues` | `['monitoring.workflow', 'delivery.workflow.events', 'scheduler.workflow.events']` (frozen array) | The observers' copies of a run: the queues bound to `monitoring.workflow.fanout` for a reader that watches the run and is not part of it (the monitoring consumer, the delivery endpoint's WS feed, the scheduler's end-of-run watch). A dead letter from one of them is a lost copy of the run's telemetry, not a lost run (INFRA 559). Declared once in `queueConfig` (`WORKFLOW_OBSERVER_COPIES`) — membership cannot be read off an entry, because the binding is each consumer's own act at boot — and composed by `queueName()` (d.1336). |
769
831
 
770
832
  - **Frozen on purpose.** An importer must not be able to rewrite platform topology: a
771
833
  name overridden on one side of a fanout loses the events silently, which is why batch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/mq-client-core",
3
- "version": "5.1.0",
3
+ "version": "6.0.0",
4
4
  "description": "Core MQ client library for RabbitMQ - shared by infrastructure services and connectors",
5
5
  "oa": {
6
6
  "category": "connector"
@@ -23,7 +23,7 @@
23
23
  "author": "OnlineApps",
24
24
  "license": "MIT",
25
25
  "dependencies": {
26
- "@onlineapps/logger-contract": "2.0.0",
26
+ "@onlineapps/logger-contract": "2.1.0",
27
27
  "@onlineapps/runtime-config": "1.2.0",
28
28
  "ajv": "^8.12.0",
29
29
  "amqplib": "0.10.9"