@onlineapps/mq-client-core 3.2.0 → 4.0.1

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,374 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [4.0.1] — 2026-09-17
8
+
9
+ ### Fixed — zrušení konzumenta brokerem přestalo být tiché (d.605)
10
+
11
+ `basic.cancel` od brokera — smazaná fronta, přesun fronty při HA failoveru
12
+ (`x-cancel-on-ha-failover`) — doručí amqplib do callbacku `consume()` jako
13
+ zprávu `null`. Klient na ni odpovídal holým `return`: žádný záznam, žádná změna
14
+ stavu, žádné znovupřipojení. Značka konzumenta zůstala nastavená, a protože
15
+ právě ona je jediný fakt, kterým `performHealthCheck()` odpovídá „je konzument
16
+ TOHOTO klienta připojený", hlásil klient `healthy: true` o frontě, ze které už
17
+ nic nečetl. Ticho je vada stejné závažnosti jako špatný výsledek
18
+ (`automation-gates.md` §5).
19
+
20
+ Nově v tomto pořadí: značka se zahodí (týž fakt, který d.386b zavedl pro
21
+ odmítnutou re-registraci — od té chvíle je klient `unhealthy` a jmenuje frontu),
22
+ ztráta se zapíše na úrovni **error** se jménem fronty i zrušené značky, a
23
+ konzument se znovu připojí **kolejí, kterou už používá obnova kanálu**: tělo
24
+ smyčky `_reRegisterConsumers()` dostalo jméno (`_reRegisterConsumer()`) a cancel
25
+ volá tutéž funkci pro jednu frontu. Celá smyčka by znovu připojila i konzumenty,
26
+ kteří o své místo nepřišli.
27
+
28
+ Klasifikaci, deklaraci a prefetch tedy dělá `_prepareQueueForConsume()` —
29
+ vlastněná business fronta se znovu deklaruje, nevlastněná infrastrukturní se jen
30
+ `checkQueue` a její absence je odmítnutí se značkou `CONSUMER_QUEUE_MISSING`,
31
+ nikdy tiché založení. Odmítnuté znovupřipojení nechá konzumenta sledovaného bez
32
+ značky a důvod jede na události, kterou balíček už má —
33
+ `consumer:re-registration:failed`.
34
+
35
+ **Žádný nový veřejný kontrakt**: žádná nová metoda, žádná nová událost, žádná
36
+ změna podpisu. Každý stav, do kterého se cesta může dostat, už balíček hlásí.
37
+
38
+ Ticho zůstává tam, kde zrušení není vada: vlastní `cancelConsumer()` callback
39
+ vůbec nevolá (amqplib odregistruje konzumenta na `basic.cancel-ok`; broker
40
+ neposílá cancel), a cancel, který dorazí během `disconnect()`, je zapsán na
41
+ úrovni debug — úmyslné zavření není porucha.
42
+
43
+ Kdo z toho co má: každá služba, která konzumuje frontu, se po re-pinu na tuto
44
+ verzi dozví, že o konzumenta přišla — dnes to nepozná ani z logu, ani ze
45
+ zdravotní kontroly.
46
+
47
+ ### Fixed — „připojený konzument" má jednu definici, a čte ji i levný reportér (d.613)
48
+
49
+ `getConsumerState()` vracelo jako `active` klíče `_activeConsumers`, tedy
50
+ konzumenty, které klient **sleduje**. `_performHealthCheck()` odpovídá na tutéž
51
+ otázku podle značky (`consumerInfo.consumerTag`), kterou d.386b a d.605 nechávají
52
+ nastavenou jen po dobu skutečného připojení. Jeden soubor, dvě definice slova
53
+ „aktivní" — a rozcházely se přesně tam, kde na tom záleží: konzument, jemuž
54
+ někdo smazal frontu a jehož znovupřipojení broker odmítl (406), zůstává
55
+ sledovaný bez značky, takže zdravotní kontrola hlásila 0 připojených, kdežto
56
+ `getConsumerState()` frontu dál jmenovalo jako aktivní.
57
+
58
+ To není kosmetika. `getConsumerState()` je ta **levná synchronní** odpověď, na
59
+ kterou se čtenář dostane: `docs/standards/rabbitmq-channel-best-practices.md`
60
+ § „Consumers stop receiving after a reconnect" i
61
+ `docs/operations/rabbitmq-connection-recovery-troubleshooting.md` posílají
62
+ operátora právě sem pro tento případ — a dostal odpověď, že konzument je aktivní.
63
+
64
+ Nově obě čtení volají jednu privátní funkci `_isConsumerAttached(consumerInfo)`;
65
+ `active` jmenuje **připojené** fronty a `tracked` zůstává počtem sledovaných,
66
+ takže `tracked > active.length` je celá věta „tento klient z některé fronty nic
67
+ nebere". Tvar návratové hodnoty se nemění (žádný klíč nepřibyl ani nezmizel),
68
+ mění se pravdivost hodnoty `active`; mimo tento balíček ji dnes nečte žádný kód
69
+ (změřeno přes `api`, `api_biz/*`, `fe_adminui`), takže se nic dalšího
70
+ nepřepisuje.
71
+
72
+ Kdo z toho co má: `@onlineapps/conn-infra-mq` to po re-pinu pošle dál ve
73
+ `getHealth().consumers` a `@onlineapps/service-wrapper` z toho odvodí
74
+ `components.mq` — služba se ztraceným konzumentem přestane být v heartbeatu
75
+ `healthy`.
76
+
77
+ ## [4.0.0] — 2026-09-16
78
+
79
+ ### Changed — značku odmítnutí nese i deklarační rail, jednou funkcí pro obě cesty (d.582)
80
+
81
+ d.577 dala verdikt cestě `consume()`. `assertQueue()` — veřejný rail, kterým jde
82
+ **každá** deklarace a po kterém jde FÁZE 0.8 `@onlineapps/service-wrapper`, když
83
+ si služba zakládá vlastní business fronty — dál propouštěl syrovou chybu
84
+ z amqplib. Odmítnutá deklarace tak odcházela s `code` = **číslo** `406`
85
+ a bez jakékoli klasifikace: `@onlineapps/conn-orch-registry` přenáší, co
86
+ rozhodlo jádro (d.579), a neměl co přenést; wrapper, který končí fázi natrvalo na
87
+ `code: 'MQ_CONNECTION_FATAL'`, označil odmítnutí za přechodné.
88
+
89
+ Teď obě cesty odpovídají **jednou funkcí** `_asRefusal(kind, queue, err)`: `kind`
90
+ volí větu a třídu obalu, verdikt se píše jednou a v pravopisu, který už používá
91
+ `_failFatally()`. Odmítnutá deklarace vychází jako chyba s
92
+ `code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused'`, jménem fronty v `queue`
93
+ a původní chybou brokeru — včetně reply kódu — v `cause`. Dvě kopie jedné
94
+ klasifikace by se mohly rozejít (`change-discipline.md` § One rail per concern).
95
+
96
+ `_failFatally()` se ani tady nevolá: odmítnuta je fronta, ne spojení — klient
97
+ zůstává připojený a `isConnectionFatal()` je dál `false`.
98
+
99
+ Kdo z toho co má: po re-pinu na tuto verzi doručí `ensureQueues()`
100
+ v `@onlineapps/conn-orch-registry` (d.579) značku wrapperu, takže 406 z FÁZE 0.8
101
+ skončí boot natrvalo místo aby ho opakoval proti frontě, kterou služba nikdy
102
+ nesmí založit.
103
+
104
+ Chování, které se **nemění**: 404, „channel ended", 503 i odmítnutí volajícího
105
+ (`Queue lifetime is not the caller's to set`) odcházejí přesně tak, jak přišly;
106
+ přijatá deklarace dál vrací `queue.declare-ok` brokeru. Kontrolní noha d.295
107
+ v `connect-refusal-fails-fast.integration.test.js` čte reply kód nově o jedno
108
+ `cause` níž — její vlastní tvrzení (spojení nebylo v sázce, klient se nestaví
109
+ mimo provoz) platí beze změny.
110
+
111
+ ### Changed — odmítnutí fronty brokerem nese stejný verdikt jako odmítnutí spojení (d.577)
112
+
113
+ Fronta, která na brokeru existuje s jinými argumenty, než jaké `queueConfig`
114
+ deklaruje, odpoví na `assertQueue()` kódem **406 PRECONDITION-FAILED**.
115
+ `_prepareQueueForConsume()` z toho stavěl `ConsumeError` **bez `code` a bez
116
+ `reason`** — kód brokeru přežil jen v `cause` (a o jedno obalení výš, v
117
+ `@onlineapps/conn-orch-registry`, až v `cause.cause`). `REFUSAL_REPLY_CODES` a
118
+ `_isBrokerRefusal()` se přitom ptaly jen v connect/reconnect.
119
+
120
+ Co to stálo: `@onlineapps/service-wrapper` ukončí FÁZI 0.7 trvale jen na
121
+ `code: 'MQ_CONNECTION_FATAL'`, takže 406 na frontě byl klasifikován jako
122
+ PŘECHODNÝ a služba opakovala boot proti frontě, kterou nikdy nesmí založit —
123
+ každý pokus dostane stejnou odpověď (změřeno sondou W532 proti živému brokeru).
124
+
125
+ Teď je to **jeden slovník pro jeden fakt**: každá chyba z cesty
126
+ `consume()` — klasifikace fronty, deklarace i registrace konzumenta — kterou
127
+ broker odmítl kódem z `REFUSAL_REPLY_CODES` (403/530/406), vychází jako
128
+ `ConsumeError` s `code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused'` a
129
+ původní chybou brokeru v `cause`. Klasifikuje se **kódem odpovědi**, ne zněním
130
+ hlášky (`_isBrokerRefusal()`, jediná kolej). Verdikt píše jedno místo — `catch`
131
+ v `consume()` transportu — a `BaseClient.consume()` takovou chybu přeposílá
132
+ **beze změny**: obalení by před přesnou větu brokeru postavilo obecnější a
133
+ značku zanořilo o jedno `cause` hlouběji.
134
+
135
+ Co se záměrně **nemění**: `_failFatally()` se tu nevolá. Odmítnuta je fronta, ne
136
+ spojení — klient zůstává připojený, publikační cesta nad týmž spojením nemá co
137
+ odpovídat a líné zotavení se nezastavuje. Chyba, kterou broker neodmítl, odchází
138
+ přesně tak, jak přišla: 404 dál nese `CONSUMER_QUEUE_MISSING`, chybějící
139
+ dead-letter route dál `CONSUMER_DEAD_LETTER_ROUTE_MISSING`, 503 a „channel
140
+ ended" dál žádnou značku.
141
+
142
+ ### Documentation — kadence heartbeatu se tady nedeklaruje (d.574)
143
+
144
+ Dva komentáře v `src/config/queueConfig.js` psaly kadenci heartbeatu číslem
145
+ („every 10s"). Balíček ji nevlastní: je to povinná volba konstruktoru
146
+ `RegistryClient` (`@onlineapps/conn-orch-registry`, `heartbeatInterval`), kterou
147
+ `@onlineapps/service-wrapper` plní z `BIZ_HEARTBEAT_INTERVAL_MS`. Ručně psané
148
+ číslo v komentáři je popisný fakt bez mechanismu, který ho drží pravdivý
149
+ (`.claude/rules/doc-code-binding.md` §1), takže obě věty teď ukazují na
150
+ vlastníka a číslo nenesou. Chování ani API se nemění.
151
+
152
+ ### Changed — odmítnutí brokerem na cestě bootu nese verdikt (d.295)
153
+
154
+ `connect()` přeposílal syrovou chybu z amqplib bez jakéhokoli verdiktu, takže
155
+ volající nad ním nemohl poznat, jestli broker **odpověděl a odmítl** (403
156
+ ACCESS-REFUSED, 530 NOT-ALLOWED, 406 PRECONDITION-FAILED), nebo jestli **nikdo
157
+ neodpověděl** (ECONNREFUSED, timeout). `_isBrokerRefusal()` ten rozdíl umí od
158
+ d.339, ale četla ho jediná cesta — `_reconnectWithBackoff()`, tedy spojení
159
+ ztracené za běhu. Teď ho čte i první `connect()`: odmítnutí z něj vychází jako
160
+ chyba s `code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused'`, původní chybou
161
+ v `cause` a hláškou §5, která jmenuje credentials, vhost a topologii jako fix.
162
+ `isConnectionFatal()` je od té chvíle `true`, takže se proti odmítajícímu brokeru
163
+ už nerozbíhá líné zotavení.
164
+
165
+ Verdikt se na této cestě **neohlašuje** na `connection:fatal` ani přes `onFatal`:
166
+ volající na `connect()` čeká, takže se ho dozví výjimkou, a druhé oznámení téže
167
+ události je přesně ta škoda, kterou popisuje `_reportRecoveryFailure()`. Výsledek
168
+ bootu patří volajícímu bootu (`@onlineapps/service-wrapper`, poznámka `BOOT
169
+ budget` v `_initializeMQ()`), ne kanálu pro asynchronní ztrátu.
170
+
171
+ `BaseClient.connect()` verdikt propouští beze změny. Dosud ho obalil do obecné
172
+ `ConnectionError` s fixem „read error.cause" — přesná odpověď degradovaná na
173
+ vágní, a značka, na kterou volající přestává opakovat, se ztratila úplně
174
+ (`ConnectionError` bere `code` jako argument konstruktoru a to volání žádný
175
+ nepředávalo).
176
+
177
+ Co se **nemění**: `REFUSAL_REPLY_CODES` zůstávají `[403, 530, 406]` — 503
178
+ COMMAND-INVALID se nepřidává, žádné měření si to nevyžádalo. Neodpovídající
179
+ broker odchází z `connect()` přesně jako dřív, syrový a opakovatelný, protože
180
+ „vzdát to navždy" nad výpadkem zakazuje konfirmace
181
+ `mq-client-lifecycle-contract` 001 bod 3. A 406 z `assertQueue()` nad živým
182
+ spojením zůstává chybou fronty, ne ztrátou spojení.
183
+
184
+ ### Changed — `publish()` cituje příčinu, nehádá ji (d.514)
185
+
186
+ `BaseClient.publish()` přepisoval všechno, co transport vyhodí, na jednu větu:
187
+ *„Failed to publish to queue "<q>" - Expected: the queue to exist and the channel
188
+ to be open. Fix: read error.cause for the broker reason."* Dvě ze tří jejích
189
+ klauzulí byly dohad. Transport odmítá publikaci i z důvodů, které s chybějící
190
+ frontou ani se zavřeným kanálem nemají nic společného — pravidlo vlastnictví
191
+ (`QueueNotFoundError`, `kind: 'unowned'`, d.419), vyčerpaný rozpočet opakování,
192
+ odmítnutí brokerem — a u každého z nich hláška posílala čtenáře prověřit frontu,
193
+ která existuje, a kanál, který je otevřený. Pravá věta ležela v `error.cause`,
194
+ kam se řádek logu nedívá.
195
+
196
+ Je to táž vada, kterou d.297 odstranila o volání vedle (`_consumerRefusal()`),
197
+ a odpovídá se stejně: vrstva **nejmenuje nejpravděpodobnější příčinu, cituje tu,
198
+ kterou skutečně chytila**. Nový tvar hlášky §5 je
199
+ `[BaseClient] Failed to publish to queue "<q>": <věta transportu> - Expected:
200
+ publish() to hand the message to the broker. Fix: act on the reason above …
201
+ error.cause carries the original error, error.queue names the target queue.`
202
+
203
+ Co se **nemění**: třída (`PublishError`), `error.queue`, `error.cause` ani objekt
204
+ v něm — volající, který se rozhoduje podle třídy, čte dál objekt. Nemění se ani
205
+ `ConnectionError` z nepřipojeného klienta a `SerializationError` z nepřevoditelné
206
+ zprávy; obě mají vlastní, pravdivou větu už teď. Odmítnutí, které vyhodí hodnotu
207
+ bez `message` (neErrorová rejekce), se do hlášky přenáší celé (`String(thrown)`),
208
+ protože „undefined" uprostřed odmítnutí je horší odpověď než řetězec, kterým
209
+ někdo odmítl.
210
+
211
+ ### Removed (BREAKING) — `recoveryScope`: klíč, který po d.419 nic nečetlo (d.514)
212
+
213
+ `recoveryScope` byl poslední zbytek dveří, kterými klient zakládal frontu bez
214
+ vlastníka. d.419 odebrala `queueCreationFilter` i `queueCreationCallback`
215
+ a `RecoveryWorker.createQueue()`; hodnota `recoveryScope` ale dál putovala ze
216
+ schématu přes `rabbitmqClient.js` do `RecoveryWorker`, kde se přiřadila do pole
217
+ `this._scope`, **které nečetlo nic v celém balíčku** (měřeno 2026-09-15:
218
+ `grep -rn "_scope" src/` → jediný výskyt je přiřazení a jedna zmínka v komentáři).
219
+ Deklarace, kterou nikdo nečte, tvrdí čtenáři, že na ní něco závisí.
220
+
221
+ Odchází celý řetěz: `configSchema.recoveryScope`, `defaultConfig.recoveryScope`,
222
+ argument `scope` konstruktoru `RecoveryWorker` i pole `this._scope`. Schéma je
223
+ ZAVŘENÉ, takže konfigurace, která klíč pořád nese, je odmítnuta **jménem** při
224
+ konstrukci — ne tiše ignorována.
225
+
226
+ **Čtyři otázky** (`.claude/rules/change-discipline.md` § Removing something
227
+ removes its declaration): **(1) proč vznikl** — `scope` říkal, jestli tenhle
228
+ klient smí zakládat fronty; v `business` scope je zakládal, v `infrastructure`
229
+ ne. Byla to volba provozovatele nad ad-hoc jmény z doby, kdy neexistovala
230
+ centrální šablona, ze které by se dala fronta deklarovat. **(2) která část
231
+ koncepce ho nesla** — hlavička `RecoveryWorker`, § Queue Creation: *„ostatní
232
+ jména … smí založit, pokud to scope a `queueCreationFilter` dovolují"*. Ta část
233
+ **neplatí**: nahradila ji konfirmace `mq-consumer-contract` 006 — *„queues come
234
+ into being only from declarations"* — a d.419 podle ní zavřela obě zbylé dveře.
235
+ **(3) proč ho dnes nikdo nečte** — ustoupil rozhodnutí: s dveřmi zmizel jediný
236
+ čtenář a klíč zůstal ležet. Vacuózní byl ale už předtím, a je to zapsané v kódu,
237
+ který se teď maže: `ConnectorMQClient` nastavoval `'business'` **každé** biz
238
+ službě, takže „smí zakládat" platilo plošně a nerozhodovalo nic. **(4) je náhrada
239
+ koncepčnější** — ano. O tvaru hlášení rozhoduje třída vlastnictví, kterou
240
+ odpověděla centrální deklarace (`error.kind` z `QueueNotFoundError`: `infrastructure`
241
+ / `business` / `unowned`). Ta plyne z `queueConfig`, ne z toho, kdo klienta
242
+ postavil, takže dva klienti namířené na tutéž frontu nemohou dostat různou
243
+ odpověď — a odpověď se nemůže rozejít s deklarací, protože je z ní odvozená.
244
+
245
+ **Pro volajícího:** `@onlineapps/conn-infra-mq` klíč jak je publikován pořád
246
+ posílá (`ConnectorMQClient.js`, `recoveryScope: config.recoveryScope || 'business'`;
247
+ pinuje `@onlineapps/mq-client-core` 2.0.1, kde klíč ještě existuje). Až se pin
248
+ pohne, bude odmítnut jménem při konstrukci. Úprava konektoru patří do dávky, která
249
+ pin posouvá, ne do téhle — stejně jako u `queueCreationCallback` v d.419.
250
+
251
+ ### Removed — výjimka pro `rpc.reply.*` v `consume()`, nedosažitelná od d.259 (d.514)
252
+
253
+ `_prepareQueueForConsume()` měla třetí třídu jména: co začínalo `rpc.reply.`,
254
+ přeskočilo klasifikaci, rozlišení argumentů z centrální konfigurace i otázku na
255
+ brokera (`checkQueue`/`assertQueue`). Mezi ty dvě výjimky ale d.259 postavila
256
+ bránu nedoručitelných zpráv, a ta takové jméno **odmítne dřív**: `queueConfig`
257
+ ho neklasifikuje jako infrastrukturní ani business (měřeno:
258
+ `isInfrastructureQueue('rpc.reply.abc123') === false`, `isBusinessQueue` totéž),
259
+ takže pro něj nedeklaruje žádnou cestu, a `getDeadLetterRoute()` vrací `null`
260
+ při `isDeadLetterTarget() === false`. Druhá polovina výjimky tak byla mrtvý kód
261
+ a první nerozhodovala nic (obě klasifikace jsou pro to jméno `false` i bez ní).
262
+
263
+ **Čtyři otázky**: **(1) proč vznikla** — RPC odpovědní cesta: volající si sám
264
+ založil dočasnou, nedurabilní frontu s vlastní životností a konzumoval z ní, takže
265
+ `consume()` nad ní nesměl nic deklarovat. **(2) která část koncepce ji nesla** —
266
+ právě ta cesta. **Neplatí**: `createTemporaryQueue()` byla zrušena
267
+ (`conn-infra-mq` d.275b) a konfirmace `mq-consumer-contract` 003 bod 3 to říká
268
+ větou — *„`rpc.reply.*` is not a topology question — `createTemporaryQueue()` has
269
+ no caller, it is a removal question"*. **(3) proč ji dnes nic nečte** — ustoupila
270
+ rozhodnutí d.259: konzument bez cesty pro nedoručitelnou zprávu se nepřipojí,
271
+ a fronta bez deklarace žádnou cestu nemá. **(4) je náhrada koncepčnější** — ano,
272
+ je to jedna kolej: fronta je konzumovatelná právě tehdy, když centrální deklarace
273
+ říká, kam s odmítnutou zprávou. Neexistuje druhá třída jmen, která klasifikaci
274
+ a deklaraci přeskočí na slovo volajícího. Výjimka slibovala podporu, kterou d.259
275
+ odebrala, takže kód teď říká, co systém dělá.
276
+
277
+ Chování se **nemění** a je změřené proti živému brokeru: `rpc.reply.*` fronta,
278
+ která na brokeru **skutečně je** (nedurabilní, jak ji zakládala zrušená cesta), je
279
+ odmítnuta touž větou jako každé jiné jméno bez deklarované cesty, nic se k ní
280
+ nepřipojí a nic se nepředeklaruje
281
+ (`tests/integration/dead-letter-route-required.integration.test.js`).
282
+
283
+ ### Changed — `amqplib` přišpendlen přesně na `0.10.9` (d.514)
284
+
285
+ `^0.10.3` je plovoucí rozsah, a tenhle balíček na konkrétním chování amqplib
286
+ **staví změřená tvrzení**: „ani `Connection`, ani `Channel` nenese vlastnost
287
+ `closed`" (d.260, amqplib 0.10.9, živý broker, 2026-09-12) je předpoklad
288
+ `isConnected()`, `getChannelState()`, všech tří `_ensure*Channel()`
289
+ a `_performHealthCheck()`. S plovoucím rozsahem může tentýž commit nainstalovat
290
+ jiný kód jiný den — přesně to, čemu pravidlo o pinech brání u `@onlineapps/*`
291
+ (`architecture-principles.md` § Version pinning: „a floating range makes the same
292
+ commit install different code on different days"). Hodnota `0.10.9` není nová,
293
+ je to co bylo v `package-lock.json` nainstalováno (měřeno:
294
+ `require('amqplib/package.json').version` → `0.10.9`); mění se jen to, že ji
295
+ `package.json` říká nahlas. `amqplib` není v SSOT `api/config/libraries.json`
296
+ (měřeno: grep bez shody), takže tenhle pin nevlastní publikační vlna.
297
+
298
+ ### Changed (testy) — sady `liveness-health-publish*` říkají, proč mají zotavení vypnuté (d.514)
299
+
300
+ Obě běží s `reconnectEnabled: false`. Důvod je pravdivý a teď je napsaný: co v nich
301
+ umírá, je KANÁL, a obnova spojení má vlastní sady (`reconnect-cycles-lazy-retry`,
302
+ `reconnect-exhaustion`, `disconnect-ends-reconnect`). Napsané je i to, co ten
303
+ příznak nese: **nenese nic** — měřeno 2026-09-15, obě sady jsou zelené i s
304
+ `reconnectEnabled: true` (integrace 4/4 třikrát po sobě, unit 11/11) — a právě
305
+ proto neviděly d.467, kde `performHealthCheck()` spouštěl obnovovací cyklus
306
+ bezpodmínečně. Případ s obnovou se do nich **nedoplňuje**: ta otázka má vlastní
307
+ kolej se zapnutou obnovou (`health-check-does-not-recover-a-live-client`, unit
308
+ i integrace), a druhá akceptační sada nad jedním pojmem je duplicita, kterou
309
+ `change-discipline.md` § One rail per concern označuje za vadu.
310
+
311
+ ### Fixed — pracující konzument s prefetchem 1 už není poplach (d.550)
312
+
313
+ Změřeno na BIZ-pdfgen a BIZ-converteru, dvakrát při každém bootu:
314
+
315
+ ⚠️ Prefetch utilization high: queue 'biz-pdfgen.registry' has 1/1 messages
316
+ in-flight (100%, threshold: 80%)
317
+
318
+ Registrační fronta má prefetch 1, takže jedna zpráva v letu je 100 % okna — a
319
+ zároveň naprosto normální stav konzumenta, který právě dělá tu jedinou práci,
320
+ kterou smí. Poplach se proto spustil při KAŽDÉM doručení do takové fronty a
321
+ žádný práh na tom nic nemění: `1/1 >= 0.8` platí stejně jako `1/1 >= 0.99`.
322
+ Varování, které nemůže nenastat, neříká nic — jen učí čtenáře přehlížet řádek,
323
+ na kterém jednou bude stát skutečné zahlcení.
324
+
325
+ `_checkPrefetchUtilization()` nově okno jedné zprávy neměří: jeho jediné dva
326
+ stavy jsou 0 % (nečinný) a 100 % (pracuje) a ani jeden není zjištění, kvůli
327
+ kterému by měl kdo zasáhnout. Pro okno větší než jedna zpráva se nemění nic —
328
+ `prefetchUtilizationThreshold` zůstává, jak ho deklaruje
329
+ `src/config/defaultConfig.js`, žádný nový práh ani default nevzniká, a plné okno
330
+ dvou i deseti zpráv se hlásí dál se stejným textem (kontrolní případy v obou
331
+ tierech: `tests/unit/prefetch-of-one-is-not-a-warning.test.js`,
332
+ `tests/integration/prefetch-of-one-is-not-a-warning.integration.test.js`).
333
+
334
+ ### Changed (testy) — kontrolní případ redakce jmenuje allow-list, ne jeho polovinu (d.514)
335
+
336
+ `tests/unit/redact-credentials.test.js` měl kontrolní případ nadepsaný *„URL bez
337
+ pověření se vrací nezměněná“*. Tvrzení zní jako obecné pravidlo, které od
338
+ `@onlineapps/logger-contract` 2.0.0 (d.476) neplatí: redakce je ALLOW-LIST —
339
+ zachová protokol, host, port a cestu, a všechno ostatní odejde bez ohledu na to,
340
+ jestli v tom pověření je. URL bez pověření, ale s dotazem (`?heartbeat=60`) se
341
+ nezměněná nevrací. Kontrolní případ, který jmenuje polovinu pravidla, zůstává
342
+ zelený i nad implementací, která dotaz propouští — tedy přesně nad vadou, kvůli
343
+ které allow-list vznikl.
344
+
345
+ Hodnota v assertu se **nemění** (`amqp://api_services_queuer:5672/oa_vhost` se
346
+ vrací identické); mění se věta, kterou test tvrdí, a důvod je u ní zapsaný.
347
+ Sousední případ, který zahození dotazu měří, zůstává jedinou kolejí pro to
348
+ tvrzení — nový se nepřidává. Žádná změna produkčního kódu.
349
+
350
+ ### Documentation — terminální frontu zakládá vlastník prefixu, který ji jmenuje (d.568)
351
+
352
+ Deklarace není pokyn k založení. Zápis sekce v `queueConfig` říká, **jak** fronta
353
+ vypadá; **kdo** ji na brokeru založí, rozhoduje `options.queues` toho, kdo volá
354
+ `initInfrastructureQueues()`. Odvození celého seznamu z prefixů je jen VÝCHOZÍ větev
355
+ té funkce (`shared/infrastructure-tools/src/orchestration/initInfrastructureQueues.js`)
356
+ a žádný dnešní volající jí nenechá rozhodnout: gateway jmenuje přesně pět front
357
+ `workflow.*`, které vlastní (`infra/api_gateway/index.js`). `monitoring.dlq`
358
+ a `telemetry.dlq` tedy patří do volání monitoringu, ne gateway.
359
+
360
+ INFRA-monitoring to změřilo 2026-09-16 na dev brokeru: 78 front, `.dlq` mezi nimi jen
361
+ `workflow.dlq` a `delivery.dlq`. Sedm front rodin `monitoring.*` a `telemetry.*` nese od
362
+ d.475 `x-dead-letter-routing-key: '<prefix>.dlq'`, takže `nack(requeue=false)` nad nimi
363
+ směřuje na routing key, pod kterým žádná fronta není — a broker zprávu zahodí.
364
+ Terminál založí vlastník prefixu u sebe (INFRA-monitoring, W9c).
365
+
366
+ Opraveny věty v živém textu: JSDoc `monitoring.dlq` a `telemetry.dlq`
367
+ (`src/config/queueConfig.js`), úroveň § terminální fronty v `README.md` a hlavička
368
+ testu `tests/unit/dead-letter-topology.test.js`, která tvrdila, že zápis sekce JE pokyn
369
+ k založení. Dobový záznam v `[3.2.0]` se nemění. Deklarace sama je v pořádku a nově to
370
+ měří test: `declarationOptions('monitoring.dlq')` a `('telemetry.dlq')` vrací přesně
371
+ `{ durable: true, arguments: { 'x-max-length': 50000 } }` — žádné TTL, žádná další
372
+ cesta — s kontrolním případem `workflow.dlq` (totéž) a `monitoring.audit` (routovaná
373
+ fronta též rodiny si TTL, strop i cestu drží). Žádná změna produkčního kódu.
374
+
7
375
  ## [3.2.0] — 2026-09-15
8
376
 
9
377
  ### Added — dead-letter cesty pro rodiny `monitoring.*` a `telemetry.*` (d.475)
package/README.md CHANGED
@@ -187,7 +187,10 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
187
187
  three channels, the consumers this client holds, and what is waiting in the
188
188
  publish buffer (`{ size, inMemory }` — there is one buffer). All five refuse
189
189
  before `connect()` has built a transport, rather than answering with an invented
190
- zero.
190
+ zero. `getConsumerState()` answers `{ tracked, active }`: `tracked` counts the
191
+ consumers this client would restore, `active` names the ones ATTACHED right now
192
+ — the same question the health check asks — so `tracked > active.length` says
193
+ this client takes no message off some queue it registered.
191
194
  - `disconnectAll()` (static, on the class and on the package export) - Close every
192
195
  client this module holds; see § Shutdown.
193
196
  - `onError(callback)` - Register error handler
@@ -195,7 +198,12 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
195
198
  connection (see § Connection loss)
196
199
 
197
200
  `publish()` and the workflow helpers built on it resolve to `undefined`; failure is
198
- signalled by a thrown `PublishError`, never by a falsy return.
201
+ signalled by a thrown `PublishError`, never by a falsy return. That `PublishError`
202
+ **quotes the reason it caught** — `[BaseClient] Failed to publish to queue "<q>":
203
+ <the transport's own sentence> - Expected: … Fix: …` — and keeps the object it came
204
+ from in `error.cause`, so a caller deciding on the class still reads the object
205
+ while a log line already carries the reason. The layer names no likeliest cause of
206
+ its own; the same rule `consume()` follows (§ `errors` and `errorCodes`).
199
207
 
200
208
  ### `consume()` — the delivery contract
201
209
 
@@ -235,6 +243,17 @@ policy exists to end) and it is never silent: the client logs it at error level,
235
243
  the queue, the attempts and the error, and the `message_dlq` event is published as for any
236
244
  other rejection.
237
245
 
246
+ **A terminal queue is declared here and CREATED by the owner of the prefix that names
247
+ it.** A section entry says what the queue looks like, never who brings it into being:
248
+ `initInfrastructureQueues()` derives the full list from the sections only when its caller
249
+ passes no `queues` list, and every caller passes one — the gateway's names the five
250
+ `workflow.*` queues it owns and nothing else (`infra/api_gateway/index.js`). So
251
+ `monitoring.dlq` and `telemetry.dlq` belong to the monitoring service's own call. Until a
252
+ terminal exists on the broker, the drop above happens one step earlier and without the
253
+ `x-death` trail: the rejection is routed to a name no queue carries (measured 2026-09-16
254
+ on the dev broker — 78 queues, `.dlq` among them only `workflow.dlq` and
255
+ `delivery.dlq`).
256
+
238
257
  ```javascript
239
258
  await client.consume(queue, async (msg, delivery) => {
240
259
  // delivery = { attempt, maxAttempts, isFinalAttempt }
@@ -312,6 +331,39 @@ There is no third meaning. `requeueOnError: false` together with `maxAttempts`
312
331
  above 1 is a contradiction and is **refused where it was written**, rather than
313
332
  one of the two being silently ignored.
314
333
 
334
+ #### A cancel from the BROKER — the consumer that was stopped without asking
335
+
336
+ `basic.cancel` sent by the broker is how a consumer learns its attachment is gone
337
+ while the channel and the connection stay up: the queue was deleted, or the broker
338
+ moved it (`x-cancel-on-ha-failover`). amqplib delivers it to the consume callback
339
+ as a `null` message, and it reaches nothing else in this client — the channel
340
+ handlers listen for `error` and `close`, and both stay silent.
341
+
342
+ It is **not** a silent event. In this order:
343
+
344
+ - the consumer's tag is dropped, because it names an attachment the broker no
345
+ longer holds — so `performHealthCheck()` stops counting that consumer as
346
+ attached the moment the cancel arrives, rather than one recovery later;
347
+ - the loss is logged at **error** level, naming the queue and the tag the broker
348
+ cancelled;
349
+ - the consumer is re-attached on the rail a channel recreate uses, for that queue
350
+ alone: the queue is classified, declared or checked and the prefetch re-applied
351
+ exactly as `consume()` does it. So a business queue the service owns is
352
+ re-declared, and an infrastructure queue this client does not own is only
353
+ checked — a deleted one is refused with `CONSUMER_QUEUE_MISSING`, never
354
+ silently recreated.
355
+
356
+ If that re-attachment is refused, the consumer stays **tracked with no tag** — the
357
+ same state a refused re-registration leaves — so the client reports itself
358
+ unhealthy and names the queue, `consumer:re-registration:failed` carries the
359
+ broker's reason and code, and the next channel recreate tries again.
360
+
361
+ Two cancels that are NOT this: `cancelConsumer()` on this client (the broker
362
+ answers `basic.cancel-ok`, the callback is never invoked, and the consumer is
363
+ dropped from the registry on purpose), and a cancel arriving while
364
+ `disconnect()` is running — an intentional close is not a fault, and it is
365
+ recorded at debug level.
366
+
315
367
  ### Connection loss — the contract
316
368
 
317
369
  When the broker goes away, the transport runs connection-level recovery with
@@ -459,9 +511,12 @@ arguments no longer match. The rule is enforced in two places, and both are
459
511
  required: the 404 branch of `_publishOnce()` refuses the publish, and
460
512
  `RecoveryWorker.handleQueueNotFound()` refuses to create the queue behind it.
461
513
 
462
- `recoveryScope: 'business'` (the default every `ConnectorMQClient` sets) says the
463
- client belongs to a business service. It has never meant "may create business
464
- queues".
514
+ Which of the three sentences the refusal carries is decided by the ownership class
515
+ in the error (`kind`), which the central declaration answers — never by how the
516
+ client was constructed. `recoveryScope` used to say which kind of service a client
517
+ belonged to; it never meant "may create business queues", and once d.419 closed the
518
+ door it had guarded, nothing read it. The key is retired (d.514) and a config still
519
+ carrying it is refused by name at construction.
465
520
 
466
521
  **An exchange is named by a publisher, never declared by one.** `publish(queue,
467
522
  message, { exchange, routingKey })` sends to the exchange the caller names and
@@ -521,12 +576,28 @@ for `'Connection closed unexpectedly'` while the transport emitted `'RabbitMQ co
521
576
  closed unexpectedly'`, one capital letter apart, so the branch written to ignore that
522
577
  error had never once run (fixed 2026-09-08, `errors.ConnectionError` now carries `code`).
523
578
 
524
- `consume()` refuses for two named reasons, and they ask for OPPOSITE actions:
579
+ `consume()` refuses for three named reasons, and they ask for OPPOSITE actions:
525
580
 
526
581
  | `error.code` | what happened | what fixes it |
527
582
  |---|---|---|
528
583
  | `CONSUMER_QUEUE_MISSING` | the queue does not exist | start the service that owns the queue — a consumer never creates one |
529
584
  | `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 |
585
+ | `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 |
586
+
587
+ The third one is the SAME verdict a refused connection carries (§ *permanently lost*),
588
+ because it asks the lifecycle owner for the same thing: stop retrying. It refuses the
589
+ QUEUE, not the connection — the client stays connected and `isConnectionFatal()` stays
590
+ `false`. `@onlineapps/service-wrapper` reads `code` to end FÁZE 0.7 permanently; before
591
+ 2026-09-16 a 406 on a queue carried no code at all, so the service kept booting against a
592
+ queue it will never be allowed to declare.
593
+
594
+ **`assertQueue()` answers the same way**, and for the same reason: a service declaring
595
+ its own business queues meets the refusal there, not on the consume path. A declaration
596
+ the broker refuses (403/530/406) leaves that rail as an error carrying
597
+ `code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused'`, the queue name in `queue` and
598
+ the broker's own error — reply code included — in `cause`. One function states both
599
+ (`_asRefusal(kind, queue, err)`): two copies of one classification would be free to
600
+ drift. Anything the broker did not refuse leaves either rail exactly as it arrived.
530
601
 
531
602
  Any other failure carries **no** code and its `ConsumeError` says so: the reason is in
532
603
  `error.cause`, and this layer does not name the likeliest-sounding one. Until 2026-09-14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/mq-client-core",
3
- "version": "3.2.0",
3
+ "version": "4.0.1",
4
4
  "description": "Core MQ client library for RabbitMQ - shared by infrastructure services and connectors",
5
5
  "oa": {
6
6
  "category": "connector"
@@ -26,7 +26,7 @@
26
26
  "@onlineapps/logger-contract": "2.0.0",
27
27
  "@onlineapps/runtime-config": "1.2.0",
28
28
  "ajv": "^8.12.0",
29
- "amqplib": "^0.10.3"
29
+ "amqplib": "0.10.9"
30
30
  },
31
31
  "devDependencies": {
32
32
  "jest": "^29.7.0"
package/src/BaseClient.js CHANGED
@@ -196,6 +196,24 @@ function invalidConfigurationMessage(unknownKeys, declaredKeys) {
196
196
  * @throws {ValidationError} If the argument is not a plain object of properties, or
197
197
  * redeclares a key the library already owns.
198
198
  */
199
+ /**
200
+ * What was thrown, rendered for a sentence a human reads in a log line.
201
+ *
202
+ * Neither branch invents anything: an `Error` is quoted by its own `message`,
203
+ * and a value that is not an `Error` is quoted whole, because "undefined" in the
204
+ * middle of a refusal is a worse answer than the string somebody rejected with.
205
+ * The object itself always stays reachable as `error.cause`.
206
+ *
207
+ * @param {*} thrown - Whatever the transport rejected with.
208
+ * @returns {string}
209
+ */
210
+ function describeThrown(thrown) {
211
+ if (thrown instanceof Error && typeof thrown.message === 'string' && thrown.message !== '') {
212
+ return thrown.message;
213
+ }
214
+ return String(thrown);
215
+ }
216
+
199
217
  function schemaWithSubclassProperties(extraProperties) {
200
218
  if (extraProperties === null || extraProperties === undefined) {
201
219
  return configSchema;
@@ -395,6 +413,18 @@ class BaseClient {
395
413
  this._logger.info('[BaseClient] Transport connected successfully');
396
414
  this._connected = true;
397
415
  } catch (err) {
416
+ // A refusal is already answered, so it passes through untouched. The
417
+ // transport reached the verdict (`code: 'MQ_CONNECTION_FATAL'`,
418
+ // `reason: 'broker-refused'`) and wrote the sentence that names
419
+ // credentials, vhost and topology as the fix; wrapping it here would
420
+ // replace that with "read error.cause" — a precise answer demoted to a
421
+ // vague one, and the marker the caller stops retrying on lost with it
422
+ // (`ConnectionError` takes its code as a constructor argument and this
423
+ // call passed none, so the wrap erased it). d.295.
424
+ if (err && err.code === 'MQ_CONNECTION_FATAL') {
425
+ throw err;
426
+ }
427
+
398
428
  throw new ConnectionError(
399
429
  '[BaseClient] Failed to connect to broker - Expected: a reachable broker at the configured host. '
400
430
  + 'Fix: read error.cause for the transport reason, then check the MQ url, credentials and network the service was started with.',
@@ -476,9 +506,23 @@ class BaseClient {
476
506
  try {
477
507
  await this._transport.publish(queue, buffer, options);
478
508
  } catch (err) {
509
+ // The reason is QUOTED, never guessed. Until d.514 this sentence read
510
+ // "Expected: the queue to exist and the channel to be open", and two of its
511
+ // three clauses were a guess: the transport refuses a publish for reasons
512
+ // that have nothing to do with a missing queue or a closed channel — the
513
+ // ownership rule (`QueueNotFoundError`, `kind: 'unowned'`, d.419), a spent
514
+ // retry budget, a broker refusal — and for each of those the reader was sent
515
+ // to check a queue that exists and a channel that is open. The true sentence
516
+ // sat in `error.cause`, where a log line does not look. Same defect, same
517
+ // answer as d.297 one call over (`_consumerRefusal()`): name no likeliest
518
+ // cause, carry the one actually caught. `cause` is unchanged, so a caller
519
+ // deciding on the class still reads the object.
479
520
  throw new PublishError(
480
- `[BaseClient] Failed to publish to queue "${queue}" - Expected: the queue to exist and the channel to be open. `
481
- + 'Fix: read error.cause for the broker reason; error.queue names the target queue.',
521
+ `[BaseClient] Failed to publish to queue "${queue}": ${describeThrown(err)} - `
522
+ + 'Expected: publish() to hand the message to the broker. '
523
+ + 'Fix: act on the reason above — this layer does not know which failure it was, and naming the '
524
+ + 'likeliest would send you to fix something that may not be broken. error.cause carries the '
525
+ + 'original error, error.queue names the target queue.',
482
526
  queue,
483
527
  err
484
528
  );
@@ -623,6 +667,18 @@ class BaseClient {
623
667
  _consumerRefusal(queue, err) {
624
668
  const code = err && err.code;
625
669
 
670
+ // The broker ANSWERED and refused this queue (403/530/406). Rethrown
671
+ // UNCHANGED, for the reason `@onlineapps/service-wrapper`'s `MQConnector`
672
+ // gives one layer up: that message carries the broker's own words and names
673
+ // the permission or the topology as the fix, and the verdict it carries
674
+ // (`code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused'`) is what the
675
+ // lifecycle owner reads to stop retrying a decision. Re-wrapping it here
676
+ // would put a sentence that knows less in front of one that knows more, and
677
+ // bury the marker one `cause` deeper (d.577).
678
+ if (code === 'MQ_CONNECTION_FATAL') {
679
+ return err;
680
+ }
681
+
626
682
  if (code === CONSUMER_DEAD_LETTER_ROUTE_MISSING) {
627
683
  return new ConsumeError(
628
684
  `[BaseClient] Cannot consume from queue "${queue}": queueConfig declares no dead-letter route for it - `
@@ -1069,9 +1125,14 @@ class BaseClient {
1069
1125
  }
1070
1126
 
1071
1127
  /**
1072
- * The consumers this client currently holds: how many, and on which queues.
1128
+ * The consumers this client currently holds: how many it TRACKS, and which of
1129
+ * them are ATTACHED right now. A consumer whose queue was deleted, or whose
1130
+ * re-registration the broker refused, stays tracked without its tag — so
1131
+ * `tracked > active.length` says this client takes no message off some queue
1132
+ * it registered (d.613; one definition of "attached",
1133
+ * `RabbitMQClient._isConsumerAttached()`).
1073
1134
  *
1074
- * @returns {Object} `{ tracked: <count>, active: [<queue names>] }`
1135
+ * @returns {Object} `{ tracked: <count>, active: [<attached queue names>] }`
1075
1136
  * @throws {ConnectionError} Before `connect()` has built a transport.
1076
1137
  */
1077
1138
  getConsumerState() {