@onlineapps/mq-client-core 5.0.0 → 5.1.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 +145 -0
- package/README.md +50 -8
- package/package.json +1 -1
- package/src/BaseClient.js +21 -2
- package/src/buffer/InMemoryBuffer.js +98 -13
- package/src/buffer/MessageBuffer.js +5 -3
- package/src/config/configSchema.js +12 -6
- package/src/config/defaultConfig.js +7 -6
- package/src/layers/PublishLayer.js +152 -23
- package/src/monitoring-initiated.js +6 -9
- package/src/transports/rabbitmqClient.js +546 -75
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,151 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [5.1.0] — 2026-10-03
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `publish(queue, message, { bufferOnFailure: false })` — volba pro jedno volání: zpráva, jejíž
|
|
12
|
+
publikace selhala přechodně (vyčerpané pokusy, `publishRetryEnabled: false`, selhaný reconnect,
|
|
13
|
+
na který retry čekal), se NEuloží do bufferu, takže ji žádný reconnect znovu neodešle. Výchozí
|
|
14
|
+
`true` = chování beze změny. Pro volajícího, který zprávu drží trvale jinde (orchestrátor,
|
|
15
|
+
`publish_pending`, conf `step-publish-failure` 001; d.1122). Hodnota jiná než boolean se
|
|
16
|
+
odmítne `ValidationError` před prvním pokusem.
|
|
17
|
+
- Chyba ze tří přechodných cest nese `buffered` (boolean) — na chybě `PublishLayer` i na obalu
|
|
18
|
+
`PublishError` z `BaseClient.publish()`: `true` jen když buffer zprávu opravdu drží. Trvalé
|
|
19
|
+
odmítnutí `buffered` nenese. Věta `Fix:` chyby už neslibuje „is buffered“ tam, kde zpráva
|
|
20
|
+
v bufferu není (volba `false`, nebo buffer zprávu nepřijal).
|
|
21
|
+
- Chyba publishe nese `sent` (boolean) — zda rámec zprávy OPUSTIL klienta (`sendToQueue()` /
|
|
22
|
+
`publish()` kanálu vrátil bez výjimky), vedle `buffered` z d.1122: na obalu `PublishError`
|
|
23
|
+
z `BaseClient.publish()` i na chybě `PublishLayer` (`true`, když ho odeslal KTERÝKOLI pokus
|
|
24
|
+
volání); chyba jednoho pokusu transportu (`_publishOnce()`) nese `sent` toho pokusu. Záznam
|
|
25
|
+
bufferu nese totéž. Neříká nic o tom, zda broker zprávu uložil: při memory alarmu broker
|
|
26
|
+
odeslané rámce doručí sám po jeho zrušení (d.1143: 3 kopie publishe na exchange, 1 kopie
|
|
27
|
+
prvního publishe do fronty, 0 u neodeslaného). Rozhodnutí vlastníka
|
|
28
|
+
`docs/governance/confirmations/mq-publish-buffer.md` 001 (D); orchestrátor ho kopíruje do
|
|
29
|
+
záznamu `publish_pending` (`step-publish-failure` 003). Chyba transportu bez booleovského
|
|
30
|
+
`sent` je vada souseda: `PublishLayer` ji odmítne jménem a zprávu nebufferuje (d.1145).
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- Každé čekání na odpověď brokera má limit `brokerAnswerTimeout` — jedna kolej, žádný nový klíč
|
|
35
|
+
(rozhodnutí vlastníka, `docs/governance/confirmations/mq-broker-answer-budget.md` 001, verdikt A;
|
|
36
|
+
d.1135). Dosud ho mělo jen potvrzení publishe a `close-ok` při `disconnect()`; `assertQueue`,
|
|
37
|
+
`checkQueue` (veřejné i kontrola před publishem do fronty), `bindQueue`, `assertExchange`,
|
|
38
|
+
`purgeQueue`, `deleteQueue`, `consume`, `cancelConsumer`, prefetch a otevření kanálu
|
|
39
|
+
(`createChannel` / `createConfirmChannel`, při `connect()`, obnově i reconnectu) čekaly bez
|
|
40
|
+
limitu. Změřeno d.1126 na izolovaném brokeru platformního obrazu: při alarmu paměti (spojení
|
|
41
|
+
žije, heartbeat teče) visela po 300 s, u zmrazeného brokeru je ukončil až heartbeat po ~90 s.
|
|
42
|
+
Co se mění pro volajícího — hodnotami:
|
|
43
|
+
- volání, na které broker v limitu neodpoví, skončí po `brokerAnswerTimeout` chybou
|
|
44
|
+
`ConnectionError` s větou `The broker did not answer <operace> <cíl> within
|
|
45
|
+
brokerAnswerTimeout (<N>ms)`; kanál, který požadavek nesl, se vyřadí a příští operace otevře
|
|
46
|
+
nový (kanál, který broker otevře až po limitu, se hned zavře);
|
|
47
|
+
- publish do fronty, jehož kontrola `checkQueue` (nebo obnova kanálu) nedostala odpověď, je
|
|
48
|
+
přechodné selhání: `TransientPublishError` → retry → buffer, s `bufferOnFailure: false`
|
|
49
|
+
`PublishError(cause=TransientPublishError)` a `buffered: false` (D2, conf
|
|
50
|
+
`step-publish-failure` 001);
|
|
51
|
+
- holé `Channel ended, no reply will be forthcoming` z amqplibu (kanál nebo spojení skončilo
|
|
52
|
+
dřív, než broker odpověděl) se na těchto voláních vrací jako `ConnectionError` s původní
|
|
53
|
+
chybou v `cause`; číslované odmítnutí brokera (404, 406, …) zůstává beze změny;
|
|
54
|
+
- `connect()`, jemuž broker neodpoví na otevření kanálu, skončí do limitu a zavře spojení
|
|
55
|
+
v témže limitu (neodpovězené zavření zahodí socket jako `disconnect()`).
|
|
56
|
+
Heartbeat, potvrzení publishe a teardown beze změny.
|
|
57
|
+
- Klient poslouchá `blocked`/`unblocked` svého spojení (broker v memory/disk alarmu blokuje
|
|
58
|
+
spojení, které publikuje; d.1143 změřil na obrazu platformy). `blocked` = `warn` s důvodem
|
|
59
|
+
brokera; `unblocked` = `warn` a flush bufferu jednou kolejí `PublishLayer.flushBuffered('unblocked')`:
|
|
60
|
+
znovu se pošle JEN záznam, jehož rámec nikdy neopustil klienta; záznam s `sent: true` se vyřadí
|
|
61
|
+
s `warn` (fronta/exchange, stáří v bufferu), protože broker podržené rámce po alarmu doručí sám.
|
|
62
|
+
Flush po reconnectu beze změny — `flushBuffered('reconnected')` pošle všechno. Změřeno na
|
|
63
|
+
izolovaném brokeru d1145-rmq: po uvolnění alarmu u brokera 3 kopie odeslaného publishe na
|
|
64
|
+
exchange (beze změny, žádná navíc), 1 kopie odeslaného prvního publishe do fronty (beze změny)
|
|
65
|
+
a nově 1 kopie publishe, který se neodeslal (dřív 0 a tichá expirace); buffer po uvolnění
|
|
66
|
+
prázdný (dřív 2 a 1). Rozhodnutí vlastníka `docs/governance/confirmations/mq-publish-buffer.md`
|
|
67
|
+
001 (D), d.1145.
|
|
68
|
+
- `PublishLayer.flushBuffered(trigger)` — jediný argument je nově POVINNÝ spouštěč
|
|
69
|
+
`'reconnected'` | `'unblocked'` (rozhoduje, co se stane se záznamem, jehož rámec už odešel);
|
|
70
|
+
jiná hodnota se odmítne jménem. `InMemoryBuffer.flush(flushFn, { sentEntries })` totéž
|
|
71
|
+
(`'resend'` | `'drop'`). Uvnitř balíčku jediný volající je transport (d.1145).
|
|
72
|
+
Druhý argument `flushOptions` (slučoval se do voleb každé znovu poslané zprávy) je PRYČ:
|
|
73
|
+
nikdo ho nepředával (0 volajících v `shared/**`, `infra/**`, `api_biz/*/src`); znovu poslaná
|
|
74
|
+
zpráva nese přesně volby, se kterými ji buffer držel (d.1145).
|
|
75
|
+
- Vypršení zprávy v bufferu je `error`, ne `info`: jeden řádek na cíl (fronta nebo exchange)
|
|
76
|
+
a hodnotu `sent`, s počtem, stářím nejstarší a TTL; `sent=false` = zpráva je ZTRACENÁ,
|
|
77
|
+
`sent=true` = broker ji mohl doručit sám. Dřív „Removed N expired buffered messages“ bez
|
|
78
|
+
fronty i stáří. Rozhodnutí vlastníka `docs/governance/confirmations/mq-publish-buffer.md`
|
|
79
|
+
001 (D), d.1145.
|
|
80
|
+
- Publish, jehož potvrzení ještě čekalo, když broker zavřel spojení nebo kanál (`320
|
|
81
|
+
CONNECTION_FORCED`, restart či stop brokeru, výpadek spojení), končil `PermanentPublishError`
|
|
82
|
+
bez bufferu a bez retry — zpráva byla ztracena, ač o ní broker nic neřekl (měřeno d.1126).
|
|
83
|
+
Příčina: amqplib odpovídá nepotvrzeným publishům `Error('channel closed')` ze svého `close`
|
|
84
|
+
posluchače registrovaného v konstruktoru kanálu, tedy DŘÍV, než klient kanál označil za
|
|
85
|
+
mrtvý; callback pak četl živý kanál. Klientův `close` posluchač send kanálu se nyní
|
|
86
|
+
registruje `prependListener` a běží první, takže tahle cesta je přechodná
|
|
87
|
+
(`TransientPublishError`) a jde dnešní cestou retry/buffer, včetně `bufferOnFailure: false`
|
|
88
|
+
(`buffered: false`). `nack` brokeru a číslovaný close frame této zprávy zůstávají trvalé
|
|
89
|
+
(d.1134).
|
|
90
|
+
- Hláška `PublishError` větve fronty při zavření kanálu s čekajícím potvrzením tvrdila „and not
|
|
91
|
+
reconnecting“ — v S3 nepravda: spojení v tu chvíli ještě neohlásilo `close` a reconnect
|
|
92
|
+
následuje. Nově: „Channel closed during publish to queue "<q>" before the broker confirmed the
|
|
93
|
+
message …“, bez tvrzení o reconnectu, `Fix:` odkazuje na `error.buffered` (d.1134).
|
|
94
|
+
- Re-registrace konzumentů po obnově kanálu se ptá na živost konzumentského kanálu před KAŽDÝM
|
|
95
|
+
konzumentem (a znovu po kroku fronty), ne jednou před smyčkou. Kanál, který zemřel uprostřed
|
|
96
|
+
smyčky (zavřel ho broker, nebo ho vyřadil `brokerAnswerTimeout`, d.1135), dřív vedl
|
|
97
|
+
u dalšího konzumenta na `TypeError: Cannot read properties of null (reading 'consume')`
|
|
98
|
+
a na zbytečnou deklaraci jeho fronty. Nově `ConnectionError` „Cannot re-register the consumer
|
|
99
|
+
of <q>: consumer channel is not available …“ v `consumer:re-registration:failed`; konzument
|
|
100
|
+
zůstává sledovaný bez tagu a obnoví ho příští obnova, jako dosud (d.1138).
|
|
101
|
+
- Hláška `_waitForReconnection()` po vypršení jmenovala env `RABBITMQ_RECONNECT_WAIT_TIMEOUT`,
|
|
102
|
+
který nic nečte. Nově jmenuje, co se čte: klíč konfigurace `reconnectWaitTimeout` („set in the
|
|
103
|
+
client configuration“), nebo odvození `max(60000, maxReconnectAttempts × reconnectMaxDelay)`,
|
|
104
|
+
když klíč zadaný není; `Fix:` odkazuje na tentýž klíč. Chování beze změny (d.1138).
|
|
105
|
+
- Přechodná chyba cesty publishe (`_publishPathTransient()`) tvrdila „queue channel is not
|
|
106
|
+
available (connection may be closed)“ i při ŽIVÉM spojení blokovaném memory alarmem (d.1143,
|
|
107
|
+
nález 3; od d.1135 končí touto chybou i neodpovězená kontrola `checkQueue`). Věta se nově
|
|
108
|
+
volí ze stavu klienta: zavřené spojení → „the connection is closed“; blokované → „the broker
|
|
109
|
+
has blocked this connection (reason: …)“; jinak „the queue channel is not available on a
|
|
110
|
+
live, unblocked connection“ s příčinou v `error.cause`. Třída beze změny (d.1145).
|
|
111
|
+
|
|
112
|
+
### Fixed
|
|
113
|
+
|
|
114
|
+
- Spojení, které obnova navázala až po `disconnect()` volajícího, se zavírá v rozpočtu
|
|
115
|
+
`brokerAnswerTimeout` (`_closeWithinTeardownBudget()`, jedna kolej, conf
|
|
116
|
+
`mq-broker-answer-budget` 001). Dřív `await revived.close()` bez limitu: broker, který v tu
|
|
117
|
+
chvíli neodpovídal, držel kolo obnovy (`_reconnecting: true`) a socket otevřené navždy.
|
|
118
|
+
Neodpovězené zavření nově zahodí socket jako teardown (d.1138).
|
|
119
|
+
|
|
120
|
+
### Documentation — JSDoc a popis klíče na pravdu, docblock jedné koleje `workflow_initiated` bez smazané cesty (d.1134, d.1138, d.1176)
|
|
121
|
+
|
|
122
|
+
- JSDoc `workflowIdOf()` tvrdil, že alias `workflowId` dosud píše webhook submit gatewaye.
|
|
123
|
+
Nepravda: žádný producent v `shared/**` ani `infra/**` alias nepíše (hlavičky gatewaye nesou
|
|
124
|
+
jen `workflow_id`, `infra/api_gateway/shared/workflowMessageHeaders.js`); čtení aliasu
|
|
125
|
+
v transportu neexistuje od d.416, chování beze změny (d.1134).
|
|
126
|
+
- Popis klíče `heartbeat` v `configSchema.js` na pravdu: hodnota se navrhuje brokeru v URL
|
|
127
|
+
spojení (ne „předává se amqplibu“) a `0` heartbeat NEVYPNE — vyjednávání amqplibu vezme
|
|
128
|
+
stranu, která limit stanoví, tedy hodnotu brokera (změřeno d.1126: pauza zjištěna po 179 s).
|
|
129
|
+
Chování beze změny (d.1138).
|
|
130
|
+
- Docblock `src/monitoring-initiated.js` popisuje rozhodnutí o jedné koleji události
|
|
131
|
+
`workflow_initiated` (conf `scheduler` 009 a 024) bez cesty na smazaný soubor gatewaye
|
|
132
|
+
a bez vyprávění o kopiích, které to rozhodnutí rušilo; chování beze změny (d.1176).
|
|
133
|
+
|
|
134
|
+
### Tests — sady bez nástěnného času a s pravdivým popisem bloku (d.1174, d.1138)
|
|
135
|
+
|
|
136
|
+
- Test, ne chování: sada `connect-timeout-closes-late-handshake.test.js` čekala nástěnný čas
|
|
137
|
+
(`connectTimeout` 20 ms, pak `setTimeout` 80 ms na druhý pokus reconnectu) a pod zátěží runneru
|
|
138
|
+
`oa-staging` (pipeline 2904670335) četla `amqp.connect` 2× místo 3×, proto hodiny teď řídí
|
|
139
|
+
test (`jest.useFakeTimers()` + `advanceTimersByTimeAsync`) a výsledek nezávisí na zátěži
|
|
140
|
+
stroje (d.1174).
|
|
141
|
+
- Popis bloku testu `connection-parameters-reach-the-broker.test.js` („…one name for its two
|
|
142
|
+
consumers“) srovnán s d.1135: budget `brokerAnswerTimeout` platí pro každé čekání na odpověď
|
|
143
|
+
brokera, ne pro dva konzumenty (d.1138).
|
|
144
|
+
- Test, ne chování: akceptační sada čekací koleje `waiting-helper.test.js` měřila vzorkování
|
|
145
|
+
`waitForValue`/`waitForEvent` na nástěnných hodinách a pod loadem 17–30 (pre-K sweep
|
|
146
|
+
`release/w4`) vzorek rastru 20 ms zpožděný o víc než 40 ms minul okno 150–190 ms, takže
|
|
147
|
+
„s `pollMs: 20` tentýž stav VIDÍ" četl `true`; hodiny teď řídí test (`jest.useFakeTimers()`
|
|
148
|
+
+ `advanceTimersByTimeAsync`, čas čte `jest.now()`); hodnoty a počty v tvrzeních beze změny,
|
|
149
|
+
podlahy pauzy přesně podle pravidla, bez rezervy na plánovač; kolej `tests/waiting.js`
|
|
150
|
+
nedotčena (d.1192).
|
|
151
|
+
|
|
7
152
|
## [5.0.0] — 2026-09-27
|
|
8
153
|
|
|
9
154
|
### Removed — BREAKING — fronta `workflow.control` (d.1061)
|
package/README.md
CHANGED
|
@@ -86,11 +86,18 @@ says" — and every connection on the platform ran on the broker's number rather
|
|
|
86
86
|
the configured one (measured on the dev broker: 39 live connections, `timeout: 60`
|
|
87
87
|
on all of them).
|
|
88
88
|
|
|
89
|
-
`brokerAnswerTimeout` bounds **
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
89
|
+
`brokerAnswerTimeout` bounds **every** wait on the broker's answer — the confirm of
|
|
90
|
+
one publish attempt, the `close-ok` of each handle `disconnect()` closes, and every
|
|
91
|
+
request a channel sends and waits on: `assertQueue`, `checkQueue` (also the check before a
|
|
92
|
+
queue publish), `bindQueue`, `assertExchange`, `purgeQueue`, `deleteQueue`, `consume`,
|
|
93
|
+
`cancelConsumer`, the prefetch, and every channel open (conf `mq-broker-answer-budget` 001).
|
|
94
|
+
A request left unanswered for that long discards the channel that carried it (the next
|
|
95
|
+
operation opens a fresh one) and ends in `ConnectionError` naming the operation, its target
|
|
96
|
+
and the budget; on the publish path it is `TransientPublishError`, so retry, the buffer and
|
|
97
|
+
`bufferOnFailure: false` apply as to any transient failure. This is what ends the wait on a
|
|
98
|
+
broker in a memory alarm, which keeps the connection and its heartbeats alive while it
|
|
99
|
+
answers nothing. It was called `publishConfirmationTimeout` until 2026-09-14 — the name of
|
|
100
|
+
one of its consumers — and writing that name now is refused, with the new one named.
|
|
94
101
|
|
|
95
102
|
`maxReconnectAttempts` and `maxReconnectCycles` resolve the usual way — explicit
|
|
96
103
|
config → env (`RABBITMQ_MAX_RECONNECT_ATTEMPTS`, `RABBITMQ_MAX_RECONNECT_CYCLES`)
|
|
@@ -134,7 +141,10 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
|
|
|
134
141
|
|
|
135
142
|
- `connect(options?)` - Connect to RabbitMQ
|
|
136
143
|
- `disconnect()` - Disconnect from RabbitMQ
|
|
137
|
-
- `publish(queue, message, options?)` - Publish message to queue
|
|
144
|
+
- `publish(queue, message, options?)` - Publish message to queue. `options.bufferOnFailure`
|
|
145
|
+
(boolean, default `true`): `false` leaves the publish buffer for this one call — a
|
|
146
|
+
transiently failed message is NOT held and no reconnect replays it; the thrown
|
|
147
|
+
`PublishError` then carries `buffered: false` (see § The publish buffer).
|
|
138
148
|
- `publishTelemetry(queue, message, options?)` - Publish the client's OWN event (the
|
|
139
149
|
monitoring helpers below use it) on a confirm channel of its own, opened with the
|
|
140
150
|
first such publish. A refusal there closes that channel only, never the one a
|
|
@@ -570,8 +580,40 @@ buffered, because no retry and no reconnect declares the exchange (d.693).
|
|
|
570
580
|
A publish that fails transiently (the connection is closing, a channel is being
|
|
571
581
|
recreated, the broker did not confirm in time) is held in memory — bounded by
|
|
572
582
|
`publishBufferMaxSize` messages and `publishBufferTtlMs` each — and replayed after
|
|
573
|
-
the reconnect, critical priority first. `getBufferState()` reports it and
|
|
574
|
-
`publish:buffered` / `buffer:flush:failed` announce it.
|
|
583
|
+
the reconnect or after the broker unblocks the connection (below), critical priority first. `getBufferState()` reports it and
|
|
584
|
+
`publish:buffered` / `buffer:flush:failed` announce it. That includes a publish whose
|
|
585
|
+
confirm was still pending when the channel or the connection died under it (the broker
|
|
586
|
+
closed it, restarted or stopped): the broker said nothing about that message, so it is
|
|
587
|
+
transient, never a refusal — only a `nack` or a numbered close frame naming this send
|
|
588
|
+
is permanent (d.1134).
|
|
589
|
+
|
|
590
|
+
**One call can leave the buffer: `publish(queue, message, { bufferOnFailure: false })`.**
|
|
591
|
+
It is for a caller that keeps the message durably elsewhere and must not have a second
|
|
592
|
+
copy replayed behind its back — the orchestrator writing a `publish_pending` record
|
|
593
|
+
(`docs/governance/confirmations/step-publish-failure.md` 001). On each of the three
|
|
594
|
+
transient paths (retries exhausted, `publishRetryEnabled: false`, the reconnect a retry
|
|
595
|
+
waited for failed) the error `publish()` throws carries `buffered` (boolean): `true` only
|
|
596
|
+
when the buffer holds the message for the next reconnect, `false` when nothing will
|
|
597
|
+
replay it. A permanent refusal never reaches the buffer and carries no `buffered`. A
|
|
598
|
+
value that is not a boolean is refused before any attempt (`ValidationError`). Retry
|
|
599
|
+
stays the library's either way — the option decides only what happens after it.
|
|
600
|
+
|
|
601
|
+
**Every failed publish says whether its frame left the client: `error.sent`** (boolean, on
|
|
602
|
+
the `PublishError` from `publish()` and on its cause; conf
|
|
603
|
+
`docs/governance/confirmations/mq-publish-buffer.md` 001). `true` when any attempt wrote the
|
|
604
|
+
frame to the channel — the broker may hold it, and after a memory alarm deliver it, although
|
|
605
|
+
the call failed; `false` when no attempt got that far. It says nothing about whether the
|
|
606
|
+
broker stored the message. The buffer entry records the same fact, and it decides what
|
|
607
|
+
happens when the outage ends:
|
|
608
|
+
|
|
609
|
+
| The outage ends with | A held message whose frame was never sent | … whose frame was sent |
|
|
610
|
+
|---|---|---|
|
|
611
|
+
| a reconnect (`reconnected`) | re-sent | re-sent — the old socket and what the broker had not read are gone |
|
|
612
|
+
| the broker unblocking the connection (`connection.unblocked`, end of a memory or disk alarm) | re-sent | dropped with a `warn` — the broker delivers the frames it held by itself |
|
|
613
|
+
|
|
614
|
+
`connection.blocked` is logged as a `warn` with the broker's reason. A held message that
|
|
615
|
+
outlives `publishBufferTtlMs` is logged as an `error` naming its queue or exchange, the
|
|
616
|
+
count, the age and `sent` — `sent=false` is a message lost for good.
|
|
575
617
|
|
|
576
618
|
**There is no persistent buffer, and `persistentBufferEnabled` /
|
|
577
619
|
`persistentRedisClient` are refused by name.** They declared one until
|
package/package.json
CHANGED
package/src/BaseClient.js
CHANGED
|
@@ -494,9 +494,14 @@ class BaseClient {
|
|
|
494
494
|
* infrastructure or business queue is created by its owner before anything
|
|
495
495
|
* publishes to it, and an ownerless name is declared with the arguments the
|
|
496
496
|
* central `queueConfig` resolves (`transports/rabbitmqClient.js`, the 404 branch).
|
|
497
|
+
* @param {boolean} [options.bufferOnFailure=true] - `false` = a transiently failed message
|
|
498
|
+
* is NOT held in the library's buffer, so no reconnect replays it; for a caller that keeps
|
|
499
|
+
* the message durably elsewhere. Owned by `layers/PublishLayer.js`; a non-boolean is refused.
|
|
497
500
|
* @returns {Promise<void>}
|
|
498
501
|
* @throws {ConnectionError} If not connected.
|
|
499
|
-
* @throws {PublishError} If publish fails.
|
|
502
|
+
* @throws {PublishError} If publish fails. After a transient failure (retries exhausted,
|
|
503
|
+
* retry disabled, the awaited reconnect failed) it carries `buffered` (boolean): `true`
|
|
504
|
+
* only when the buffer holds the message for the next reconnect.
|
|
500
505
|
*/
|
|
501
506
|
async publish(queue, message, options = {}) {
|
|
502
507
|
if (!this._connected || !this._transport) {
|
|
@@ -539,7 +544,7 @@ class BaseClient {
|
|
|
539
544
|
// answer as d.297 one call over (`_consumerRefusal()`): name no likeliest
|
|
540
545
|
// cause, carry the one actually caught. `cause` is unchanged, so a caller
|
|
541
546
|
// deciding on the class still reads the object.
|
|
542
|
-
|
|
547
|
+
const failure = new PublishError(
|
|
543
548
|
`[BaseClient] Failed to publish to queue "${queue}": ${describeThrown(err)} - `
|
|
544
549
|
+ 'Expected: publish() to hand the message to the broker. '
|
|
545
550
|
+ 'Fix: act on the reason above — this layer does not know which failure it was, and naming the '
|
|
@@ -548,6 +553,20 @@ class BaseClient {
|
|
|
548
553
|
queue,
|
|
549
554
|
err
|
|
550
555
|
);
|
|
556
|
+
// Whether the library's buffer holds the message is decided and written by
|
|
557
|
+
// `PublishLayer` (options.bufferOnFailure, d.1122); it is carried up here unchanged so
|
|
558
|
+
// the caller reads it on the error it catches. Absent when the failure never reached
|
|
559
|
+
// the buffer decision — "not asked" is not "false".
|
|
560
|
+
if (err && typeof err.buffered === 'boolean') {
|
|
561
|
+
failure.buffered = err.buffered;
|
|
562
|
+
}
|
|
563
|
+
// Whether any attempt wrote the frame (d.1145, conf `mq-publish-buffer` 001): carried
|
|
564
|
+
// like `buffered`, so the caller — the orchestrator's `publish_pending` record
|
|
565
|
+
// (`step-publish-failure` 003) — reads it on the error it catches.
|
|
566
|
+
if (err && typeof err.sent === 'boolean') {
|
|
567
|
+
failure.sent = err.sent;
|
|
568
|
+
}
|
|
569
|
+
throw failure;
|
|
551
570
|
}
|
|
552
571
|
}
|
|
553
572
|
|
|
@@ -10,6 +10,19 @@ const { assertLogger } = require('@onlineapps/logger-contract');
|
|
|
10
10
|
* Primárně určený pro transient scénáře (health checky apod.).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* How a log line names where a buffered message was going: its exchange when it was published
|
|
15
|
+
* to one, its queue otherwise.
|
|
16
|
+
*
|
|
17
|
+
* @param {{queue: string, options: Object}} item
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function describeTarget(item) {
|
|
21
|
+
return item.options && typeof item.options.exchange === 'string' && item.options.exchange !== ''
|
|
22
|
+
? `exchange "${item.options.exchange}"`
|
|
23
|
+
: `queue "${item.queue}"`;
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
class InMemoryBuffer {
|
|
14
27
|
/**
|
|
15
28
|
* Both numbers are REQUIRED and have no stand-in here.
|
|
@@ -61,7 +74,7 @@ class InMemoryBuffer {
|
|
|
61
74
|
this._maxSize = options.maxSize;
|
|
62
75
|
this._ttlMs = options.ttlMs;
|
|
63
76
|
|
|
64
|
-
/** @type {Array<{queue:string, buffer:Buffer, options:Object, createdAt:number, priority:string}>} */
|
|
77
|
+
/** @type {Array<{queue:string, buffer:Buffer, options:Object, createdAt:number, priority:string, sent:boolean}>} */
|
|
65
78
|
this._items = [];
|
|
66
79
|
}
|
|
67
80
|
|
|
@@ -69,7 +82,18 @@ class InMemoryBuffer {
|
|
|
69
82
|
* Přidá zprávu do bufferu.
|
|
70
83
|
* Staré/expirující zprávy jsou průběžně čištěny, při překročení kapacity odhazujeme nejstarší.
|
|
71
84
|
*/
|
|
72
|
-
async add(queue, buffer, options = {}, priority = 'normal') {
|
|
85
|
+
async add(queue, buffer, options = {}, priority = 'normal', sent) {
|
|
86
|
+
// Whether the frame of this message already LEFT the client (d.1145, conf
|
|
87
|
+
// `mq-publish-buffer` 001): the flush after `unblocked` re-sends only the entries that
|
|
88
|
+
// never did, because the broker delivers the frames it was holding by itself. Required —
|
|
89
|
+
// an entry that cannot say would be re-sent or dropped by a guess.
|
|
90
|
+
if (typeof sent !== 'boolean') {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`[InMemoryBuffer] Cannot hold a message for queue '${queue}' without knowing whether its frame was sent - `
|
|
93
|
+
+ `got sent=${JSON.stringify(sent)}. Expected: a boolean from the failed publish (error.sent). `
|
|
94
|
+
+ 'Fix: pass it from PublishLayer, which reads it off the transport\'s error.'
|
|
95
|
+
);
|
|
96
|
+
}
|
|
73
97
|
const now = Date.now();
|
|
74
98
|
this._cleanupExpired(now);
|
|
75
99
|
|
|
@@ -86,6 +110,7 @@ class InMemoryBuffer {
|
|
|
86
110
|
options,
|
|
87
111
|
createdAt: now,
|
|
88
112
|
priority,
|
|
113
|
+
sent,
|
|
89
114
|
});
|
|
90
115
|
}
|
|
91
116
|
|
|
@@ -94,7 +119,20 @@ class InMemoryBuffer {
|
|
|
94
119
|
* @param {Function} flushFn - async (queue, buffer, options) => void
|
|
95
120
|
* @returns {Promise<number>} - počet úspěšně flushnutých zpráv
|
|
96
121
|
*/
|
|
97
|
-
async flush(flushFn) {
|
|
122
|
+
async flush(flushFn, { sentEntries } = {}) {
|
|
123
|
+
// What happens to an entry whose frame already LEFT the client (d.1145, conf
|
|
124
|
+
// `mq-publish-buffer` 001): 'resend' after a reconnect — the old socket and whatever the
|
|
125
|
+
// broker had not read from it are gone; 'drop' after the broker unblocked the connection —
|
|
126
|
+
// it reads what was waiting in the socket and delivers those frames by itself, so a
|
|
127
|
+
// re-send would be one more copy. Required: a flush that guessed would do one or the other
|
|
128
|
+
// wrong.
|
|
129
|
+
if (sentEntries !== 'resend' && sentEntries !== 'drop') {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`[InMemoryBuffer] flush() needs to know what to do with a sent entry - got sentEntries=${JSON.stringify(sentEntries)}. `
|
|
132
|
+
+ "Expected: 'resend' (after a reconnect) or 'drop' (after the broker unblocked the connection). "
|
|
133
|
+
+ 'Fix: flush through PublishLayer.flushBuffered(trigger), which maps its trigger to one of them.'
|
|
134
|
+
);
|
|
135
|
+
}
|
|
98
136
|
const now = Date.now();
|
|
99
137
|
this._cleanupExpired(now);
|
|
100
138
|
|
|
@@ -103,9 +141,23 @@ class InMemoryBuffer {
|
|
|
103
141
|
}
|
|
104
142
|
|
|
105
143
|
// Zkopírujeme a vyprázdníme interní pole, abychom se vyhnuli reentrancy problémům
|
|
106
|
-
|
|
144
|
+
let items = this._items.slice();
|
|
107
145
|
this._items = [];
|
|
108
146
|
|
|
147
|
+
if (sentEntries === 'drop') {
|
|
148
|
+
for (const item of items.filter((entry) => entry.sent)) {
|
|
149
|
+
this._logger.warn(
|
|
150
|
+
`[InMemoryBuffer] A buffered message for ${describeTarget(item)} is not re-sent after the broker unblocked `
|
|
151
|
+
+ `the connection: its frame had been sent, and it was buffered ${now - item.createdAt} ms ago - Expected: the broker delivers `
|
|
152
|
+
+ 'the frames it held during the alarm by itself once it reads the socket again, so a re-send would '
|
|
153
|
+
+ 'be one more copy. Fix: nothing to do here; a consumer that must not see a message twice '
|
|
154
|
+
+ 'deduplicates it (conf mq-consumer-contract 009), and a message the broker did NOT keep is '
|
|
155
|
+
+ 'a broker-side loss this client cannot see.'
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
items = items.filter((entry) => !entry.sent);
|
|
159
|
+
}
|
|
160
|
+
|
|
109
161
|
// Kritické zprávy (priority === 'critical') házíme dopředu
|
|
110
162
|
items.sort((a, b) => {
|
|
111
163
|
if (a.priority === b.priority) return a.createdAt - b.createdAt;
|
|
@@ -125,7 +177,9 @@ class InMemoryBuffer {
|
|
|
125
177
|
this._logger.warn(
|
|
126
178
|
`[InMemoryBuffer] Failed to flush message for queue '${item.queue}': ${err.message}`
|
|
127
179
|
);
|
|
128
|
-
|
|
180
|
+
// The replay is one more attempt: if IT wrote the frame, the entry says so from now on.
|
|
181
|
+
const sent = item.sent || (err !== null && typeof err === 'object' && err.sent === true);
|
|
182
|
+
await this.add(item.queue, item.buffer, item.options, item.priority, sent);
|
|
129
183
|
}
|
|
130
184
|
}
|
|
131
185
|
|
|
@@ -140,15 +194,46 @@ class InMemoryBuffer {
|
|
|
140
194
|
return this._items.length;
|
|
141
195
|
}
|
|
142
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Drop the entries older than the TTL — and say, as an ERROR, what was dropped (d.1145, conf
|
|
199
|
+
* `mq-publish-buffer` 001).
|
|
200
|
+
*
|
|
201
|
+
* Until d.1145 this was one `info` line with a count and nothing else, the only trace of a
|
|
202
|
+
* message nobody would ever deliver (a memory alarm keeps the connection alive, so no
|
|
203
|
+
* reconnect flush comes — d.1143). One error per destination and `sent` value, with the
|
|
204
|
+
* count and the age of the oldest: a message whose frame never left is LOST, one whose frame
|
|
205
|
+
* had been sent may have been delivered by the broker on its own, and the two are never
|
|
206
|
+
* folded into one sentence.
|
|
207
|
+
*
|
|
208
|
+
* @param {number} now - the clock reading the caller took
|
|
209
|
+
* @private
|
|
210
|
+
*/
|
|
143
211
|
_cleanupExpired(now) {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
212
|
+
const expired = this._items.filter((item) => now - item.createdAt > this._ttlMs);
|
|
213
|
+
if (expired.length === 0) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
this._items = this._items.filter((item) => now - item.createdAt <= this._ttlMs);
|
|
217
|
+
|
|
218
|
+
const groups = new Map();
|
|
219
|
+
for (const item of expired) {
|
|
220
|
+
const key = `${describeTarget(item)}|${item.sent}`;
|
|
221
|
+
const group = groups.get(key) || { target: describeTarget(item), sent: item.sent, count: 0, oldestMs: 0 };
|
|
222
|
+
group.count += 1;
|
|
223
|
+
group.oldestMs = Math.max(group.oldestMs, now - item.createdAt);
|
|
224
|
+
groups.set(key, group);
|
|
225
|
+
}
|
|
226
|
+
for (const { target, sent, count, oldestMs } of groups.values()) {
|
|
227
|
+
this._logger.error(
|
|
228
|
+
`[InMemoryBuffer] ${count} buffered message(s) for ${target} expired after the buffer TTL `
|
|
229
|
+
+ `(${this._ttlMs} ms; oldest ${oldestMs} ms), sent=${sent} - `
|
|
230
|
+
+ (sent
|
|
231
|
+
? 'the frame had been sent, so the broker may have delivered it on its own; nothing re-sends it now. '
|
|
232
|
+
: 'the frame never left this client, so the message is LOST; nothing re-sends it now. ')
|
|
233
|
+
+ 'Expected: a reconnect, or the broker unblocking the connection, within publishBufferTtlMs. '
|
|
234
|
+
+ 'Fix: find why the broker stayed away that long (an alarm: management API /api/health/checks/alarms; '
|
|
235
|
+
+ 'an outage: the connection log); a message that must survive longer belongs to a caller that keeps '
|
|
236
|
+
+ 'it durably and publishes with bufferOnFailure: false.'
|
|
152
237
|
);
|
|
153
238
|
}
|
|
154
239
|
}
|
|
@@ -50,7 +50,9 @@ class MessageBuffer {
|
|
|
50
50
|
async add(queue, buffer, options = {}, meta = {}) {
|
|
51
51
|
const priority = meta.priority || 'normal';
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
// `sent` is passed through unread: the in-memory buffer owns the rule that it is required
|
|
54
|
+
// (d.1145, conf `mq-publish-buffer` 001).
|
|
55
|
+
await this._inMemory.add(queue, buffer, options, priority, meta.sent);
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/**
|
|
@@ -58,8 +60,8 @@ class MessageBuffer {
|
|
|
58
60
|
* @param {Function} flushFn - async (queue, buffer, options) => void
|
|
59
61
|
* @returns {Promise<number>} how many messages were replayed
|
|
60
62
|
*/
|
|
61
|
-
async flush(flushFn) {
|
|
62
|
-
return await this._inMemory.flush(flushFn);
|
|
63
|
+
async flush(flushFn, flushPolicy) {
|
|
64
|
+
return await this._inMemory.flush(flushFn, flushPolicy);
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
/**
|
|
@@ -136,8 +136,12 @@ module.exports = {
|
|
|
136
136
|
type: 'integer',
|
|
137
137
|
minimum: 0,
|
|
138
138
|
description:
|
|
139
|
-
'AMQP heartbeat in SECONDS,
|
|
140
|
-
+ 'RABBITMQ_HEARTBEAT, else the module default
|
|
139
|
+
'AMQP heartbeat in SECONDS, proposed to the broker in the connection URL (?heartbeat=) at '
|
|
140
|
+
+ 'connect. Explicit value, else ENV RABBITMQ_HEARTBEAT, else the module default '
|
|
141
|
+
+ '(../defaults.js heartbeatSeconds). 0 does NOT switch the heartbeat off: amqplib\'s '
|
|
142
|
+
+ 'negotiation takes whichever side sets a limit, so the broker\'s own value applies '
|
|
143
|
+
+ '(RabbitMQ 60 s unless its configuration says otherwise; measured d.1126: a paused broker '
|
|
144
|
+
+ 'detected after 179 s). Only a broker that also offers 0 disables it.',
|
|
141
145
|
},
|
|
142
146
|
serviceName: {
|
|
143
147
|
type: 'string',
|
|
@@ -287,10 +291,12 @@ module.exports = {
|
|
|
287
291
|
type: 'integer',
|
|
288
292
|
minimum: 0,
|
|
289
293
|
description:
|
|
290
|
-
'How long this client waits for an ANSWER from the broker, in milliseconds: the
|
|
291
|
-
+ 'one publish attempt,
|
|
292
|
-
+ '
|
|
293
|
-
+ '
|
|
294
|
+
'How long this client waits for an ANSWER from the broker, in milliseconds — every wait: the '
|
|
295
|
+
+ 'confirm of one publish attempt, the close-ok of each handle disconnect() closes, and every '
|
|
296
|
+
+ 'request a channel sends and waits on (queue/exchange declarations, checkQueue, bindQueue, '
|
|
297
|
+
+ 'purge, delete, consume, cancel, prefetch, channel open; conf mq-broker-answer-budget 001, '
|
|
298
|
+
+ 'd.1135). One number for one question — it was named publishConfirmationTimeout until d.299, '
|
|
299
|
+
+ 'after the teardown had been spending it since d.283.',
|
|
294
300
|
},
|
|
295
301
|
connectTimeout: {
|
|
296
302
|
type: 'integer',
|
|
@@ -120,12 +120,13 @@ module.exports = {
|
|
|
120
120
|
publishRetryMaxDelay: 5000,
|
|
121
121
|
publishRetryBackoffMultiplier: 2,
|
|
122
122
|
|
|
123
|
-
// How long this client waits for an ANSWER from the broker, in ms
|
|
124
|
-
// of one publish attempt,
|
|
125
|
-
// closes
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
// d.
|
|
123
|
+
// How long this client waits for an ANSWER from the broker, in ms — every wait:
|
|
124
|
+
// the confirm of one publish attempt, the `close-ok` of each handle
|
|
125
|
+
// `disconnect()` closes, and every request a channel sends and waits on
|
|
126
|
+
// (conf `mq-broker-answer-budget` 001, d.1135) — one number for all of them,
|
|
127
|
+
// which is why it carries the name of none. It was `publishConfirmationTimeout`
|
|
128
|
+
// until d.299, a name that was true of the publish path and false of the
|
|
129
|
+
// teardown that had used it since d.283.
|
|
129
130
|
brokerAnswerTimeout: 5000,
|
|
130
131
|
|
|
131
132
|
// How long the connect handshake may take before the attempt is abandoned, in
|