@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 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 **both** things this client waits for the broker to
90
- answer: the confirm of one publish attempt, and the `close-ok` of each handle
91
- `disconnect()` closes. It was called `publishConfirmationTimeout` until
92
- 2026-09-14 — the name of one of its two consumers — and writing that name now is
93
- refused, with the new one named.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/mq-client-core",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Core MQ client library for RabbitMQ - shared by infrastructure services and connectors",
5
5
  "oa": {
6
6
  "category": "connector"
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
- throw new PublishError(
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
- const items = this._items.slice();
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
- await this.add(item.queue, item.buffer, item.options, item.priority);
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 before = this._items.length;
145
- this._items = this._items.filter(
146
- (item) => now - item.createdAt <= this._ttlMs
147
- );
148
- const removed = before - this._items.length;
149
- if (removed > 0) {
150
- this._logger.info(
151
- `[InMemoryBuffer] Removed ${removed} expired buffered messages`
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
- await this._inMemory.add(queue, buffer, options, priority);
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, handed to amqplib at connect. Explicit value, else ENV '
140
- + 'RABBITMQ_HEARTBEAT, else the module default (../defaults.js heartbeatSeconds).',
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 confirm of '
291
- + 'one publish attempt, and the close-ok of each handle disconnect() closes. One number, two '
292
- + 'consumers — it was named publishConfirmationTimeout until d.299, after the teardown had '
293
- + 'been spending it since d.283.',
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: the confirm
124
- // of one publish attempt, and the `close-ok` of each handle `disconnect()`
125
- // closes — one number for two consumers, which is why it carries the name of
126
- // neither. It was `publishConfirmationTimeout` until d.299, a name that was
127
- // true of the publish path and false of the teardown that had used it since
128
- // d.283.
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