@onlineapps/mq-client-core 4.0.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,76 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [4.0.1] — 2026-09-17
8
+
9
+ ### Fixed — zrušení konzumenta brokerem přestalo být tiché (d.605)
10
+
11
+ `basic.cancel` od brokera — smazaná fronta, přesun fronty při HA failoveru
12
+ (`x-cancel-on-ha-failover`) — doručí amqplib do callbacku `consume()` jako
13
+ zprávu `null`. Klient na ni odpovídal holým `return`: žádný záznam, žádná změna
14
+ stavu, žádné znovupřipojení. Značka konzumenta zůstala nastavená, a protože
15
+ právě ona je jediný fakt, kterým `performHealthCheck()` odpovídá „je konzument
16
+ TOHOTO klienta připojený", hlásil klient `healthy: true` o frontě, ze které už
17
+ nic nečetl. Ticho je vada stejné závažnosti jako špatný výsledek
18
+ (`automation-gates.md` §5).
19
+
20
+ Nově v tomto pořadí: značka se zahodí (týž fakt, který d.386b zavedl pro
21
+ odmítnutou re-registraci — od té chvíle je klient `unhealthy` a jmenuje frontu),
22
+ ztráta se zapíše na úrovni **error** se jménem fronty i zrušené značky, a
23
+ konzument se znovu připojí **kolejí, kterou už používá obnova kanálu**: tělo
24
+ smyčky `_reRegisterConsumers()` dostalo jméno (`_reRegisterConsumer()`) a cancel
25
+ volá tutéž funkci pro jednu frontu. Celá smyčka by znovu připojila i konzumenty,
26
+ kteří o své místo nepřišli.
27
+
28
+ Klasifikaci, deklaraci a prefetch tedy dělá `_prepareQueueForConsume()` —
29
+ vlastněná business fronta se znovu deklaruje, nevlastněná infrastrukturní se jen
30
+ `checkQueue` a její absence je odmítnutí se značkou `CONSUMER_QUEUE_MISSING`,
31
+ nikdy tiché založení. Odmítnuté znovupřipojení nechá konzumenta sledovaného bez
32
+ značky a důvod jede na události, kterou balíček už má —
33
+ `consumer:re-registration:failed`.
34
+
35
+ **Žádný nový veřejný kontrakt**: žádná nová metoda, žádná nová událost, žádná
36
+ změna podpisu. Každý stav, do kterého se cesta může dostat, už balíček hlásí.
37
+
38
+ Ticho zůstává tam, kde zrušení není vada: vlastní `cancelConsumer()` callback
39
+ vůbec nevolá (amqplib odregistruje konzumenta na `basic.cancel-ok`; broker
40
+ neposílá cancel), a cancel, který dorazí během `disconnect()`, je zapsán na
41
+ úrovni debug — úmyslné zavření není porucha.
42
+
43
+ Kdo z toho co má: každá služba, která konzumuje frontu, se po re-pinu na tuto
44
+ verzi dozví, že o konzumenta přišla — dnes to nepozná ani z logu, ani ze
45
+ zdravotní kontroly.
46
+
47
+ ### Fixed — „připojený konzument" má jednu definici, a čte ji i levný reportér (d.613)
48
+
49
+ `getConsumerState()` vracelo jako `active` klíče `_activeConsumers`, tedy
50
+ konzumenty, které klient **sleduje**. `_performHealthCheck()` odpovídá na tutéž
51
+ otázku podle značky (`consumerInfo.consumerTag`), kterou d.386b a d.605 nechávají
52
+ nastavenou jen po dobu skutečného připojení. Jeden soubor, dvě definice slova
53
+ „aktivní" — a rozcházely se přesně tam, kde na tom záleží: konzument, jemuž
54
+ někdo smazal frontu a jehož znovupřipojení broker odmítl (406), zůstává
55
+ sledovaný bez značky, takže zdravotní kontrola hlásila 0 připojených, kdežto
56
+ `getConsumerState()` frontu dál jmenovalo jako aktivní.
57
+
58
+ To není kosmetika. `getConsumerState()` je ta **levná synchronní** odpověď, na
59
+ kterou se čtenář dostane: `docs/standards/rabbitmq-channel-best-practices.md`
60
+ § „Consumers stop receiving after a reconnect" i
61
+ `docs/operations/rabbitmq-connection-recovery-troubleshooting.md` posílají
62
+ operátora právě sem pro tento případ — a dostal odpověď, že konzument je aktivní.
63
+
64
+ Nově obě čtení volají jednu privátní funkci `_isConsumerAttached(consumerInfo)`;
65
+ `active` jmenuje **připojené** fronty a `tracked` zůstává počtem sledovaných,
66
+ takže `tracked > active.length` je celá věta „tento klient z některé fronty nic
67
+ nebere". Tvar návratové hodnoty se nemění (žádný klíč nepřibyl ani nezmizel),
68
+ mění se pravdivost hodnoty `active`; mimo tento balíček ji dnes nečte žádný kód
69
+ (změřeno přes `api`, `api_biz/*`, `fe_adminui`), takže se nic dalšího
70
+ nepřepisuje.
71
+
72
+ Kdo z toho co má: `@onlineapps/conn-infra-mq` to po re-pinu pošle dál ve
73
+ `getHealth().consumers` a `@onlineapps/service-wrapper` z toho odvodí
74
+ `components.mq` — služba se ztraceným konzumentem přestane být v heartbeatu
75
+ `healthy`.
76
+
7
77
  ## [4.0.0] — 2026-09-16
8
78
 
9
79
  ### Changed — značku odmítnutí nese i deklarační rail, jednou funkcí pro obě cesty (d.582)
package/README.md CHANGED
@@ -187,7 +187,10 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
187
187
  three channels, the consumers this client holds, and what is waiting in the
188
188
  publish buffer (`{ size, inMemory }` — there is one buffer). All five refuse
189
189
  before `connect()` has built a transport, rather than answering with an invented
190
- zero.
190
+ zero. `getConsumerState()` answers `{ tracked, active }`: `tracked` counts the
191
+ consumers this client would restore, `active` names the ones ATTACHED right now
192
+ — the same question the health check asks — so `tracked > active.length` says
193
+ this client takes no message off some queue it registered.
191
194
  - `disconnectAll()` (static, on the class and on the package export) - Close every
192
195
  client this module holds; see § Shutdown.
193
196
  - `onError(callback)` - Register error handler
@@ -328,6 +331,39 @@ There is no third meaning. `requeueOnError: false` together with `maxAttempts`
328
331
  above 1 is a contradiction and is **refused where it was written**, rather than
329
332
  one of the two being silently ignored.
330
333
 
334
+ #### A cancel from the BROKER — the consumer that was stopped without asking
335
+
336
+ `basic.cancel` sent by the broker is how a consumer learns its attachment is gone
337
+ while the channel and the connection stay up: the queue was deleted, or the broker
338
+ moved it (`x-cancel-on-ha-failover`). amqplib delivers it to the consume callback
339
+ as a `null` message, and it reaches nothing else in this client — the channel
340
+ handlers listen for `error` and `close`, and both stay silent.
341
+
342
+ It is **not** a silent event. In this order:
343
+
344
+ - the consumer's tag is dropped, because it names an attachment the broker no
345
+ longer holds — so `performHealthCheck()` stops counting that consumer as
346
+ attached the moment the cancel arrives, rather than one recovery later;
347
+ - the loss is logged at **error** level, naming the queue and the tag the broker
348
+ cancelled;
349
+ - the consumer is re-attached on the rail a channel recreate uses, for that queue
350
+ alone: the queue is classified, declared or checked and the prefetch re-applied
351
+ exactly as `consume()` does it. So a business queue the service owns is
352
+ re-declared, and an infrastructure queue this client does not own is only
353
+ checked — a deleted one is refused with `CONSUMER_QUEUE_MISSING`, never
354
+ silently recreated.
355
+
356
+ If that re-attachment is refused, the consumer stays **tracked with no tag** — the
357
+ same state a refused re-registration leaves — so the client reports itself
358
+ unhealthy and names the queue, `consumer:re-registration:failed` carries the
359
+ broker's reason and code, and the next channel recreate tries again.
360
+
361
+ Two cancels that are NOT this: `cancelConsumer()` on this client (the broker
362
+ answers `basic.cancel-ok`, the callback is never invoked, and the consumer is
363
+ dropped from the registry on purpose), and a cancel arriving while
364
+ `disconnect()` is running — an intentional close is not a fault, and it is
365
+ recorded at debug level.
366
+
331
367
  ### Connection loss — the contract
332
368
 
333
369
  When the broker goes away, the transport runs connection-level recovery with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/mq-client-core",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "description": "Core MQ client library for RabbitMQ - shared by infrastructure services and connectors",
5
5
  "oa": {
6
6
  "category": "connector"
package/src/BaseClient.js CHANGED
@@ -1125,9 +1125,14 @@ class BaseClient {
1125
1125
  }
1126
1126
 
1127
1127
  /**
1128
- * The consumers this client currently holds: how many, and on which queues.
1128
+ * The consumers this client currently holds: how many it TRACKS, and which of
1129
+ * them are ATTACHED right now. A consumer whose queue was deleted, or whose
1130
+ * re-registration the broker refused, stays tracked without its tag — so
1131
+ * `tracked > active.length` says this client takes no message off some queue
1132
+ * it registered (d.613; one definition of "attached",
1133
+ * `RabbitMQClient._isConsumerAttached()`).
1129
1134
  *
1130
- * @returns {Object} `{ tracked: <count>, active: [<queue names>] }`
1135
+ * @returns {Object} `{ tracked: <count>, active: [<attached queue names>] }`
1131
1136
  * @throws {ConnectionError} Before `connect()` has built a transport.
1132
1137
  */
1133
1138
  getConsumerState() {
@@ -525,13 +525,52 @@ class RabbitMQClient extends EventEmitter {
525
525
  }
526
526
 
527
527
  /**
528
- * Get current consumer state
529
- * @returns {Object} Consumer state information
528
+ * THE definition of "this client's consumer is attached to that queue".
529
+ *
530
+ * The tag is the one fact that is about THIS client: the broker gives it to
531
+ * the attachment it created, and since d.386b / d.605 the entry carries it
532
+ * only while that attachment exists — a refused re-registration and a cancel
533
+ * sent by the broker both drop it while the consumer stays TRACKED, because
534
+ * the next recovery must find it on the list.
535
+ *
536
+ * It is a method rather than an expression repeated twice for the reason
537
+ * `change-discipline.md` § One rail per concern states: until d.613
538
+ * `getConsumerState()` answered from `_activeConsumers.keys()` and
539
+ * `_performHealthCheck()` from the tag, so one file held two definitions of
540
+ * "active" — and the cheap synchronous reader, the one a service and an
541
+ * operator actually read, was the one that answered about a consumer the
542
+ * broker had already cancelled.
543
+ *
544
+ * @param {Object|undefined} consumerInfo - The entry `_activeConsumers` holds
545
+ * @returns {boolean}
546
+ * @private
547
+ */
548
+ _isConsumerAttached(consumerInfo) {
549
+ return typeof consumerInfo?.consumerTag === 'string' && consumerInfo.consumerTag !== '';
550
+ }
551
+
552
+ /**
553
+ * Get current consumer state.
554
+ *
555
+ * `tracked` counts the consumers this client would restore; `active` names the
556
+ * ones that are ATTACHED right now (`_isConsumerAttached()`, the same question
557
+ * `_performHealthCheck()` asks). `tracked > active.length` is therefore the
558
+ * cheap, synchronous statement that this client takes no message off some
559
+ * queue it registered — the state
560
+ * `docs/operations/rabbitmq-connection-recovery-troubleshooting.md` sends an
561
+ * operator here to read, and which the queue-name list could not carry.
562
+ *
563
+ * @returns {{tracked: number, active: string[]}} Consumer state information
530
564
  */
531
565
  getConsumerState() {
566
+ const active = [];
567
+ for (const [queue, consumerInfo] of this._activeConsumers.entries()) {
568
+ if (this._isConsumerAttached(consumerInfo)) active.push(queue);
569
+ }
570
+
532
571
  return {
533
572
  tracked: this._activeConsumers.size,
534
- active: Array.from(this._activeConsumers.keys())
573
+ active
535
574
  };
536
575
  }
537
576
 
@@ -1858,54 +1897,160 @@ class RabbitMQClient extends EventEmitter {
1858
1897
  if (this._activeConsumers.size > 0) {
1859
1898
  this._logger.info(`[RabbitMQClient] [mq-client-core] Re-registering ${this._activeConsumers.size} consumers...`);
1860
1899
  for (const [queue, consumerInfo] of this._activeConsumers.entries()) {
1861
- try {
1862
- await this._prepareQueueForConsume(queue, {
1863
- durable: consumerInfo.options.durable,
1864
- queueOptions: consumerInfo.options.queueOptions
1865
- });
1866
-
1867
- // The consumer's prefetch is re-applied here for the same reason the
1868
- // queue step is: the channel is new, and QoS lives on the channel.
1869
- await this._applyConsumerPrefetch(queue, consumerInfo.options.prefetch);
1870
-
1871
- const consumeResult = await this._consumerChannel.consume(
1872
- queue,
1873
- consumerInfo.handler,
1874
- { noAck: consumerInfo.options.noAck }
1875
- );
1876
- consumerInfo.consumerTag = consumeResult.consumerTag;
1900
+ if (await this._reRegisterConsumer(queue, consumerInfo)) {
1877
1901
  reRegistered++;
1878
- this._logger.info(`[RabbitMQClient] [mq-client-core] \u2713 Re-registered consumer for queue: ${queue} (consumerTag: ${consumeResult.consumerTag})`);
1879
- } catch (err) {
1902
+ } else {
1880
1903
  failed++;
1881
- this._logger.error(`[RabbitMQClient] [mq-client-core] Failed to re-register consumer for queue ${queue}:`, err.message);
1882
- // The consumer STAYS tracked. Whatever refused it — a 406 against the
1883
- // owner's declaration, a missing infrastructure queue, a channel that
1884
- // went away mid-flight — is a condition somebody fixes at its source,
1885
- // and the next recreate must find the consumer still on the list.
1886
- //
1887
- // Its TAG does not stay, and the two are not the same fact (d.386b). A
1888
- // consumer tag names an attachment on ONE channel; the channel that
1889
- // issued this one is gone and the new one refused to re-attach, so the
1890
- // string the entry carried names nothing on the broker. Two readers
1891
- // took it for an attachment that exists: `_performHealthCheck()` counts
1892
- // `active` by "is the tag set", so a client whose only consumer had
1893
- // just been refused reported `healthy: true` and `1/1 consumers active`
1894
- // — the false OK d.386 exists to end, one layer below where d.386 could
1895
- // reach it; and `cancelConsumer()` sent `basic.cancel` with it, asking
1896
- // the broker to stop a consumer it does not hold.
1897
- consumerInfo.consumerTag = null;
1898
- // The broker's own code is what a listener acts on (406 = the queue
1899
- // exists with different arguments), and the rail wraps it in a typed
1900
- // error, so it is read from the cause when the wrapper carries none.
1901
- const code = err.code !== undefined ? err.code : (err.cause ? err.cause.code : undefined);
1902
- this.emit('consumer:re-registration:failed', { queue, error: err.message, code });
1903
1904
  }
1904
1905
  }
1905
1906
  }
1906
1907
  return { reRegistered, failed };
1907
1908
  }
1908
1909
 
1910
+ /**
1911
+ * Re-attach ONE tracked consumer on the current consumer channel.
1912
+ *
1913
+ * This is the body of the loop above, given a name in d.605 because a second
1914
+ * event needs exactly it: `basic.cancel` from the broker takes ONE consumer
1915
+ * away while every other one stays attached, so re-running the whole loop
1916
+ * would re-attach consumers that never lost their place — a second
1917
+ * `basic.consume` for each of them on the broker. One rail, one set of
1918
+ * consequences (`change-discipline.md` § One rail per concern).
1919
+ *
1920
+ * Everything a first `consume()` does is done here: the queue is classified,
1921
+ * declared or checked by `_prepareQueueForConsume()` — so an owned business
1922
+ * queue is re-declared and an unowned infrastructure queue is only checked,
1923
+ * its absence answered with `CONSUMER_QUEUE_MISSING` rather than a silent
1924
+ * creation — and the prefetch is re-applied, because QoS lives on the channel.
1925
+ *
1926
+ * @param {string} queue - Queue name
1927
+ * @param {Object} consumerInfo - The entry `_activeConsumers` holds for it
1928
+ * @returns {Promise<boolean>} `true` when the consumer is attached again
1929
+ * @private
1930
+ */
1931
+ async _reRegisterConsumer(queue, consumerInfo) {
1932
+ try {
1933
+ await this._prepareQueueForConsume(queue, {
1934
+ durable: consumerInfo.options.durable,
1935
+ queueOptions: consumerInfo.options.queueOptions
1936
+ });
1937
+
1938
+ // The consumer's prefetch is re-applied here for the same reason the
1939
+ // queue step is: the channel is new, and QoS lives on the channel.
1940
+ await this._applyConsumerPrefetch(queue, consumerInfo.options.prefetch);
1941
+
1942
+ const consumeResult = await this._consumerChannel.consume(
1943
+ queue,
1944
+ consumerInfo.handler,
1945
+ { noAck: consumerInfo.options.noAck }
1946
+ );
1947
+ consumerInfo.consumerTag = consumeResult.consumerTag;
1948
+ this._logger.info(`[RabbitMQClient] [mq-client-core] ✓ Re-registered consumer for queue: ${queue} (consumerTag: ${consumeResult.consumerTag})`);
1949
+ return true;
1950
+ } catch (err) {
1951
+ this._logger.error(`[RabbitMQClient] [mq-client-core] Failed to re-register consumer for queue ${queue}:`, err.message);
1952
+ // The consumer STAYS tracked. Whatever refused it — a 406 against the
1953
+ // owner's declaration, a missing infrastructure queue, a channel that
1954
+ // went away mid-flight — is a condition somebody fixes at its source,
1955
+ // and the next recreate must find the consumer still on the list.
1956
+ //
1957
+ // Its TAG does not stay, and the two are not the same fact (d.386b). A
1958
+ // consumer tag names an attachment on ONE channel; the channel that
1959
+ // issued this one is gone and the new one refused to re-attach, so the
1960
+ // string the entry carried names nothing on the broker. Two readers
1961
+ // took it for an attachment that exists: `_performHealthCheck()` counts
1962
+ // `active` by "is the tag set", so a client whose only consumer had
1963
+ // just been refused reported `healthy: true` and `1/1 consumers active`
1964
+ // — the false OK d.386 exists to end, one layer below where d.386 could
1965
+ // reach it; and `cancelConsumer()` sent `basic.cancel` with it, asking
1966
+ // the broker to stop a consumer it does not hold.
1967
+ consumerInfo.consumerTag = null;
1968
+ // The broker's own code is what a listener acts on (406 = the queue
1969
+ // exists with different arguments), and the rail wraps it in a typed
1970
+ // error, so it is read from the cause when the wrapper carries none.
1971
+ const code = err.code !== undefined ? err.code : (err.cause ? err.cause.code : undefined);
1972
+ this.emit('consumer:re-registration:failed', { queue, error: err.message, code });
1973
+ return false;
1974
+ }
1975
+ }
1976
+
1977
+ /**
1978
+ * The broker cancelled one of this client's consumers.
1979
+ *
1980
+ * `basic.cancel` sent BY THE BROKER is not an error frame and does not touch
1981
+ * the channel or the connection: the queue was deleted, or the broker moved it
1982
+ * (`x-cancel-on-ha-failover`). Nothing else in this client hears it — the
1983
+ * channel handlers listen for `error` and `close`, and both stay silent — so
1984
+ * this method is the whole of the client's answer, and until d.605 that answer
1985
+ * was nothing at all.
1986
+ *
1987
+ * Three things happen, in this order:
1988
+ *
1989
+ * 1. the TAG is dropped, first, because it is the one fact
1990
+ * `_performHealthCheck()` reads to answer "is THIS client's consumer
1991
+ * attached" (d.386b). Dropping it before anything else leaves no window in
1992
+ * which the check reports an attachment the broker has already cancelled;
1993
+ * 2. the loss is stated at ERROR level, naming the queue and the cancelled
1994
+ * tag. A consumer that stops reading a queue is not a debug-level event,
1995
+ * and the record is what tells an operator which queue went quiet;
1996
+ * 3. the consumer is re-attached through `_reRegisterConsumer()`, the rail a
1997
+ * channel recreate already uses. A refusal there keeps the consumer
1998
+ * tracked with no tag and travels on `consumer:re-registration:failed` —
1999
+ * which is why this method introduces no event and no public method of its
2000
+ * own: every state it can reach is a state the package already reports.
2001
+ *
2002
+ * @param {string} queue - The queue whose consumer the broker cancelled
2003
+ * @returns {Promise<void>}
2004
+ * @private
2005
+ */
2006
+ async _onBrokerCancel(queue) {
2007
+ if (this._closedByCaller) {
2008
+ // An intentional close is not a fault — the doctrine `disconnect()` states
2009
+ // and both channel handlers already follow.
2010
+ this._logger.debug(
2011
+ `[RabbitMQClient] [mq-client-core] [CONSUMER] Broker cancelled the consumer for queue "${queue}" while the client was being closed by its caller - no alarm, no re-attachment`
2012
+ );
2013
+ return;
2014
+ }
2015
+
2016
+ const consumerInfo = this._activeConsumers.get(queue);
2017
+ const cancelledTag = consumerInfo ? consumerInfo.consumerTag : null;
2018
+
2019
+ if (consumerInfo) {
2020
+ consumerInfo.consumerTag = null;
2021
+ }
2022
+
2023
+ this._logger.error(
2024
+ `[RabbitMQClient] Consumer cancelled by the broker - the consumer on queue "${queue}" `
2025
+ + `(consumerTag: ${cancelledTag}) was stopped by the BROKER, not by this client: the queue `
2026
+ + 'was deleted, or the broker moved it (x-cancel-on-ha-failover). '
2027
+ + 'Expected: a consumer stays attached until cancelConsumer() or disconnect(). '
2028
+ + 'Fix: none here - the client re-attaches it on the rail a channel recreate uses; if that '
2029
+ + 'is refused, the consumer stays tracked with no tag, performHealthCheck() reports this '
2030
+ + 'client unhealthy and names the queue, and consumer:re-registration:failed carries the '
2031
+ + 'broker reason.',
2032
+ { queue, consumerTag: cancelledTag }
2033
+ );
2034
+
2035
+ if (!consumerInfo) {
2036
+ // The client no longer holds this consumer, so there is nothing to
2037
+ // re-attach and no state left to correct. The loss is still stated above.
2038
+ return;
2039
+ }
2040
+
2041
+ if (!this._isChannelAlive(this._consumerChannel)) {
2042
+ // Re-attaching on a dead channel is not this method's job: the channel
2043
+ // recovery re-registers every tracked consumer when it recreates the
2044
+ // channel (d.262), and the consumer stays tracked for exactly that.
2045
+ this._logger.warn(
2046
+ `[RabbitMQClient] [mq-client-core] [CONSUMER] Consumer for queue "${queue}" stays tracked and unattached - the consumer channel is not alive, so the channel recovery re-registers it`
2047
+ );
2048
+ return;
2049
+ }
2050
+
2051
+ await this._reRegisterConsumer(queue, consumerInfo);
2052
+ }
2053
+
1909
2054
  /**
1910
2055
  * Disconnects: closes channel and connection.
1911
2056
  * @returns {Promise<void>}
@@ -2061,8 +2206,10 @@ class RabbitMQClient extends EventEmitter {
2061
2206
  // this client's and everybody else's alike, so it cannot say whether
2062
2207
  // the one asking is among them. The tag can: it is the name the
2063
2208
  // broker gave THIS client's attachment, and since d.386b it is set
2064
- // only while that attachment exists.
2065
- if (consumerInfo.consumerTag) {
2209
+ // only while that attachment exists. The question is asked through
2210
+ // `_isConsumerAttached()` — the one definition `getConsumerState()`
2211
+ // now reads too (d.613).
2212
+ if (this._isConsumerAttached(consumerInfo)) {
2066
2213
  health.consumers.active++;
2067
2214
  } else {
2068
2215
  health.consumers.inactive.push(queue);
@@ -3532,7 +3679,21 @@ class RabbitMQClient extends EventEmitter {
3532
3679
  // Wrap handler to track message processing and prefetch utilization
3533
3680
  const messageHandler = async (msg) => {
3534
3681
  if (msg === null) {
3535
- return; // Consumer cancellation
3682
+ // `null` is not a message. It is `basic.cancel` FROM THE BROKER,
3683
+ // handed to this callback by amqplib (`handleCancel()` →
3684
+ // `dispatchMessage(fields, null)`), and it is the only way a consumer
3685
+ // learns that its attachment is gone while the channel and the
3686
+ // connection stay up: the queue was deleted, or the broker moved it
3687
+ // (`x-cancel-on-ha-failover`).
3688
+ //
3689
+ // Until d.605 the answer was this bare `return`. Nothing was logged,
3690
+ // the tag stayed set — so `_performHealthCheck()` went on counting the
3691
+ // consumer as attached — and nothing re-attached it. The service
3692
+ // reported itself healthy while no message was taken off the queue,
3693
+ // which is the failure `automation-gates.md` §5 calls a defect of the
3694
+ // same severity as a wrong result.
3695
+ await this._onBrokerCancel(queue);
3696
+ return;
3536
3697
  }
3537
3698
 
3538
3699
  // IMPORTANT: