bug_bunny 5.1.1 → 5.2.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/AGENTS.md +1 -1
- data/CHANGELOG.md +15 -0
- data/CLAUDE.md +1 -1
- data/README.md +28 -1
- data/docs/behavior/behavior.md +37 -9
- data/docs/config/configuracion.md +54 -47
- data/docs/consumed/rabbitmq.md +4 -4
- data/docs/errors/errors.md +4 -4
- data/docs/release/release.md +9 -7
- data/docs/test/testing.md +16 -6
- data/lib/bug_bunny/configuration.rb +22 -2
- data/lib/bug_bunny/consumer.rb +184 -31
- data/lib/bug_bunny/drain_tracker.rb +54 -0
- data/lib/bug_bunny/version.rb +1 -1
- data/lib/bug_bunny.rb +1 -0
- data/skill/SKILL.md +11 -4
- data/skill/references/consumer.md +27 -1
- data/skill/references/errores.md +1 -1
- data/skill/references/routing.md +1 -1
- data/spec/integration/drain_spec.rb +177 -0
- data/spec/unit/configuration_spec.rb +27 -1
- data/spec/unit/drain_tracker_spec.rb +37 -0
- metadata +6 -3
data/lib/bug_bunny/consumer.rb
CHANGED
|
@@ -41,6 +41,16 @@ module BugBunny
|
|
|
41
41
|
new(connection).subscribe(**args)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Método de conveniencia para instanciar y drenar en un solo paso.
|
|
45
|
+
#
|
|
46
|
+
# @param connection [Bunny::Session] Una conexión TCP activa a RabbitMQ. Es de quien
|
|
47
|
+
# llama: `drain` no la cierra (ver {#drain}).
|
|
48
|
+
# @param args [Hash] Argumentos que se pasarán al método {#drain}.
|
|
49
|
+
# @return [Integer] Cantidad de mensajes procesados.
|
|
50
|
+
def self.drain(connection:, **args)
|
|
51
|
+
new(connection).drain(**args)
|
|
52
|
+
end
|
|
53
|
+
|
|
44
54
|
# Inicializa un nuevo consumidor.
|
|
45
55
|
#
|
|
46
56
|
# @param connection [Bunny::Session] Conexión nativa de Bunny.
|
|
@@ -68,40 +78,14 @@ module BugBunny
|
|
|
68
78
|
attempt = 0
|
|
69
79
|
|
|
70
80
|
begin
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
q.bind(x, routing_key: routing_key)
|
|
75
|
-
|
|
76
|
-
# 📊 LOGGING DE OBSERVABILIDAD: Calculamos las opciones finales para mostrarlas en consola
|
|
77
|
-
final_x_opts = BugBunny::Session::DEFAULT_EXCHANGE_OPTIONS
|
|
78
|
-
.merge(BugBunny.configuration.exchange_options || {})
|
|
79
|
-
.merge(exchange_opts || {})
|
|
80
|
-
final_q_opts = BugBunny::Session::DEFAULT_QUEUE_OPTIONS
|
|
81
|
-
.merge(BugBunny.configuration.queue_options || {})
|
|
82
|
-
.merge(queue_opts || {})
|
|
83
|
-
|
|
84
|
-
safe_log(:info, 'consumer.start', queue: queue_name, queue_opts: final_q_opts)
|
|
85
|
-
safe_log(:info, 'consumer.bound', exchange: exchange_name, exchange_type: exchange_type,
|
|
86
|
-
routing_key: routing_key, exchange_opts: final_x_opts)
|
|
81
|
+
queue = declare_infrastructure(queue_name: queue_name, exchange_name: exchange_name,
|
|
82
|
+
routing_key: routing_key, exchange_type: exchange_type,
|
|
83
|
+
exchange_opts: exchange_opts, queue_opts: queue_opts)
|
|
87
84
|
|
|
88
85
|
start_health_check(queue_name)
|
|
89
86
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
logger = BugBunny.configuration.logger
|
|
93
|
-
|
|
94
|
-
core = lambda {
|
|
95
|
-
if logger.respond_to?(:tagged)
|
|
96
|
-
logger.tagged(trace_id) { process_message(delivery_info, properties, body) }
|
|
97
|
-
elsif defined?(Rails) && Rails.logger.respond_to?(:tagged)
|
|
98
|
-
Rails.logger.tagged(trace_id) { process_message(delivery_info, properties, body) }
|
|
99
|
-
else
|
|
100
|
-
process_message(delivery_info, properties, body)
|
|
101
|
-
end
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
BugBunny.configuration.consumer_middlewares.call(delivery_info, properties, body, &core)
|
|
87
|
+
queue.subscribe(manual_ack: true, block: block) do |delivery_info, properties, body|
|
|
88
|
+
handle_delivery(delivery_info, properties, body)
|
|
105
89
|
end
|
|
106
90
|
rescue StandardError => e
|
|
107
91
|
attempt += 1
|
|
@@ -126,6 +110,71 @@ module BugBunny
|
|
|
126
110
|
shutdown
|
|
127
111
|
end
|
|
128
112
|
|
|
113
|
+
# Consume la cola hasta vaciarla y retorna: el modo para correr un consumidor como job
|
|
114
|
+
# (Sidekiq, un Job de k8s) en vez de como un proceso eterno.
|
|
115
|
+
#
|
|
116
|
+
# A diferencia de `subscribe(block: false)`, que retorna al instante, este método
|
|
117
|
+
# bloquea mientras haya mensajes y vuelve cuando la cola queda quieta:
|
|
118
|
+
#
|
|
119
|
+
# 1. Si la cola tiene 0 mensajes al arrancar, retorna `0` sin esperar.
|
|
120
|
+
# 2. Si hay mensajes, se suscribe con `manual_ack: true` respetando `channel_prefetch`,
|
|
121
|
+
# igual que el modo bloqueante.
|
|
122
|
+
# 3. Termina cuando pasaron `drain_idle_timeout` segundos (ver {Configuration}) sin
|
|
123
|
+
# entregas y no queda ningún mensaje en proceso. Cancela el consumer y cierra el
|
|
124
|
+
# canal ({#shutdown}).
|
|
125
|
+
#
|
|
126
|
+
# **Mensajes que llegan mientras drena:** un mensaje que llega antes de que venza la
|
|
127
|
+
# ventana de inactividad se procesa en esta vuelta; lo que llega después queda para
|
|
128
|
+
# la próxima corrida. Una entrega ya recibida cuando se cancela se procesa antes de
|
|
129
|
+
# volver (Bunny drena su work pool al cancelar); si igual no llegara a ack-earse, vuelve
|
|
130
|
+
# a la cola (at-least-once, nunca se pierde).
|
|
131
|
+
#
|
|
132
|
+
# **Con un flujo sostenido, no retorna.** Si los mensajes llegan más seguido que
|
|
133
|
+
# `drain_idle_timeout`, la ventana nunca vence: la duración la decide el productor, no
|
|
134
|
+
# la cola que había al arrancar. Acotalo desde afuera (el timeout del job de Sidekiq,
|
|
135
|
+
# `activeDeadlineSeconds` en k8s), sabiendo que cortarlo a mitad de una entrega da
|
|
136
|
+
# redelivery.
|
|
137
|
+
#
|
|
138
|
+
# **Una entrega que falla sale de la cola.** Si un middleware o el manejo de error
|
|
139
|
+
# levanta antes del ack, se rechaza sin requeue, igual que los errores de
|
|
140
|
+
# `process_message`; no queda ocupando el prefetch.
|
|
141
|
+
#
|
|
142
|
+
# No tiene loop de reconexión ni health check: un job que falla lo reintenta el
|
|
143
|
+
# framework que lo corre. (Bunny sí recupera la conexión por su cuenta si
|
|
144
|
+
# `automatically_recover` está activo.)
|
|
145
|
+
#
|
|
146
|
+
# **La conexión es de quien llama:** `drain` cierra su canal al volver, no la conexión,
|
|
147
|
+
# porque puede ser compartida (un pool, la del publisher). Si la creaste para esta
|
|
148
|
+
# corrida, cerrala vos (ver el README).
|
|
149
|
+
#
|
|
150
|
+
# @param queue_name [String] Nombre de la cola a drenar.
|
|
151
|
+
# @param exchange_name [String] Nombre del exchange al cual enlazar la cola.
|
|
152
|
+
# @param routing_key [String] Patrón de enrutamiento (ej: 'users.*').
|
|
153
|
+
# @param exchange_type [String] Tipo de exchange ('direct', 'topic', 'fanout').
|
|
154
|
+
# @param exchange_opts [Hash] Opciones adicionales para el exchange (durable, auto_delete).
|
|
155
|
+
# @param queue_opts [Hash] Opciones adicionales para la cola (durable, auto_delete).
|
|
156
|
+
# @return [Integer] Cantidad de mensajes procesados en esta vuelta (incluye los rechazados:
|
|
157
|
+
# también salieron de la cola).
|
|
158
|
+
def drain(queue_name:, exchange_name:, routing_key:, exchange_type: 'direct', exchange_opts: {},
|
|
159
|
+
queue_opts: {})
|
|
160
|
+
started_at = monotonic_now
|
|
161
|
+
queue = declare_infrastructure(queue_name: queue_name, exchange_name: exchange_name,
|
|
162
|
+
routing_key: routing_key, exchange_type: exchange_type,
|
|
163
|
+
exchange_opts: exchange_opts, queue_opts: queue_opts)
|
|
164
|
+
|
|
165
|
+
pending_count = queue.message_count
|
|
166
|
+
safe_log(:info, 'consumer.drain_start', queue: queue_name, pending_count: pending_count)
|
|
167
|
+
return 0 if pending_count.zero?
|
|
168
|
+
|
|
169
|
+
processed_count = consume_until_idle(queue)
|
|
170
|
+
|
|
171
|
+
safe_log(:info, 'consumer.drain_finished', queue: queue_name, processed_count: processed_count,
|
|
172
|
+
duration_s: (monotonic_now - started_at).round(3))
|
|
173
|
+
processed_count
|
|
174
|
+
ensure
|
|
175
|
+
shutdown
|
|
176
|
+
end
|
|
177
|
+
|
|
129
178
|
# Detiene el health check timer y cierra el canal de forma ordenada.
|
|
130
179
|
#
|
|
131
180
|
# Llamar explícitamente al hacer shutdown del worker (SIGTERM, at_exit, etc.).
|
|
@@ -141,6 +190,110 @@ module BugBunny
|
|
|
141
190
|
|
|
142
191
|
private
|
|
143
192
|
|
|
193
|
+
# Declara exchange y cola, los enlaza y loguea las opciones efectivas.
|
|
194
|
+
#
|
|
195
|
+
# @param queue_name [String] Nombre de la cola.
|
|
196
|
+
# @param exchange_name [String] Nombre del exchange.
|
|
197
|
+
# @param routing_key [String] Patrón de enrutamiento del binding.
|
|
198
|
+
# @param exchange_type [String] Tipo de exchange.
|
|
199
|
+
# @param exchange_opts [Hash] Opciones del exchange para esta llamada.
|
|
200
|
+
# @param queue_opts [Hash] Opciones de la cola para esta llamada.
|
|
201
|
+
# @return [Bunny::Queue] La cola declarada y enlazada.
|
|
202
|
+
def declare_infrastructure(queue_name:, exchange_name:, routing_key:, exchange_type:, exchange_opts:, queue_opts:)
|
|
203
|
+
exchange = session.exchange(name: exchange_name, type: exchange_type, opts: exchange_opts)
|
|
204
|
+
queue = session.queue(queue_name, queue_opts)
|
|
205
|
+
queue.bind(exchange, routing_key: routing_key)
|
|
206
|
+
|
|
207
|
+
# 📊 LOGGING DE OBSERVABILIDAD: Calculamos las opciones finales para mostrarlas en consola
|
|
208
|
+
effective_exchange_opts = BugBunny::Session::DEFAULT_EXCHANGE_OPTIONS
|
|
209
|
+
.merge(BugBunny.configuration.exchange_options || {})
|
|
210
|
+
.merge(exchange_opts || {})
|
|
211
|
+
effective_queue_opts = BugBunny::Session::DEFAULT_QUEUE_OPTIONS
|
|
212
|
+
.merge(BugBunny.configuration.queue_options || {})
|
|
213
|
+
.merge(queue_opts || {})
|
|
214
|
+
|
|
215
|
+
safe_log(:info, 'consumer.start', queue: queue_name, queue_opts: effective_queue_opts)
|
|
216
|
+
safe_log(:info, 'consumer.bound', exchange: exchange_name, exchange_type: exchange_type,
|
|
217
|
+
routing_key: routing_key, exchange_opts: effective_exchange_opts)
|
|
218
|
+
queue
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Pasa una entrega por los middlewares y el logger con tags, y la procesa.
|
|
222
|
+
#
|
|
223
|
+
# @param delivery_info [Bunny::DeliveryInfo, Bunny::GetResponse] Metadatos de entrega.
|
|
224
|
+
# @param properties [Bunny::MessageProperties] Headers y propiedades AMQP.
|
|
225
|
+
# @param body [String] El payload crudo del mensaje.
|
|
226
|
+
# @return [void]
|
|
227
|
+
def handle_delivery(delivery_info, properties, body)
|
|
228
|
+
settled = false
|
|
229
|
+
|
|
230
|
+
core = lambda {
|
|
231
|
+
with_log_tags(properties.correlation_id) { process_message(delivery_info, properties, body) }
|
|
232
|
+
settled = true
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
BugBunny.configuration.consumer_middlewares.call(delivery_info, properties, body, &core)
|
|
236
|
+
rescue StandardError => e
|
|
237
|
+
settle_failed_delivery(delivery_info, settled, e)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Corre el bloque con el `correlation_id` como tag del logger, si el logger los soporta.
|
|
241
|
+
#
|
|
242
|
+
# @param trace_id [String, nil]
|
|
243
|
+
# @return [Object] lo que devuelva el bloque
|
|
244
|
+
def with_log_tags(trace_id, &block)
|
|
245
|
+
logger = BugBunny.configuration.logger
|
|
246
|
+
if logger.respond_to?(:tagged)
|
|
247
|
+
logger.tagged(trace_id, &block)
|
|
248
|
+
elsif defined?(Rails) && Rails.logger.respond_to?(:tagged)
|
|
249
|
+
Rails.logger.tagged(trace_id, &block)
|
|
250
|
+
else
|
|
251
|
+
yield
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Una entrega cuyo procesamiento levantó FUERA del rescue de `process_message` (un
|
|
256
|
+
# middleware, o `handle_fatal_error`) quedaba sin ack ni reject: con prefetch 1 ocupaba
|
|
257
|
+
# el único lugar, el broker dejaba de entregar y `drain` volvía "con éxito" con la cola
|
|
258
|
+
# llena. Se rechaza sin requeue, igual que `process_message` hace con sus propios
|
|
259
|
+
# errores. Si ya se había resuelto (el error vino después del ack), sólo se loguea:
|
|
260
|
+
# rechazar un tag ya confirmado cierra el canal.
|
|
261
|
+
#
|
|
262
|
+
# @param delivery_info [Bunny::DeliveryInfo, Bunny::GetResponse] Metadatos de entrega.
|
|
263
|
+
# @param settled [Boolean] si `process_message` ya hizo ack o reject.
|
|
264
|
+
# @param error [StandardError] lo que levantó.
|
|
265
|
+
# @return [void]
|
|
266
|
+
def settle_failed_delivery(delivery_info, settled, error)
|
|
267
|
+
safe_log(:error, 'consumer.delivery_failed', settled: settled, **exception_metadata(error))
|
|
268
|
+
session.channel.reject(delivery_info.delivery_tag, false) unless settled
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Se suscribe sin bloquear y espera a que la cola quede quieta `drain_idle_timeout`
|
|
272
|
+
# segundos sin ningún mensaje en proceso; después cancela la suscripción.
|
|
273
|
+
#
|
|
274
|
+
# @param queue [Bunny::Queue] La cola ya declarada y enlazada.
|
|
275
|
+
# @return [Integer] Cantidad de mensajes procesados.
|
|
276
|
+
def consume_until_idle(queue)
|
|
277
|
+
idle_timeout = BugBunny.configuration.drain_idle_timeout
|
|
278
|
+
poll_interval = BugBunny.configuration.drain_poll_interval
|
|
279
|
+
tracker = BugBunny::DrainTracker.new
|
|
280
|
+
|
|
281
|
+
subscription = queue.subscribe(manual_ack: true, block: false) do |delivery_info, properties, body|
|
|
282
|
+
tracker.track { handle_delivery(delivery_info, properties, body) }
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
sleep poll_interval until tracker.idle?(idle_timeout)
|
|
286
|
+
|
|
287
|
+
subscription.cancel
|
|
288
|
+
sleep poll_interval while tracker.busy?
|
|
289
|
+
tracker.processed
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# @return [Float] Reloj monotónico en segundos.
|
|
293
|
+
def monotonic_now
|
|
294
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
295
|
+
end
|
|
296
|
+
|
|
144
297
|
# Procesa un mensaje individual recibido de la cola orquestando el ruteo declarativo.
|
|
145
298
|
#
|
|
146
299
|
# Realiza la orquestación completa: Parsing -> Reconocimiento de Ruta -> Ejecución -> Respuesta.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
|
|
5
|
+
module BugBunny
|
|
6
|
+
# Lleva la cuenta de un drenaje de {Consumer#drain}: cuántos mensajes se procesaron,
|
|
7
|
+
# cuántos están en proceso y cuándo fue la última actividad.
|
|
8
|
+
#
|
|
9
|
+
# Es thread-safe: las entregas corren en el work pool de Bunny y la espera en el hilo
|
|
10
|
+
# que llamó a `drain`.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
class DrainTracker
|
|
14
|
+
# @param clock [#call] Reloj monotónico en segundos (inyectable para tests).
|
|
15
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
16
|
+
@clock = clock
|
|
17
|
+
@processed = Concurrent::AtomicFixnum.new(0)
|
|
18
|
+
@in_flight = Concurrent::AtomicFixnum.new(0)
|
|
19
|
+
@last_activity = Concurrent::AtomicReference.new(clock.call)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Envuelve el procesamiento de una entrega. Cuenta la entrega como procesada aunque
|
|
23
|
+
# el bloque levante: {Consumer#handle_delivery} rechaza la que falla antes del ack, así
|
|
24
|
+
# que en todos los caminos el mensaje ya salió de la cola (ack o reject).
|
|
25
|
+
#
|
|
26
|
+
# @yield El procesamiento de la entrega.
|
|
27
|
+
# @return [void]
|
|
28
|
+
def track
|
|
29
|
+
@in_flight.increment
|
|
30
|
+
yield
|
|
31
|
+
ensure
|
|
32
|
+
@processed.increment
|
|
33
|
+
@last_activity.set(@clock.call)
|
|
34
|
+
@in_flight.decrement
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Boolean] `true` si hay alguna entrega en proceso.
|
|
38
|
+
def busy?
|
|
39
|
+
@in_flight.value.positive?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param idle_timeout [Numeric] Segundos sin actividad.
|
|
43
|
+
# @return [Boolean] `true` si no hay nada en proceso y pasaron `idle_timeout` segundos
|
|
44
|
+
# desde la última actividad.
|
|
45
|
+
def idle?(idle_timeout)
|
|
46
|
+
!busy? && @clock.call - @last_activity.get >= idle_timeout
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Integer] Entregas procesadas hasta ahora.
|
|
50
|
+
def processed
|
|
51
|
+
@processed.value
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/bug_bunny/version.rb
CHANGED
data/lib/bug_bunny.rb
CHANGED
|
@@ -16,6 +16,7 @@ require_relative 'bug_bunny/middleware/raise_error'
|
|
|
16
16
|
require_relative 'bug_bunny/middleware/json_response'
|
|
17
17
|
require_relative 'bug_bunny/client'
|
|
18
18
|
require_relative 'bug_bunny/session'
|
|
19
|
+
require_relative 'bug_bunny/drain_tracker'
|
|
19
20
|
require_relative 'bug_bunny/consumer'
|
|
20
21
|
require_relative 'bug_bunny/request'
|
|
21
22
|
require_relative 'bug_bunny/producer'
|
data/skill/SKILL.md
CHANGED
|
@@ -19,7 +19,9 @@ Gema Ruby: capa de routing RESTful sobre AMQP/RabbitMQ. Microservicios se comuni
|
|
|
19
19
|
|
|
20
20
|
## Contrato resumido (piso mínimo)
|
|
21
21
|
|
|
22
|
-
> Resume el contrato de **`bug_bunny` 5.
|
|
22
|
+
> Resume el contrato de **`bug_bunny` 5.2.0** (anclado a `v5.2.0`). Suficiente para el uso típico sin abrir el detalle; el detalle version-locked está en el **Índice de artefactos** de abajo. Antipatrones/API completa: más abajo (embebido interim, ver Cobertura y fronteras).
|
|
23
|
+
>
|
|
24
|
+
> **Si venís de 5.1.x, un cambio de conducta (5.2.0):** una entrega cuyo middleware —o `handle_fatal_error`— levanta **antes del ack** ahora se **rechaza sin requeue** (evento `consumer.delivery_failed`), en `subscribe` y en `drain`. Antes quedaba sin ack ni reject y trababa el consumidor. Si un middleware levantaba a propósito para que el mensaje se reintentara, ahora el mensaje se pierde: el reintento es tuyo. Detalle en [`docs/behavior/behavior.md`](../docs/behavior/behavior.md).
|
|
23
25
|
>
|
|
24
26
|
> **Si venís de 4.x, dos breaking a mirar antes de subir:** `5.0.0` eliminó la constante pública `BugBunny::SecurityError` (un `rescue BugBunny::SecurityError` que sobreviva revienta con `NameError` y **enmascara la excepción original**) y `4.18.0` cambió el wrapping `Bunny::Exception` → `CommunicationError`. Detalle en `CHANGELOG.md`.
|
|
25
27
|
|
|
@@ -29,7 +31,7 @@ Gema Ruby: capa de routing RESTful sobre AMQP/RabbitMQ. Microservicios se comuni
|
|
|
29
31
|
|---|---|
|
|
30
32
|
| `BugBunny::Client` | `client.request(url, method: :get)` (RPC sync) · `client.publish(url, body:)` (fire-and-forget, 202) · `client.publish(url, confirmed: true, mandatory: true)` (publisher confirms) |
|
|
31
33
|
| `BugBunny::Resource` | ORM tipo AR: `self.exchange=` / `self.resource_name=` / `connection_pool=`; `find/where/create/save/destroy` |
|
|
32
|
-
| `BugBunny::Consumer` | `BugBunny::Consumer.subscribe(connection
|
|
34
|
+
| `BugBunny::Consumer` | `BugBunny::Consumer.subscribe(connection:, queue_name:, exchange_name:, routing_key:)` (loop bloqueante) · `BugBunny::Consumer.drain(...)` (mismos args; consume hasta que la cola queda quieta y retorna cuántos procesó — para correrlo como job, 5.2.0) |
|
|
33
35
|
| `BugBunny::Controller` | `before/around/after_action`, `rescue_from`, `render status:, json:` |
|
|
34
36
|
| `BugBunny.routes.draw` | `resources :x` · `namespace` · `member`/`collection` |
|
|
35
37
|
| `BugBunny.configure` | `host/port/username/password` · `rpc_timeout` (default 10) · `nack_raise`/`return_raise` (default `true`) · `on_return` |
|
|
@@ -56,11 +58,12 @@ client.publish('events', body: { type: 'x' }) # => { 'status' => 202 }
|
|
|
56
58
|
- `confirmed:true + mandatory:true` con `return_raise` (default `true`) → `PublishUnroutable` si no rutea.
|
|
57
59
|
- `BugBunny::Consumer.subscribe` requiere `connection:`. No correr el Consumer en threads de Puma (loop bloqueante).
|
|
58
60
|
- `exchange_options: { durable: true }` debe matchear la declaración del consumer, o `Bunny::PreconditionFailed`.
|
|
61
|
+
- **`drain` (5.2.0):** la conexión es **de quien llama** — `drain` cierra su canal, no la conexión; si la creás por corrida, cerrala (`ensure connection.close`) o cada corrida deja una abierta. Con un flujo sostenido más rápido que `drain_idle_timeout` **no retorna**: acotalo desde el job. Detalle en [`skill/references/consumer.md`](references/consumer.md) y [`docs/behavior/behavior.md`](../docs/behavior/behavior.md).
|
|
59
62
|
- **Errores de transporte (4.18+):** TCP fail, conn rota, canal cerrado → siempre `BugBunny::CommunicationError`. No rescatar `Bunny::TCPConnectionFailed`/`ConnectionClosedError` directo — quedó atrás de la frontera. La original sigue accesible vía `.cause`.
|
|
60
63
|
|
|
61
64
|
## Índice de artefactos (fuente de verdad)
|
|
62
65
|
|
|
63
|
-
El detalle vive en `docs/<capa>/` (modelo `dev-*`); esta skill **indexa y resume**, no duplica. Links relativos = version-locked (mismo tag del release, `v5.
|
|
66
|
+
El detalle vive en `docs/<capa>/` (modelo `dev-*`); esta skill **indexa y resume**, no duplica. Links relativos = version-locked (mismo tag del release, `v5.2.0`; `gemspec.files` incluye `docs/**`, así que estos archivos viajan dentro del `.gem` que ya tenés instalado).
|
|
64
67
|
|
|
65
68
|
| Capa | Artefacto | Estado |
|
|
66
69
|
|---|---|---|
|
|
@@ -252,6 +255,10 @@ BugBunny.configure do |config|
|
|
|
252
255
|
config.health_check_interval = 60
|
|
253
256
|
config.health_check_file = 'tmp/bb_health'
|
|
254
257
|
|
|
258
|
+
# Consumer.drain (5.2.0): segundos sin entregas para dar la cola por vacía, y cada cuánto se chequea
|
|
259
|
+
config.drain_idle_timeout = 5
|
|
260
|
+
config.drain_poll_interval = 0.1
|
|
261
|
+
|
|
255
262
|
# Routing
|
|
256
263
|
config.controller_namespace = 'BugBunny::Controllers'
|
|
257
264
|
end
|
|
@@ -428,7 +435,7 @@ Limitación de RSpec: `instance_double` valida que el método exista pero **no**
|
|
|
428
435
|
|
|
429
436
|
### Guard anti-RCE (403, no es excepción)
|
|
430
437
|
**Causa:** El mensaje intenta ejecutar un controlador que no hereda de `BugBunny::Controller`.
|
|
431
|
-
**Comportamiento:** El worker responde **403 Forbidden** + reject + log `event=consumer.security_violation` (`consumer.rb:
|
|
438
|
+
**Comportamiento:** El worker responde **403 Forbidden** + reject + log `event=consumer.security_violation` (`consumer.rb:375-381`); no levanta una excepción dedicada.
|
|
432
439
|
**Resolución:** Verificar la jerarquía de controladores y que `config.controller_namespace` coincida.
|
|
433
440
|
|
|
434
441
|
### BugBunny::RouteNotFoundError (404)
|
|
@@ -11,10 +11,36 @@ consumer = BugBunny::Consumer.subscribe(
|
|
|
11
11
|
exchange_type: 'topic',
|
|
12
12
|
exchange_opts: { durable: true },
|
|
13
13
|
queue_opts: { auto_delete: false },
|
|
14
|
-
block: true #
|
|
14
|
+
block: true # false retorna al instante y cierra el canal: no consume nada (#64)
|
|
15
15
|
)
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
## Drain (drenar y salir)
|
|
19
|
+
|
|
20
|
+
Para correr un consumidor **como job**: consume hasta que la cola queda quieta y retorna cuántos mensajes procesó (incluye los rechazados). Con la cola vacía retorna `0` sin esperar.
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
connection = BugBunny.create_connection
|
|
24
|
+
begin
|
|
25
|
+
processed_count = BugBunny::Consumer.drain(
|
|
26
|
+
connection: connection,
|
|
27
|
+
queue_name: 'my_app_queue',
|
|
28
|
+
exchange_name: 'my_exchange',
|
|
29
|
+
routing_key: 'users.*'
|
|
30
|
+
)
|
|
31
|
+
ensure
|
|
32
|
+
connection.close # drain cierra su canal, no la conexión: la conexión es de quien llama
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- Respeta `channel_prefetch`, igual que `subscribe`.
|
|
37
|
+
- Termina tras `drain_idle_timeout` segundos (default `5`) sin entregas y sin nada en proceso; lo chequea cada `drain_poll_interval` (default `0.1`).
|
|
38
|
+
- Un mensaje que llega dentro de esa ventana entra en esta vuelta; los posteriores, en la próxima corrida. Una entrega ya recibida al cancelar se procesa antes de volver; si igual no se ack-eara, vuelve a la cola (at-least-once).
|
|
39
|
+
- **Con un flujo sostenido no retorna**: si los mensajes llegan más seguido que `drain_idle_timeout`, la ventana nunca vence. Acotalo desde afuera (timeout del job).
|
|
40
|
+
- **Una entrega que falla sale de la cola**: si un middleware levanta antes del ack, se rechaza sin requeue y no traba el prefetch.
|
|
41
|
+
- **La conexión es de quien llama**: `drain` cierra su canal, no la conexión. Si la creaste para la corrida, cerrala (si no, cada corrida deja una abierta).
|
|
42
|
+
- **No** tiene loop de reconexión ni health check: si falla, lo reintenta el framework del job (Bunny sí recupera la conexión por su cuenta con `automatically_recover`).
|
|
43
|
+
|
|
18
44
|
## Flujo de Procesamiento
|
|
19
45
|
|
|
20
46
|
1. Escucha en la queue con `manual_ack: true`.
|
data/skill/references/errores.md
CHANGED
|
@@ -63,7 +63,7 @@ message, details } }`), parsealo en el boundary del servicio desde
|
|
|
63
63
|
|
|
64
64
|
### Guard anti-RCE (403, no es excepción)
|
|
65
65
|
**Causa:** Un mensaje intenta ejecutar un controlador que no hereda de `BugBunny::Controller`.
|
|
66
|
-
**Cuándo:** El consumer resuelve la clase (`constantize`) pero falla `controller_class < BugBunny::Controller` (`consumer.rb:
|
|
66
|
+
**Cuándo:** El consumer resuelve la clase (`constantize`) pero falla `controller_class < BugBunny::Controller` (`consumer.rb:375-381`).
|
|
67
67
|
**Comportamiento:** El worker **no levanta una excepción** — loguea `event=consumer.security_violation`, responde **403 Forbidden** al caller RPC y rechaza el mensaje sin requeue.
|
|
68
68
|
**Resolución:** Verificar que el controlador herede de `BugBunny::Controller` y que `config.controller_namespace` sea correcto.
|
|
69
69
|
|
data/skill/references/routing.md
CHANGED
|
@@ -69,7 +69,7 @@ El consumer resuelve el controlador concatenando:
|
|
|
69
69
|
|
|
70
70
|
Ejemplo: namespace `:admin`, controller `:reports` → `BugBunny::Controllers::Admin::ReportsController`
|
|
71
71
|
|
|
72
|
-
Valida que el controlador sea subclase de `BugBunny::Controller` (guard anti-RCE). Si no, el worker loguea `consumer.security_violation`, responde **403 Forbidden** y rechaza el mensaje sin requeue (`consumer.rb:
|
|
72
|
+
Valida que el controlador sea subclase de `BugBunny::Controller` (guard anti-RCE). Si no, el worker loguea `consumer.security_violation`, responde **403 Forbidden** y rechaza el mensaje sin requeue (`consumer.rb:375-381`) — no levanta una excepción dedicada.
|
|
73
73
|
|
|
74
74
|
## Route Object
|
|
75
75
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'support/integration_helper'
|
|
5
|
+
|
|
6
|
+
module DrainSpec
|
|
7
|
+
# Controller que sólo cuenta cuántas veces se lo invocó.
|
|
8
|
+
class PingController < BugBunny::Controller
|
|
9
|
+
def self.handled_count
|
|
10
|
+
@handled_count ||= Concurrent::AtomicFixnum.new(0)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def index
|
|
14
|
+
self.class.handled_count.increment
|
|
15
|
+
render status: 200, json: { pong: true }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
RSpec.describe 'Consumer.drain', :integration do
|
|
21
|
+
let(:queue_name) { unique('drain_q') }
|
|
22
|
+
let(:exchange_name) { unique('drain_x') }
|
|
23
|
+
let(:client) { BugBunny::Client.new(pool: TEST_POOL) }
|
|
24
|
+
let(:admin_connection) { BugBunny.create_connection }
|
|
25
|
+
|
|
26
|
+
# Durable y no exclusiva: drain declara la cola con su propia conexión, y RabbitMQ 4
|
|
27
|
+
# rechaza las colas no durables y no exclusivas.
|
|
28
|
+
let(:queue_opts) { { durable: true, exclusive: false, auto_delete: false } }
|
|
29
|
+
|
|
30
|
+
let(:drain_args) do
|
|
31
|
+
{ queue_name: queue_name, exchange_name: exchange_name, exchange_type: 'topic',
|
|
32
|
+
routing_key: 'ping', queue_opts: queue_opts }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
before do
|
|
36
|
+
DrainSpec::PingController.handled_count.value = 0
|
|
37
|
+
BugBunny.configure do |config|
|
|
38
|
+
config.controller_namespace = 'DrainSpec'
|
|
39
|
+
config.drain_idle_timeout = 1
|
|
40
|
+
config.drain_poll_interval = 0.05
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
admin_channel = admin_connection.create_channel
|
|
44
|
+
effective_exchange_opts = BugBunny::Session::DEFAULT_EXCHANGE_OPTIONS.merge(BugBunny.configuration.exchange_options)
|
|
45
|
+
exchange = admin_channel.topic(exchange_name, effective_exchange_opts)
|
|
46
|
+
admin_channel.queue(queue_name, queue_opts).bind(exchange, routing_key: 'ping')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
after do
|
|
50
|
+
BugBunny.configure do |config|
|
|
51
|
+
config.controller_namespace = 'BugBunny::Controllers'
|
|
52
|
+
config.drain_idle_timeout = 5
|
|
53
|
+
config.drain_poll_interval = 0.1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
cleanup_channel = admin_connection.create_channel
|
|
57
|
+
cleanup_channel.queue_delete(queue_name)
|
|
58
|
+
cleanup_channel.exchange_delete(exchange_name)
|
|
59
|
+
admin_connection.close
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def publish_ping
|
|
63
|
+
client.publish('ping', method: :get, exchange: exchange_name, exchange_type: 'topic', routing_key: 'ping')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def passive_queue
|
|
67
|
+
admin_connection.create_channel.queue(queue_name, queue_opts.merge(passive: true))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def messages_in_queue
|
|
71
|
+
passive_queue.message_count
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# La conexión es del llamador: drain cierra su canal, no la conexión.
|
|
75
|
+
def drain
|
|
76
|
+
connection = BugBunny.create_connection
|
|
77
|
+
BugBunny::Consumer.drain(connection: connection, **drain_args)
|
|
78
|
+
ensure
|
|
79
|
+
connection&.close
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'procesa todos los mensajes encolados, los ack-ea y retorna cuántos fueron' do
|
|
83
|
+
BugBunny.configure { |config| config.channel_prefetch = 3 }
|
|
84
|
+
5.times { publish_ping }
|
|
85
|
+
sleep 0.3
|
|
86
|
+
|
|
87
|
+
expect(drain).to eq(5)
|
|
88
|
+
expect(DrainSpec::PingController.handled_count.value).to eq(5)
|
|
89
|
+
expect(messages_in_queue).to eq(0)
|
|
90
|
+
ensure
|
|
91
|
+
BugBunny.configure { |config| config.channel_prefetch = 1 }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'con la cola vacía retorna 0 sin esperar la ventana de inactividad' do
|
|
95
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
96
|
+
|
|
97
|
+
expect(drain).to eq(0)
|
|
98
|
+
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at).to be < 1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Criterio 5: shutdown corre al volver y cierra el canal. Se mira el canal y no el
|
|
102
|
+
# `consumer_count`: ése queda en 0 por el `cancel` aunque falte el `ensure shutdown`, así
|
|
103
|
+
# que un test sobre él pasaría con el defecto intacto. El canal se toma antes: es el mismo
|
|
104
|
+
# que después usa drain.
|
|
105
|
+
it 'cierra su canal al volver, con la cola vacía y con mensajes' do
|
|
106
|
+
[0, 2].each do |pending|
|
|
107
|
+
pending.times { publish_ping }
|
|
108
|
+
sleep 0.3 if pending.positive?
|
|
109
|
+
|
|
110
|
+
connection = BugBunny.create_connection
|
|
111
|
+
consumer = BugBunny::Consumer.new(connection)
|
|
112
|
+
channel = consumer.session.channel
|
|
113
|
+
|
|
114
|
+
consumer.drain(**drain_args)
|
|
115
|
+
|
|
116
|
+
expect(channel).not_to be_open, "con #{pending} mensajes el canal quedó abierto"
|
|
117
|
+
ensure
|
|
118
|
+
connection&.close
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Review de #65: un middleware que levanta dejaba la entrega sin ack ni reject; con
|
|
123
|
+
# prefetch 1 trababa la cola y drain volvía "con éxito" sin haber sacado nada.
|
|
124
|
+
it 'una entrega cuyo middleware levanta sale de la cola y no traba el prefetch' do
|
|
125
|
+
exploding = Class.new(BugBunny::ConsumerMiddleware::Base) do
|
|
126
|
+
def call(*)
|
|
127
|
+
raise 'middleware roto'
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
BugBunny.consumer_middlewares.use exploding
|
|
131
|
+
BugBunny.configure { |config| config.channel_prefetch = 1 }
|
|
132
|
+
3.times { publish_ping }
|
|
133
|
+
sleep 0.3
|
|
134
|
+
|
|
135
|
+
expect(drain).to eq(3)
|
|
136
|
+
expect(messages_in_queue).to eq(0)
|
|
137
|
+
expect(DrainSpec::PingController.handled_count.value).to eq(0)
|
|
138
|
+
ensure
|
|
139
|
+
BugBunny.configuration.instance_variable_set(:@consumer_middlewares, BugBunny::ConsumerMiddleware::Stack.new)
|
|
140
|
+
BugBunny.configure { |config| config.channel_prefetch = 1 }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Review de #65: la guarda `unless settled` de `settle_failed_delivery`. Si el error
|
|
144
|
+
# llega DESPUÉS del ack, rechazar el tag ya confirmado hace que el broker cierre el
|
|
145
|
+
# canal; sin la guarda, drain terminaba en Timeout::Error con mensajes en la cola.
|
|
146
|
+
it 'un middleware que levanta después del ack no rechaza la entrega ya resuelta' do
|
|
147
|
+
after_ack = Class.new(BugBunny::ConsumerMiddleware::Base) do
|
|
148
|
+
def call(*args)
|
|
149
|
+
@app.call(*args)
|
|
150
|
+
raise 'después del ack'
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
BugBunny.consumer_middlewares.use after_ack
|
|
154
|
+
BugBunny.configure { |config| config.channel_prefetch = 1 }
|
|
155
|
+
3.times { publish_ping }
|
|
156
|
+
sleep 0.3
|
|
157
|
+
|
|
158
|
+
expect(drain).to eq(3)
|
|
159
|
+
expect(messages_in_queue).to eq(0)
|
|
160
|
+
expect(DrainSpec::PingController.handled_count.value).to eq(3)
|
|
161
|
+
ensure
|
|
162
|
+
BugBunny.configuration.instance_variable_set(:@consumer_middlewares, BugBunny::ConsumerMiddleware::Stack.new)
|
|
163
|
+
BugBunny.configure { |config| config.channel_prefetch = 1 }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'procesa en la misma vuelta un mensaje que llega dentro de la ventana de inactividad' do
|
|
167
|
+
publish_ping
|
|
168
|
+
sleep 0.3
|
|
169
|
+
|
|
170
|
+
drain_thread = Thread.new { drain }
|
|
171
|
+
sleep 0.5
|
|
172
|
+
publish_ping
|
|
173
|
+
|
|
174
|
+
expect(drain_thread.value).to eq(2)
|
|
175
|
+
expect(messages_in_queue).to eq(0)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -11,7 +11,15 @@ RSpec.describe BugBunny::Configuration do
|
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
# Restaura la configuración de spec_helper (host, credenciales) y no una con defaults:
|
|
15
|
+
# si no, los specs de integración que corren después se conectan como `guest` y se
|
|
16
|
+
# saltean como "RabbitMQ no disponible" en vez de correr.
|
|
17
|
+
around do |example|
|
|
18
|
+
original_configuration = BugBunny.configuration
|
|
19
|
+
example.run
|
|
20
|
+
ensure
|
|
21
|
+
BugBunny.configuration = original_configuration
|
|
22
|
+
end
|
|
15
23
|
|
|
16
24
|
describe 'defaults' do
|
|
17
25
|
it 'pasan validate! sin ninguna configuración adicional' do
|
|
@@ -124,6 +132,24 @@ RSpec.describe BugBunny::Configuration do
|
|
|
124
132
|
end
|
|
125
133
|
end
|
|
126
134
|
|
|
135
|
+
describe 'drain_idle_timeout' do
|
|
136
|
+
it 'levanta ConfigurationError si es 0' do
|
|
137
|
+
expect { configure_with(drain_idle_timeout: 0) }
|
|
138
|
+
.to raise_error(BugBunny::ConfigurationError, /drain_idle_timeout must be in/)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
describe 'drain_poll_interval' do
|
|
143
|
+
it 'acepta fracciones de segundo' do
|
|
144
|
+
expect { configure_with(drain_poll_interval: 0.05) }.not_to raise_error
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'levanta ConfigurationError si es 0' do
|
|
148
|
+
expect { configure_with(drain_poll_interval: 0) }
|
|
149
|
+
.to raise_error(BugBunny::ConfigurationError, /drain_poll_interval must be in/)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
127
153
|
describe 'configuración válida completa' do
|
|
128
154
|
it 'acepta todos los atributos con valores correctos' do
|
|
129
155
|
expect do
|