bug_bunny 4.13.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 +47 -0
- data/README.md +51 -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 +154 -7
- 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 +73 -7
- data/skill/references/client-middleware.md +66 -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 +266 -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.
|
|
@@ -97,6 +99,39 @@ BugBunny implementa las [OpenTelemetry semantic conventions for messaging](https
|
|
|
97
99
|
- **Consumer:** Extrae los campos de los logs estructurados sin mutar los headers originales. Los eventos `consumer.message_received` y `consumer.message_processed` incluyen estos campos automáticamente.
|
|
98
100
|
- **RPC Reply:** El consumer inyecta los mismos campos en el reply para cerrar el ciclo de traza del lado del cliente.
|
|
99
101
|
|
|
102
|
+
### Eventos de log y duraciones internas
|
|
103
|
+
|
|
104
|
+
BugBunny mide y emite duraciones automáticamente. **No envolver llamadas a `client.publish` con `Process.clock_gettime` en código de aplicación** — duplica el trabajo. Las duraciones siguen las [OpenTelemetry metric semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/) (`duration_s` como `Float` en segundos).
|
|
105
|
+
|
|
106
|
+
| Evento | Nivel | Emitido por | Campos clave |
|
|
107
|
+
|---|---|---|---|
|
|
108
|
+
| `producer.publish` | INFO | `Producer#publish_message` (pre) | `method`, `path`, `messaging_*` |
|
|
109
|
+
| `producer.publish_payload` | INFO | `Producer#publish_message` | `payload` (truncado), `body_size` |
|
|
110
|
+
| `producer.publish_detail` | DEBUG | `Producer#publish_message` | `exchange_opts` final |
|
|
111
|
+
| `producer.published` | INFO | `Producer#publish_message` (post) | `method`, `path`, `routing_key`, `messaging_message_id`, **`duration_s`** (publish solo) |
|
|
112
|
+
| `producer.confirmed` | INFO | `Producer#confirmed` (post-ACK) | `method`, `path`, `routing_key`, **`publish_duration_s`**, **`confirm_duration_s`**, **`duration_s`** (total) |
|
|
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) |
|
|
116
|
+
| `producer.rpc_waiting` | DEBUG | `Producer#rpc` | `messaging_message_id`, `timeout_s` |
|
|
117
|
+
| `producer.rpc_response_received` | INFO | `Producer#rpc` (reply recibido) | `method`, `path`, **`duration_s`** (round-trip total), `response_body` |
|
|
118
|
+
| `producer.rpc_response_orphaned` | WARN | reply listener | `correlation_id` |
|
|
119
|
+
| `consumer.message_received` | INFO | `Consumer#process_message` | `method`, `path`, `messaging_*` |
|
|
120
|
+
| `consumer.message_processed` | INFO | `Consumer#process_message` (post) | `response_status`, **`duration_s`**, `controller`, `action`, `messaging_*` |
|
|
121
|
+
| `consumer.execution_error` | ERROR | `Consumer#process_message` (rescue) | **`duration_s`**, `error_class`, `error_message` |
|
|
122
|
+
| `consumer.route_not_found` | WARN | `Consumer#process_message` | `method`, `path` |
|
|
123
|
+
| `consumer.connection_error` | ERROR | `Consumer#subscribe` (retry loop) | `attempt_count`, `retry_in_s`, `error_*` |
|
|
124
|
+
| `session.broker_return` | WARN | `Session` (mandatory unrouted) | `reply_code`, `reply_text`, `exchange`, `routing_key` |
|
|
125
|
+
|
|
126
|
+
**Resumen de qué mide cada `duration_s`:**
|
|
127
|
+
|
|
128
|
+
- `producer.published.duration_s` — solo el `basic_publish` (TCP enqueue al broker).
|
|
129
|
+
- `producer.confirmed.publish_duration_s` — el publish.
|
|
130
|
+
- `producer.confirmed.confirm_duration_s` — la espera del ACK del broker (`wait_for_confirms`).
|
|
131
|
+
- `producer.confirmed.duration_s` — total (publish + ACK).
|
|
132
|
+
- `producer.rpc_response_received.duration_s` — round-trip RPC completo (publish + procesamiento remoto + reply).
|
|
133
|
+
- `consumer.message_processed.duration_s` — procesamiento server-side (router + controller + reply).
|
|
134
|
+
|
|
100
135
|
---
|
|
101
136
|
|
|
102
137
|
## API: Configuración Global
|
|
@@ -129,8 +164,14 @@ BugBunny.configure do |config|
|
|
|
129
164
|
config.rpc_reply_headers = -> { { 'X-Trace-Id' => Tracer.id } }
|
|
130
165
|
config.on_rpc_reply = ->(h) { Tracer.hydrate(h['X-Trace-Id']) }
|
|
131
166
|
|
|
132
|
-
# Publisher Confirms —
|
|
133
|
-
#
|
|
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.
|
|
134
175
|
# Firma: ->(return_info, properties, body)
|
|
135
176
|
config.on_return = ->(ri, _props, body) { MyAlerts.unroutable(rk: ri.routing_key, body: body) }
|
|
136
177
|
|
|
@@ -202,12 +243,33 @@ client.publish('events', method: :post, body: { type: 'order.placed' })
|
|
|
202
243
|
client.publish('acct.start', exchange: 'acct_x', body: payload,
|
|
203
244
|
confirmed: true, mandatory: true, confirm_timeout: 0.5)
|
|
204
245
|
# → { 'status' => 202, 'body' => nil } # broker ACK confirmado
|
|
205
|
-
# Si broker NACK: logea `producer.confirms_nacked` y raise BugBunny::PublishNacked
|
|
206
|
-
# (default — opt-out con config.nack_raise = false o nack_raise: false per request).
|
|
207
|
-
# Si timeout: raise BugBunny::RequestTimeout.
|
|
208
|
-
# Si mandatory + no ruteable: dispara `Configuration#on_return` (default: logea `session.broker_return`).
|
|
209
246
|
```
|
|
210
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
|
+
|
|
211
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.
|
|
212
274
|
|
|
213
275
|
---
|
|
@@ -226,6 +288,10 @@ Cada slot del pool cachea su `Session` y `Producer` durante su vida útil. Esto
|
|
|
226
288
|
### ¿Cómo funciona la cascada de configuración?
|
|
227
289
|
3 niveles: Gem defaults → Global config (`BugBunny.configure`) → Per-request (args en `client.request` o `Resource.with`). Se mergean con `merge`.
|
|
228
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
|
+
|
|
229
295
|
### ¿Cómo funciona fork safety?
|
|
230
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.
|
|
231
297
|
|
|
@@ -59,19 +59,22 @@ El `Producer` es usado internamente por el `Client`. Implementa tres patrones de
|
|
|
59
59
|
- Reply listener (`basic_consume`) auto-iniciado en el primer RPC.
|
|
60
60
|
- Double-checked locking mutex para seguridad del listener.
|
|
61
61
|
- Timeout lanza `BugBunny::RequestTimeout`.
|
|
62
|
+
- **Emite `producer.rpc_response_received` (INFO) con `duration_s` = round-trip total** (publish + procesamiento remoto + reply). No medir en código de aplicación.
|
|
62
63
|
|
|
63
64
|
### Fire-and-Forget (`Producer#fire`)
|
|
64
65
|
|
|
65
66
|
- Publica en el exchange y retorna `{ 'status' => 202 }` inmediatamente.
|
|
66
67
|
- Sin confirmación de procesamiento.
|
|
68
|
+
- **Emite `producer.published` (INFO) con `duration_s`** = solo el `basic_publish` (TCP enqueue al broker).
|
|
67
69
|
|
|
68
70
|
### Confirmed (`Producer#confirmed`)
|
|
69
71
|
|
|
70
72
|
- Publica y bloquea hasta `channel.wait_for_confirms` del broker.
|
|
71
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`.
|
|
72
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.
|
|
73
|
-
- 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.
|
|
74
76
|
- Errores del canal se envuelven en `BugBunny::CommunicationError`; errores `BugBunny::Error` pre-existentes se propagan sin envolver.
|
|
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.
|
|
75
78
|
|
|
76
79
|
## Middleware Stack (Client-side, Onion Architecture)
|
|
77
80
|
|
|
@@ -180,18 +183,35 @@ client.publish('x', confirmed: true, mandatory: true)
|
|
|
180
183
|
▼
|
|
181
184
|
Producer#confirmed
|
|
182
185
|
│
|
|
183
|
-
├──>
|
|
186
|
+
├──> setup_return_listener (si return_raise? → Session#register_return_listener(cid))
|
|
187
|
+
│ │
|
|
188
|
+
│ └─ @session.@pending_returns[cid] = { event:, info: nil }
|
|
184
189
|
│
|
|
185
|
-
├──>
|
|
190
|
+
├──> publish_message (exchange.publish con mandatory: true, correlation_id auto-asignado)
|
|
186
191
|
│
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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)
|
|
195
215
|
```
|
|
196
216
|
|
|
197
217
|
### `Configuration#on_return`
|
|
@@ -226,14 +246,43 @@ Excepciones del callback se capturan y se logean como `session.on_return_failed`
|
|
|
226
246
|
| Auditoría, billing, eventos críticos | `:confirmed` (con `mandatory: true` si es ruteable) |
|
|
227
247
|
| Request-response síncrono | `:rpc` |
|
|
228
248
|
|
|
229
|
-
`: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.
|
|
230
279
|
|
|
231
280
|
## Cascada de Configuración (3 niveles)
|
|
232
281
|
|
|
233
282
|
```ruby
|
|
234
|
-
# Level 1: Gem defaults
|
|
235
|
-
{ durable: false, auto_delete: false }
|
|
236
|
-
{ 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)
|
|
237
286
|
|
|
238
287
|
# Level 2: Global config
|
|
239
288
|
BugBunny.configure { |c| c.exchange_options = { durable: true } }
|
|
@@ -243,3 +292,5 @@ client.request('users', exchange_options: { durable: true })
|
|
|
243
292
|
|
|
244
293
|
# Merge final: Level1.merge(Level2).merge(Level3)
|
|
245
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)
|