bug_bunny 4.14.0 → 4.16.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6081a471e20278bc190a7d7517b69cf6fe6d852293bca353974c5ba1cbe4e746
4
- data.tar.gz: 2e1b952c9627b7f3f1f9ab1e2af25af49ca0422ff59f062ac7499c14265d6a57
3
+ metadata.gz: 67e58763b6f088c44e17121922ecb618c9ccef8738096d534c578eea021c5f94
4
+ data.tar.gz: 47fe7485dd056e9ffb8a7e5268b97e4d0173e0441984506b5528f5c43643da7a
5
5
  SHA512:
6
- metadata.gz: 6cf7de01b98c925c8f02de1e09aafc0113f24f93145498ace1616445deecf6319bde3464d9afcd89b5b461904fbe878d4bfb24ea3a2a310149a32f6f2b06f4d0
7
- data.tar.gz: 2560004c6036b238456fe9c9af9bf478dde67b1ff07240fdac942ecab303467d1db04f3029c2579463e5017b7c9f25ad2e5f1c5ef2c999f86bcde0bedb42eb67
6
+ metadata.gz: f0a5927c4a4a450b414ee38ffbc25a0a97a80b2678d32a19d524a51367572f7b5e2935e29a81bf9afbdd2cfb0b634e9b60a322b828ff14ba0f79e60982148723
7
+ data.tar.gz: e66726447226ceb96f2d3b81dd759209fd8e54e7ea67bb85870cc805e44747e81f8121a821fd7a6fa65435ae3ba95e3cd62631ef0687fa0543f29848e21f4682
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.16.0] - 2026-05-13
4
+
5
+ ### Cambios de comportamiento (semi-breaking)
6
+ - **`DEFAULT_QUEUE_OPTIONS` cambió a `{ exclusive: false, durable: true, auto_delete: false }`** (#42). En versiones previas el default era `{ exclusive: false, durable: false, auto_delete: true }` — la combinación `transient_nonexcl_queues` que **RabbitMQ 4.x deprecó por default**: el broker rechaza la declaración matando la conexión. El nuevo default es el patrón "queue compartida duradera": sobrevive restart del broker, múltiples consumers pueden compartirla, no se elimina cuando se desconecta el último consumer. Esto matchea cómo la mayoría de servicios Wispro ya configuran sus queues explícitamente.
7
+
8
+ **Para restaurar el comportamiento anterior** (servicios sobre RabbitMQ 3.x con queues legacy efímeras pre-existentes):
9
+
10
+ ```ruby
11
+ BugBunny.configure do |c|
12
+ c.queue_options = { exclusive: false, durable: false, auto_delete: true }
13
+ end
14
+ ```
15
+
16
+ **Síntoma si se necesita override y no se aplicó:** `Bunny::PreconditionFailed - inequivalent arg 'durable' for queue 'foo'`. Indica que la queue existe en el broker con `durable: false` pero el nuevo default intenta declararla con `durable: true`. Aplicar el override de arriba o borrar manualmente la queue legacy en el broker.
17
+
18
+ ### Documentación
19
+ - README + SKILL.md actualizados con sección "queue_options recomendadas" cubriendo patrones worker-pool (default nuevo) y single-instance (`exclusive: true`).
20
+
21
+ ## [4.15.0] - 2026-05-13
22
+
23
+ ### Nuevas funcionalidades
24
+ - **`return_raise` flag para mandatory + basic.return (#38):** `Producer#confirmed` ahora levanta `BugBunny::PublishUnroutable` cuando el broker retorna un mensaje publicado con `mandatory: true` que no pudo rutearse a ninguna cola. Espejo simétrico de `nack_raise`/`PublishNacked`. La excepción expone `path`, `exchange`, `routing_key`, `reply_code`, `reply_text` y `correlation_id`. Internamente la gema implementa el bridge cross-thread (reader thread → publish thread) que antes cada caller tenía que replicar manualmente con `Concurrent::Map` + lambda. Configurable globalmente vía `BugBunny.configuration.return_raise` (default `true`) y por request via `client.publish(..., return_raise: false)`. El callback global `on_return` se sigue invocando antes del raise. — @Gabriel
25
+
26
+ ### Cambios de comportamiento (semi-breaking)
27
+ - **Default `return_raise: true`:** Publicaciones con `confirmed: true, mandatory: true` que reciben `basic.return` del broker ahora levantan excepción por default. En 4.14.0 el return solo se logueaba (o invocaba el callback `on_return`) y la llamada retornaba 202 silenciosamente — ocultando pérdida de mensajes. Para mantener el comportamiento previo: `BugBunny.configuration.return_raise = false` o `return_raise: false` per request. El flag es **inerte cuando `mandatory: false`** — sin mandatory el broker nunca emite return.
28
+
29
+ ### Detalles internos
30
+ - `Producer#confirmed` auto-asigna `correlation_id` (UUID) cuando falta y `mandatory + return_raise` están activos — la correlación bridge↔return depende del cid.
31
+ - Nuevo bound de espera `Producer::RETURN_RACE_WINDOW_S = 0.05` tras un ack positivo: tolera el race scheduling entre reader thread (donde Bunny invoca `on_return`) y publish thread (donde se devuelve `wait_for_confirms`). AMQP garantiza orden wire (return precede a ack), pero defendemos contra GVL.
32
+ - `Session` ahora mantiene un registry interno `@pending_returns` (`Concurrent::Map` de cid → `{event, info}`). `handle_broker_return` setea el event *antes* de invocar el user_cb global — una excepción del callback no impide el raise en el caller.
33
+ - Nuevo evento de log `producer.publish_unroutable` (WARN) con `path`, `exchange`, `routing_key`, `reply_code`, `reply_text`, `messaging_message_id`. Se emite antes de levantar `PublishUnroutable`.
34
+ - Nuevo evento de log `client.return_raise_ignored` (WARN) cuando se pasa `return_raise: true` sin `confirmed: true` o sin `mandatory: true` — el flag se ignora.
35
+
3
36
  ## [4.14.0] - 2026-05-12
4
37
 
5
38
  ### Nuevas funcionalidades
data/README.md CHANGED
@@ -129,7 +129,11 @@ BugBunny.configure do |config|
129
129
  config.read_timeout = 10
130
130
  config.write_timeout = 10
131
131
 
132
- # AMQP defaults applied to all exchanges and queues
132
+ # AMQP defaults applied to all exchanges and queues.
133
+ # Gem defaults (since 4.16):
134
+ # DEFAULT_EXCHANGE_OPTIONS = { durable: false, auto_delete: false }
135
+ # DEFAULT_QUEUE_OPTIONS = { exclusive: false, durable: true, auto_delete: false }
136
+ # Override only if your service needs different infrastructure semantics.
133
137
  config.exchange_options = { durable: true }
134
138
  config.queue_options = { durable: true }
135
139
 
@@ -142,7 +146,13 @@ BugBunny.configure do |config|
142
146
  # Health check file for Kubernetes / Docker Swarm liveness probes
143
147
  config.health_check_file = '/tmp/bug_bunny_health'
144
148
 
149
+ # Publisher Confirms — fail-loud defaults (both flags default to true).
150
+ # Set to false to restore legacy log-only behavior.
151
+ config.nack_raise = true # broker NACK → raise BugBunny::PublishNacked
152
+ config.return_raise = true # broker basic.return (mandatory) → raise BugBunny::PublishUnroutable
153
+
145
154
  # Callback invoked when the broker returns an unroutable mandatory message.
155
+ # Runs BEFORE PublishUnroutable is raised (if return_raise is true).
146
156
  # When nil (default), BugBunny logs the return as `session.broker_return` at :warn.
147
157
  # Signature: ->(return_info, properties, body) { ... }
148
158
  config.on_return = ->(return_info, _props, body) {
@@ -220,11 +230,26 @@ client.publish('acct.start',
220
230
  | `confirmed` | Boolean | `false` | Block until `wait_for_confirms` returns. |
221
231
  | `mandatory` | Boolean | `false` | Broker returns the message if it cannot be routed to any queue. Requires `confirmed: true` to be useful. |
222
232
  | `confirm_timeout` | Float | `nil` | Seconds to wait for the broker ACK. Raises `BugBunny::RequestTimeout` if exceeded. |
223
- | `nack_raise` | Boolean | `nil` | Per-request override of `config.nack_raise`. When `nil`, falls back to the global flag. |
233
+ | `nack_raise` | Boolean | `nil` | Per-request override of `config.nack_raise`. When `nil`, falls back to the global flag (default `true`). |
234
+ | `return_raise` | Boolean | `nil` | Per-request override of `config.return_raise`. When `nil`, falls back to the global flag (default `true`). Requires `confirmed: true` and `mandatory: true` to take effect. |
235
+
236
+ **Two broker signals, two exceptions:**
237
+
238
+ | Broker signal | Default behavior | Exception class | Fields |
239
+ |---|---|---|---|
240
+ | `basic.nack` (explicit rejection) | Raises | `BugBunny::PublishNacked` | `path`, `nacked_count` |
241
+ | `basic.return` (unroutable + `mandatory: true`) | Raises | `BugBunny::PublishUnroutable` | `path`, `exchange`, `routing_key`, `reply_code`, `reply_text`, `correlation_id` |
224
242
 
225
- If the broker NACKs the publish (explicit rejection disk full, internal confirm policy, etc.), the call raises `BugBunny::PublishNacked` by default. The exception exposes `path` and `nacked_count`. Critical publishers (audit, billing, RADIUS accounting) can let it bubble up to translate into HTTP 5xx so upstream systems retry. To restore the legacy "log-only" behaviour, set `BugBunny.configuration.nack_raise = false` globally or pass `nack_raise: false` per request.
243
+ Both exceptions translate naturally into HTTP 5xx in critical publishers (audit, billing, RADIUS accounting) so upstream systems retry. The `config.on_return` callback (if defined) still runs before `PublishUnroutable` is raised useful for alerting/metrics. To restore the legacy "log-only" behaviour:
244
+
245
+ ```ruby
246
+ BugBunny.configure do |c|
247
+ c.nack_raise = false # or pass `nack_raise: false` per request
248
+ c.return_raise = false # or pass `return_raise: false` per request
249
+ end
250
+ ```
226
251
 
227
- Unroutable returned messages are handled by a single global callback (see `config.on_return` below). The default handler logs them as `session.broker_return` at `warn` level — nothing is dropped silently.
252
+ When `mandatory: false` (the default), `return_raise` is inert the broker never emits `basic.return` without mandatory.
228
253
 
229
254
  ---
230
255
 
@@ -285,9 +310,15 @@ BugBunny maps RabbitMQ responses to a semantic exception hierarchy, similar to h
285
310
 
286
311
  ```
287
312
  BugBunny::Error
313
+ ├── CommunicationError (network / channel failure)
314
+ ├── ConfigurationError (invalid config attribute)
315
+ ├── SecurityError (unauthorized controller resolution)
316
+ ├── PublishNacked (broker basic.nack on :confirmed publish)
317
+ ├── PublishUnroutable (broker basic.return on mandatory + :confirmed)
288
318
  ├── ClientError (4xx)
289
319
  │ ├── BadRequest (400)
290
320
  │ ├── NotFound (404)
321
+ │ │ └── RoutingError (consumer-side: no route for verb + path)
291
322
  │ ├── NotAcceptable (406)
292
323
  │ ├── RequestTimeout (408)
293
324
  │ ├── Conflict (409)
@@ -95,10 +95,16 @@ module BugBunny
95
95
  # @option args [Float] :confirm_timeout Segundos a esperar el confirm. `nil` espera indefinidamente.
96
96
  # @option args [Boolean] :nack_raise Override per-request del flag
97
97
  # `BugBunny.configuration.nack_raise`. Si `nil` (default), se usa la configuración global.
98
+ # @option args [Boolean] :return_raise Override per-request del flag
99
+ # `BugBunny.configuration.return_raise`. Si `nil` (default), se usa la configuración global.
100
+ # Requiere `mandatory: true` y `confirmed: true` para tener efecto — sino se emite un
101
+ # warning y el flag se ignora.
98
102
  # @yield [req] Bloque para configurar el objeto Request.
99
103
  # @return [Hash] `{ 'status' => 202, 'body' => nil }`.
100
104
  # @raise [BugBunny::RequestTimeout] Si `confirmed: true` y el broker no confirma a tiempo.
101
105
  # @raise [BugBunny::PublishNacked] Si `confirmed: true`, el broker NACKea, y `nack_raise` resuelto es true.
106
+ # @raise [BugBunny::PublishUnroutable] Si `confirmed: true`, `mandatory: true`, el broker retorna el
107
+ # mensaje como no-ruteable, y `return_raise` resuelto es true.
102
108
  def publish(url, **args)
103
109
  send(url, **args) do |req|
104
110
  req.delivery_mode = args[:confirmed] ? :confirmed : :publish
@@ -120,6 +126,10 @@ module BugBunny
120
126
  # Configuración del usuario (bloque específico por request)
121
127
  yield req if block_given?
122
128
 
129
+ # Check post-block: el block API puede setear delivery_mode/mandatory después
130
+ # de los keyword args. Evaluamos el warning sobre el estado final del Request.
131
+ warn_return_raise_misuse(req)
132
+
123
133
  # Ejecución dentro del Pool.
124
134
  # Session y Producer se reutilizan por slot de conexión (ver #session_for / #producer_for).
125
135
  @pool.with do |conn|
@@ -179,6 +189,33 @@ module BugBunny
179
189
  req.mandatory = args[:mandatory] if args.key?(:mandatory)
180
190
  req.confirm_timeout = args[:confirm_timeout] if args.key?(:confirm_timeout)
181
191
  req.nack_raise = args[:nack_raise] if args.key?(:nack_raise)
192
+ req.return_raise = args[:return_raise] if args.key?(:return_raise)
193
+ end
194
+
195
+ # Emite un warning si el Request final tiene `return_raise: true` pero le falta
196
+ # `delivery_mode == :confirmed` o `mandatory: true`. El flag requiere ambos para
197
+ # tener efecto: sin confirmed no hay synchronization point sobre el cual levantar,
198
+ # y sin mandatory el broker nunca retorna.
199
+ #
200
+ # Se evalúa sobre el Request post-block (no sobre args) para no producir falsos
201
+ # positivos cuando el caller usa el block API para setear `req.delivery_mode` o
202
+ # `req.mandatory` después de los keyword args.
203
+ #
204
+ # Solo warnea cuando `request.return_raise` fue explícitamente `true` por request —
205
+ # ignora el default global (que también puede ser `true`) para no inundar logs en
206
+ # publishes regulares sin mandatory.
207
+ #
208
+ # @param request [BugBunny::Request]
209
+ # @return [void]
210
+ def warn_return_raise_misuse(request)
211
+ return unless request.return_raise == true
212
+ return if request.delivery_mode == :confirmed && request.mandatory
213
+
214
+ BugBunny.configuration.logger&.warn do
215
+ 'component=bug_bunny event=client.return_raise_ignored ' \
216
+ 'reason=requires_confirmed_and_mandatory ' \
217
+ "delivery_mode=#{request.delivery_mode} mandatory=#{!!request.mandatory}"
218
+ end
182
219
  end
183
220
 
184
221
  # Recupera o crea la Session asociada al slot de conexión dado.
@@ -162,6 +162,19 @@ module BugBunny
162
162
  # `Client#publish`.
163
163
  attr_accessor :nack_raise
164
164
 
165
+ # @return [Boolean] Si `true` (default), {BugBunny::Producer#confirmed} levanta
166
+ # {BugBunny::PublishUnroutable} cuando el broker retorna un mensaje publicado
167
+ # con `mandatory: true` que no pudo rutearse. Si `false`, el return solo se
168
+ # procesa via {#on_return} callback (modo legacy) y la llamada retorna
169
+ # `{ 'status' => 202 }`.
170
+ #
171
+ # El flag es inerte cuando `mandatory: false` (sin mandatory, el broker nunca
172
+ # retorna). El callback {#on_return} se sigue invocando antes del raise.
173
+ #
174
+ # El valor puede sobreescribirse por request pasando `return_raise:` en
175
+ # `Client#publish`.
176
+ attr_accessor :return_raise
177
+
165
178
  # @!endgroup
166
179
 
167
180
  # Inicializa la configuración con valores por defecto seguros.
@@ -239,6 +252,7 @@ module BugBunny
239
252
  @on_rpc_reply = nil
240
253
  @on_return = nil
241
254
  @nack_raise = true
255
+ @return_raise = true
242
256
  end
243
257
 
244
258
  def validate_required!(attr, value, rules)
@@ -50,6 +50,67 @@ module BugBunny
50
50
  end
51
51
  end
52
52
 
53
+ # Error lanzado cuando el broker retorna un mensaje publicado con `mandatory: true`
54
+ # que no pudo rutearse a ninguna cola en modo `:confirmed`.
55
+ #
56
+ # Un return implica que el publish llegó al broker pero ninguna binding aceptó la
57
+ # routing key — el mensaje se considera no entregado desde la perspectiva del
58
+ # publisher. Espejo simétrico de {PublishNacked} pero para la señal `basic.return`
59
+ # en lugar de `basic.nack`.
60
+ #
61
+ # Se levanta por default desde {BugBunny::Producer#confirmed} cuando el request
62
+ # tiene `mandatory: true`. Para opt-out, configurar
63
+ # `BugBunny.configuration.return_raise = false` o pasar `return_raise: false`
64
+ # por request. El callback `BugBunny.configuration.on_return` se sigue invocando
65
+ # antes del raise (orden: registro interno → user_cb → raise en el caller).
66
+ #
67
+ # @example
68
+ # rescue BugBunny::PublishUnroutable => e
69
+ # e.path # => 'acct.start'
70
+ # e.exchange # => 'acct_x'
71
+ # e.routing_key # => 'acct.unbound'
72
+ # e.reply_code # => 312
73
+ # e.reply_text # => 'NO_ROUTE'
74
+ # e.correlation_id # => 'corr-uuid-...'
75
+ class PublishUnroutable < Error
76
+ # @return [String] Ruta lógica del request cuyo publish fue retornado.
77
+ attr_reader :path
78
+
79
+ # @return [String] Nombre del exchange destino.
80
+ attr_reader :exchange
81
+
82
+ # @return [String] Routing key utilizada en el publish.
83
+ attr_reader :routing_key
84
+
85
+ # @return [Integer, nil] Código AMQP de la razón (ej: 312 = NO_ROUTE).
86
+ attr_reader :reply_code
87
+
88
+ # @return [String, nil] Texto humano-legible que describe la razón.
89
+ attr_reader :reply_text
90
+
91
+ # @return [String, nil] Correlation ID del request retornado.
92
+ attr_reader :correlation_id
93
+
94
+ # @param path [String] Ruta lógica del request (ej: 'acct.start').
95
+ # @param exchange [String] Nombre del exchange destino.
96
+ # @param routing_key [String] Routing key del publish.
97
+ # @param reply_code [Integer, nil] Código AMQP del return.
98
+ # @param reply_text [String, nil] Texto del return.
99
+ # @param correlation_id [String, nil] Correlation ID del mensaje retornado.
100
+ # rubocop:disable Metrics/ParameterLists
101
+ def initialize(path:, exchange:, routing_key:, reply_code: nil, reply_text: nil, correlation_id: nil)
102
+ @path = path
103
+ @exchange = exchange
104
+ @routing_key = routing_key
105
+ @reply_code = reply_code
106
+ @reply_text = reply_text
107
+ @correlation_id = correlation_id
108
+ super("broker returned unroutable message on path=#{path} exchange=#{exchange} " \
109
+ "routing_key=#{routing_key} reply_code=#{reply_code} reply_text=#{reply_text}")
110
+ end
111
+ # rubocop:enable Metrics/ParameterLists
112
+ end
113
+
53
114
  # ==========================================
54
115
  # Categoría: Errores del Cliente (4xx)
55
116
  # ==========================================
@@ -15,6 +15,13 @@ module BugBunny
15
15
  class Producer
16
16
  include BugBunny::Observability
17
17
 
18
+ # Bound de espera (segundos) que el publish thread aplica tras un ack positivo para
19
+ # tolerar el scheduling race entre reader thread (donde Bunny invoca `on_return`) y
20
+ # publish thread (donde se devuelve `wait_for_confirms`). AMQP garantiza que el
21
+ # `basic.return` precede al `basic.ack` en la wire, así que en la práctica el event
22
+ # ya está seteado al llegar acá; este wait es defensa contra GVL.
23
+ RETURN_RACE_WINDOW_S = 0.05
24
+
18
25
  # Inicializa el productor.
19
26
  #
20
27
  # Prepara las estructuras de concurrencia necesarias para manejar múltiples
@@ -58,8 +65,13 @@ module BugBunny
58
65
  # @raise [BugBunny::RequestTimeout] Si el broker no confirma dentro de `confirm_timeout` segundos.
59
66
  # @raise [BugBunny::PublishNacked] Si el broker NACKea la publicación y `nack_raise` está activo
60
67
  # (default `true` — ver {BugBunny::Configuration#nack_raise}).
68
+ # @raise [BugBunny::PublishUnroutable] Si `mandatory: true` y el broker retorna el mensaje como
69
+ # no-ruteable, con `return_raise` activo (default `true` — ver
70
+ # {BugBunny::Configuration#return_raise}).
61
71
  # @raise [BugBunny::CommunicationError] Si el canal AMQP falla durante la publicación o confirm.
62
72
  def confirmed(request)
73
+ return_listener = nil
74
+ return_listener = setup_return_listener(request)
63
75
  started_at = monotonic_now
64
76
  publish_duration = publish_message(request)
65
77
 
@@ -68,6 +80,7 @@ module BugBunny
68
80
  confirm_duration = duration_s(confirm_started_at)
69
81
 
70
82
  handle_confirm_result(request, acked)
83
+ handle_return_result(request, return_listener)
71
84
  log_confirmed(request, publish_duration, confirm_duration, started_at)
72
85
 
73
86
  { 'status' => 202, 'body' => nil }
@@ -75,6 +88,8 @@ module BugBunny
75
88
  raise
76
89
  rescue StandardError => e
77
90
  raise BugBunny::CommunicationError, "Publisher confirms failed: #{e.message}"
91
+ ensure
92
+ teardown_return_listener(request, return_listener)
78
93
  end
79
94
 
80
95
  # Envía un mensaje y bloquea el hilo actual esperando una respuesta (RPC).
@@ -240,6 +255,95 @@ module BugBunny
240
255
  BugBunny.configuration.nack_raise
241
256
  end
242
257
 
258
+ # Resuelve el flag `return_raise` con prioridad request > configuración global.
259
+ # El flag solo tiene efecto cuando `mandatory: true` — sin mandatory el broker
260
+ # nunca emite `basic.return`.
261
+ #
262
+ # @param request [BugBunny::Request]
263
+ # @return [Boolean]
264
+ def return_raise?(request)
265
+ return false unless request.mandatory
266
+
267
+ return request.return_raise unless request.return_raise.nil?
268
+
269
+ BugBunny.configuration.return_raise
270
+ end
271
+
272
+ # Registra un listener de `basic.return` en la Session asociada al `correlation_id`
273
+ # del request. Si return_raise? es false (mandatory off o flag desactivado), retorna
274
+ # `nil` y el resto del flow ignora la lógica de unroutable.
275
+ #
276
+ # Auto-asigna `correlation_id` si falta — sin cid no hay clave de correlación.
277
+ #
278
+ # @param request [BugBunny::Request]
279
+ # @return [Hash, nil] Hash con `:cid` y `:slot` (el slot expone `:event` y `:info`),
280
+ # o `nil` si no aplica.
281
+ def setup_return_listener(request)
282
+ return nil unless return_raise?(request)
283
+
284
+ request.correlation_id ||= SecureRandom.uuid
285
+ cid = request.correlation_id.to_s
286
+ _event, slot = @session.register_return_listener(cid)
287
+ { cid: cid, slot: slot }
288
+ end
289
+
290
+ # Tras un ack positivo, espera brevemente al event del listener para tolerar el
291
+ # scheduling race entre reader thread (donde corre `on_return`) y publish thread.
292
+ # En AMQP el `basic.return` precede al `basic.ack`, así que normalmente el event
293
+ # ya está seteado al llegar acá; el bounded wait defiende contra GVL/scheduling.
294
+ #
295
+ # @param request [BugBunny::Request]
296
+ # @param return_listener [Hash, nil] Resultado de {#setup_return_listener}.
297
+ # @return [void]
298
+ # @raise [BugBunny::PublishUnroutable] Si el listener recibió un return.
299
+ def handle_return_result(request, return_listener)
300
+ return unless return_listener
301
+
302
+ slot = return_listener[:slot]
303
+ slot[:event].wait(RETURN_RACE_WINDOW_S)
304
+ info = slot[:info]
305
+ return if info.nil?
306
+
307
+ raise_unroutable!(request, return_listener[:cid], info)
308
+ end
309
+
310
+ # Logea y levanta {BugBunny::PublishUnroutable} con los datos del return.
311
+ #
312
+ # @param request [BugBunny::Request]
313
+ # @param cid [String]
314
+ # @param info [Bunny::ReturnInfo, #exchange, #routing_key, #reply_code, #reply_text]
315
+ # @raise [BugBunny::PublishUnroutable]
316
+ def raise_unroutable!(request, cid, info)
317
+ safe_log(:warn, 'producer.publish_unroutable',
318
+ path: request.path,
319
+ exchange: info.exchange,
320
+ routing_key: info.routing_key,
321
+ reply_code: info.reply_code,
322
+ reply_text: info.reply_text,
323
+ messaging_message_id: cid)
324
+
325
+ raise BugBunny::PublishUnroutable.new(
326
+ path: request.path,
327
+ exchange: info.exchange,
328
+ routing_key: info.routing_key,
329
+ reply_code: info.reply_code,
330
+ reply_text: info.reply_text,
331
+ correlation_id: cid
332
+ )
333
+ end
334
+
335
+ # Cleanup del listener en la Session. Siempre se llama desde el `ensure` de
336
+ # {#confirmed}, sea cual sea el outcome (ack, nack, timeout, return).
337
+ #
338
+ # @param request [BugBunny::Request]
339
+ # @param return_listener [Hash, nil]
340
+ # @return [void]
341
+ def teardown_return_listener(_request, return_listener)
342
+ return unless return_listener
343
+
344
+ @session.unregister_return_listener(return_listener[:cid])
345
+ end
346
+
243
347
  # Registra la petición en el log calculando las opciones de infraestructura.
244
348
  #
245
349
  # @param request [BugBunny::Request] Objeto Request que se está enviando.
@@ -29,6 +29,9 @@ module BugBunny
29
29
  # `nil` espera indefinidamente.
30
30
  # @attr nack_raise [Boolean, nil] Override per-request del flag global
31
31
  # `BugBunny.configuration.nack_raise`. `nil` (default) delega a la configuración global.
32
+ # @attr return_raise [Boolean, nil] Override per-request del flag global
33
+ # `BugBunny.configuration.return_raise`. `nil` (default) delega a la configuración global.
34
+ # Requiere `mandatory: true` y `delivery_mode = :confirmed` para tener efecto.
32
35
  class Request
33
36
  attr_accessor :body, :headers, :params, :path, :method, :exchange, :exchange_type, :routing_key, :timeout,
34
37
  :delivery_mode, :queue_options
@@ -37,7 +40,7 @@ module BugBunny
37
40
  attr_accessor :exchange_options
38
41
 
39
42
  # Publisher Confirms (delivery_mode = :confirmed)
40
- attr_accessor :mandatory, :confirm_timeout, :nack_raise
43
+ attr_accessor :mandatory, :confirm_timeout, :nack_raise, :return_raise
41
44
 
42
45
  # Metadatos AMQP Estándar
43
46
  attr_accessor :app_id, :content_type, :content_encoding, :priority,
@@ -66,6 +69,7 @@ module BugBunny
66
69
  @mandatory = false
67
70
  @confirm_timeout = nil
68
71
  @nack_raise = nil
72
+ @return_raise = nil
69
73
  end
70
74
 
71
75
  # Combina el path con los params como query string.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'concurrent'
4
+
3
5
  module BugBunny
4
6
  # Clase interna que encapsula una unidad de trabajo sobre una conexión RabbitMQ.
5
7
  #
@@ -16,7 +18,20 @@ module BugBunny
16
18
  DEFAULT_EXCHANGE_OPTIONS = { durable: false, auto_delete: false }.freeze
17
19
 
18
20
  # Opciones predeterminadas de la gema para Colas.
19
- DEFAULT_QUEUE_OPTIONS = { exclusive: false, durable: false, auto_delete: true }.freeze
21
+ #
22
+ # `durable: true, exclusive: false, auto_delete: false` es el patrón "queue compartida
23
+ # duradera" — sobrevive restart del broker, múltiples consumers (worker pool) pueden
24
+ # consumir, no se elimina cuando el último consumer se desconecta.
25
+ #
26
+ # Histórico: hasta 4.15.x el default era `{ exclusive: false, durable: false,
27
+ # auto_delete: true }` (combo `transient_nonexcl_queues` deprecada en RabbitMQ 4.x).
28
+ # Ver issue #42 para detalles de la migración. Para restaurar el comportamiento
29
+ # anterior, configurar explícitamente:
30
+ #
31
+ # BugBunny.configure do |c|
32
+ # c.queue_options = { exclusive: false, durable: false, auto_delete: true }
33
+ # end
34
+ DEFAULT_QUEUE_OPTIONS = { exclusive: false, durable: true, auto_delete: false }.freeze
20
35
 
21
36
  # @!endgroup
22
37
 
@@ -36,6 +51,35 @@ module BugBunny
36
51
  @channel_mutex = Mutex.new
37
52
  @logger = BugBunny.configuration.logger
38
53
  @configured_returns = {}
54
+ @pending_returns = Concurrent::Map.new
55
+ end
56
+
57
+ # Registra interés en una eventual señal `basic.return` correlacionada con `cid`.
58
+ #
59
+ # Devuelve un par `(event, slot)`. El caller espera el `event` tras `wait_for_confirms`;
60
+ # si el broker retorna el mensaje, {#handle_broker_return} setea el event y deposita
61
+ # la `return_info` en `slot[:info]` antes de invocar el callback global del usuario.
62
+ #
63
+ # El caller es responsable de invocar {#unregister_return_listener} en un `ensure`
64
+ # para evitar fugas en el registry interno.
65
+ #
66
+ # @param cid [String] Correlation ID del request en curso.
67
+ # @return [Array(Concurrent::Event, Hash)] Tupla `[event, slot]`. `slot[:info]` será
68
+ # poblado con la `Bunny::ReturnInfo` si el broker retorna el mensaje.
69
+ # @api private
70
+ def register_return_listener(cid)
71
+ slot = { event: Concurrent::Event.new, info: nil }
72
+ @pending_returns[cid.to_s] = slot
73
+ [slot[:event], slot]
74
+ end
75
+
76
+ # Elimina el listener registrado por {#register_return_listener}.
77
+ #
78
+ # @param cid [String]
79
+ # @return [void]
80
+ # @api private
81
+ def unregister_return_listener(cid)
82
+ @pending_returns.delete(cid.to_s)
39
83
  end
40
84
 
41
85
  # Obtiene el canal actual o crea uno nuevo si es necesario.
@@ -112,6 +156,7 @@ module BugBunny
112
156
  @channel&.close if @channel&.open?
113
157
  @channel = nil
114
158
  @configured_returns.clear
159
+ release_pending_returns!
115
160
  end
116
161
  end
117
162
 
@@ -132,6 +177,19 @@ module BugBunny
132
177
  raise BugBunny::CommunicationError, "Failed to create channel: #{e.message}"
133
178
  end
134
179
 
180
+ # Libera todos los listeners pendientes seteando sus events. Permite que los publish
181
+ # threads bloqueados en `event.wait` despierten cuando la sesión se cierra (shutdown),
182
+ # en lugar de esperar a `confirm_timeout`. Solo se invoca desde {#close} — el cleanup
183
+ # per-publish corre via `ensure` en {Producer#confirmed} → {#unregister_return_listener}.
184
+ #
185
+ # @return [void]
186
+ def release_pending_returns!
187
+ @pending_returns.each_pair do |_cid, slot|
188
+ slot[:event].set
189
+ end
190
+ @pending_returns.clear
191
+ end
192
+
135
193
  # Registra el handler `basic.return` sobre el `Bunny::Exchange` indicado.
136
194
  #
137
195
  # Bunny dispatcha `basic.return` por exchange (no por canal): el callback se setea
@@ -156,11 +214,50 @@ module BugBunny
156
214
  # Procesa un evento `basic.return` del broker. Nunca propaga excepciones del callback
157
215
  # de usuario para no romper el hilo de I/O de Bunny.
158
216
  #
217
+ # Orden de operaciones:
218
+ # 1. Si hay un listener registrado con el `correlation_id` del mensaje retornado,
219
+ # se deposita la `return_info` en su slot y se setea el event. Esto se hace
220
+ # *antes* del callback de usuario para que una excepción del user_cb no impida
221
+ # que el publish thread despierte y raisee `PublishUnroutable`.
222
+ # 2. Se invoca el callback global `configuration.on_return` (o se logea si no hay).
223
+ #
159
224
  # @param return_info [Bunny::ReturnInfo]
160
225
  # @param properties [Bunny::MessageProperties]
161
226
  # @param body [String]
162
227
  # @return [void]
163
228
  def handle_broker_return(return_info, properties, body)
229
+ signal_return_listener(properties, return_info)
230
+ dispatch_return_callback(return_info, properties, body)
231
+ rescue StandardError => e
232
+ safe_log(:error, 'session.on_return_failed', **exception_metadata(e))
233
+ end
234
+
235
+ # Deposita la info del return en el slot asociado al `correlation_id` del mensaje
236
+ # retornado y setea el event para despertar al publish thread.
237
+ #
238
+ # @param properties [Bunny::MessageProperties]
239
+ # @param return_info [Bunny::ReturnInfo]
240
+ # @return [void]
241
+ def signal_return_listener(properties, return_info)
242
+ cid = properties.respond_to?(:correlation_id) ? properties.correlation_id : nil
243
+ return if cid.nil?
244
+
245
+ slot = @pending_returns[cid.to_s]
246
+ return unless slot
247
+
248
+ slot[:info] = return_info
249
+ slot[:event].set
250
+ end
251
+
252
+ # Invoca el callback global `on_return` o logea el evento si no hay callback.
253
+ # Las excepciones del user_cb se capturan en el rescue de {#handle_broker_return}
254
+ # — el event interno ya fue seteado antes de llegar acá.
255
+ #
256
+ # @param return_info [Bunny::ReturnInfo]
257
+ # @param properties [Bunny::MessageProperties]
258
+ # @param body [String]
259
+ # @return [void]
260
+ def dispatch_return_callback(return_info, properties, body)
164
261
  user_cb = BugBunny.configuration.on_return
165
262
  if user_cb
166
263
  user_cb.call(return_info, properties, body)
@@ -172,8 +269,6 @@ module BugBunny
172
269
  routing_key: return_info.routing_key,
173
270
  body_size: body.respond_to?(:bytesize) ? body.bytesize : nil)
174
271
  end
175
- rescue StandardError => e
176
- safe_log(:error, 'session.on_return_failed', **exception_metadata(e))
177
272
  end
178
273
 
179
274
  # Garantiza que la conexión TCP esté abierta.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = '4.14.0'
4
+ VERSION = '4.16.0'
5
5
  end