bug_bunny 4.14.0 → 4.17.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 +4 -4
- data/CHANGELOG.md +71 -0
- data/README.md +102 -4
- data/lib/bug_bunny/client.rb +66 -4
- data/lib/bug_bunny/configuration.rb +14 -0
- data/lib/bug_bunny/exception.rb +61 -0
- data/lib/bug_bunny/producer.rb +104 -0
- data/lib/bug_bunny/request.rb +5 -1
- data/lib/bug_bunny/session.rb +98 -3
- data/lib/bug_bunny/version.rb +1 -1
- data/skill/SKILL.md +80 -7
- data/skill/references/client-middleware.md +75 -15
- data/skill/references/errores.md +7 -0
- data/spec/integration/publisher_confirms_spec.rb +304 -0
- data/spec/spec_helper.rb +4 -1
- data/spec/support/integration_helper.rb +8 -1
- data/spec/unit/client_session_pool_spec.rb +174 -0
- data/spec/unit/configuration_spec.rb +12 -0
- data/spec/unit/producer_spec.rb +207 -0
- data/spec/unit/request_spec.rb +16 -0
- data/spec/unit/session_spec.rb +72 -0
- metadata +4 -3
data/lib/bug_bunny/session.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
data/lib/bug_bunny/version.rb
CHANGED
data/skill/SKILL.md
CHANGED
|
@@ -15,7 +15,9 @@ Skill de conocimiento completo sobre BugBunny. Consultame para cualquier pregunt
|
|
|
15
15
|
**Session** — `BugBunny::Session` envuelve canales de Bunny con thread-safety y double-checked locking.
|
|
16
16
|
**RPC** — Patrón síncrono que usa la pseudo-cola `amq.rabbitmq.reply-to` para respuestas sin crear queues temporales.
|
|
17
17
|
**Fire-and-Forget** — Patrón asíncrono donde el producer publica y continúa sin esperar respuesta. Retorna `{ 'status' => 202 }`.
|
|
18
|
-
**Publisher Confirms** — Extensión de RabbitMQ que confirma al publisher que el broker recibió el mensaje. BugBunny lo expone como `confirmed: true` en `Client#publish`: el publish bloquea hasta `wait_for_confirms`.
|
|
18
|
+
**Publisher Confirms** — Extensión de RabbitMQ que confirma al publisher que el broker recibió el mensaje. BugBunny lo expone como `confirmed: true` en `Client#publish`: el publish bloquea hasta `wait_for_confirms`. Dos señales asíncronas del broker, ambas convertidas en excepciones raise-eables en el publish thread:
|
|
19
|
+
- **NACK** (rechazo explícito) → `BugBunny::PublishNacked` (configurable via `config.nack_raise = false`).
|
|
20
|
+
- **basic.return** (mandatory unroutable) → `BugBunny::PublishUnroutable` (configurable via `config.return_raise = false`).
|
|
19
21
|
**Mandatory** — Flag de `basic.publish` que pide al broker retornar el mensaje al publisher si no es ruteable a ninguna cola. Se procesa via `basic.return` (no es respuesta del request).
|
|
20
22
|
**basic.return** — Evento asincrónico que Bunny dispatcha por exchange (`Bunny::Exchange#on_return`). BugBunny registra un handler único por nombre de exchange al resolverlo en `Session#exchange` y lo delega a `Configuration#on_return` o al default que logea.
|
|
21
23
|
**Resource** — ORM tipo ActiveRecord que mapea operaciones CRUD a llamadas AMQP.
|
|
@@ -109,6 +111,8 @@ BugBunny mide y emite duraciones automáticamente. **No envolver llamadas a `cli
|
|
|
109
111
|
| `producer.published` | INFO | `Producer#publish_message` (post) | `method`, `path`, `routing_key`, `messaging_message_id`, **`duration_s`** (publish solo) |
|
|
110
112
|
| `producer.confirmed` | INFO | `Producer#confirmed` (post-ACK) | `method`, `path`, `routing_key`, **`publish_duration_s`**, **`confirm_duration_s`**, **`duration_s`** (total) |
|
|
111
113
|
| `producer.confirms_nacked` | WARN | `Producer#confirmed` (NACK) | `count`, `path` |
|
|
114
|
+
| `producer.publish_unroutable` | WARN | `Producer#confirmed` (basic.return) | `path`, `exchange`, `routing_key`, `reply_code`, `reply_text`, `messaging_message_id` |
|
|
115
|
+
| `client.return_raise_ignored` | WARN | `Client#publish` | `delivery_mode`, `mandatory` (cuando `return_raise:true` se pasa sin prereqs) |
|
|
112
116
|
| `producer.rpc_waiting` | DEBUG | `Producer#rpc` | `messaging_message_id`, `timeout_s` |
|
|
113
117
|
| `producer.rpc_response_received` | INFO | `Producer#rpc` (reply recibido) | `method`, `path`, **`duration_s`** (round-trip total), `response_body` |
|
|
114
118
|
| `producer.rpc_response_orphaned` | WARN | reply listener | `correlation_id` |
|
|
@@ -160,8 +164,14 @@ BugBunny.configure do |config|
|
|
|
160
164
|
config.rpc_reply_headers = -> { { 'X-Trace-Id' => Tracer.id } }
|
|
161
165
|
config.on_rpc_reply = ->(h) { Tracer.hydrate(h['X-Trace-Id']) }
|
|
162
166
|
|
|
163
|
-
# Publisher Confirms —
|
|
164
|
-
#
|
|
167
|
+
# Publisher Confirms — fail-loud por default (ambos true).
|
|
168
|
+
# Setear false para volver al comportamiento legacy "log y retorna 202".
|
|
169
|
+
config.nack_raise = true # NACK → raise BugBunny::PublishNacked
|
|
170
|
+
config.return_raise = true # basic.return (mandatory) → raise BugBunny::PublishUnroutable
|
|
171
|
+
|
|
172
|
+
# Callback global para basic.return. Corre ANTES del raise PublishUnroutable
|
|
173
|
+
# cuando return_raise es true. Si on_return es nil, BugBunny logea
|
|
174
|
+
# `session.broker_return` con nivel :warn.
|
|
165
175
|
# Firma: ->(return_info, properties, body)
|
|
166
176
|
config.on_return = ->(ri, _props, body) { MyAlerts.unroutable(rk: ri.routing_key, body: body) }
|
|
167
177
|
|
|
@@ -233,12 +243,48 @@ client.publish('events', method: :post, body: { type: 'order.placed' })
|
|
|
233
243
|
client.publish('acct.start', exchange: 'acct_x', body: payload,
|
|
234
244
|
confirmed: true, mandatory: true, confirm_timeout: 0.5)
|
|
235
245
|
# → { 'status' => 202, 'body' => nil } # broker ACK confirmado
|
|
236
|
-
# Si broker NACK: logea `producer.confirms_nacked` y raise BugBunny::PublishNacked
|
|
237
|
-
# (default — opt-out con config.nack_raise = false o nack_raise: false per request).
|
|
238
|
-
# Si timeout: raise BugBunny::RequestTimeout.
|
|
239
|
-
# Si mandatory + no ruteable: dispara `Configuration#on_return` (default: logea `session.broker_return`).
|
|
240
246
|
```
|
|
241
247
|
|
|
248
|
+
**Receta canónica de publisher productivo (auditoría / billing / accounting):**
|
|
249
|
+
```ruby
|
|
250
|
+
client.publish('acct.start',
|
|
251
|
+
exchange: 'ingest.radius',
|
|
252
|
+
exchange_type: :topic,
|
|
253
|
+
exchange_options: { durable: true }, # matchear declaración del consumer
|
|
254
|
+
body: payload,
|
|
255
|
+
confirmed: true, # broker ACK síncrono
|
|
256
|
+
mandatory: true, # raise PublishUnroutable si no hay binding
|
|
257
|
+
persistent: true, # delivery_mode: 2 — sobrevive restart
|
|
258
|
+
correlation_id: SecureRandom.uuid, # tracing explícito
|
|
259
|
+
app_id: 'radius_manager')
|
|
260
|
+
```
|
|
261
|
+
A partir de 4.17, `persistent`, `correlation_id`, `priority`, `app_id`, `content_type`, `content_encoding` y `expiration` están en `Client::REQUEST_ATTRS` y se aceptan como kwargs. El block API sigue funcionando para overrides puntuales o para atributos no expuestos (`timestamp`, `type`).
|
|
262
|
+
|
|
263
|
+
**Dos señales del broker, dos excepciones simétricas:**
|
|
264
|
+
|
|
265
|
+
| Señal | Default | Excepción | Campos |
|
|
266
|
+
|---|---|---|---|
|
|
267
|
+
| `basic.nack` | raise | `BugBunny::PublishNacked` | `path`, `nacked_count` |
|
|
268
|
+
| `basic.return` (mandatory unrouted) | raise | `BugBunny::PublishUnroutable` | `path`, `exchange`, `routing_key`, `reply_code`, `reply_text`, `correlation_id` |
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
# Comportamiento default (4.13+ para nack_raise, 4.15+ para return_raise):
|
|
272
|
+
# broker NACK → PublishNacked
|
|
273
|
+
# mandatory + unroutable → on_return callback (si está) → PublishUnroutable
|
|
274
|
+
# timeout en wait_for_confirms → RequestTimeout
|
|
275
|
+
#
|
|
276
|
+
# Opt-out (modo "log + 202 silencioso"):
|
|
277
|
+
config.nack_raise = false # o per-request: nack_raise: false
|
|
278
|
+
config.return_raise = false # o per-request: return_raise: false
|
|
279
|
+
|
|
280
|
+
# Override per-request gana sobre config global:
|
|
281
|
+
client.publish('foo', confirmed: true, mandatory: true, return_raise: false, body: {})
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
**Bridge cross-thread interno (basic.return):** `basic.return` viaja en el reader thread de Bunny (asíncrono). Para hacer `PublishUnroutable` raise-able en el publish thread, BugBunny mantiene un `Session#@pending_returns = Concurrent::Map` correlacionado por `correlation_id`. `Producer#confirmed` auto-asigna `correlation_id` (UUID v4) si falta, registra un `Concurrent::Event` antes del publish, y tras `wait_for_confirms` true espera `RETURN_RACE_WINDOW_S` (50ms) para tolerar GVL scheduling — AMQP garantiza orden wire `return → ack`. El `on_return` user callback se invoca igual antes del raise (orden: signal event → user_cb → raise), así una excepción en el callback no impide el raise.
|
|
285
|
+
|
|
286
|
+
**Inert cases:** `return_raise: true` sin `mandatory: true` o sin `confirmed: true` emite `client.return_raise_ignored` (WARN) y se ignora — el flag requiere ambos prereqs.
|
|
287
|
+
|
|
242
288
|
`Bunny::Channel#wait_for_confirms` no soporta timeout nativo en Bunny 2.x. BugBunny lo implementa lanzando la espera en un hilo auxiliar y usando `Concurrent::IVar#value(timeout)` como reloj.
|
|
243
289
|
|
|
244
290
|
---
|
|
@@ -257,6 +303,10 @@ Cada slot del pool cachea su `Session` y `Producer` durante su vida útil. Esto
|
|
|
257
303
|
### ¿Cómo funciona la cascada de configuración?
|
|
258
304
|
3 niveles: Gem defaults → Global config (`BugBunny.configure`) → Per-request (args en `client.request` o `Resource.with`). Se mergean con `merge`.
|
|
259
305
|
|
|
306
|
+
**Defaults de la gema desde 4.16:**
|
|
307
|
+
- `DEFAULT_EXCHANGE_OPTIONS = { durable: false, auto_delete: false }`
|
|
308
|
+
- `DEFAULT_QUEUE_OPTIONS = { exclusive: false, durable: true, auto_delete: false }` — queue compartida duradera, válida en RabbitMQ 3.x y 4.x. Previo a 4.16 era `{ durable: false, auto_delete: true }` (deprecated `transient_nonexcl_queues` en RMQ 4.x). Para legacy: override explícito en `config.queue_options`.
|
|
309
|
+
|
|
260
310
|
### ¿Cómo funciona fork safety?
|
|
261
311
|
`BugBunny::Railtie` registra hooks en `ActiveSupport::ForkTracker` (Rails 7.1+), `Puma.events.on_worker_boot` y `Spring.after_fork` para llamar `BugBunny.disconnect` y evitar sockets TCP heredados.
|
|
262
312
|
|
|
@@ -276,6 +326,29 @@ No guardar el resultado de `Order.with(...)` en una variable para múltiples lla
|
|
|
276
326
|
### Registrar middleware durante call()
|
|
277
327
|
No registrar consumer middlewares durante la ejecución de `call()`. El stack toma un snapshot al inicio; los registros concurrentes no afectan la ejecución actual.
|
|
278
328
|
|
|
329
|
+
### Pasar `path:` como kwarg a `Client#publish` / `#request`
|
|
330
|
+
El primer argumento es **posicional** (`url`). No hay kwarg `:path`. Splatear un hash que tenga `path:` falla con `ArgumentError: wrong number of arguments`. Construir args sin path y pasar la URL aparte:
|
|
331
|
+
```ruby
|
|
332
|
+
args = { exchange: 'x', body: payload }
|
|
333
|
+
client.publish('event.name', **args) # ✅
|
|
334
|
+
client.publish(**args.merge(path: 'event.name')) # ❌
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Asumir que `confirmed: true` implica persistencia
|
|
338
|
+
`confirmed: true` solo activa Publisher Confirms (broker ACK síncrono). **NO** setea `delivery_mode: 2`. El default de `Request#persistent` es `false` — el mensaje vive en RAM del broker y se pierde si reinicia. Para eventos críticos sobre queue durable hay que pasar `persistent: true` (a partir de 4.17) o setearlo via block. Tabla de decisión:
|
|
339
|
+
|
|
340
|
+
| Necesitás | Pasar |
|
|
341
|
+
|---|---|
|
|
342
|
+
| Broker confirma recepción | `confirmed: true` |
|
|
343
|
+
| Mensaje sobrevive restart | `persistent: true` (requiere queue `durable: true`) |
|
|
344
|
+
| Raise si no rutea | `mandatory: true` (+ `return_raise: true` default) |
|
|
345
|
+
|
|
346
|
+
### Olvidar `exchange_options: { durable: true }` en publishers a exchange compartido
|
|
347
|
+
`DEFAULT_EXCHANGE_OPTIONS = { durable: false, auto_delete: false }`. Si un consumer previamente declaró el exchange como durable (caso normal en producción), un publisher que use el default va a recibir `Bunny::PreconditionFailed - inequivalent arg 'durable'` al re-declarar. Solución: pasar `exchange_options: { durable: true }` en el publisher, o setear global `BugBunny.configure { |c| c.exchange_options = { durable: true } }`.
|
|
348
|
+
|
|
349
|
+
### Confiar en `instance_double(BugBunny::Client)` para detectar errores de signature
|
|
350
|
+
Limitación de RSpec: `instance_double` valida que el método exista pero **no** valida arity estricta cuando el caller hace `**args` splat. Tests con mocks pasan, integration con broker real rompe. Mitigación: para cada publisher nuevo, sumar un smoke spec `:integration` que declare queue exclusiva con binding, publique, haga `queue.pop`, y verifique `correlation_id`, `delivery_mode`, `headers`, `routing_key`.
|
|
351
|
+
|
|
279
352
|
---
|
|
280
353
|
|
|
281
354
|
## Errores Comunes
|
|
@@ -47,6 +47,18 @@ end
|
|
|
47
47
|
| `mandatory` | Boolean | `false` | Pide al broker retornar el mensaje si no es ruteable. Solo útil con `confirmed: true`. |
|
|
48
48
|
| `confirm_timeout` | Float | `nil` | Segundos máximos a esperar el ACK. `nil` = espera indefinida. Excedido → `BugBunny::RequestTimeout`. |
|
|
49
49
|
| `nack_raise` | Boolean | `nil` | Override per-request de `config.nack_raise`. `nil` = usa flag global. |
|
|
50
|
+
| `return_raise` | Boolean | `nil` | Override per-request de `config.return_raise`. Requiere `confirmed: true` y `mandatory: true`. |
|
|
51
|
+
| `persistent` | Boolean | `false` | `delivery_mode: 2` AMQP. Mensaje sobrevive restart del broker. Requiere queue durable. |
|
|
52
|
+
| `correlation_id` | String | nil | ID de correlación AMQP. Auto-asignado UUID en RPC y en `confirmed + mandatory + return_raise`. |
|
|
53
|
+
| `priority` | Integer | nil | Prioridad 0-255. Requiere queue con `x-max-priority`. |
|
|
54
|
+
| `app_id` | String | nil | Identificador del publisher (AMQP `app-id`). |
|
|
55
|
+
| `content_type` | String | `'application/json'` | MIME type del payload. |
|
|
56
|
+
| `content_encoding` | String | nil | Encoding del payload (`'gzip'`, `'deflate'`, etc.). |
|
|
57
|
+
| `expiration` | String | nil | TTL del mensaje en ms (formato AMQP). |
|
|
58
|
+
|
|
59
|
+
**Gotcha:** el primer argumento de `Client#publish` / `#request` es **posicional** (`url`). No existe el kwarg `:path`. Splatear un hash con `path:` falla con `ArgumentError` o se ignora silencioso.
|
|
60
|
+
|
|
61
|
+
**Atributos no expuestos como kwarg** (solo via block API): `timestamp` (default `Time.now.to_i`), `type` (default `full_path`), `reply_to` (RPC interno).
|
|
50
62
|
|
|
51
63
|
## Producer (bajo nivel)
|
|
52
64
|
|
|
@@ -72,7 +84,7 @@ El `Producer` es usado internamente por el `Client`. Implementa tres patrones de
|
|
|
72
84
|
- Publica y bloquea hasta `channel.wait_for_confirms` del broker.
|
|
73
85
|
- Bunny 2.x **no soporta timeout** nativo en `wait_for_confirms` — BugBunny envuelve la llamada en un hilo auxiliar y usa `Concurrent::IVar#value(timeout)` como reloj. Si `confirm_timeout` expira → `BugBunny::RequestTimeout`.
|
|
74
86
|
- Si `wait_for_confirms` devuelve `false` (broker NACKea), se logea `producer.confirms_nacked` con `count` y `path`. Por default (`config.nack_raise = true`) levanta `BugBunny::PublishNacked` con `path` y `nacked_count`. Para opt-out: `config.nack_raise = false` o pasar `nack_raise: false` per request — en ese caso solo logea y retorna 202.
|
|
75
|
-
- Si `mandatory: true` y el mensaje no es ruteable, el broker dispara `basic.return`. El handler se atacha vía `Bunny::Exchange#on_return` en `Session#exchange` la primera vez que se resuelve cada exchange (cacheado por nombre, una sola vez por canal) y delega a `Configuration#on_return` o al logger por default.
|
|
87
|
+
- Si `mandatory: true` y el mensaje no es ruteable, el broker dispara `basic.return`. El handler se atacha vía `Bunny::Exchange#on_return` en `Session#exchange` la primera vez que se resuelve cada exchange (cacheado por nombre, una sola vez por canal) y delega a `Configuration#on_return` o al logger por default. **Por default (`config.return_raise = true`)** además levanta `BugBunny::PublishUnroutable` en el publish thread con `path`, `exchange`, `routing_key`, `reply_code`, `reply_text`, `correlation_id`. Para opt-out: `config.return_raise = false` o pasar `return_raise: false` per request — solo logea/invoca callback y retorna 202.
|
|
76
88
|
- Errores del canal se envuelven en `BugBunny::CommunicationError`; errores `BugBunny::Error` pre-existentes se propagan sin envolver.
|
|
77
89
|
- **Emite `producer.confirmed` (INFO) con tres duraciones desglosadas**: `publish_duration_s` (TCP enqueue), `confirm_duration_s` (`wait_for_confirms`), `duration_s` (total). Útil para distinguir latencia de red vs latencia de confirm policy del broker.
|
|
78
90
|
|
|
@@ -183,18 +195,35 @@ client.publish('x', confirmed: true, mandatory: true)
|
|
|
183
195
|
▼
|
|
184
196
|
Producer#confirmed
|
|
185
197
|
│
|
|
186
|
-
├──>
|
|
198
|
+
├──> setup_return_listener (si return_raise? → Session#register_return_listener(cid))
|
|
199
|
+
│ │
|
|
200
|
+
│ └─ @session.@pending_returns[cid] = { event:, info: nil }
|
|
201
|
+
│
|
|
202
|
+
├──> publish_message (exchange.publish con mandatory: true, correlation_id auto-asignado)
|
|
203
|
+
│
|
|
204
|
+
├──> wait_for_confirms! (espera ACK del broker, con timeout opcional)
|
|
187
205
|
│
|
|
188
|
-
├──>
|
|
206
|
+
├──> handle_confirm_result
|
|
207
|
+
│ ├─ acked == true → continúa
|
|
208
|
+
│ └─ acked == false → log WARN producer.confirms_nacked
|
|
209
|
+
│ └─ raise BugBunny::PublishNacked (si config.nack_raise || req.nack_raise)
|
|
189
210
|
│
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
211
|
+
├──> handle_return_result (si listener registrado)
|
|
212
|
+
│ ├─ slot.event.wait(RETURN_RACE_WINDOW_S = 0.05)
|
|
213
|
+
│ ├─ slot.info == nil → return (ack normal sin return)
|
|
214
|
+
│ └─ slot.info != nil → log WARN producer.publish_unroutable
|
|
215
|
+
│ └─ raise BugBunny::PublishUnroutable
|
|
216
|
+
│
|
|
217
|
+
└──> ensure: teardown_return_listener (Session#unregister_return_listener(cid))
|
|
218
|
+
|
|
219
|
+
Asíncronamente en el reader thread, si el broker no pudo rutear:
|
|
220
|
+
broker ──basic.return──> Exchange#on_return ──> Session#handle_broker_return
|
|
221
|
+
│
|
|
222
|
+
├─ signal_return_listener (busca cid en @pending_returns,
|
|
223
|
+
│ setea slot.info + slot.event)
|
|
224
|
+
│
|
|
225
|
+
└─ dispatch_return_callback (invoca Configuration#on_return
|
|
226
|
+
o logea session.broker_return)
|
|
198
227
|
```
|
|
199
228
|
|
|
200
229
|
### `Configuration#on_return`
|
|
@@ -229,14 +258,43 @@ Excepciones del callback se capturan y se logean como `session.on_return_failed`
|
|
|
229
258
|
| Auditoría, billing, eventos críticos | `:confirmed` (con `mandatory: true` si es ruteable) |
|
|
230
259
|
| Request-response síncrono | `:rpc` |
|
|
231
260
|
|
|
232
|
-
`:confirmed` cuesta un round-trip al broker pero **no** al consumer remoto — más rápido que RPC, con garantía de entrega al broker.
|
|
261
|
+
`:confirmed` cuesta un round-trip al broker pero **no** al consumer remoto — más rápido que RPC, con garantía de entrega al broker. Dos modos de falla broker-side, ambos raise-eables por default:
|
|
262
|
+
|
|
263
|
+
- **NACK** (raro: confirm policies internas, disk full, replicación insuficiente) → `BugBunny::PublishNacked` (`path`, `nacked_count`). Opt-out: `config.nack_raise = false`.
|
|
264
|
+
- **basic.return + mandatory** (queue inexistente, sin bindings, routing key sin match) → `BugBunny::PublishUnroutable` (`path`, `exchange`, `routing_key`, `reply_code`, `reply_text`, `correlation_id`). Opt-out: `config.return_raise = false`.
|
|
265
|
+
|
|
266
|
+
El `on_return` user callback se invoca igual antes del raise (orden: signal interno → user_cb → raise en caller), así que alerting/metrics siguen funcionando.
|
|
267
|
+
|
|
268
|
+
### Bridge cross-thread (`basic.return` → publish thread)
|
|
269
|
+
|
|
270
|
+
`basic.return` llega en el reader thread de Bunny mientras el publish thread está dentro de `wait_for_confirms`. Para que `PublishUnroutable` sea raise-eable sincrónicamente desde la perspectiva del caller, `Session` mantiene un registry indexado por `correlation_id`:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
# Session (api privada)
|
|
274
|
+
@pending_returns = Concurrent::Map.new
|
|
275
|
+
# slot = { event: Concurrent::Event.new, info: nil }
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`Producer#confirmed`:
|
|
279
|
+
1. Si `return_raise?` resuelto es true, auto-asigna `request.correlation_id` si falta (UUID v4 vía `SecureRandom`).
|
|
280
|
+
2. Registra slot vía `Session#register_return_listener(cid)`.
|
|
281
|
+
3. Publica con `correlation_id` propagado a las message properties (el broker lo echo back en `basic.return.properties`).
|
|
282
|
+
4. Tras `wait_for_confirms` true, `event.wait(RETURN_RACE_WINDOW_S = 0.05)` para tolerar GVL scheduling. AMQP wire garantiza `return → ack`, así que normalmente el event ya está seteado.
|
|
283
|
+
5. Si `slot[:info]` no es nil, logea `producer.publish_unroutable` y raise.
|
|
284
|
+
6. `ensure` block siempre llama `Session#unregister_return_listener(cid)` para cleanup.
|
|
285
|
+
|
|
286
|
+
`Session#handle_broker_return` (reader thread):
|
|
287
|
+
1. `signal_return_listener` busca cid por `properties.correlation_id`, setea `slot[:info]` y `slot[:event]`. **Esto corre ANTES del user_cb** — una excepción en el callback no impide el raise.
|
|
288
|
+
2. `dispatch_return_callback` invoca `Configuration#on_return` o logea `session.broker_return`.
|
|
289
|
+
|
|
290
|
+
Si `return_raise: true` se pasa sin `confirmed: true` o sin `mandatory: true`, el flag es inerte y se emite `client.return_raise_ignored` WARN. El bridge no aplica en `:publish` puro porque no hay synchronization point (`wait_for_confirms`) sobre el cual raise-ear en el caller.
|
|
233
291
|
|
|
234
292
|
## Cascada de Configuración (3 niveles)
|
|
235
293
|
|
|
236
294
|
```ruby
|
|
237
|
-
# Level 1: Gem defaults
|
|
238
|
-
{ durable: false, auto_delete: false }
|
|
239
|
-
{ exclusive: false, durable:
|
|
295
|
+
# Level 1: Gem defaults (BugBunny::Session)
|
|
296
|
+
{ durable: false, auto_delete: false } # DEFAULT_EXCHANGE_OPTIONS
|
|
297
|
+
{ exclusive: false, durable: true, auto_delete: false } # DEFAULT_QUEUE_OPTIONS (cambio en 4.16)
|
|
240
298
|
|
|
241
299
|
# Level 2: Global config
|
|
242
300
|
BugBunny.configure { |c| c.exchange_options = { durable: true } }
|
|
@@ -246,3 +304,5 @@ client.request('users', exchange_options: { durable: true })
|
|
|
246
304
|
|
|
247
305
|
# Merge final: Level1.merge(Level2).merge(Level3)
|
|
248
306
|
```
|
|
307
|
+
|
|
308
|
+
**Nota 4.16+:** `DEFAULT_QUEUE_OPTIONS` previamente era `{ exclusive: false, durable: false, auto_delete: true }` (combo `transient_nonexcl_queues` deprecada en RabbitMQ 4.x). El nuevo default es queue compartida duradera. Para restaurar el comportamiento previo: `c.queue_options = { exclusive: false, durable: false, auto_delete: true }`.
|
data/skill/references/errores.md
CHANGED
|
@@ -9,6 +9,7 @@ StandardError
|
|
|
9
9
|
├── BugBunny::ConfigurationError
|
|
10
10
|
├── BugBunny::SecurityError
|
|
11
11
|
├── BugBunny::PublishNacked
|
|
12
|
+
├── BugBunny::PublishUnroutable
|
|
12
13
|
├── BugBunny::ClientError (4xx)
|
|
13
14
|
│ ├── BugBunny::BadRequest (400)
|
|
14
15
|
│ ├── BugBunny::NotFound (404)
|
|
@@ -44,6 +45,12 @@ StandardError
|
|
|
44
45
|
**Atributos:** `path` (ruta del request) y `nacked_count` (cantidad de delivery tags NACKeados).
|
|
45
46
|
**Resolución:** Para casos críticos (auditoría, billing, RADIUS accounting), dejar que la excepción bubble para que el caller upstream reintente (ej: HTTP 503 → retry). Para tolerar NACKs (eventos best-effort), `BugBunny.configuration.nack_raise = false` global o `nack_raise: false` per request — en ese caso solo se logea `producer.confirms_nacked`.
|
|
46
47
|
|
|
48
|
+
### BugBunny::PublishUnroutable
|
|
49
|
+
**Causa:** El broker retornó un mensaje publicado con `mandatory: true` que no pudo rutearse a ninguna cola en modo `:confirmed` (queue inexistente, sin bindings que matcheen la routing key, etc.). Espejo simétrico de `PublishNacked` pero para la señal `basic.return` en lugar de `basic.nack`.
|
|
50
|
+
**Cuándo:** `Producer#confirmed` con `mandatory: true` detecta un `basic.return` correlacionado por `correlation_id` antes/durante el ACK. Se levanta por default (`config.return_raise = true`, introducido en 4.15).
|
|
51
|
+
**Atributos:** `path`, `exchange`, `routing_key`, `reply_code` (típicamente 312 = NO_ROUTE), `reply_text` y `correlation_id`.
|
|
52
|
+
**Resolución:** Mismo patrón que `PublishNacked` — bubble en publishers críticos para traducir a HTTP 5xx + retry upstream. El `config.on_return` callback se sigue invocando antes del raise (alerting/metrics intactos). Para opt-out: `config.return_raise = false` global o `return_raise: false` per request. Inert cuando `mandatory: false` (sin mandatory el broker nunca emite `basic.return`).
|
|
53
|
+
|
|
47
54
|
## Errores de Cliente (4xx)
|
|
48
55
|
|
|
49
56
|
### BugBunny::BadRequest (400)
|