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 +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +35 -4
- data/lib/bug_bunny/client.rb +37 -0
- 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 +42 -7
- data/skill/references/client-middleware.md +63 -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 +72 -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/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,33 @@ 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
|
+
**Dos señales del broker, dos excepciones simétricas:**
|
|
249
|
+
|
|
250
|
+
| Señal | Default | Excepción | Campos |
|
|
251
|
+
|---|---|---|---|
|
|
252
|
+
| `basic.nack` | raise | `BugBunny::PublishNacked` | `path`, `nacked_count` |
|
|
253
|
+
| `basic.return` (mandatory unrouted) | raise | `BugBunny::PublishUnroutable` | `path`, `exchange`, `routing_key`, `reply_code`, `reply_text`, `correlation_id` |
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
# Comportamiento default (4.13+ para nack_raise, 4.15+ para return_raise):
|
|
257
|
+
# broker NACK → PublishNacked
|
|
258
|
+
# mandatory + unroutable → on_return callback (si está) → PublishUnroutable
|
|
259
|
+
# timeout en wait_for_confirms → RequestTimeout
|
|
260
|
+
#
|
|
261
|
+
# Opt-out (modo "log + 202 silencioso"):
|
|
262
|
+
config.nack_raise = false # o per-request: nack_raise: false
|
|
263
|
+
config.return_raise = false # o per-request: return_raise: false
|
|
264
|
+
|
|
265
|
+
# Override per-request gana sobre config global:
|
|
266
|
+
client.publish('foo', confirmed: true, mandatory: true, return_raise: false, body: {})
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**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.
|
|
270
|
+
|
|
271
|
+
**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.
|
|
272
|
+
|
|
242
273
|
`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
274
|
|
|
244
275
|
---
|
|
@@ -257,6 +288,10 @@ Cada slot del pool cachea su `Session` y `Producer` durante su vida útil. Esto
|
|
|
257
288
|
### ¿Cómo funciona la cascada de configuración?
|
|
258
289
|
3 niveles: Gem defaults → Global config (`BugBunny.configure`) → Per-request (args en `client.request` o `Resource.with`). Se mergean con `merge`.
|
|
259
290
|
|
|
291
|
+
**Defaults de la gema desde 4.16:**
|
|
292
|
+
- `DEFAULT_EXCHANGE_OPTIONS = { durable: false, auto_delete: false }`
|
|
293
|
+
- `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`.
|
|
294
|
+
|
|
260
295
|
### ¿Cómo funciona fork safety?
|
|
261
296
|
`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
297
|
|
|
@@ -72,7 +72,7 @@ El `Producer` es usado internamente por el `Client`. Implementa tres patrones de
|
|
|
72
72
|
- Publica y bloquea hasta `channel.wait_for_confirms` del broker.
|
|
73
73
|
- 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
74
|
- 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.
|
|
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. **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
76
|
- Errores del canal se envuelven en `BugBunny::CommunicationError`; errores `BugBunny::Error` pre-existentes se propagan sin envolver.
|
|
77
77
|
- **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
78
|
|
|
@@ -183,18 +183,35 @@ client.publish('x', confirmed: true, mandatory: true)
|
|
|
183
183
|
▼
|
|
184
184
|
Producer#confirmed
|
|
185
185
|
│
|
|
186
|
-
├──>
|
|
186
|
+
├──> setup_return_listener (si return_raise? → Session#register_return_listener(cid))
|
|
187
|
+
│ │
|
|
188
|
+
│ └─ @session.@pending_returns[cid] = { event:, info: nil }
|
|
187
189
|
│
|
|
188
|
-
├──>
|
|
190
|
+
├──> publish_message (exchange.publish con mandatory: true, correlation_id auto-asignado)
|
|
189
191
|
│
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
192
|
+
├──> wait_for_confirms! (espera ACK del broker, con timeout opcional)
|
|
193
|
+
│
|
|
194
|
+
├──> handle_confirm_result
|
|
195
|
+
│ ├─ acked == true → continúa
|
|
196
|
+
│ └─ acked == false → log WARN producer.confirms_nacked
|
|
197
|
+
│ └─ raise BugBunny::PublishNacked (si config.nack_raise || req.nack_raise)
|
|
198
|
+
│
|
|
199
|
+
├──> handle_return_result (si listener registrado)
|
|
200
|
+
│ ├─ slot.event.wait(RETURN_RACE_WINDOW_S = 0.05)
|
|
201
|
+
│ ├─ slot.info == nil → return (ack normal sin return)
|
|
202
|
+
│ └─ slot.info != nil → log WARN producer.publish_unroutable
|
|
203
|
+
│ └─ raise BugBunny::PublishUnroutable
|
|
204
|
+
│
|
|
205
|
+
└──> ensure: teardown_return_listener (Session#unregister_return_listener(cid))
|
|
206
|
+
|
|
207
|
+
Asíncronamente en el reader thread, si el broker no pudo rutear:
|
|
208
|
+
broker ──basic.return──> Exchange#on_return ──> Session#handle_broker_return
|
|
209
|
+
│
|
|
210
|
+
├─ signal_return_listener (busca cid en @pending_returns,
|
|
211
|
+
│ setea slot.info + slot.event)
|
|
212
|
+
│
|
|
213
|
+
└─ dispatch_return_callback (invoca Configuration#on_return
|
|
214
|
+
o logea session.broker_return)
|
|
198
215
|
```
|
|
199
216
|
|
|
200
217
|
### `Configuration#on_return`
|
|
@@ -229,14 +246,43 @@ Excepciones del callback se capturan y se logean como `session.on_return_failed`
|
|
|
229
246
|
| Auditoría, billing, eventos críticos | `:confirmed` (con `mandatory: true` si es ruteable) |
|
|
230
247
|
| Request-response síncrono | `:rpc` |
|
|
231
248
|
|
|
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.
|
|
249
|
+
`: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:
|
|
250
|
+
|
|
251
|
+
- **NACK** (raro: confirm policies internas, disk full, replicación insuficiente) → `BugBunny::PublishNacked` (`path`, `nacked_count`). Opt-out: `config.nack_raise = false`.
|
|
252
|
+
- **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`.
|
|
253
|
+
|
|
254
|
+
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.
|
|
255
|
+
|
|
256
|
+
### Bridge cross-thread (`basic.return` → publish thread)
|
|
257
|
+
|
|
258
|
+
`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`:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
# Session (api privada)
|
|
262
|
+
@pending_returns = Concurrent::Map.new
|
|
263
|
+
# slot = { event: Concurrent::Event.new, info: nil }
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`Producer#confirmed`:
|
|
267
|
+
1. Si `return_raise?` resuelto es true, auto-asigna `request.correlation_id` si falta (UUID v4 vía `SecureRandom`).
|
|
268
|
+
2. Registra slot vía `Session#register_return_listener(cid)`.
|
|
269
|
+
3. Publica con `correlation_id` propagado a las message properties (el broker lo echo back en `basic.return.properties`).
|
|
270
|
+
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.
|
|
271
|
+
5. Si `slot[:info]` no es nil, logea `producer.publish_unroutable` y raise.
|
|
272
|
+
6. `ensure` block siempre llama `Session#unregister_return_listener(cid)` para cleanup.
|
|
273
|
+
|
|
274
|
+
`Session#handle_broker_return` (reader thread):
|
|
275
|
+
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.
|
|
276
|
+
2. `dispatch_return_callback` invoca `Configuration#on_return` o logea `session.broker_return`.
|
|
277
|
+
|
|
278
|
+
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
279
|
|
|
234
280
|
## Cascada de Configuración (3 niveles)
|
|
235
281
|
|
|
236
282
|
```ruby
|
|
237
|
-
# Level 1: Gem defaults
|
|
238
|
-
{ durable: false, auto_delete: false }
|
|
239
|
-
{ exclusive: false, durable:
|
|
283
|
+
# Level 1: Gem defaults (BugBunny::Session)
|
|
284
|
+
{ durable: false, auto_delete: false } # DEFAULT_EXCHANGE_OPTIONS
|
|
285
|
+
{ exclusive: false, durable: true, auto_delete: false } # DEFAULT_QUEUE_OPTIONS (cambio en 4.16)
|
|
240
286
|
|
|
241
287
|
# Level 2: Global config
|
|
242
288
|
BugBunny.configure { |c| c.exchange_options = { durable: true } }
|
|
@@ -246,3 +292,5 @@ client.request('users', exchange_options: { durable: true })
|
|
|
246
292
|
|
|
247
293
|
# Merge final: Level1.merge(Level2).merge(Level3)
|
|
248
294
|
```
|
|
295
|
+
|
|
296
|
+
**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)
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'support/integration_helper'
|
|
5
|
+
|
|
6
|
+
# Specs de integración para Publisher Confirms en modo :confirmed + mandatory.
|
|
7
|
+
# Verifican el flow end-to-end del bridge `basic.return` → `PublishUnroutable`
|
|
8
|
+
# contra un RabbitMQ real.
|
|
9
|
+
#
|
|
10
|
+
# Se skippean automáticamente si el broker no está disponible (ver
|
|
11
|
+
# `spec_helper.rb` → `before(:each, :integration)`).
|
|
12
|
+
RSpec.describe 'Publisher Confirms — return_raise', :integration do
|
|
13
|
+
let(:client) { BugBunny::Client.new(pool: TEST_POOL) }
|
|
14
|
+
|
|
15
|
+
# Exchange unbound: existe pero ninguna cola está bindeada a él.
|
|
16
|
+
# Cualquier publish con mandatory:true sobre este exchange retornará.
|
|
17
|
+
let(:unbound_exchange) { unique('unroutable_x') }
|
|
18
|
+
# Exchange con cola bindeada: publish con mandatory:true llega bien.
|
|
19
|
+
let(:routable_exchange) { unique('routable_x') }
|
|
20
|
+
|
|
21
|
+
# Declara el exchange sin bindings para asegurar que `basic.return` se dispare.
|
|
22
|
+
# Usa una conexión fresca para no contaminar el pool.
|
|
23
|
+
def declare_unbound_exchange!(name)
|
|
24
|
+
conn = BugBunny.create_connection
|
|
25
|
+
ch = conn.create_channel
|
|
26
|
+
ch.topic(name, BugBunny.configuration.exchange_options)
|
|
27
|
+
ch.close
|
|
28
|
+
conn.close
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
before do
|
|
32
|
+
declare_unbound_exchange!(unbound_exchange)
|
|
33
|
+
# Reset flag a default conocido por si algún spec previo lo cambió.
|
|
34
|
+
BugBunny.configuration.return_raise = true
|
|
35
|
+
BugBunny.configuration.on_return = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
after do
|
|
39
|
+
BugBunny.configuration.return_raise = true
|
|
40
|
+
BugBunny.configuration.on_return = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe 'mandatory: true sobre exchange sin bindings' do
|
|
44
|
+
it 'levanta BugBunny::PublishUnroutable por default (return_raise=true)' do
|
|
45
|
+
expect {
|
|
46
|
+
client.publish('acct.unbound',
|
|
47
|
+
exchange: unbound_exchange,
|
|
48
|
+
exchange_type: 'topic',
|
|
49
|
+
confirmed: true,
|
|
50
|
+
mandatory: true,
|
|
51
|
+
body: { tenant: 42 })
|
|
52
|
+
}.to raise_error(BugBunny::PublishUnroutable) do |err|
|
|
53
|
+
expect(err.path).to eq('acct.unbound')
|
|
54
|
+
expect(err.exchange).to eq(unbound_exchange)
|
|
55
|
+
expect(err.routing_key).to eq('acct.unbound')
|
|
56
|
+
expect(err.reply_code).to eq(312)
|
|
57
|
+
expect(err.reply_text).to match(/NO_ROUTE/i)
|
|
58
|
+
expect(err.correlation_id).to be_a(String)
|
|
59
|
+
expect(err.correlation_id).not_to be_empty
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'NO levanta si el request override `return_raise: false`' do
|
|
64
|
+
result = client.publish('acct.unbound',
|
|
65
|
+
exchange: unbound_exchange,
|
|
66
|
+
exchange_type: 'topic',
|
|
67
|
+
confirmed: true,
|
|
68
|
+
mandatory: true,
|
|
69
|
+
return_raise: false,
|
|
70
|
+
body: { tenant: 42 })
|
|
71
|
+
|
|
72
|
+
expect(result).to eq('status' => 202, 'body' => nil)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'NO levanta si la config global tiene `return_raise = false`' do
|
|
76
|
+
BugBunny.configuration.return_raise = false
|
|
77
|
+
|
|
78
|
+
result = client.publish('acct.unbound',
|
|
79
|
+
exchange: unbound_exchange,
|
|
80
|
+
exchange_type: 'topic',
|
|
81
|
+
confirmed: true,
|
|
82
|
+
mandatory: true,
|
|
83
|
+
body: { tenant: 42 })
|
|
84
|
+
|
|
85
|
+
expect(result).to eq('status' => 202, 'body' => nil)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'invoca el callback global on_return antes de levantar' do
|
|
89
|
+
captured = nil
|
|
90
|
+
BugBunny.configuration.on_return = lambda { |return_info, _props, _body|
|
|
91
|
+
captured = { exchange: return_info.exchange, rk: return_info.routing_key }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
expect {
|
|
95
|
+
client.publish('acct.unbound',
|
|
96
|
+
exchange: unbound_exchange,
|
|
97
|
+
exchange_type: 'topic',
|
|
98
|
+
confirmed: true,
|
|
99
|
+
mandatory: true,
|
|
100
|
+
body: { tenant: 42 })
|
|
101
|
+
}.to raise_error(BugBunny::PublishUnroutable)
|
|
102
|
+
|
|
103
|
+
expect(captured).not_to be_nil
|
|
104
|
+
expect(captured[:exchange]).to eq(unbound_exchange)
|
|
105
|
+
expect(captured[:rk]).to eq('acct.unbound')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'levanta igual cuando el user_cb on_return explota' do
|
|
109
|
+
BugBunny.configuration.on_return = ->(_, _, _) { raise 'boom in user cb' }
|
|
110
|
+
|
|
111
|
+
expect {
|
|
112
|
+
client.publish('acct.unbound',
|
|
113
|
+
exchange: unbound_exchange,
|
|
114
|
+
exchange_type: 'topic',
|
|
115
|
+
confirmed: true,
|
|
116
|
+
mandatory: true,
|
|
117
|
+
body: { tenant: 42 })
|
|
118
|
+
}.to raise_error(BugBunny::PublishUnroutable)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'override per-request gana sobre config global = false' do
|
|
122
|
+
BugBunny.configuration.return_raise = false
|
|
123
|
+
|
|
124
|
+
expect {
|
|
125
|
+
client.publish('acct.unbound',
|
|
126
|
+
exchange: unbound_exchange,
|
|
127
|
+
exchange_type: 'topic',
|
|
128
|
+
confirmed: true,
|
|
129
|
+
mandatory: true,
|
|
130
|
+
return_raise: true,
|
|
131
|
+
body: { tenant: 42 })
|
|
132
|
+
}.to raise_error(BugBunny::PublishUnroutable)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
describe 'mandatory: true sobre exchange con binding (happy path)' do
|
|
137
|
+
# Declara queue exclusive + binding contra `routable_exchange` para que el
|
|
138
|
+
# publish sea ruteable. Exclusive evita la deprecación de transient_nonexcl_queues
|
|
139
|
+
# en versiones modernas de RabbitMQ.
|
|
140
|
+
def with_exclusive_binding(exchange:, routing_key:)
|
|
141
|
+
conn = BugBunny.create_connection
|
|
142
|
+
ch = conn.create_channel
|
|
143
|
+
x = ch.topic(exchange, BugBunny.configuration.exchange_options)
|
|
144
|
+
q = ch.queue('', exclusive: true, auto_delete: true)
|
|
145
|
+
q.bind(x, routing_key: routing_key)
|
|
146
|
+
yield
|
|
147
|
+
ensure
|
|
148
|
+
ch&.close
|
|
149
|
+
conn&.close
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it 'retorna 202 sin levantar — el mensaje rutea normal' do
|
|
153
|
+
with_exclusive_binding(exchange: routable_exchange, routing_key: 'acct.#') do
|
|
154
|
+
result = client.publish('acct.start',
|
|
155
|
+
exchange: routable_exchange,
|
|
156
|
+
exchange_type: 'topic',
|
|
157
|
+
confirmed: true,
|
|
158
|
+
mandatory: true,
|
|
159
|
+
body: { tenant: 99 })
|
|
160
|
+
|
|
161
|
+
expect(result).to eq('status' => 202, 'body' => nil)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
describe 'mandatory: false (flag inerte)' do
|
|
167
|
+
it 'no levanta aunque return_raise=true y la routing key no rutee a ninguna cola' do
|
|
168
|
+
result = client.publish('acct.unbound',
|
|
169
|
+
exchange: unbound_exchange,
|
|
170
|
+
exchange_type: 'topic',
|
|
171
|
+
confirmed: true,
|
|
172
|
+
mandatory: false,
|
|
173
|
+
return_raise: true,
|
|
174
|
+
body: { tenant: 1 })
|
|
175
|
+
|
|
176
|
+
expect(result).to eq('status' => 202, 'body' => nil)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
describe 'concurrencia multi-thread sobre el mismo client' do
|
|
181
|
+
# Verifica que la correlación por correlation_id aísla los outcomes:
|
|
182
|
+
# N threads publican simultáneamente sobre el mismo exchange unbound, cada uno
|
|
183
|
+
# debe recibir SU propio PublishUnroutable (no el de otro thread).
|
|
184
|
+
it 'cada caller recibe su propio raise sin contaminación cross-thread' do
|
|
185
|
+
threads = 8
|
|
186
|
+
results = Concurrent::Array.new
|
|
187
|
+
|
|
188
|
+
pool = Array.new(threads) do |i|
|
|
189
|
+
Thread.new do
|
|
190
|
+
rk = "thread.#{i}.unbound"
|
|
191
|
+
client.publish(rk,
|
|
192
|
+
exchange: unbound_exchange,
|
|
193
|
+
exchange_type: 'topic',
|
|
194
|
+
confirmed: true,
|
|
195
|
+
mandatory: true,
|
|
196
|
+
body: { tid: i })
|
|
197
|
+
results << { tid: i, raised: false }
|
|
198
|
+
rescue BugBunny::PublishUnroutable => e
|
|
199
|
+
results << { tid: i, raised: true, rk: e.routing_key, cid: e.correlation_id }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
pool.each(&:join)
|
|
204
|
+
|
|
205
|
+
expect(results.size).to eq(threads)
|
|
206
|
+
expect(results.all? { |r| r[:raised] }).to be(true), 'todos deberían haber raised'
|
|
207
|
+
expect(results.map { |r| r[:rk] }.sort).to eq((0...threads).map { |i| "thread.#{i}.unbound" }.sort)
|
|
208
|
+
# Todos los correlation_ids deben ser distintos (no hubo cross-thread leakage)
|
|
209
|
+
cids = results.map { |r| r[:cid] }
|
|
210
|
+
expect(cids.uniq.size).to eq(threads)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
describe 'aislamiento entre exchanges sobre el mismo channel' do
|
|
215
|
+
# Publish A sobre exchange unbound (debe raisear) y publish B sobre exchange routable
|
|
216
|
+
# (debe pasar). Validamos que el return de A no contamina B.
|
|
217
|
+
it 'return en exchange A no afecta publish concurrente a exchange B' do
|
|
218
|
+
bound_ex = unique('bound_b_x')
|
|
219
|
+
bound_q = unique('bound_b_q')
|
|
220
|
+
|
|
221
|
+
# Setup: exchange routable con queue exclusive bindeada
|
|
222
|
+
conn = BugBunny.create_connection
|
|
223
|
+
ch = conn.create_channel
|
|
224
|
+
x = ch.topic(bound_ex, BugBunny.configuration.exchange_options)
|
|
225
|
+
q = ch.queue('', exclusive: true, auto_delete: true)
|
|
226
|
+
q.bind(x, routing_key: '#')
|
|
227
|
+
|
|
228
|
+
results = Concurrent::Array.new
|
|
229
|
+
|
|
230
|
+
t_a = Thread.new do
|
|
231
|
+
client.publish('a.unbound',
|
|
232
|
+
exchange: unbound_exchange,
|
|
233
|
+
exchange_type: 'topic',
|
|
234
|
+
confirmed: true,
|
|
235
|
+
mandatory: true,
|
|
236
|
+
body: { side: 'A' })
|
|
237
|
+
results << { side: 'A', raised: false }
|
|
238
|
+
rescue BugBunny::PublishUnroutable
|
|
239
|
+
results << { side: 'A', raised: true }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
t_b = Thread.new do
|
|
243
|
+
client.publish('b.routable',
|
|
244
|
+
exchange: bound_ex,
|
|
245
|
+
exchange_type: 'topic',
|
|
246
|
+
confirmed: true,
|
|
247
|
+
mandatory: true,
|
|
248
|
+
body: { side: 'B' })
|
|
249
|
+
results << { side: 'B', raised: false }
|
|
250
|
+
rescue BugBunny::PublishUnroutable
|
|
251
|
+
results << { side: 'B', raised: true }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
[t_a, t_b].each(&:join)
|
|
255
|
+
|
|
256
|
+
a = results.find { |r| r[:side] == 'A' }
|
|
257
|
+
b = results.find { |r| r[:side] == 'B' }
|
|
258
|
+
expect(a[:raised]).to be(true), 'A (unbound) debería haber raised'
|
|
259
|
+
expect(b[:raised]).to be(false), 'B (routable) NO debería haber raised'
|
|
260
|
+
ensure
|
|
261
|
+
ch&.close
|
|
262
|
+
conn&.close
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
describe 'no hay leak en el registry tras publishes seriales' do
|
|
267
|
+
# 30 publishes seriales (mix routable + unroutable). Tras todos, el registry
|
|
268
|
+
# @pending_returns debe estar en 0. Detecta entries colgadas por cleanup mal hecho.
|
|
269
|
+
it 'registry vuelve a 0 tras una serie de publishes' do
|
|
270
|
+
30.times do |i|
|
|
271
|
+
target = i.even? ? unbound_exchange : nil
|
|
272
|
+
if target
|
|
273
|
+
begin
|
|
274
|
+
client.publish("serial.#{i}",
|
|
275
|
+
exchange: target,
|
|
276
|
+
exchange_type: 'topic',
|
|
277
|
+
confirmed: true,
|
|
278
|
+
mandatory: true,
|
|
279
|
+
body: { i: i })
|
|
280
|
+
rescue BugBunny::PublishUnroutable
|
|
281
|
+
# esperado en routes no-ruteables
|
|
282
|
+
end
|
|
283
|
+
else
|
|
284
|
+
# publish sin mandatory para no triggerear return — no-op para el registry
|
|
285
|
+
client.publish("serial.#{i}",
|
|
286
|
+
exchange: unbound_exchange,
|
|
287
|
+
exchange_type: 'topic',
|
|
288
|
+
confirmed: true,
|
|
289
|
+
mandatory: false,
|
|
290
|
+
body: { i: i })
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Inspeccionar cada Session del pool — el registry debe estar vacío en todas.
|
|
295
|
+
total_pending = 0
|
|
296
|
+
TEST_POOL.with do |conn|
|
|
297
|
+
session = conn.instance_variable_get(:@_bug_bunny_session)
|
|
298
|
+
registry = session.instance_variable_get(:@pending_returns)
|
|
299
|
+
total_pending += registry.size
|
|
300
|
+
end
|
|
301
|
+
expect(total_pending).to eq(0)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -23,8 +23,11 @@ BugBunny.configure do |config|
|
|
|
23
23
|
config.password = ENV.fetch('RABBITMQ_PASS', 'guest')
|
|
24
24
|
config.vhost = '/'
|
|
25
25
|
config.logger = Logger.new($stdout).tap { |l| l.level = Logger::WARN }
|
|
26
|
+
# exchange_options: explícito para que los specs declaren exchanges efímeros.
|
|
27
|
+
# queue_options: NO override — confiamos en `DEFAULT_QUEUE_OPTIONS` (durable shared,
|
|
28
|
+
# válido en RMQ 3.x y 4.x). Specs que necesitan colas efímeras usan
|
|
29
|
+
# `TEST_WORKER_QUEUE_OPTS` desde `spec/support/integration_helper.rb`.
|
|
26
30
|
config.exchange_options = { durable: false, auto_delete: true }
|
|
27
|
-
config.queue_options = { exclusive: false, durable: false, auto_delete: true }
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
TEST_POOL ||= ConnectionPool.new(size: 5, timeout: 5) { BugBunny.create_connection }
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require 'timeout'
|
|
4
4
|
|
|
5
|
+
# Queue options usados por los workers de integración. \`exclusive: true\` evita
|
|
6
|
+
# la deprecación de \`transient_nonexcl_queues\` en RabbitMQ 4.x — la queue queda
|
|
7
|
+
# ligada a la conexión del worker y desaparece automáticamente al cerrarla.
|
|
8
|
+
# Sobreescribe la cascada de \`BugBunny.configuration.queue_options\`.
|
|
9
|
+
TEST_WORKER_QUEUE_OPTS = { exclusive: true, durable: false, auto_delete: true }.freeze unless defined?(TEST_WORKER_QUEUE_OPTS)
|
|
10
|
+
|
|
5
11
|
# Helpers compartidos para specs de integración con RabbitMQ real.
|
|
6
12
|
# Incluido automáticamente en todos los specs marcados con :integration.
|
|
7
13
|
RSpec.shared_context 'integration helpers' do
|
|
@@ -31,6 +37,7 @@ RSpec.shared_context 'integration helpers' do
|
|
|
31
37
|
exchange_name: exchange,
|
|
32
38
|
exchange_type: exchange_type,
|
|
33
39
|
routing_key: routing_key,
|
|
40
|
+
queue_opts: TEST_WORKER_QUEUE_OPTS,
|
|
34
41
|
block: true
|
|
35
42
|
)
|
|
36
43
|
rescue StandardError => e
|
|
@@ -57,7 +64,7 @@ RSpec.shared_context 'integration helpers' do
|
|
|
57
64
|
worker_thread = Thread.new do
|
|
58
65
|
ch = conn.create_channel
|
|
59
66
|
x = ch.public_send(exchange_type, exchange, BugBunny.configuration.exchange_options)
|
|
60
|
-
q = ch.queue(queue,
|
|
67
|
+
q = ch.queue(queue, TEST_WORKER_QUEUE_OPTS)
|
|
61
68
|
q.bind(x, routing_key: routing_key)
|
|
62
69
|
q.subscribe(block: true) do |delivery, props, body|
|
|
63
70
|
messages << { body: body, routing_key: delivery.routing_key, headers: props.headers }
|
|
@@ -215,6 +215,78 @@ RSpec.describe BugBunny::Client, 'session pooling' do
|
|
|
215
215
|
end
|
|
216
216
|
end
|
|
217
217
|
|
|
218
|
+
describe 'warn_return_raise_misuse' do
|
|
219
|
+
let(:log_io) { StringIO.new }
|
|
220
|
+
|
|
221
|
+
before do
|
|
222
|
+
@prev_logger = BugBunny.configuration.logger
|
|
223
|
+
BugBunny.configuration.logger = Logger.new(log_io).tap { |l| l.level = Logger::WARN }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
after do
|
|
227
|
+
BugBunny.configuration.logger = @prev_logger
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def stub_producer_to_noop
|
|
231
|
+
allow_any_instance_of(BugBunny::Producer).to receive(:confirmed) { { 'status' => 202, 'body' => nil } }
|
|
232
|
+
allow_any_instance_of(BugBunny::Producer).to receive(:fire) { { 'status' => 202, 'body' => nil } }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it 'logea warning cuando return_raise:true se pasa sin confirmed' do
|
|
236
|
+
stub_producer_to_noop
|
|
237
|
+
client = described_class.new(pool: fake_pool(fake_conn))
|
|
238
|
+
|
|
239
|
+
client.publish('foo', exchange: 'x', exchange_type: 'direct',
|
|
240
|
+
return_raise: true, mandatory: true)
|
|
241
|
+
|
|
242
|
+
expect(log_io.string).to include('event=client.return_raise_ignored')
|
|
243
|
+
expect(log_io.string).to include('delivery_mode=publish')
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
it 'logea warning cuando return_raise:true se pasa sin mandatory' do
|
|
247
|
+
stub_producer_to_noop
|
|
248
|
+
client = described_class.new(pool: fake_pool(fake_conn))
|
|
249
|
+
|
|
250
|
+
client.publish('foo', exchange: 'x', exchange_type: 'direct',
|
|
251
|
+
return_raise: true, confirmed: true)
|
|
252
|
+
|
|
253
|
+
expect(log_io.string).to include('event=client.return_raise_ignored')
|
|
254
|
+
expect(log_io.string).to include('mandatory=false')
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it 'NO logea warning cuando confirmed+mandatory se setean via block API' do
|
|
258
|
+
stub_producer_to_noop
|
|
259
|
+
client = described_class.new(pool: fake_pool(fake_conn))
|
|
260
|
+
|
|
261
|
+
client.publish('foo', exchange: 'x', exchange_type: 'direct', return_raise: true) do |req|
|
|
262
|
+
req.delivery_mode = :confirmed
|
|
263
|
+
req.mandatory = true
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
expect(log_io.string).not_to include('client.return_raise_ignored')
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
it 'NO logea warning cuando return_raise no fue seteado per-request (deja el default global)' do
|
|
270
|
+
stub_producer_to_noop
|
|
271
|
+
client = described_class.new(pool: fake_pool(fake_conn))
|
|
272
|
+
|
|
273
|
+
# Default global es true, pero el caller no fue explícito → no warneamos
|
|
274
|
+
client.publish('foo', exchange: 'x', exchange_type: 'direct')
|
|
275
|
+
|
|
276
|
+
expect(log_io.string).not_to include('client.return_raise_ignored')
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it 'NO logea warning cuando confirmed+mandatory+return_raise:true coexisten' do
|
|
280
|
+
stub_producer_to_noop
|
|
281
|
+
client = described_class.new(pool: fake_pool(fake_conn))
|
|
282
|
+
|
|
283
|
+
client.publish('foo', exchange: 'x', exchange_type: 'direct',
|
|
284
|
+
confirmed: true, mandatory: true, return_raise: true)
|
|
285
|
+
|
|
286
|
+
expect(log_io.string).not_to include('client.return_raise_ignored')
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
218
290
|
describe 'Session no se cierra entre requests' do
|
|
219
291
|
it 'no invoca close en la Session al terminar el request' do
|
|
220
292
|
conn = fake_conn
|