@onlineapps/mq-client-core 3.1.0 → 4.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 +331 -0
- package/README.md +42 -6
- package/package.json +3 -3
- package/src/BaseClient.js +58 -2
- package/src/config/configSchema.js +7 -14
- package/src/config/defaultConfig.js +3 -7
- package/src/config/queueConfig.js +105 -13
- package/src/transports/rabbitmqClient.js +255 -31
- package/src/workers/RecoveryWorker.js +10 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,337 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [4.0.0] — 2026-09-16
|
|
8
|
+
|
|
9
|
+
### Changed — značku odmítnutí nese i deklarační rail, jednou funkcí pro obě cesty (d.582)
|
|
10
|
+
|
|
11
|
+
d.577 dala verdikt cestě `consume()`. `assertQueue()` — veřejný rail, kterým jde
|
|
12
|
+
**každá** deklarace a po kterém jde FÁZE 0.8 `@onlineapps/service-wrapper`, když
|
|
13
|
+
si služba zakládá vlastní business fronty — dál propouštěl syrovou chybu
|
|
14
|
+
z amqplib. Odmítnutá deklarace tak odcházela s `code` = **číslo** `406`
|
|
15
|
+
a bez jakékoli klasifikace: `@onlineapps/conn-orch-registry` přenáší, co
|
|
16
|
+
rozhodlo jádro (d.579), a neměl co přenést; wrapper, který končí fázi natrvalo na
|
|
17
|
+
`code: 'MQ_CONNECTION_FATAL'`, označil odmítnutí za přechodné.
|
|
18
|
+
|
|
19
|
+
Teď obě cesty odpovídají **jednou funkcí** `_asRefusal(kind, queue, err)`: `kind`
|
|
20
|
+
volí větu a třídu obalu, verdikt se píše jednou a v pravopisu, který už používá
|
|
21
|
+
`_failFatally()`. Odmítnutá deklarace vychází jako chyba s
|
|
22
|
+
`code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused'`, jménem fronty v `queue`
|
|
23
|
+
a původní chybou brokeru — včetně reply kódu — v `cause`. Dvě kopie jedné
|
|
24
|
+
klasifikace by se mohly rozejít (`change-discipline.md` § One rail per concern).
|
|
25
|
+
|
|
26
|
+
`_failFatally()` se ani tady nevolá: odmítnuta je fronta, ne spojení — klient
|
|
27
|
+
zůstává připojený a `isConnectionFatal()` je dál `false`.
|
|
28
|
+
|
|
29
|
+
Kdo z toho co má: po re-pinu na tuto verzi doručí `ensureQueues()`
|
|
30
|
+
v `@onlineapps/conn-orch-registry` (d.579) značku wrapperu, takže 406 z FÁZE 0.8
|
|
31
|
+
skončí boot natrvalo místo aby ho opakoval proti frontě, kterou služba nikdy
|
|
32
|
+
nesmí založit.
|
|
33
|
+
|
|
34
|
+
Chování, které se **nemění**: 404, „channel ended", 503 i odmítnutí volajícího
|
|
35
|
+
(`Queue lifetime is not the caller's to set`) odcházejí přesně tak, jak přišly;
|
|
36
|
+
přijatá deklarace dál vrací `queue.declare-ok` brokeru. Kontrolní noha d.295
|
|
37
|
+
v `connect-refusal-fails-fast.integration.test.js` čte reply kód nově o jedno
|
|
38
|
+
`cause` níž — její vlastní tvrzení (spojení nebylo v sázce, klient se nestaví
|
|
39
|
+
mimo provoz) platí beze změny.
|
|
40
|
+
|
|
41
|
+
### Changed — odmítnutí fronty brokerem nese stejný verdikt jako odmítnutí spojení (d.577)
|
|
42
|
+
|
|
43
|
+
Fronta, která na brokeru existuje s jinými argumenty, než jaké `queueConfig`
|
|
44
|
+
deklaruje, odpoví na `assertQueue()` kódem **406 PRECONDITION-FAILED**.
|
|
45
|
+
`_prepareQueueForConsume()` z toho stavěl `ConsumeError` **bez `code` a bez
|
|
46
|
+
`reason`** — kód brokeru přežil jen v `cause` (a o jedno obalení výš, v
|
|
47
|
+
`@onlineapps/conn-orch-registry`, až v `cause.cause`). `REFUSAL_REPLY_CODES` a
|
|
48
|
+
`_isBrokerRefusal()` se přitom ptaly jen v connect/reconnect.
|
|
49
|
+
|
|
50
|
+
Co to stálo: `@onlineapps/service-wrapper` ukončí FÁZI 0.7 trvale jen na
|
|
51
|
+
`code: 'MQ_CONNECTION_FATAL'`, takže 406 na frontě byl klasifikován jako
|
|
52
|
+
PŘECHODNÝ a služba opakovala boot proti frontě, kterou nikdy nesmí založit —
|
|
53
|
+
každý pokus dostane stejnou odpověď (změřeno sondou W532 proti živému brokeru).
|
|
54
|
+
|
|
55
|
+
Teď je to **jeden slovník pro jeden fakt**: každá chyba z cesty
|
|
56
|
+
`consume()` — klasifikace fronty, deklarace i registrace konzumenta — kterou
|
|
57
|
+
broker odmítl kódem z `REFUSAL_REPLY_CODES` (403/530/406), vychází jako
|
|
58
|
+
`ConsumeError` s `code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused'` a
|
|
59
|
+
původní chybou brokeru v `cause`. Klasifikuje se **kódem odpovědi**, ne zněním
|
|
60
|
+
hlášky (`_isBrokerRefusal()`, jediná kolej). Verdikt píše jedno místo — `catch`
|
|
61
|
+
v `consume()` transportu — a `BaseClient.consume()` takovou chybu přeposílá
|
|
62
|
+
**beze změny**: obalení by před přesnou větu brokeru postavilo obecnější a
|
|
63
|
+
značku zanořilo o jedno `cause` hlouběji.
|
|
64
|
+
|
|
65
|
+
Co se záměrně **nemění**: `_failFatally()` se tu nevolá. Odmítnuta je fronta, ne
|
|
66
|
+
spojení — klient zůstává připojený, publikační cesta nad týmž spojením nemá co
|
|
67
|
+
odpovídat a líné zotavení se nezastavuje. Chyba, kterou broker neodmítl, odchází
|
|
68
|
+
přesně tak, jak přišla: 404 dál nese `CONSUMER_QUEUE_MISSING`, chybějící
|
|
69
|
+
dead-letter route dál `CONSUMER_DEAD_LETTER_ROUTE_MISSING`, 503 a „channel
|
|
70
|
+
ended" dál žádnou značku.
|
|
71
|
+
|
|
72
|
+
### Documentation — kadence heartbeatu se tady nedeklaruje (d.574)
|
|
73
|
+
|
|
74
|
+
Dva komentáře v `src/config/queueConfig.js` psaly kadenci heartbeatu číslem
|
|
75
|
+
(„every 10s"). Balíček ji nevlastní: je to povinná volba konstruktoru
|
|
76
|
+
`RegistryClient` (`@onlineapps/conn-orch-registry`, `heartbeatInterval`), kterou
|
|
77
|
+
`@onlineapps/service-wrapper` plní z `BIZ_HEARTBEAT_INTERVAL_MS`. Ručně psané
|
|
78
|
+
číslo v komentáři je popisný fakt bez mechanismu, který ho drží pravdivý
|
|
79
|
+
(`.claude/rules/doc-code-binding.md` §1), takže obě věty teď ukazují na
|
|
80
|
+
vlastníka a číslo nenesou. Chování ani API se nemění.
|
|
81
|
+
|
|
82
|
+
### Changed — odmítnutí brokerem na cestě bootu nese verdikt (d.295)
|
|
83
|
+
|
|
84
|
+
`connect()` přeposílal syrovou chybu z amqplib bez jakéhokoli verdiktu, takže
|
|
85
|
+
volající nad ním nemohl poznat, jestli broker **odpověděl a odmítl** (403
|
|
86
|
+
ACCESS-REFUSED, 530 NOT-ALLOWED, 406 PRECONDITION-FAILED), nebo jestli **nikdo
|
|
87
|
+
neodpověděl** (ECONNREFUSED, timeout). `_isBrokerRefusal()` ten rozdíl umí od
|
|
88
|
+
d.339, ale četla ho jediná cesta — `_reconnectWithBackoff()`, tedy spojení
|
|
89
|
+
ztracené za běhu. Teď ho čte i první `connect()`: odmítnutí z něj vychází jako
|
|
90
|
+
chyba s `code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused'`, původní chybou
|
|
91
|
+
v `cause` a hláškou §5, která jmenuje credentials, vhost a topologii jako fix.
|
|
92
|
+
`isConnectionFatal()` je od té chvíle `true`, takže se proti odmítajícímu brokeru
|
|
93
|
+
už nerozbíhá líné zotavení.
|
|
94
|
+
|
|
95
|
+
Verdikt se na této cestě **neohlašuje** na `connection:fatal` ani přes `onFatal`:
|
|
96
|
+
volající na `connect()` čeká, takže se ho dozví výjimkou, a druhé oznámení téže
|
|
97
|
+
události je přesně ta škoda, kterou popisuje `_reportRecoveryFailure()`. Výsledek
|
|
98
|
+
bootu patří volajícímu bootu (`@onlineapps/service-wrapper`, poznámka `BOOT
|
|
99
|
+
budget` v `_initializeMQ()`), ne kanálu pro asynchronní ztrátu.
|
|
100
|
+
|
|
101
|
+
`BaseClient.connect()` verdikt propouští beze změny. Dosud ho obalil do obecné
|
|
102
|
+
`ConnectionError` s fixem „read error.cause" — přesná odpověď degradovaná na
|
|
103
|
+
vágní, a značka, na kterou volající přestává opakovat, se ztratila úplně
|
|
104
|
+
(`ConnectionError` bere `code` jako argument konstruktoru a to volání žádný
|
|
105
|
+
nepředávalo).
|
|
106
|
+
|
|
107
|
+
Co se **nemění**: `REFUSAL_REPLY_CODES` zůstávají `[403, 530, 406]` — 503
|
|
108
|
+
COMMAND-INVALID se nepřidává, žádné měření si to nevyžádalo. Neodpovídající
|
|
109
|
+
broker odchází z `connect()` přesně jako dřív, syrový a opakovatelný, protože
|
|
110
|
+
„vzdát to navždy" nad výpadkem zakazuje konfirmace
|
|
111
|
+
`mq-client-lifecycle-contract` 001 bod 3. A 406 z `assertQueue()` nad živým
|
|
112
|
+
spojením zůstává chybou fronty, ne ztrátou spojení.
|
|
113
|
+
|
|
114
|
+
### Changed — `publish()` cituje příčinu, nehádá ji (d.514)
|
|
115
|
+
|
|
116
|
+
`BaseClient.publish()` přepisoval všechno, co transport vyhodí, na jednu větu:
|
|
117
|
+
*„Failed to publish to queue "<q>" - Expected: the queue to exist and the channel
|
|
118
|
+
to be open. Fix: read error.cause for the broker reason."* Dvě ze tří jejích
|
|
119
|
+
klauzulí byly dohad. Transport odmítá publikaci i z důvodů, které s chybějící
|
|
120
|
+
frontou ani se zavřeným kanálem nemají nic společného — pravidlo vlastnictví
|
|
121
|
+
(`QueueNotFoundError`, `kind: 'unowned'`, d.419), vyčerpaný rozpočet opakování,
|
|
122
|
+
odmítnutí brokerem — a u každého z nich hláška posílala čtenáře prověřit frontu,
|
|
123
|
+
která existuje, a kanál, který je otevřený. Pravá věta ležela v `error.cause`,
|
|
124
|
+
kam se řádek logu nedívá.
|
|
125
|
+
|
|
126
|
+
Je to táž vada, kterou d.297 odstranila o volání vedle (`_consumerRefusal()`),
|
|
127
|
+
a odpovídá se stejně: vrstva **nejmenuje nejpravděpodobnější příčinu, cituje tu,
|
|
128
|
+
kterou skutečně chytila**. Nový tvar hlášky §5 je
|
|
129
|
+
`[BaseClient] Failed to publish to queue "<q>": <věta transportu> - Expected:
|
|
130
|
+
publish() to hand the message to the broker. Fix: act on the reason above …
|
|
131
|
+
error.cause carries the original error, error.queue names the target queue.`
|
|
132
|
+
|
|
133
|
+
Co se **nemění**: třída (`PublishError`), `error.queue`, `error.cause` ani objekt
|
|
134
|
+
v něm — volající, který se rozhoduje podle třídy, čte dál objekt. Nemění se ani
|
|
135
|
+
`ConnectionError` z nepřipojeného klienta a `SerializationError` z nepřevoditelné
|
|
136
|
+
zprávy; obě mají vlastní, pravdivou větu už teď. Odmítnutí, které vyhodí hodnotu
|
|
137
|
+
bez `message` (neErrorová rejekce), se do hlášky přenáší celé (`String(thrown)`),
|
|
138
|
+
protože „undefined" uprostřed odmítnutí je horší odpověď než řetězec, kterým
|
|
139
|
+
někdo odmítl.
|
|
140
|
+
|
|
141
|
+
### Removed (BREAKING) — `recoveryScope`: klíč, který po d.419 nic nečetlo (d.514)
|
|
142
|
+
|
|
143
|
+
`recoveryScope` byl poslední zbytek dveří, kterými klient zakládal frontu bez
|
|
144
|
+
vlastníka. d.419 odebrala `queueCreationFilter` i `queueCreationCallback`
|
|
145
|
+
a `RecoveryWorker.createQueue()`; hodnota `recoveryScope` ale dál putovala ze
|
|
146
|
+
schématu přes `rabbitmqClient.js` do `RecoveryWorker`, kde se přiřadila do pole
|
|
147
|
+
`this._scope`, **které nečetlo nic v celém balíčku** (měřeno 2026-09-15:
|
|
148
|
+
`grep -rn "_scope" src/` → jediný výskyt je přiřazení a jedna zmínka v komentáři).
|
|
149
|
+
Deklarace, kterou nikdo nečte, tvrdí čtenáři, že na ní něco závisí.
|
|
150
|
+
|
|
151
|
+
Odchází celý řetěz: `configSchema.recoveryScope`, `defaultConfig.recoveryScope`,
|
|
152
|
+
argument `scope` konstruktoru `RecoveryWorker` i pole `this._scope`. Schéma je
|
|
153
|
+
ZAVŘENÉ, takže konfigurace, která klíč pořád nese, je odmítnuta **jménem** při
|
|
154
|
+
konstrukci — ne tiše ignorována.
|
|
155
|
+
|
|
156
|
+
**Čtyři otázky** (`.claude/rules/change-discipline.md` § Removing something
|
|
157
|
+
removes its declaration): **(1) proč vznikl** — `scope` říkal, jestli tenhle
|
|
158
|
+
klient smí zakládat fronty; v `business` scope je zakládal, v `infrastructure`
|
|
159
|
+
ne. Byla to volba provozovatele nad ad-hoc jmény z doby, kdy neexistovala
|
|
160
|
+
centrální šablona, ze které by se dala fronta deklarovat. **(2) která část
|
|
161
|
+
koncepce ho nesla** — hlavička `RecoveryWorker`, § Queue Creation: *„ostatní
|
|
162
|
+
jména … smí založit, pokud to scope a `queueCreationFilter` dovolují"*. Ta část
|
|
163
|
+
**neplatí**: nahradila ji konfirmace `mq-consumer-contract` 006 — *„queues come
|
|
164
|
+
into being only from declarations"* — a d.419 podle ní zavřela obě zbylé dveře.
|
|
165
|
+
**(3) proč ho dnes nikdo nečte** — ustoupil rozhodnutí: s dveřmi zmizel jediný
|
|
166
|
+
čtenář a klíč zůstal ležet. Vacuózní byl ale už předtím, a je to zapsané v kódu,
|
|
167
|
+
který se teď maže: `ConnectorMQClient` nastavoval `'business'` **každé** biz
|
|
168
|
+
službě, takže „smí zakládat" platilo plošně a nerozhodovalo nic. **(4) je náhrada
|
|
169
|
+
koncepčnější** — ano. O tvaru hlášení rozhoduje třída vlastnictví, kterou
|
|
170
|
+
odpověděla centrální deklarace (`error.kind` z `QueueNotFoundError`: `infrastructure`
|
|
171
|
+
/ `business` / `unowned`). Ta plyne z `queueConfig`, ne z toho, kdo klienta
|
|
172
|
+
postavil, takže dva klienti namířené na tutéž frontu nemohou dostat různou
|
|
173
|
+
odpověď — a odpověď se nemůže rozejít s deklarací, protože je z ní odvozená.
|
|
174
|
+
|
|
175
|
+
**Pro volajícího:** `@onlineapps/conn-infra-mq` klíč jak je publikován pořád
|
|
176
|
+
posílá (`ConnectorMQClient.js`, `recoveryScope: config.recoveryScope || 'business'`;
|
|
177
|
+
pinuje `@onlineapps/mq-client-core` 2.0.1, kde klíč ještě existuje). Až se pin
|
|
178
|
+
pohne, bude odmítnut jménem při konstrukci. Úprava konektoru patří do dávky, která
|
|
179
|
+
pin posouvá, ne do téhle — stejně jako u `queueCreationCallback` v d.419.
|
|
180
|
+
|
|
181
|
+
### Removed — výjimka pro `rpc.reply.*` v `consume()`, nedosažitelná od d.259 (d.514)
|
|
182
|
+
|
|
183
|
+
`_prepareQueueForConsume()` měla třetí třídu jména: co začínalo `rpc.reply.`,
|
|
184
|
+
přeskočilo klasifikaci, rozlišení argumentů z centrální konfigurace i otázku na
|
|
185
|
+
brokera (`checkQueue`/`assertQueue`). Mezi ty dvě výjimky ale d.259 postavila
|
|
186
|
+
bránu nedoručitelných zpráv, a ta takové jméno **odmítne dřív**: `queueConfig`
|
|
187
|
+
ho neklasifikuje jako infrastrukturní ani business (měřeno:
|
|
188
|
+
`isInfrastructureQueue('rpc.reply.abc123') === false`, `isBusinessQueue` totéž),
|
|
189
|
+
takže pro něj nedeklaruje žádnou cestu, a `getDeadLetterRoute()` vrací `null`
|
|
190
|
+
při `isDeadLetterTarget() === false`. Druhá polovina výjimky tak byla mrtvý kód
|
|
191
|
+
a první nerozhodovala nic (obě klasifikace jsou pro to jméno `false` i bez ní).
|
|
192
|
+
|
|
193
|
+
**Čtyři otázky**: **(1) proč vznikla** — RPC odpovědní cesta: volající si sám
|
|
194
|
+
založil dočasnou, nedurabilní frontu s vlastní životností a konzumoval z ní, takže
|
|
195
|
+
`consume()` nad ní nesměl nic deklarovat. **(2) která část koncepce ji nesla** —
|
|
196
|
+
právě ta cesta. **Neplatí**: `createTemporaryQueue()` byla zrušena
|
|
197
|
+
(`conn-infra-mq` d.275b) a konfirmace `mq-consumer-contract` 003 bod 3 to říká
|
|
198
|
+
větou — *„`rpc.reply.*` is not a topology question — `createTemporaryQueue()` has
|
|
199
|
+
no caller, it is a removal question"*. **(3) proč ji dnes nic nečte** — ustoupila
|
|
200
|
+
rozhodnutí d.259: konzument bez cesty pro nedoručitelnou zprávu se nepřipojí,
|
|
201
|
+
a fronta bez deklarace žádnou cestu nemá. **(4) je náhrada koncepčnější** — ano,
|
|
202
|
+
je to jedna kolej: fronta je konzumovatelná právě tehdy, když centrální deklarace
|
|
203
|
+
říká, kam s odmítnutou zprávou. Neexistuje druhá třída jmen, která klasifikaci
|
|
204
|
+
a deklaraci přeskočí na slovo volajícího. Výjimka slibovala podporu, kterou d.259
|
|
205
|
+
odebrala, takže kód teď říká, co systém dělá.
|
|
206
|
+
|
|
207
|
+
Chování se **nemění** a je změřené proti živému brokeru: `rpc.reply.*` fronta,
|
|
208
|
+
která na brokeru **skutečně je** (nedurabilní, jak ji zakládala zrušená cesta), je
|
|
209
|
+
odmítnuta touž větou jako každé jiné jméno bez deklarované cesty, nic se k ní
|
|
210
|
+
nepřipojí a nic se nepředeklaruje
|
|
211
|
+
(`tests/integration/dead-letter-route-required.integration.test.js`).
|
|
212
|
+
|
|
213
|
+
### Changed — `amqplib` přišpendlen přesně na `0.10.9` (d.514)
|
|
214
|
+
|
|
215
|
+
`^0.10.3` je plovoucí rozsah, a tenhle balíček na konkrétním chování amqplib
|
|
216
|
+
**staví změřená tvrzení**: „ani `Connection`, ani `Channel` nenese vlastnost
|
|
217
|
+
`closed`" (d.260, amqplib 0.10.9, živý broker, 2026-09-12) je předpoklad
|
|
218
|
+
`isConnected()`, `getChannelState()`, všech tří `_ensure*Channel()`
|
|
219
|
+
a `_performHealthCheck()`. S plovoucím rozsahem může tentýž commit nainstalovat
|
|
220
|
+
jiný kód jiný den — přesně to, čemu pravidlo o pinech brání u `@onlineapps/*`
|
|
221
|
+
(`architecture-principles.md` § Version pinning: „a floating range makes the same
|
|
222
|
+
commit install different code on different days"). Hodnota `0.10.9` není nová,
|
|
223
|
+
je to co bylo v `package-lock.json` nainstalováno (měřeno:
|
|
224
|
+
`require('amqplib/package.json').version` → `0.10.9`); mění se jen to, že ji
|
|
225
|
+
`package.json` říká nahlas. `amqplib` není v SSOT `api/config/libraries.json`
|
|
226
|
+
(měřeno: grep bez shody), takže tenhle pin nevlastní publikační vlna.
|
|
227
|
+
|
|
228
|
+
### Changed (testy) — sady `liveness-health-publish*` říkají, proč mají zotavení vypnuté (d.514)
|
|
229
|
+
|
|
230
|
+
Obě běží s `reconnectEnabled: false`. Důvod je pravdivý a teď je napsaný: co v nich
|
|
231
|
+
umírá, je KANÁL, a obnova spojení má vlastní sady (`reconnect-cycles-lazy-retry`,
|
|
232
|
+
`reconnect-exhaustion`, `disconnect-ends-reconnect`). Napsané je i to, co ten
|
|
233
|
+
příznak nese: **nenese nic** — měřeno 2026-09-15, obě sady jsou zelené i s
|
|
234
|
+
`reconnectEnabled: true` (integrace 4/4 třikrát po sobě, unit 11/11) — a právě
|
|
235
|
+
proto neviděly d.467, kde `performHealthCheck()` spouštěl obnovovací cyklus
|
|
236
|
+
bezpodmínečně. Případ s obnovou se do nich **nedoplňuje**: ta otázka má vlastní
|
|
237
|
+
kolej se zapnutou obnovou (`health-check-does-not-recover-a-live-client`, unit
|
|
238
|
+
i integrace), a druhá akceptační sada nad jedním pojmem je duplicita, kterou
|
|
239
|
+
`change-discipline.md` § One rail per concern označuje za vadu.
|
|
240
|
+
|
|
241
|
+
### Fixed — pracující konzument s prefetchem 1 už není poplach (d.550)
|
|
242
|
+
|
|
243
|
+
Změřeno na BIZ-pdfgen a BIZ-converteru, dvakrát při každém bootu:
|
|
244
|
+
|
|
245
|
+
⚠️ Prefetch utilization high: queue 'biz-pdfgen.registry' has 1/1 messages
|
|
246
|
+
in-flight (100%, threshold: 80%)
|
|
247
|
+
|
|
248
|
+
Registrační fronta má prefetch 1, takže jedna zpráva v letu je 100 % okna — a
|
|
249
|
+
zároveň naprosto normální stav konzumenta, který právě dělá tu jedinou práci,
|
|
250
|
+
kterou smí. Poplach se proto spustil při KAŽDÉM doručení do takové fronty a
|
|
251
|
+
žádný práh na tom nic nemění: `1/1 >= 0.8` platí stejně jako `1/1 >= 0.99`.
|
|
252
|
+
Varování, které nemůže nenastat, neříká nic — jen učí čtenáře přehlížet řádek,
|
|
253
|
+
na kterém jednou bude stát skutečné zahlcení.
|
|
254
|
+
|
|
255
|
+
`_checkPrefetchUtilization()` nově okno jedné zprávy neměří: jeho jediné dva
|
|
256
|
+
stavy jsou 0 % (nečinný) a 100 % (pracuje) a ani jeden není zjištění, kvůli
|
|
257
|
+
kterému by měl kdo zasáhnout. Pro okno větší než jedna zpráva se nemění nic —
|
|
258
|
+
`prefetchUtilizationThreshold` zůstává, jak ho deklaruje
|
|
259
|
+
`src/config/defaultConfig.js`, žádný nový práh ani default nevzniká, a plné okno
|
|
260
|
+
dvou i deseti zpráv se hlásí dál se stejným textem (kontrolní případy v obou
|
|
261
|
+
tierech: `tests/unit/prefetch-of-one-is-not-a-warning.test.js`,
|
|
262
|
+
`tests/integration/prefetch-of-one-is-not-a-warning.integration.test.js`).
|
|
263
|
+
|
|
264
|
+
### Changed (testy) — kontrolní případ redakce jmenuje allow-list, ne jeho polovinu (d.514)
|
|
265
|
+
|
|
266
|
+
`tests/unit/redact-credentials.test.js` měl kontrolní případ nadepsaný *„URL bez
|
|
267
|
+
pověření se vrací nezměněná“*. Tvrzení zní jako obecné pravidlo, které od
|
|
268
|
+
`@onlineapps/logger-contract` 2.0.0 (d.476) neplatí: redakce je ALLOW-LIST —
|
|
269
|
+
zachová protokol, host, port a cestu, a všechno ostatní odejde bez ohledu na to,
|
|
270
|
+
jestli v tom pověření je. URL bez pověření, ale s dotazem (`?heartbeat=60`) se
|
|
271
|
+
nezměněná nevrací. Kontrolní případ, který jmenuje polovinu pravidla, zůstává
|
|
272
|
+
zelený i nad implementací, která dotaz propouští — tedy přesně nad vadou, kvůli
|
|
273
|
+
které allow-list vznikl.
|
|
274
|
+
|
|
275
|
+
Hodnota v assertu se **nemění** (`amqp://api_services_queuer:5672/oa_vhost` se
|
|
276
|
+
vrací identické); mění se věta, kterou test tvrdí, a důvod je u ní zapsaný.
|
|
277
|
+
Sousední případ, který zahození dotazu měří, zůstává jedinou kolejí pro to
|
|
278
|
+
tvrzení — nový se nepřidává. Žádná změna produkčního kódu.
|
|
279
|
+
|
|
280
|
+
### Documentation — terminální frontu zakládá vlastník prefixu, který ji jmenuje (d.568)
|
|
281
|
+
|
|
282
|
+
Deklarace není pokyn k založení. Zápis sekce v `queueConfig` říká, **jak** fronta
|
|
283
|
+
vypadá; **kdo** ji na brokeru založí, rozhoduje `options.queues` toho, kdo volá
|
|
284
|
+
`initInfrastructureQueues()`. Odvození celého seznamu z prefixů je jen VÝCHOZÍ větev
|
|
285
|
+
té funkce (`shared/infrastructure-tools/src/orchestration/initInfrastructureQueues.js`)
|
|
286
|
+
a žádný dnešní volající jí nenechá rozhodnout: gateway jmenuje přesně pět front
|
|
287
|
+
`workflow.*`, které vlastní (`infra/api_gateway/index.js`). `monitoring.dlq`
|
|
288
|
+
a `telemetry.dlq` tedy patří do volání monitoringu, ne gateway.
|
|
289
|
+
|
|
290
|
+
INFRA-monitoring to změřilo 2026-09-16 na dev brokeru: 78 front, `.dlq` mezi nimi jen
|
|
291
|
+
`workflow.dlq` a `delivery.dlq`. Sedm front rodin `monitoring.*` a `telemetry.*` nese od
|
|
292
|
+
d.475 `x-dead-letter-routing-key: '<prefix>.dlq'`, takže `nack(requeue=false)` nad nimi
|
|
293
|
+
směřuje na routing key, pod kterým žádná fronta není — a broker zprávu zahodí.
|
|
294
|
+
Terminál založí vlastník prefixu u sebe (INFRA-monitoring, W9c).
|
|
295
|
+
|
|
296
|
+
Opraveny věty v živém textu: JSDoc `monitoring.dlq` a `telemetry.dlq`
|
|
297
|
+
(`src/config/queueConfig.js`), úroveň § terminální fronty v `README.md` a hlavička
|
|
298
|
+
testu `tests/unit/dead-letter-topology.test.js`, která tvrdila, že zápis sekce JE pokyn
|
|
299
|
+
k založení. Dobový záznam v `[3.2.0]` se nemění. Deklarace sama je v pořádku a nově to
|
|
300
|
+
měří test: `declarationOptions('monitoring.dlq')` a `('telemetry.dlq')` vrací přesně
|
|
301
|
+
`{ durable: true, arguments: { 'x-max-length': 50000 } }` — žádné TTL, žádná další
|
|
302
|
+
cesta — s kontrolním případem `workflow.dlq` (totéž) a `monitoring.audit` (routovaná
|
|
303
|
+
fronta též rodiny si TTL, strop i cestu drží). Žádná změna produkčního kódu.
|
|
304
|
+
|
|
305
|
+
## [3.2.0] — 2026-09-15
|
|
306
|
+
|
|
307
|
+
### Added — dead-letter cesty pro rodiny `monitoring.*` a `telemetry.*` (d.475)
|
|
308
|
+
|
|
309
|
+
Sedm front, které konzumenti monitoringu čtou — `monitoring.workflow`,
|
|
310
|
+
`monitoring.services`, `monitoring.audit`,
|
|
311
|
+
`monitoring.infrastructure.health.events`, `telemetry.logs.queue`,
|
|
312
|
+
`telemetry.metrics.queue`, `telemetry.traces.queue` — dostalo
|
|
313
|
+
`x-dead-letter-exchange: ''` a `x-dead-letter-routing-key: '<prefix>.dlq'`, a obě
|
|
314
|
+
rodiny svou koncovou frontu: `monitoring.dlq` a `telemetry.dlq`, deklarované
|
|
315
|
+
stejným tvarem jako `workflow.dlq` a `delivery.dlq` (durable, `x-max-length`
|
|
316
|
+
50000, žádné TTL, žádná další cesta). TTL ani strop žádné existující fronty se
|
|
317
|
+
nemění.
|
|
318
|
+
|
|
319
|
+
Je to mechanické pravidlo konfirmace `mq-consumer-contract` 003 bod 1 použité
|
|
320
|
+
podruhé, přesně jak bod 3 předepsal („`monitoring.*`, `telemetry.*` … dostanou
|
|
321
|
+
`<prefix>.dlq`, až jejich konzumenti přejdou na politiku knihovny") — nová
|
|
322
|
+
konfirmace k tomu nepatří. Do té doby brána d.259 ty konzumenty vůbec nepustila:
|
|
323
|
+
`consume()` odmítá frontu, jejíž deklarace neříká, kam s odmítnutou zprávou.
|
|
324
|
+
|
|
325
|
+
Ostatní rodiny téže věty — `registry.*`, `validation.*`, `infrastructure.*` —
|
|
326
|
+
cestu **nedostávají**: jejich konzumenti pořád potvrzují na vlastních surových
|
|
327
|
+
kanálech (`infra/api_services_registry/src/listeners/registry.listener.js`,
|
|
328
|
+
`infra/api_services_{registry,validator}/src/services/mqManager.js` — DL-007),
|
|
329
|
+
a `.dlq`, do které nikdo nezapisuje, je fronta bez pisatele.
|
|
330
|
+
|
|
331
|
+
Fronty zakládá `initInfrastructureQueues()` z deklarace samotné (prochází
|
|
332
|
+
`infrastructurePrefixes()` a klíče sekcí), takže obě nové vzniknou při nejbližším
|
|
333
|
+
bootu jejího jediného volajícího, `infra/api_gateway`. Běžící `monitoring.*`
|
|
334
|
+
a `telemetry.*` fronty nesou staré argumenty, takže je podle konfirmace 003 bodu
|
|
335
|
+
4 čeká smazání a znovuzaložení ve vlně; do té doby je `assertQueue` nad nimi
|
|
336
|
+
s novou deklarací 406.
|
|
337
|
+
|
|
7
338
|
## [3.1.0] — 2026-09-15
|
|
8
339
|
|
|
9
340
|
### Added — `topology` nese i tři telemetrické fronty (d.461)
|
package/README.md
CHANGED
|
@@ -195,7 +195,12 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
|
|
|
195
195
|
connection (see § Connection loss)
|
|
196
196
|
|
|
197
197
|
`publish()` and the workflow helpers built on it resolve to `undefined`; failure is
|
|
198
|
-
signalled by a thrown `PublishError`, never by a falsy return.
|
|
198
|
+
signalled by a thrown `PublishError`, never by a falsy return. That `PublishError`
|
|
199
|
+
**quotes the reason it caught** — `[BaseClient] Failed to publish to queue "<q>":
|
|
200
|
+
<the transport's own sentence> - Expected: … Fix: …` — and keeps the object it came
|
|
201
|
+
from in `error.cause`, so a caller deciding on the class still reads the object
|
|
202
|
+
while a log line already carries the reason. The layer names no likeliest cause of
|
|
203
|
+
its own; the same rule `consume()` follows (§ `errors` and `errorCodes`).
|
|
199
204
|
|
|
200
205
|
### `consume()` — the delivery contract
|
|
201
206
|
|
|
@@ -218,7 +223,8 @@ carries `{ queue, messageCount, consumerCount }` and no arguments (measured, 202
|
|
|
218
223
|
|
|
219
224
|
**A TERMINAL queue is consumed, and it is not an exception to that rule but its other
|
|
220
225
|
half.** A queue something else dead-letters TO — `<service>.dlq`, `workflow.failed`,
|
|
221
|
-
`workflow.dlq`, `delivery.dlq` — declares no onward
|
|
226
|
+
`workflow.dlq`, `delivery.dlq`, `monitoring.dlq`, `telemetry.dlq` — declares no onward
|
|
227
|
+
route because the topology says it
|
|
222
228
|
must not have one: a dead-letter route on a dead-letter queue closes a loop or starts a
|
|
223
229
|
chain with no end (`api/docs/governance/confirmations/mq-consumer-contract.md` 002
|
|
224
230
|
point 2, 003 point 2). So the gate asks a second question, `queueConfig.isDeadLetterTarget(queue)`,
|
|
@@ -234,6 +240,17 @@ policy exists to end) and it is never silent: the client logs it at error level,
|
|
|
234
240
|
the queue, the attempts and the error, and the `message_dlq` event is published as for any
|
|
235
241
|
other rejection.
|
|
236
242
|
|
|
243
|
+
**A terminal queue is declared here and CREATED by the owner of the prefix that names
|
|
244
|
+
it.** A section entry says what the queue looks like, never who brings it into being:
|
|
245
|
+
`initInfrastructureQueues()` derives the full list from the sections only when its caller
|
|
246
|
+
passes no `queues` list, and every caller passes one — the gateway's names the five
|
|
247
|
+
`workflow.*` queues it owns and nothing else (`infra/api_gateway/index.js`). So
|
|
248
|
+
`monitoring.dlq` and `telemetry.dlq` belong to the monitoring service's own call. Until a
|
|
249
|
+
terminal exists on the broker, the drop above happens one step earlier and without the
|
|
250
|
+
`x-death` trail: the rejection is routed to a name no queue carries (measured 2026-09-16
|
|
251
|
+
on the dev broker — 78 queues, `.dlq` among them only `workflow.dlq` and
|
|
252
|
+
`delivery.dlq`).
|
|
253
|
+
|
|
237
254
|
```javascript
|
|
238
255
|
await client.consume(queue, async (msg, delivery) => {
|
|
239
256
|
// delivery = { attempt, maxAttempts, isFinalAttempt }
|
|
@@ -458,9 +475,12 @@ arguments no longer match. The rule is enforced in two places, and both are
|
|
|
458
475
|
required: the 404 branch of `_publishOnce()` refuses the publish, and
|
|
459
476
|
`RecoveryWorker.handleQueueNotFound()` refuses to create the queue behind it.
|
|
460
477
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
478
|
+
Which of the three sentences the refusal carries is decided by the ownership class
|
|
479
|
+
in the error (`kind`), which the central declaration answers — never by how the
|
|
480
|
+
client was constructed. `recoveryScope` used to say which kind of service a client
|
|
481
|
+
belonged to; it never meant "may create business queues", and once d.419 closed the
|
|
482
|
+
door it had guarded, nothing read it. The key is retired (d.514) and a config still
|
|
483
|
+
carrying it is refused by name at construction.
|
|
464
484
|
|
|
465
485
|
**An exchange is named by a publisher, never declared by one.** `publish(queue,
|
|
466
486
|
message, { exchange, routingKey })` sends to the exchange the caller names and
|
|
@@ -520,12 +540,28 @@ for `'Connection closed unexpectedly'` while the transport emitted `'RabbitMQ co
|
|
|
520
540
|
closed unexpectedly'`, one capital letter apart, so the branch written to ignore that
|
|
521
541
|
error had never once run (fixed 2026-09-08, `errors.ConnectionError` now carries `code`).
|
|
522
542
|
|
|
523
|
-
`consume()` refuses for
|
|
543
|
+
`consume()` refuses for three named reasons, and they ask for OPPOSITE actions:
|
|
524
544
|
|
|
525
545
|
| `error.code` | what happened | what fixes it |
|
|
526
546
|
|---|---|---|
|
|
527
547
|
| `CONSUMER_QUEUE_MISSING` | the queue does not exist | start the service that owns the queue — a consumer never creates one |
|
|
528
548
|
| `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 |
|
|
549
|
+
| `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 |
|
|
550
|
+
|
|
551
|
+
The third one is the SAME verdict a refused connection carries (§ *permanently lost*),
|
|
552
|
+
because it asks the lifecycle owner for the same thing: stop retrying. It refuses the
|
|
553
|
+
QUEUE, not the connection — the client stays connected and `isConnectionFatal()` stays
|
|
554
|
+
`false`. `@onlineapps/service-wrapper` reads `code` to end FÁZE 0.7 permanently; before
|
|
555
|
+
2026-09-16 a 406 on a queue carried no code at all, so the service kept booting against a
|
|
556
|
+
queue it will never be allowed to declare.
|
|
557
|
+
|
|
558
|
+
**`assertQueue()` answers the same way**, and for the same reason: a service declaring
|
|
559
|
+
its own business queues meets the refusal there, not on the consume path. A declaration
|
|
560
|
+
the broker refuses (403/530/406) leaves that rail as an error carrying
|
|
561
|
+
`code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused'`, the queue name in `queue` and
|
|
562
|
+
the broker's own error — reply code included — in `cause`. One function states both
|
|
563
|
+
(`_asRefusal(kind, queue, err)`): two copies of one classification would be free to
|
|
564
|
+
drift. Anything the broker did not refuse leaves either rail exactly as it arrived.
|
|
529
565
|
|
|
530
566
|
Any other failure carries **no** code and its `ConsumeError` says so: the reason is in
|
|
531
567
|
`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
|
+
"version": "4.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,10 +23,10 @@
|
|
|
23
23
|
"author": "OnlineApps",
|
|
24
24
|
"license": "MIT",
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@onlineapps/logger-contract": "
|
|
26
|
+
"@onlineapps/logger-contract": "2.0.0",
|
|
27
27
|
"@onlineapps/runtime-config": "1.2.0",
|
|
28
28
|
"ajv": "^8.12.0",
|
|
29
|
-
"amqplib": "
|
|
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}"
|
|
481
|
-
+ '
|
|
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 - `
|
|
@@ -387,22 +387,15 @@ module.exports = {
|
|
|
387
387
|
},
|
|
388
388
|
|
|
389
389
|
// ---------------------------------------------------------------------
|
|
390
|
-
//
|
|
391
|
-
// `
|
|
392
|
-
//
|
|
393
|
-
// being from a declaration or not at all
|
|
390
|
+
// `queueCreationFilter` and `queueCreationCallback` stood here until d.419,
|
|
391
|
+
// and `recoveryScope` — the permission they were filtered by — until d.514.
|
|
392
|
+
// They were how a client declared a queue nobody owns, and a queue now comes
|
|
393
|
+
// into being from a declaration or not at all
|
|
394
394
|
// (`docs/governance/confirmations/mq-consumer-contract.md` 006). A config
|
|
395
|
-
// still carrying
|
|
396
|
-
// CLOSED, which is what makes the removal visible to the caller
|
|
397
|
-
// silently ignored.
|
|
395
|
+
// still carrying any of the three is refused by name at construction — the
|
|
396
|
+
// schema is CLOSED, which is what makes the removal visible to the caller
|
|
397
|
+
// instead of silently ignored.
|
|
398
398
|
// ---------------------------------------------------------------------
|
|
399
|
-
recoveryScope: {
|
|
400
|
-
type: 'string',
|
|
401
|
-
enum: ['infrastructure', 'business'],
|
|
402
|
-
description:
|
|
403
|
-
'Which kind of service this client belongs to. It decides nothing about queue '
|
|
404
|
-
+ 'creation: no client declares a queue (workers/RecoveryWorker.js).',
|
|
405
|
-
},
|
|
406
399
|
},
|
|
407
400
|
required: ['type', 'host'], // Only type and host required
|
|
408
401
|
|
|
@@ -186,11 +186,7 @@ module.exports = {
|
|
|
186
186
|
criticalHealthShutdown: true,
|
|
187
187
|
criticalHealthShutdownDelay: 60000,
|
|
188
188
|
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
// used to let it — `queueCreationFilter` and `queueCreationCallback` — left
|
|
193
|
-
// with that door (d.419, conf mq-consumer-contract 006).
|
|
194
|
-
// ---------------------------------------------------------------------
|
|
195
|
-
recoveryScope: 'infrastructure',
|
|
189
|
+
// `recoveryScope` stood here until d.514, with the two callbacks it filtered
|
|
190
|
+
// (`queueCreationFilter`, `queueCreationCallback`, d.419) one batch ahead of
|
|
191
|
+
// it. This client declares no queue at all, so there is no permission to give.
|
|
196
192
|
};
|
|
@@ -613,6 +613,15 @@ module.exports = {
|
|
|
613
613
|
*
|
|
614
614
|
* CRITICAL: Monitoring is an infrastructure service, monitoring queues are infrastructure queues!
|
|
615
615
|
* All services (both infrastructure and business) publish monitoring events here.
|
|
616
|
+
*
|
|
617
|
+
* Every QUEUE of this family dead-letters to `monitoring.dlq` over the DEFAULT
|
|
618
|
+
* exchange, where the routing key IS the destination queue's name — the mechanical
|
|
619
|
+
* rule of confirmation `mq-consumer-contract` 003 point 1, applied here by point 3
|
|
620
|
+
* ("`monitoring.*` … get `<prefix>.dlq` when their consumers move onto the library
|
|
621
|
+
* policy"). The exchanges declared alongside them carry no arguments and therefore
|
|
622
|
+
* no route: an exchange holds nothing to dead-letter.
|
|
623
|
+
*
|
|
624
|
+
* @see api/docs/governance/confirmations/mq-consumer-contract.md 003
|
|
616
625
|
*/
|
|
617
626
|
monitoring: {
|
|
618
627
|
/**
|
|
@@ -627,7 +636,9 @@ module.exports = {
|
|
|
627
636
|
durable: true,
|
|
628
637
|
arguments: {
|
|
629
638
|
'x-message-ttl': 300000, // 5 minutes TTL
|
|
630
|
-
'x-max-length': 10000
|
|
639
|
+
'x-max-length': 10000,
|
|
640
|
+
'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
|
|
641
|
+
'x-dead-letter-routing-key': 'monitoring.dlq'
|
|
631
642
|
}
|
|
632
643
|
},
|
|
633
644
|
|
|
@@ -637,14 +648,19 @@ module.exports = {
|
|
|
637
648
|
* InfrastructureHealthTracker publishes service status changes
|
|
638
649
|
* Message format includes event_type: 'service.registered' | 'service.validation.completed' |
|
|
639
650
|
* 'service.version.changed' | 'service.deregistered' | 'service.status.changed'
|
|
640
|
-
* NOTE: Heartbeats are NOT published here
|
|
651
|
+
* NOTE: Heartbeats are NOT published here - they are too frequent. The cadence
|
|
652
|
+
* is not a number this file owns: see the `heartbeatInterval` constructor option
|
|
653
|
+
* of RegistryClient (@onlineapps/conn-orch-registry), which ServiceWrapper feeds
|
|
654
|
+
* from BIZ_HEARTBEAT_INTERVAL_MS.
|
|
641
655
|
* NOTE: Healthcheck messages go to infrastructure.health.events exchange
|
|
642
656
|
*/
|
|
643
657
|
'services': {
|
|
644
658
|
durable: true,
|
|
645
659
|
arguments: {
|
|
646
660
|
'x-message-ttl': 600000, // 10 minutes TTL (longer for service tracking)
|
|
647
|
-
'x-max-length': 20000
|
|
661
|
+
'x-max-length': 20000,
|
|
662
|
+
'x-dead-letter-exchange': '',
|
|
663
|
+
'x-dead-letter-routing-key': 'monitoring.dlq'
|
|
648
664
|
}
|
|
649
665
|
},
|
|
650
666
|
|
|
@@ -657,7 +673,9 @@ module.exports = {
|
|
|
657
673
|
durable: true,
|
|
658
674
|
arguments: {
|
|
659
675
|
'x-message-ttl': 300000,
|
|
660
|
-
'x-max-length': 50000
|
|
676
|
+
'x-max-length': 50000,
|
|
677
|
+
'x-dead-letter-exchange': '',
|
|
678
|
+
'x-dead-letter-routing-key': 'monitoring.dlq'
|
|
661
679
|
}
|
|
662
680
|
},
|
|
663
681
|
|
|
@@ -674,11 +692,50 @@ module.exports = {
|
|
|
674
692
|
/**
|
|
675
693
|
* monitoring.infrastructure.health.events - the monitoring consumer's own copy of
|
|
676
694
|
* the infrastructure health events, bound to the infrastructure.health.events
|
|
677
|
-
* fanout exchange.
|
|
678
|
-
*
|
|
695
|
+
* fanout exchange.
|
|
696
|
+
*
|
|
697
|
+
* It carried `durable` and nothing else — the arguments the consumer asserts it
|
|
698
|
+
* with (`infra/api_monitoring/src/consumer/index.js`, `assertQueue(queue,
|
|
699
|
+
* { durable: true })`). It keeps no TTL and no cap, because nothing decided it
|
|
700
|
+
* should have either; what it gains is the family's dead-letter route, without
|
|
701
|
+
* which `consume()` refuses to attach a consumer to it at all (d.259).
|
|
679
702
|
*/
|
|
680
703
|
'infrastructure.health.events': {
|
|
681
|
-
durable: true
|
|
704
|
+
durable: true,
|
|
705
|
+
arguments: {
|
|
706
|
+
'x-dead-letter-exchange': '',
|
|
707
|
+
'x-dead-letter-routing-key': 'monitoring.dlq'
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* monitoring.dlq - Dead letter queue of the whole monitoring family
|
|
713
|
+
*
|
|
714
|
+
* The end of this family's chain: the workflow, services and audit queues and the
|
|
715
|
+
* consumer's health-events copy all reject into it over the default exchange, so
|
|
716
|
+
* no binding has to exist for the route to work. Declared with the arguments every
|
|
717
|
+
* other dead-letter queue carries — durable, capped at the same 50k, no TTL and no
|
|
718
|
+
* onward route — because the operator's dashboard matches `*.dlq` and a message
|
|
719
|
+
* that expired there is a message nobody got to decide about (confirmation
|
|
720
|
+
* `mq-consumer-contract` 002 point 2, 003 point 1).
|
|
721
|
+
*
|
|
722
|
+
* **A declaration is not a creation instruction. The terminal is brought into being
|
|
723
|
+
* by the owner of the prefix that NAMES it.** `initInfrastructureQueues()` derives
|
|
724
|
+
* the whole list from these sections only when its caller passes no `queues` list;
|
|
725
|
+
* every caller passes one, and the gateway's names exactly the five `workflow.*`
|
|
726
|
+
* queues it owns (`infra/api_gateway/index.js`). `monitoring.dlq` therefore belongs
|
|
727
|
+
* to the monitoring service's own call, beside the queues it consumes. Until that
|
|
728
|
+
* call names it, a `nack(requeue=false)` on a queue of this family dead-letters onto
|
|
729
|
+
* a routing key no queue carries and the broker DROPS the message — measured
|
|
730
|
+
* 2026-09-16 on the dev broker: 78 queues, and the only `.dlq` among them
|
|
731
|
+
* `workflow.dlq` and `delivery.dlq`.
|
|
732
|
+
*/
|
|
733
|
+
dlq: {
|
|
734
|
+
durable: true,
|
|
735
|
+
arguments: {
|
|
736
|
+
// No TTL for DLQ - messages should persist
|
|
737
|
+
'x-max-length': 50000 // Higher limit for DLQ
|
|
738
|
+
}
|
|
682
739
|
}
|
|
683
740
|
},
|
|
684
741
|
|
|
@@ -690,8 +747,15 @@ module.exports = {
|
|
|
690
747
|
* own name, and these three queues are bound to it
|
|
691
748
|
* (`docs/standards/monitoring-queues.md`, `docs/architecture/monitoring.md`).
|
|
692
749
|
*
|
|
693
|
-
* The
|
|
694
|
-
* declaration, so whoever asserts them reads it here rather than retyping it.
|
|
750
|
+
* The TTL and the cap are the ones the queues are created with today; this config is
|
|
751
|
+
* their declaration, so whoever asserts them reads it here rather than retyping it.
|
|
752
|
+
*
|
|
753
|
+
* Each of the three dead-letters to `telemetry.dlq` over the DEFAULT exchange, where
|
|
754
|
+
* the routing key IS the destination queue's name — the mechanical rule of
|
|
755
|
+
* confirmation `mq-consumer-contract` 003 point 1, applied to this family by point 3
|
|
756
|
+
* as its consumer moves onto the library policy.
|
|
757
|
+
*
|
|
758
|
+
* @see api/docs/governance/confirmations/mq-consumer-contract.md 003
|
|
695
759
|
*/
|
|
696
760
|
telemetry: {
|
|
697
761
|
/** telemetry.logs.queue - OpenTelemetry log records, drained into Loki. */
|
|
@@ -699,7 +763,9 @@ module.exports = {
|
|
|
699
763
|
durable: true,
|
|
700
764
|
arguments: {
|
|
701
765
|
'x-message-ttl': 60000, // 1 minute TTL for unprocessed messages
|
|
702
|
-
'x-max-length': 100000
|
|
766
|
+
'x-max-length': 100000, // Max 100k messages in queue
|
|
767
|
+
'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
|
|
768
|
+
'x-dead-letter-routing-key': 'telemetry.dlq'
|
|
703
769
|
}
|
|
704
770
|
},
|
|
705
771
|
|
|
@@ -708,7 +774,9 @@ module.exports = {
|
|
|
708
774
|
durable: true,
|
|
709
775
|
arguments: {
|
|
710
776
|
'x-message-ttl': 60000,
|
|
711
|
-
'x-max-length': 100000
|
|
777
|
+
'x-max-length': 100000,
|
|
778
|
+
'x-dead-letter-exchange': '',
|
|
779
|
+
'x-dead-letter-routing-key': 'telemetry.dlq'
|
|
712
780
|
}
|
|
713
781
|
},
|
|
714
782
|
|
|
@@ -717,7 +785,29 @@ module.exports = {
|
|
|
717
785
|
durable: true,
|
|
718
786
|
arguments: {
|
|
719
787
|
'x-message-ttl': 60000,
|
|
720
|
-
'x-max-length': 100000
|
|
788
|
+
'x-max-length': 100000,
|
|
789
|
+
'x-dead-letter-exchange': '',
|
|
790
|
+
'x-dead-letter-routing-key': 'telemetry.dlq'
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* telemetry.dlq - Dead letter queue of the whole telemetry family
|
|
796
|
+
*
|
|
797
|
+
* The three intake queues reject into it, and it rejects nowhere: same shape as
|
|
798
|
+
* `workflow.dlq`, `delivery.dlq` and `monitoring.dlq` — durable, capped at 50k, no
|
|
799
|
+
* TTL, no onward route. A telemetry record the intake cannot process is the one an
|
|
800
|
+
* operator most needs to look at, and a TTL here would delete it before they did.
|
|
801
|
+
*
|
|
802
|
+
* Brought into being by the owner of the `telemetry.` prefix, the same rule as
|
|
803
|
+
* `monitoring.dlq` above: declared here, created by the service whose
|
|
804
|
+
* `initInfrastructureQueues()` call names it.
|
|
805
|
+
*/
|
|
806
|
+
dlq: {
|
|
807
|
+
durable: true,
|
|
808
|
+
arguments: {
|
|
809
|
+
// No TTL for DLQ - messages should persist
|
|
810
|
+
'x-max-length': 50000 // Higher limit for DLQ
|
|
721
811
|
}
|
|
722
812
|
}
|
|
723
813
|
},
|
|
@@ -819,7 +909,9 @@ module.exports = {
|
|
|
819
909
|
* CRITICAL: This is the ONLY queue used for service-to-Registry communication.
|
|
820
910
|
* Registry listener processes different message types based on msg.type:
|
|
821
911
|
* - type: 'register' - Service registration requests (full spec + operations)
|
|
822
|
-
* - type: 'heartbeat' - Periodic health check messages (
|
|
912
|
+
* - type: 'heartbeat' - Periodic health check messages (cadence owned by the
|
|
913
|
+
* `heartbeatInterval` constructor option of RegistryClient,
|
|
914
|
+
* @onlineapps/conn-orch-registry - not declared here)
|
|
823
915
|
* - type: 'deregister' - Voluntary shutdown notice
|
|
824
916
|
* (The legacy 'apiDescription' / 'apiDescriptionRequest' types are no
|
|
825
917
|
* longer supported — see operations-registry-contract.md §2.)
|
|
@@ -415,10 +415,11 @@ class RabbitMQClient extends EventEmitter {
|
|
|
415
415
|
|
|
416
416
|
// Recovery worker — connection recovery, and the report of a queue that does
|
|
417
417
|
// not exist. It creates none: queue creation left it with the two callbacks
|
|
418
|
-
// that fed it (d.419)
|
|
418
|
+
// that fed it (d.419) and with `recoveryScope`, the permission they were
|
|
419
|
+
// filtered by (d.514). Which rule the report names is decided by the
|
|
420
|
+
// ownership class in the error, not by who built this client.
|
|
419
421
|
this._recoveryWorker = new RecoveryWorker({
|
|
420
422
|
client: this,
|
|
421
|
-
scope: this._config.recoveryScope,
|
|
422
423
|
logger: this._logger,
|
|
423
424
|
});
|
|
424
425
|
|
|
@@ -696,6 +697,142 @@ class RabbitMQClient extends EventEmitter {
|
|
|
696
697
|
return REFUSAL_REPLY_CODES.some((code) => message.includes(`server: ${code} (`));
|
|
697
698
|
}
|
|
698
699
|
|
|
700
|
+
/**
|
|
701
|
+
* State the broker's refusal of a QUEUE the same way `_failFatally()` states
|
|
702
|
+
* its refusal of a CONNECTION — as a marker on the error, read by the caller
|
|
703
|
+
* that must decide what to do about it.
|
|
704
|
+
*
|
|
705
|
+
* Measured defect (d.577, sonda W532 against the live broker): a queue that
|
|
706
|
+
* already exists with other arguments answers `assertQueue()` with `406
|
|
707
|
+
* PRECONDITION-FAILED`, and `_prepareQueueForConsume()` built a `ConsumeError`
|
|
708
|
+
* for it with no `code` and no `reason`. The reply code survived in `cause`
|
|
709
|
+
* alone — and one wrapping further up (`@onlineapps/conn-orch-registry`) in
|
|
710
|
+
* `cause.cause`, where nothing looks. `@onlineapps/service-wrapper` ends FÁZE
|
|
711
|
+
* 0.7 permanently only on `code: 'MQ_CONNECTION_FATAL'`, so a 406 on a queue
|
|
712
|
+
* was classified TRANSIENT and the service went on booting against a queue it
|
|
713
|
+
* will never be allowed to declare. Every attempt gets the same answer.
|
|
714
|
+
*
|
|
715
|
+
* ONE vocabulary for one fact, therefore: the marker is the one `_failFatally()`
|
|
716
|
+
* already writes for `broker-refused` (d.295/d.490), not a second spelling of
|
|
717
|
+
* it. A refusal of the topology and a refusal of the connection ask the reader
|
|
718
|
+
* for the same thing — stop retrying, fix what the broker named — and the
|
|
719
|
+
* lifecycle owner reads one marker, not a list of them.
|
|
720
|
+
*
|
|
721
|
+
* What it deliberately does NOT do is `_failFatally()`: that declares the
|
|
722
|
+
* CONNECTION permanently lost and stands the client down. The connection here
|
|
723
|
+
* is healthy; it is this queue the broker refuses, and the publish path over
|
|
724
|
+
* the same connection has nothing to answer for.
|
|
725
|
+
*
|
|
726
|
+
* The decision is taken on the REPLY CODE (`_isBrokerRefusal()`,
|
|
727
|
+
* `REFUSAL_REPLY_CODES`), never on the wording of a sentence somebody may
|
|
728
|
+
* reword — the defect d.164 measured. The broker's own error is kept as
|
|
729
|
+
* `cause`, so its reply code stays readable.
|
|
730
|
+
*
|
|
731
|
+
* BOTH rails that talk to the broker about a queue go through this one
|
|
732
|
+
* function (d.582). `consume()` was the first; `assertQueue()` is the other
|
|
733
|
+
* half of the same fact, and it is the one FÁZE 0.8 walks — a service that
|
|
734
|
+
* declares its business queues gets the 406 there, not on the consume path.
|
|
735
|
+
* Two copies of one classification would be free to drift
|
|
736
|
+
* (`change-discipline.md` § One rail per concern), so `kind` decides only the
|
|
737
|
+
* SENTENCE and the class of the wrapper; the verdict is written once.
|
|
738
|
+
*
|
|
739
|
+
* @param {'consume'|'declare'} kind - which rail is answering.
|
|
740
|
+
* @param {string} queue - the queue the broker was asked about.
|
|
741
|
+
* @param {Error} err - what that rail threw.
|
|
742
|
+
* @returns {Error} the same error carrying the verdict, a typed error carrying
|
|
743
|
+
* it when the throw was the broker's raw one, or `err` untouched when the
|
|
744
|
+
* broker refused nothing.
|
|
745
|
+
* @private
|
|
746
|
+
*/
|
|
747
|
+
_asRefusal(kind, queue, err) {
|
|
748
|
+
const brokerError = this._brokerRefusalCause(err);
|
|
749
|
+
|
|
750
|
+
if (!brokerError) return err;
|
|
751
|
+
|
|
752
|
+
// Already stated, and in the shape this rail hands out: the verdict is
|
|
753
|
+
// written once, not once per layer that passes it on.
|
|
754
|
+
const alreadyStated = err.code === 'MQ_CONNECTION_FATAL'
|
|
755
|
+
&& (kind !== 'consume' || err instanceof ConsumeError);
|
|
756
|
+
if (alreadyStated) return err;
|
|
757
|
+
|
|
758
|
+
// An error this rail built itself — the 406 sentence of
|
|
759
|
+
// `_prepareQueueForConsume()` — is marked in place: it names the fix more
|
|
760
|
+
// precisely than anything this function could write.
|
|
761
|
+
if (kind === 'consume' && err instanceof ConsumeError) {
|
|
762
|
+
return this._markBrokerRefusal(err);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const refusal = kind === 'consume'
|
|
766
|
+
? new ConsumeError(
|
|
767
|
+
`[RabbitMQClient] Cannot consume from queue "${queue}": the broker REFUSED the operation, `
|
|
768
|
+
+ `so retrying cannot change the answer (reason: ${brokerError.message}). `
|
|
769
|
+
+ 'Expected: an account allowed to use this queue, and a queue whose declaration is the one '
|
|
770
|
+
+ 'queueConfig prescribes. '
|
|
771
|
+
+ 'Fix: correct the permission or the topology the broker named, then restart the process; '
|
|
772
|
+
+ 'error.cause carries the broker reply code.',
|
|
773
|
+
queue,
|
|
774
|
+
brokerError
|
|
775
|
+
)
|
|
776
|
+
: new Error(
|
|
777
|
+
`[RabbitMQClient] Cannot declare queue "${queue}": the broker REFUSED the declaration, `
|
|
778
|
+
+ `so retrying cannot change the answer (reason: ${brokerError.message}). `
|
|
779
|
+
+ 'Expected: the queue to exist with the arguments queueConfig prescribes, and an account '
|
|
780
|
+
+ 'allowed to declare it. '
|
|
781
|
+
+ 'Fix: align the declaration the broker named (406 means the queue exists with other '
|
|
782
|
+
+ 'arguments) or correct the permission, then restart the process; '
|
|
783
|
+
+ 'error.cause carries the broker reply code.',
|
|
784
|
+
{ cause: brokerError }
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
if (kind !== 'consume') refusal.queue = queue;
|
|
788
|
+
return this._markBrokerRefusal(refusal);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* The broker error behind a failure, or `null` when the broker refused nothing.
|
|
793
|
+
*
|
|
794
|
+
* One `cause` deep, because that is how far the rails here wrap: the
|
|
795
|
+
* declaration step throws the broker's error, and the sentence built around it
|
|
796
|
+
* keeps it as `cause`. The decision is taken on the REPLY CODE
|
|
797
|
+
* (`_isBrokerRefusal()`, `REFUSAL_REPLY_CODES`), never on the wording of a
|
|
798
|
+
* sentence somebody may reword — the defect d.164 measured.
|
|
799
|
+
*
|
|
800
|
+
* @param {Error|null} err
|
|
801
|
+
* @returns {Error|null} the error carrying the broker's reply code
|
|
802
|
+
* @private
|
|
803
|
+
*/
|
|
804
|
+
_brokerRefusalCause(err) {
|
|
805
|
+
if (!err) return null;
|
|
806
|
+
|
|
807
|
+
// An error already carrying the verdict is NOT the broker's own — the
|
|
808
|
+
// broker's is one `cause` below it. Asked of the marked error, the text half
|
|
809
|
+
// of `_isBrokerRefusal()` would answer yes about the wrapper, because the
|
|
810
|
+
// sentence built here quotes the broker's message verbatim (measured while
|
|
811
|
+
// writing d.582: `error.cause.code` came back `'MQ_CONNECTION_FATAL'`
|
|
812
|
+
// instead of `406`). The code is asked first, so the quote cannot be
|
|
813
|
+
// mistaken for the answer.
|
|
814
|
+
if (err.code === 'MQ_CONNECTION_FATAL') {
|
|
815
|
+
return this._isBrokerRefusal(err.cause) ? err.cause : null;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (this._isBrokerRefusal(err)) return err;
|
|
819
|
+
if (this._isBrokerRefusal(err.cause)) return err.cause;
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Write the verdict. ONE spelling, the one `_failFatally()` uses.
|
|
825
|
+
*
|
|
826
|
+
* @param {Error} error
|
|
827
|
+
* @returns {Error} the same error, marked
|
|
828
|
+
* @private
|
|
829
|
+
*/
|
|
830
|
+
_markBrokerRefusal(error) {
|
|
831
|
+
error.code = 'MQ_CONNECTION_FATAL';
|
|
832
|
+
error.reason = 'broker-refused';
|
|
833
|
+
return error;
|
|
834
|
+
}
|
|
835
|
+
|
|
699
836
|
/**
|
|
700
837
|
* A recovery cycle is over and the client is NOT connected, but the broker
|
|
701
838
|
* never refused it — so this is not the end.
|
|
@@ -823,12 +960,27 @@ class RabbitMQClient extends EventEmitter {
|
|
|
823
960
|
* confused with an ordinary transient error: the `connection:fatal` event and
|
|
824
961
|
* the injected `onFatal` callback.
|
|
825
962
|
*
|
|
963
|
+
* WHO IS TOLD, and why that is not always the same. The `connection:fatal`
|
|
964
|
+
* event and the injected `onFatal` callback exist for a loss that happens
|
|
965
|
+
* asynchronously, with nobody awaiting anything — the owner would otherwise
|
|
966
|
+
* never hear of it. On the BOOT path a caller IS awaiting `connect()`, so it
|
|
967
|
+
* learns by the throw, and announcing as well would be a second notification
|
|
968
|
+
* of one event: the harm `_reportRecoveryFailure()` documents a few methods
|
|
969
|
+
* up, and a crossing of the line the boot caller draws itself (a boot failure
|
|
970
|
+
* is the boot's outcome, not a lifecycle event — see the `BOOT budget` note in
|
|
971
|
+
* `@onlineapps/service-wrapper`'s `_initializeMQ()`). Hence `announce`. The
|
|
972
|
+
* verdict, the state and the sentence are identical either way; only the
|
|
973
|
+
* channel differs (d.295).
|
|
974
|
+
*
|
|
826
975
|
* @param {Error} lastError - The error from the final failed attempt.
|
|
827
976
|
* @param {'broker-refused'|'cycles-spent'} reason - Which of the two it is.
|
|
977
|
+
* @param {Object} [options] - Reporting options.
|
|
978
|
+
* @param {boolean} [options.announce=true] - Emit `connection:fatal` and call
|
|
979
|
+
* `onFatal`. False on a path whose caller is awaiting the throw.
|
|
828
980
|
* @returns {Error} The fatal error, so callers can throw it.
|
|
829
981
|
* @private
|
|
830
982
|
*/
|
|
831
|
-
_failFatally(lastError, reason) {
|
|
983
|
+
_failFatally(lastError, reason, { announce = true } = {}) {
|
|
832
984
|
this._connectionFatal = true;
|
|
833
985
|
this._reconnecting = false;
|
|
834
986
|
this._lastConnectionError = lastError || null;
|
|
@@ -855,16 +1007,21 @@ class RabbitMQClient extends EventEmitter {
|
|
|
855
1007
|
fatal.cycles = this._reconnectCycles;
|
|
856
1008
|
fatal.cause = lastError;
|
|
857
1009
|
|
|
1010
|
+
// The log line is the RECORD and is unconditional; the two lines below are
|
|
1011
|
+
// the NOTIFICATION, and only a caller that is not awaiting needs it.
|
|
858
1012
|
this._logger.error(`[RabbitMQClient] [mq-client-core] ${fatal.message}`);
|
|
859
|
-
this.emit('connection:fatal', fatal);
|
|
860
1013
|
|
|
861
|
-
if (
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
)
|
|
1014
|
+
if (announce) {
|
|
1015
|
+
this.emit('connection:fatal', fatal);
|
|
1016
|
+
|
|
1017
|
+
if (this._onFatal) {
|
|
1018
|
+
try {
|
|
1019
|
+
this._onFatal(fatal);
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
this._logger.error(
|
|
1022
|
+
`[RabbitMQClient] [mq-client-core] onFatal handler threw: ${err.message}`
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
868
1025
|
}
|
|
869
1026
|
}
|
|
870
1027
|
|
|
@@ -1327,6 +1484,25 @@ class RabbitMQClient extends EventEmitter {
|
|
|
1327
1484
|
}
|
|
1328
1485
|
this._connection = null;
|
|
1329
1486
|
}
|
|
1487
|
+
|
|
1488
|
+
// The broker ANSWERED and refused — 403, 530, 406. The same verdict
|
|
1489
|
+
// `_reconnectWithBackoff()` reaches at runtime, on the same rail
|
|
1490
|
+
// (`_isBrokerRefusal`), because it is the same question: did anybody
|
|
1491
|
+
// answer? Until d.295 this catch rethrew whatever amqplib raised, with no
|
|
1492
|
+
// verdict on it, and the boot caller could only guess — `ServiceWrapper`
|
|
1493
|
+
// guessed "transient" and spent its whole connection budget, backoff and
|
|
1494
|
+
// all, on an answer no further attempt can change
|
|
1495
|
+
// (`architecture-principles.md` §4).
|
|
1496
|
+
//
|
|
1497
|
+
// Anything unrecognised still leaves here as it arrived, and that is the
|
|
1498
|
+
// owner's decision, not an omission: giving up forever on a broker that
|
|
1499
|
+
// never answered is forbidden
|
|
1500
|
+
// (`docs/governance/confirmations/mq-client-lifecycle-contract.md` 001
|
|
1501
|
+
// point 3), so only an ANSWERED refusal ends the attempts.
|
|
1502
|
+
if (this._isBrokerRefusal(err)) {
|
|
1503
|
+
throw this._failFatally(err, 'broker-refused', { announce: false });
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1330
1506
|
throw err;
|
|
1331
1507
|
} finally {
|
|
1332
1508
|
if (connectTimeoutTimer) {
|
|
@@ -2203,7 +2379,19 @@ class RabbitMQClient extends EventEmitter {
|
|
|
2203
2379
|
|
|
2204
2380
|
const channel = await this._requireQueueChannel('assertQueue', queue);
|
|
2205
2381
|
|
|
2206
|
-
|
|
2382
|
+
try {
|
|
2383
|
+
return await channel.assertQueue(queue, queueOptions);
|
|
2384
|
+
} catch (err) {
|
|
2385
|
+
// The SAME verdict the consume rail states, through the SAME function
|
|
2386
|
+
// (d.582). This is the rail FÁZE 0.8 walks: a service declaring its own
|
|
2387
|
+
// business queues meets the 406 here, and until this batch the raw amqplib
|
|
2388
|
+
// error left with the reply code as a NUMBER and no classification at all,
|
|
2389
|
+
// so `@onlineapps/conn-orch-registry` had nothing to carry and
|
|
2390
|
+
// `@onlineapps/service-wrapper` — which reads `error.code` — called a
|
|
2391
|
+
// refusal transient. `_failFatally()` is deliberately not called: it is
|
|
2392
|
+
// this QUEUE the broker refuses, not the connection.
|
|
2393
|
+
throw this._asRefusal('declare', queue, err);
|
|
2394
|
+
}
|
|
2207
2395
|
}
|
|
2208
2396
|
|
|
2209
2397
|
/**
|
|
@@ -2993,8 +3181,8 @@ class RabbitMQClient extends EventEmitter {
|
|
|
2993
3181
|
* declared with.
|
|
2994
3182
|
*
|
|
2995
3183
|
* ONE rail for the first `consume()` and for every re-registration after a
|
|
2996
|
-
* channel recreate. It classifies the queue (
|
|
2997
|
-
*
|
|
3184
|
+
* channel recreate. It classifies the queue (infrastructure / business /
|
|
3185
|
+
* neither), resolves the arguments from the central config, refuses a queue
|
|
2998
3186
|
* whose config declares no dead-letter route, and then CHECKS an
|
|
2999
3187
|
* infrastructure queue (consumers never create those) or ASSERTS a business
|
|
3000
3188
|
* one.
|
|
@@ -3014,15 +3202,24 @@ class RabbitMQClient extends EventEmitter {
|
|
|
3014
3202
|
const durable = options.durable !== undefined ? options.durable : this._config.durable;
|
|
3015
3203
|
let queueOptions = options.queueOptions || { durable };
|
|
3016
3204
|
|
|
3017
|
-
// Skip assertQueue for reply queues (they're already created with specific settings)
|
|
3018
|
-
// Reply queues start with 'rpc.reply.' and are created as non-durable
|
|
3019
|
-
const isReplyQueue = queue.startsWith('rpc.reply.');
|
|
3020
3205
|
// Classification is read once and used by both blocks below — the one that
|
|
3021
3206
|
// resolves the central arguments, and the one that checks or asserts the queue.
|
|
3022
|
-
|
|
3023
|
-
|
|
3207
|
+
//
|
|
3208
|
+
// There is no third class. Until d.514 a name starting `rpc.reply.` was
|
|
3209
|
+
// exempted here and again below: classification, declaration and the broker
|
|
3210
|
+
// question were all skipped, because the retired RPC reply path declared such
|
|
3211
|
+
// a queue itself, with its own non-durable lifetime. Both exemptions had been
|
|
3212
|
+
// unreachable since d.259: `queueConfig` classifies that name as nothing, so
|
|
3213
|
+
// it declares no dead-letter route, so the gate between the two blocks throws
|
|
3214
|
+
// first — measured against the live broker on a reply queue that really
|
|
3215
|
+
// existed (`tests/integration/dead-letter-route-required.integration.test.js`).
|
|
3216
|
+
// The four questions are answered in the CHANGELOG of that batch; the path the
|
|
3217
|
+
// exemption served is gone (`docs/governance/confirmations/mq-consumer-contract.md`
|
|
3218
|
+
// 003 point 3 — "`rpc.reply.*` is not a topology question … it is a removal
|
|
3219
|
+
// question", and `conn-infra-mq` removed `createTemporaryQueue()` in d.275b).
|
|
3220
|
+
const isInfraQueue = queueConfig.isInfrastructureQueue(queue);
|
|
3221
|
+
const isBusinessQueue = queueConfig.isBusinessQueue(queue);
|
|
3024
3222
|
|
|
3025
|
-
if (!isReplyQueue) {
|
|
3026
3223
|
// CRITICAL: Use queueConfig.js to get correct parameters (TTL, max-length, etc.)
|
|
3027
3224
|
// This prevents 406 PRECONDITION-FAILED errors from TTL mismatches.
|
|
3028
3225
|
// queueConfig is loaded once at the top of this module; it used to be required
|
|
@@ -3066,9 +3263,6 @@ class RabbitMQClient extends EventEmitter {
|
|
|
3066
3263
|
: `[RabbitMQClient] [mq-client-core] [CONSUMER] Asserting business queue ${queue} with the declaration queueConfig owns`
|
|
3067
3264
|
);
|
|
3068
3265
|
}
|
|
3069
|
-
// End of the queue-classification block. What follows applies to EVERY
|
|
3070
|
-
// queue this consumer may attach to, reply queues included.
|
|
3071
|
-
}
|
|
3072
3266
|
|
|
3073
3267
|
// The dead-letter policy below rejects a spent message with
|
|
3074
3268
|
// `nack(requeue=false)`, and the broker then moves it ONLY if the queue
|
|
@@ -3122,7 +3316,6 @@ class RabbitMQClient extends EventEmitter {
|
|
|
3122
3316
|
);
|
|
3123
3317
|
}
|
|
3124
3318
|
|
|
3125
|
-
if (!isReplyQueue) {
|
|
3126
3319
|
this._logger.debug(`[RabbitMQClient] [mq-client-core] [CONSUMER] Asserting queue ${queue} before consume() at ${new Date().toISOString()}`);
|
|
3127
3320
|
this._logger.debug(`[RabbitMQClient] [mq-client-core] [CONSUMER] Queue options:`, JSON.stringify(queueOptions, null, 2));
|
|
3128
3321
|
this._logger.debug(`[RabbitMQClient] [mq-client-core] [CONSUMER] _queueChannel state: exists=${!!this._queueChannel}, closed=${!this._isChannelAlive(this._queueChannel)}`);
|
|
@@ -3194,7 +3387,11 @@ class RabbitMQClient extends EventEmitter {
|
|
|
3194
3387
|
} catch (assertErr) {
|
|
3195
3388
|
// If queue exists with different arguments (406), this is a CRITICAL ERROR
|
|
3196
3389
|
// We should NOT proceed - the root cause must be fixed
|
|
3197
|
-
|
|
3390
|
+
// Read off the BROKER's reply code, which since d.582 travels as the
|
|
3391
|
+
// `cause` of the declaration rail's own refusal — never off the marker,
|
|
3392
|
+
// which says "refused" without saying which of the three codes it was.
|
|
3393
|
+
const brokerError = this._brokerRefusalCause(assertErr);
|
|
3394
|
+
if (brokerError && brokerError.code === 406) {
|
|
3198
3395
|
this._logger.error(`[RabbitMQClient] [mq-client-core] [CONSUMER] ✗ CRITICAL: Queue ${queue} exists with different arguments!`);
|
|
3199
3396
|
this._logger.error(`[RabbitMQClient] [mq-client-core] [CONSUMER] Error:`, assertErr.message);
|
|
3200
3397
|
this._logger.error(`[RabbitMQClient] [mq-client-core] [CONSUMER] Expected options:`, JSON.stringify(queueOptions, null, 2));
|
|
@@ -3205,14 +3402,15 @@ class RabbitMQClient extends EventEmitter {
|
|
|
3205
3402
|
+ 'Fix: find the assertQueue() call that declared it without parameters and fix that call; '
|
|
3206
3403
|
+ 'the queue is NOT redeclared here, because doing so would hide the drift instead of ending it.',
|
|
3207
3404
|
queue,
|
|
3208
|
-
|
|
3405
|
+
// The BROKER's error, not the declaration rail's wrapper around it:
|
|
3406
|
+
// one `cause` hop, and `error.cause.code` is the reply code (d.582).
|
|
3407
|
+
brokerError
|
|
3209
3408
|
);
|
|
3210
3409
|
}
|
|
3211
3410
|
// Other error - rethrow
|
|
3212
3411
|
throw assertErr;
|
|
3213
3412
|
}
|
|
3214
3413
|
}
|
|
3215
|
-
}
|
|
3216
3414
|
|
|
3217
3415
|
return queueOptions;
|
|
3218
3416
|
}
|
|
@@ -3497,8 +3695,15 @@ class RabbitMQClient extends EventEmitter {
|
|
|
3497
3695
|
// Return consumer tag for cancellation
|
|
3498
3696
|
return consumeResult.consumerTag;
|
|
3499
3697
|
} catch (err) {
|
|
3500
|
-
|
|
3501
|
-
|
|
3698
|
+
// ONE place states the broker's verdict for this whole rail — the queue
|
|
3699
|
+
// classification, the declaration and the consumer registration alike —
|
|
3700
|
+
// because it is ONE question ("did the broker answer and refuse?") and a
|
|
3701
|
+
// copy of it per branch is free to drift (`change-discipline.md` § One rail
|
|
3702
|
+
// per concern). Anything the broker did not refuse leaves exactly as it
|
|
3703
|
+
// arrived (d.577).
|
|
3704
|
+
const refusal = this._asRefusal('consume', queue, err);
|
|
3705
|
+
this.emit('error', refusal);
|
|
3706
|
+
throw refusal;
|
|
3502
3707
|
}
|
|
3503
3708
|
}
|
|
3504
3709
|
|
|
@@ -3941,7 +4146,22 @@ class RabbitMQClient extends EventEmitter {
|
|
|
3941
4146
|
}
|
|
3942
4147
|
|
|
3943
4148
|
/**
|
|
3944
|
-
* Check prefetch utilization for a queue and alert if threshold exceeded
|
|
4149
|
+
* Check prefetch utilization for a queue and alert if threshold exceeded.
|
|
4150
|
+
*
|
|
4151
|
+
* A window of ONE message has no utilisation to watch (d.550). Its only two
|
|
4152
|
+
* states are 0 % (idle) and 100 % (the consumer is doing the one piece of work
|
|
4153
|
+
* it is allowed), and 100 % is the normal state of a healthy consumer, not a
|
|
4154
|
+
* finding anybody can act on. The alarm therefore fired on EVERY delivery to
|
|
4155
|
+
* such a queue, whatever the threshold - `1/1 >= 0.8` holds exactly as
|
|
4156
|
+
* `1/1 >= 0.99` does. MEASURED (BIZ-pdfgen, BIZ-converter, twice per boot):
|
|
4157
|
+
* `Prefetch utilization high: queue 'biz-pdfgen.registry' has 1/1 messages
|
|
4158
|
+
* in-flight (100%, threshold: 80%)` - a warning that cannot fail to happen
|
|
4159
|
+
* says nothing, and it teaches the reader to skip the line where a real
|
|
4160
|
+
* saturation will one day stand.
|
|
4161
|
+
*
|
|
4162
|
+
* The threshold itself is unchanged and no new one is introduced: a window
|
|
4163
|
+
* larger than one message is measured exactly as before.
|
|
4164
|
+
*
|
|
3945
4165
|
* @private
|
|
3946
4166
|
* @param {string} queue - Queue name
|
|
3947
4167
|
* @param {Object} tracking - Prefetch tracking object
|
|
@@ -3950,7 +4170,11 @@ class RabbitMQClient extends EventEmitter {
|
|
|
3950
4170
|
if (!tracking || tracking.prefetchCount === 0) {
|
|
3951
4171
|
return; // No prefetch set or tracking not available
|
|
3952
4172
|
}
|
|
3953
|
-
|
|
4173
|
+
|
|
4174
|
+
if (tracking.prefetchCount <= 1) {
|
|
4175
|
+
return; // One-message window: 0 % or 100 %, and 100 % is the working state.
|
|
4176
|
+
}
|
|
4177
|
+
|
|
3954
4178
|
const utilization = tracking.inFlight / tracking.prefetchCount;
|
|
3955
4179
|
|
|
3956
4180
|
if (utilization >= this._prefetchUtilizationThreshold) {
|
|
@@ -27,13 +27,16 @@ const { assertLogger } = require('@onlineapps/logger-contract');
|
|
|
27
27
|
* Čtyři otázky (`change-discipline.md` § Removing something removes its
|
|
28
28
|
* declaration) jsou zodpovězené v CHANGELOGu dávky.
|
|
29
29
|
*
|
|
30
|
-
* `scope` tedy už o zakládání front
|
|
30
|
+
* `scope` tedy už o zakládání front nerozhodoval; od d.419 neříkal nic a od d.514
|
|
31
|
+
* tu není. Co rozhoduje o tvaru hlášení, je třída vlastnictví, kterou odpověděla
|
|
32
|
+
* CENTRÁLNÍ deklarace — `error.kind` z `QueueNotFoundError` — takže dva klienti
|
|
33
|
+
* namířené na tutéž frontu nemohou dostat různou odpověď podle toho, kdo je
|
|
34
|
+
* postavil. Čtyři otázky k odstranění jsou zodpovězené v CHANGELOGu dávky.
|
|
31
35
|
*/
|
|
32
36
|
class RecoveryWorker {
|
|
33
37
|
/**
|
|
34
38
|
* @param {Object} options
|
|
35
39
|
* @param {Object} options.client - RabbitMQClient instance
|
|
36
|
-
* @param {string} [options.scope='infrastructure'] - 'infrastructure' nebo 'business'
|
|
37
40
|
* @param {Object} options.logger - Logger s info/warn/error/debug (povinny)
|
|
38
41
|
*/
|
|
39
42
|
constructor(options = {}) {
|
|
@@ -45,7 +48,6 @@ class RecoveryWorker {
|
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
this._client = options.client;
|
|
48
|
-
this._scope = options.scope || 'infrastructure';
|
|
49
51
|
this._logger = assertLogger(
|
|
50
52
|
'RecoveryWorker',
|
|
51
53
|
options.logger,
|
|
@@ -98,9 +100,11 @@ class RecoveryWorker {
|
|
|
98
100
|
// with default arguments and no TTL. Refusing at one door while opening the
|
|
99
101
|
// other is not a guard.
|
|
100
102
|
//
|
|
101
|
-
// `
|
|
102
|
-
// never meant "may create business queues", and ConnectorMQClient
|
|
103
|
-
//
|
|
103
|
+
// `recoveryScope: 'business'` said the CLIENT belongs to a business service.
|
|
104
|
+
// It never meant "may create business queues", and `ConnectorMQClient` set it
|
|
105
|
+
// for every biz service, so that reading made the rule vacuous. The key is
|
|
106
|
+
// gone with the door it guarded (d.514); what decides here is `error.kind`,
|
|
107
|
+
// which the central declaration answers.
|
|
104
108
|
if (error.kind === 'business') {
|
|
105
109
|
this._logger.error(`[RecoveryWorker] Cannot create business queue '${error.queueName}' - it is created by its owning service via setupServiceQueues() after registration`);
|
|
106
110
|
throw error;
|