@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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,480 @@ 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
+
336
+ ## [5.1.0] — 2026-10-03
337
+
338
+ ### Added
339
+
340
+ - `publish(queue, message, { bufferOnFailure: false })` — volba pro jedno volání: zpráva, jejíž
341
+ publikace selhala přechodně (vyčerpané pokusy, `publishRetryEnabled: false`, selhaný reconnect,
342
+ na který retry čekal), se NEuloží do bufferu, takže ji žádný reconnect znovu neodešle. Výchozí
343
+ `true` = chování beze změny. Pro volajícího, který zprávu drží trvale jinde (orchestrátor,
344
+ `publish_pending`, conf `step-publish-failure` 001; d.1122). Hodnota jiná než boolean se
345
+ odmítne `ValidationError` před prvním pokusem.
346
+ - Chyba ze tří přechodných cest nese `buffered` (boolean) — na chybě `PublishLayer` i na obalu
347
+ `PublishError` z `BaseClient.publish()`: `true` jen když buffer zprávu opravdu drží. Trvalé
348
+ odmítnutí `buffered` nenese. Věta `Fix:` chyby už neslibuje „is buffered“ tam, kde zpráva
349
+ v bufferu není (volba `false`, nebo buffer zprávu nepřijal).
350
+ - Chyba publishe nese `sent` (boolean) — zda rámec zprávy OPUSTIL klienta (`sendToQueue()` /
351
+ `publish()` kanálu vrátil bez výjimky), vedle `buffered` z d.1122: na obalu `PublishError`
352
+ z `BaseClient.publish()` i na chybě `PublishLayer` (`true`, když ho odeslal KTERÝKOLI pokus
353
+ volání); chyba jednoho pokusu transportu (`_publishOnce()`) nese `sent` toho pokusu. Záznam
354
+ bufferu nese totéž. Neříká nic o tom, zda broker zprávu uložil: při memory alarmu broker
355
+ odeslané rámce doručí sám po jeho zrušení (d.1143: 3 kopie publishe na exchange, 1 kopie
356
+ prvního publishe do fronty, 0 u neodeslaného). Rozhodnutí vlastníka
357
+ `docs/governance/confirmations/mq-publish-buffer.md` 001 (D); orchestrátor ho kopíruje do
358
+ záznamu `publish_pending` (`step-publish-failure` 003). Chyba transportu bez booleovského
359
+ `sent` je vada souseda: `PublishLayer` ji odmítne jménem a zprávu nebufferuje (d.1145).
360
+
361
+ ### Changed
362
+
363
+ - Každé čekání na odpověď brokera má limit `brokerAnswerTimeout` — jedna kolej, žádný nový klíč
364
+ (rozhodnutí vlastníka, `docs/governance/confirmations/mq-broker-answer-budget.md` 001, verdikt A;
365
+ d.1135). Dosud ho mělo jen potvrzení publishe a `close-ok` při `disconnect()`; `assertQueue`,
366
+ `checkQueue` (veřejné i kontrola před publishem do fronty), `bindQueue`, `assertExchange`,
367
+ `purgeQueue`, `deleteQueue`, `consume`, `cancelConsumer`, prefetch a otevření kanálu
368
+ (`createChannel` / `createConfirmChannel`, při `connect()`, obnově i reconnectu) čekaly bez
369
+ limitu. Změřeno d.1126 na izolovaném brokeru platformního obrazu: při alarmu paměti (spojení
370
+ žije, heartbeat teče) visela po 300 s, u zmrazeného brokeru je ukončil až heartbeat po ~90 s.
371
+ Co se mění pro volajícího — hodnotami:
372
+ - volání, na které broker v limitu neodpoví, skončí po `brokerAnswerTimeout` chybou
373
+ `ConnectionError` s větou `The broker did not answer <operace> <cíl> within
374
+ brokerAnswerTimeout (<N>ms)`; kanál, který požadavek nesl, se vyřadí a příští operace otevře
375
+ nový (kanál, který broker otevře až po limitu, se hned zavře);
376
+ - publish do fronty, jehož kontrola `checkQueue` (nebo obnova kanálu) nedostala odpověď, je
377
+ přechodné selhání: `TransientPublishError` → retry → buffer, s `bufferOnFailure: false`
378
+ `PublishError(cause=TransientPublishError)` a `buffered: false` (D2, conf
379
+ `step-publish-failure` 001);
380
+ - holé `Channel ended, no reply will be forthcoming` z amqplibu (kanál nebo spojení skončilo
381
+ dřív, než broker odpověděl) se na těchto voláních vrací jako `ConnectionError` s původní
382
+ chybou v `cause`; číslované odmítnutí brokera (404, 406, …) zůstává beze změny;
383
+ - `connect()`, jemuž broker neodpoví na otevření kanálu, skončí do limitu a zavře spojení
384
+ v témže limitu (neodpovězené zavření zahodí socket jako `disconnect()`).
385
+ Heartbeat, potvrzení publishe a teardown beze změny.
386
+ - Klient poslouchá `blocked`/`unblocked` svého spojení (broker v memory/disk alarmu blokuje
387
+ spojení, které publikuje; d.1143 změřil na obrazu platformy). `blocked` = `warn` s důvodem
388
+ brokera; `unblocked` = `warn` a flush bufferu jednou kolejí `PublishLayer.flushBuffered('unblocked')`:
389
+ znovu se pošle JEN záznam, jehož rámec nikdy neopustil klienta; záznam s `sent: true` se vyřadí
390
+ s `warn` (fronta/exchange, stáří v bufferu), protože broker podržené rámce po alarmu doručí sám.
391
+ Flush po reconnectu beze změny — `flushBuffered('reconnected')` pošle všechno. Změřeno na
392
+ izolovaném brokeru d1145-rmq: po uvolnění alarmu u brokera 3 kopie odeslaného publishe na
393
+ exchange (beze změny, žádná navíc), 1 kopie odeslaného prvního publishe do fronty (beze změny)
394
+ a nově 1 kopie publishe, který se neodeslal (dřív 0 a tichá expirace); buffer po uvolnění
395
+ prázdný (dřív 2 a 1). Rozhodnutí vlastníka `docs/governance/confirmations/mq-publish-buffer.md`
396
+ 001 (D), d.1145.
397
+ - `PublishLayer.flushBuffered(trigger)` — jediný argument je nově POVINNÝ spouštěč
398
+ `'reconnected'` | `'unblocked'` (rozhoduje, co se stane se záznamem, jehož rámec už odešel);
399
+ jiná hodnota se odmítne jménem. `InMemoryBuffer.flush(flushFn, { sentEntries })` totéž
400
+ (`'resend'` | `'drop'`). Uvnitř balíčku jediný volající je transport (d.1145).
401
+ Druhý argument `flushOptions` (slučoval se do voleb každé znovu poslané zprávy) je PRYČ:
402
+ nikdo ho nepředával (0 volajících v `shared/**`, `infra/**`, `api_biz/*/src`); znovu poslaná
403
+ zpráva nese přesně volby, se kterými ji buffer držel (d.1145).
404
+ - Vypršení zprávy v bufferu je `error`, ne `info`: jeden řádek na cíl (fronta nebo exchange)
405
+ a hodnotu `sent`, s počtem, stářím nejstarší a TTL; `sent=false` = zpráva je ZTRACENÁ,
406
+ `sent=true` = broker ji mohl doručit sám. Dřív „Removed N expired buffered messages“ bez
407
+ fronty i stáří. Rozhodnutí vlastníka `docs/governance/confirmations/mq-publish-buffer.md`
408
+ 001 (D), d.1145.
409
+ - Publish, jehož potvrzení ještě čekalo, když broker zavřel spojení nebo kanál (`320
410
+ CONNECTION_FORCED`, restart či stop brokeru, výpadek spojení), končil `PermanentPublishError`
411
+ bez bufferu a bez retry — zpráva byla ztracena, ač o ní broker nic neřekl (měřeno d.1126).
412
+ Příčina: amqplib odpovídá nepotvrzeným publishům `Error('channel closed')` ze svého `close`
413
+ posluchače registrovaného v konstruktoru kanálu, tedy DŘÍV, než klient kanál označil za
414
+ mrtvý; callback pak četl živý kanál. Klientův `close` posluchač send kanálu se nyní
415
+ registruje `prependListener` a běží první, takže tahle cesta je přechodná
416
+ (`TransientPublishError`) a jde dnešní cestou retry/buffer, včetně `bufferOnFailure: false`
417
+ (`buffered: false`). `nack` brokeru a číslovaný close frame této zprávy zůstávají trvalé
418
+ (d.1134).
419
+ - Hláška `PublishError` větve fronty při zavření kanálu s čekajícím potvrzením tvrdila „and not
420
+ reconnecting“ — v S3 nepravda: spojení v tu chvíli ještě neohlásilo `close` a reconnect
421
+ následuje. Nově: „Channel closed during publish to queue "<q>" before the broker confirmed the
422
+ message …“, bez tvrzení o reconnectu, `Fix:` odkazuje na `error.buffered` (d.1134).
423
+ - Re-registrace konzumentů po obnově kanálu se ptá na živost konzumentského kanálu před KAŽDÝM
424
+ konzumentem (a znovu po kroku fronty), ne jednou před smyčkou. Kanál, který zemřel uprostřed
425
+ smyčky (zavřel ho broker, nebo ho vyřadil `brokerAnswerTimeout`, d.1135), dřív vedl
426
+ u dalšího konzumenta na `TypeError: Cannot read properties of null (reading 'consume')`
427
+ a na zbytečnou deklaraci jeho fronty. Nově `ConnectionError` „Cannot re-register the consumer
428
+ of <q>: consumer channel is not available …“ v `consumer:re-registration:failed`; konzument
429
+ zůstává sledovaný bez tagu a obnoví ho příští obnova, jako dosud (d.1138).
430
+ - Hláška `_waitForReconnection()` po vypršení jmenovala env `RABBITMQ_RECONNECT_WAIT_TIMEOUT`,
431
+ který nic nečte. Nově jmenuje, co se čte: klíč konfigurace `reconnectWaitTimeout` („set in the
432
+ client configuration“), nebo odvození `max(60000, maxReconnectAttempts × reconnectMaxDelay)`,
433
+ když klíč zadaný není; `Fix:` odkazuje na tentýž klíč. Chování beze změny (d.1138).
434
+ - Přechodná chyba cesty publishe (`_publishPathTransient()`) tvrdila „queue channel is not
435
+ available (connection may be closed)“ i při ŽIVÉM spojení blokovaném memory alarmem (d.1143,
436
+ nález 3; od d.1135 končí touto chybou i neodpovězená kontrola `checkQueue`). Věta se nově
437
+ volí ze stavu klienta: zavřené spojení → „the connection is closed“; blokované → „the broker
438
+ has blocked this connection (reason: …)“; jinak „the queue channel is not available on a
439
+ live, unblocked connection“ s příčinou v `error.cause`. Třída beze změny (d.1145).
440
+
441
+ ### Fixed
442
+
443
+ - Spojení, které obnova navázala až po `disconnect()` volajícího, se zavírá v rozpočtu
444
+ `brokerAnswerTimeout` (`_closeWithinTeardownBudget()`, jedna kolej, conf
445
+ `mq-broker-answer-budget` 001). Dřív `await revived.close()` bez limitu: broker, který v tu
446
+ chvíli neodpovídal, držel kolo obnovy (`_reconnecting: true`) a socket otevřené navždy.
447
+ Neodpovězené zavření nově zahodí socket jako teardown (d.1138).
448
+
449
+ ### Documentation — JSDoc a popis klíče na pravdu, docblock jedné koleje `workflow_initiated` bez smazané cesty (d.1134, d.1138, d.1176)
450
+
451
+ - JSDoc `workflowIdOf()` tvrdil, že alias `workflowId` dosud píše webhook submit gatewaye.
452
+ Nepravda: žádný producent v `shared/**` ani `infra/**` alias nepíše (hlavičky gatewaye nesou
453
+ jen `workflow_id`, `infra/api_gateway/shared/workflowMessageHeaders.js`); čtení aliasu
454
+ v transportu neexistuje od d.416, chování beze změny (d.1134).
455
+ - Popis klíče `heartbeat` v `configSchema.js` na pravdu: hodnota se navrhuje brokeru v URL
456
+ spojení (ne „předává se amqplibu“) a `0` heartbeat NEVYPNE — vyjednávání amqplibu vezme
457
+ stranu, která limit stanoví, tedy hodnotu brokera (změřeno d.1126: pauza zjištěna po 179 s).
458
+ Chování beze změny (d.1138).
459
+ - Docblock `src/monitoring-initiated.js` popisuje rozhodnutí o jedné koleji události
460
+ `workflow_initiated` (conf `scheduler` 009 a 024) bez cesty na smazaný soubor gatewaye
461
+ a bez vyprávění o kopiích, které to rozhodnutí rušilo; chování beze změny (d.1176).
462
+
463
+ ### Tests — sady bez nástěnného času a s pravdivým popisem bloku (d.1174, d.1138)
464
+
465
+ - Test, ne chování: sada `connect-timeout-closes-late-handshake.test.js` čekala nástěnný čas
466
+ (`connectTimeout` 20 ms, pak `setTimeout` 80 ms na druhý pokus reconnectu) a pod zátěží runneru
467
+ `oa-staging` (pipeline 2904670335) četla `amqp.connect` 2× místo 3×, proto hodiny teď řídí
468
+ test (`jest.useFakeTimers()` + `advanceTimersByTimeAsync`) a výsledek nezávisí na zátěži
469
+ stroje (d.1174).
470
+ - Popis bloku testu `connection-parameters-reach-the-broker.test.js` („…one name for its two
471
+ consumers“) srovnán s d.1135: budget `brokerAnswerTimeout` platí pro každé čekání na odpověď
472
+ brokera, ne pro dva konzumenty (d.1138).
473
+ - Test, ne chování: akceptační sada čekací koleje `waiting-helper.test.js` měřila vzorkování
474
+ `waitForValue`/`waitForEvent` na nástěnných hodinách a pod loadem 17–30 (pre-K sweep
475
+ `release/w4`) vzorek rastru 20 ms zpožděný o víc než 40 ms minul okno 150–190 ms, takže
476
+ „s `pollMs: 20` tentýž stav VIDÍ" četl `true`; hodiny teď řídí test (`jest.useFakeTimers()`
477
+ + `advanceTimersByTimeAsync`, čas čte `jest.now()`); hodnoty a počty v tvrzeních beze změny,
478
+ podlahy pauzy přesně podle pravidla, bez rezervy na plánovač; kolej `tests/waiting.js`
479
+ nedotčena (d.1192).
480
+
7
481
  ## [5.0.0] — 2026-09-27
8
482
 
9
483
  ### Removed — BREAKING — fronta `workflow.control` (d.1061)