bug_bunny 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ac482578cfbb87414309cc03502be5f11c4ee4b0e799338bfeb9bfcf65488f5
4
- data.tar.gz: a4a9e64ac077bd6564790a5de762c804d38c65623f2c7cbc839217bb8f98bd25
3
+ metadata.gz: 6a28c7b99a99dc128e597b0dd3c6d128ab0abcf634410603f1dbeee78321ff33
4
+ data.tar.gz: 176ef53af6d5b7de0d6d10426aad13dbce548c2e894fd06db43550146791c216
5
5
  SHA512:
6
- metadata.gz: e33cbdca7040793e46116fdf744d3f89bdd71a4b278a12aaa85f3845c6aba1ea86dbfded00c3108b60e04759dea07f52a7765d430fcc3bbc3ff2090b01f38c9a
7
- data.tar.gz: be41989086784220c62b3e9ee4909f064956c3bf7dcaade12473d039c16743732a75f1d60844ffc22fcbe0afb36c518e14dbb74713cc0ef7007f9658d2be13d1
6
+ metadata.gz: ed8f25adc078770d9009b145cae463a772d0bae7234dcbae31e398f65896c7a055f230952c626becab97f235cbf23c66cd590938189ef4176c4bcecd94db092e
7
+ data.tar.gz: 0e5a151b21aee8a2647c41a8407df889d36582eb45026c4ca8a6a1d04b3bd0109a0e371e1a1f00d7625247e8be9ed6baf9b14c8a7ef28c9b50b5f6a0262207f2
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ <%# spec/rails/ bootea Rails y corre en su propio proceso: se excluye salvo que se pida explícitamente. %>
2
+ <%= '--exclude-pattern spec/rails/**/*_spec.rb' unless ARGV.any? { |arg| arg.start_with?('spec/rails') } %>
data/AGENTS.md CHANGED
@@ -12,7 +12,7 @@ proyecto y convenciones de equipo, ver `CLAUDE.md`. Para la entrada humana, ver
12
12
 
13
13
  | capa | artefacto | estado | qué responde |
14
14
  |---|---|---|---|
15
- | Comportamiento | `docs/behavior/behavior.md` | completo (7 flujos) | secuencias de publish/RPC/consume/confirms, contrato de error-wrapping |
15
+ | Comportamiento | `docs/behavior/behavior.md` | completo (8 flujos) | secuencias de publish/RPC/consume/confirms, contrato de error-wrapping |
16
16
  | Glosario | `docs/glossary/glossary.md` | parcial (acreta por PR) | término de negocio → binding físico en `lib/` |
17
17
  | Errores | `docs/errors/errors.md` | completo (§a/b/d estructura + §c política inferida) | jerarquía de excepciones públicas, mapeo `status→excepción`, shape del payload, política retry |
18
18
  | Configuración | `docs/config/configuracion.md` | completo (estructura + enrich §f/g/h) | opciones de `Configuration`, defaults, failure-mode/threading, inyecciones del `Railtie`, ENV sugeridas |
@@ -38,6 +38,7 @@ a YARD/specs/CHANGELOG. Pendiente de **verificación humana** (inferencias):
38
38
 
39
39
  - Ruby: `.ruby-version` · gemas: `Gemfile.lock` · manager: chruby + Bundler.
40
40
  - Antes de commitear: `bundle exec rubocop -a` (base `rubocop-rails-omakase`),
41
- `bundle exec rspec`, YARD incremental (`bundle exec yard stats --list-undoc`).
41
+ `bundle exec rake` (las dos suites: `spec/` y `spec/rails/`, cada una en su proceso; `bundle exec rspec` solo
42
+ corre `spec/`, sin Rails), YARD incremental (`bundle exec yard stats --list-undoc`).
42
43
  - Releases: `/gem-release` (el GitHub Action publica a RubyGems al pushear tag `v*`).
43
44
  - Doc por-PR: si tocás una capa, mové su artefacto en el mismo PR (régimen RFC-001).
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.3.0] - 2026-10-16
4
+
5
+ ### Nuevas funcionalidades
6
+ - **Dentro de Rails, cada mensaje del Consumer corre dentro del executor de Rails (#70):** el railtie registra `BugBunny::ConsumerMiddleware::RailsExecutor`, que envuelve cada mensaje con `Rails.application.executor.wrap`, como Sidekiq con cada job. El Consumer procesa en el hilo de trabajo de Bunny, que Rails no conoce, así que hasta ahora lo que Rails guarda por hilo pasaba de un mensaje al siguiente: los `CurrentAttributes` (tags de Sentry, contexto de traza, métricas por ejecución) y la conexión de ActiveRecord (el hilo la retenía en vez de devolverla al pool). Fuera de Rails no se registra nada y la gema se comporta como antes. (#70, #71) — @Pslp
7
+ - **`config.consumer_rails_executor`** (default `true`): con `false`, el railtie no registra el middleware. Se lee al terminar de inicializar la app, así que se apaga desde `config/initializers`. Si la app no llamó a `BugBunny.configure` antes de ese momento, el railtie usa la configuración por default en vez de caerse al bootear. — @Pslp
8
+ - **`ConsumerMiddleware::Stack#prepend`:** registra un middleware al principio de la cadena. El del executor va primero así, para envolver también a los middlewares que cargan estado por hilo (el de tracing de ExisRay se registra en su propio `after_initialize`, en un orden que no controlamos). — @Pslp
9
+
10
+ ### ⚠️ Cambio de conducta (dentro de Rails)
11
+ - **Rails ≥ 7.0 — un error que escapa del procesamiento de un mensaje ahora además se reporta a `Rails.error`** con `handled: false`, porque es lo que hace `executor.wrap` antes de relanzar. El camino del mensaje no cambia: se rechaza sin requeue, como desde 5.2.0. Los errores dentro de las acciones del controller los sigue rescatando el Consumer y no se ven afectados. **A mirar si consumís:** si tenés un subscriber de `Rails.error` (el de Sentry, por ejemplo), esos errores ahora aparecen ahí. Para volver a la conducta anterior: `config.consumer_rails_executor = false`. — @Pslp
12
+ - **La caché de queries de ActiveRecord queda prendida durante cada mensaje** y se limpia al terminarlo: es lo que hace el executor (`ActiveRecord::QueryCache::ExecutorHooks`). En el hilo de Bunny estaba apagada. **A mirar si consumís:** un handler que relee la misma fila dentro de un mensaje esperando que otro proceso la cambie (un poll) ahora recibe el valor cacheado; usá `ActiveRecord::Base.uncached { … }` ahí. Se vuelve a la conducta anterior con `config.consumer_rails_executor = false`. — @Pslp
13
+
14
+ ### Tests
15
+ - `spec/rails/`: una app Rails mínima (ActiveRecord sobre SQLite en memoria) prueba el railtie con Rails de verdad. Corre en su propio proceso (`rake spec:rails`), para que el resto de la suite siga probando la gema fuera de Rails; `rake` sin argumentos corre las dos, y `rspec` pelado corre `spec/` (un `.rspec` excluye `spec/rails/` salvo que se lo pida: `rspec spec/rails` la corre). Suma `railties`, `activerecord` y `sqlite3` como dependencias de desarrollo. — @Pslp
16
+
17
+ ### Documentación
18
+ - README, `skill/` y los artefactos de `docs/` (configuración, comportamiento, tests, errores) describen el executor, la opción y los dos cambios de conducta; las metas que tocó #70 se re-anclaron a `67cca83`. — @Pslp
19
+
3
20
  ## [5.2.0] - 2026-09-29
4
21
 
5
22
  ### Nuevas funcionalidades
data/CLAUDE.md CHANGED
@@ -61,7 +61,7 @@ BugBunny es una gema Ruby que implementa una capa de enrutamiento RESTful sobre
61
61
 
62
62
  ### Testing
63
63
  - Framework: RSpec
64
- - Correr: `bundle exec rspec`
64
+ - Correr: `bundle exec rake` (las dos suites: `spec/` y `spec/rails/`, cada una en su proceso; `bundle exec rspec` solo corre `spec/`, sin Rails)
65
65
  - Todo código nuevo debe tener tests.
66
66
 
67
67
  ### Releases o Nuevas versiones
data/README.md CHANGED
@@ -173,6 +173,10 @@ BugBunny.configure do |config|
173
173
  config.drain_idle_timeout = 5
174
174
  config.drain_poll_interval = 0.1
175
175
 
176
+ # Rails only — wrap every consumed message in Rails.application.executor (default: true).
177
+ # See "Rails executor around each message" below. Set it from config/initializers.
178
+ config.consumer_rails_executor = true
179
+
176
180
  # Publisher Confirms — fail-loud defaults (both flags default to true).
177
181
  # Set to false to restore legacy log-only behavior.
178
182
  config.nack_raise = true # broker NACK → raise BugBunny::PublishNacked
@@ -362,6 +366,41 @@ end
362
366
  BugBunny.consumer_middlewares.use TracingMiddleware
363
367
  ```
364
368
 
369
+ `use` appends to the chain; `prepend` puts a middleware first, wrapping every other one.
370
+
371
+ ### Rails executor around each message
372
+
373
+ Inside Rails, the railtie registers `BugBunny::ConsumerMiddleware::RailsExecutor` **first** in the chain. It wraps every
374
+ message in `Rails.application.executor.wrap`, the same way Sidekiq wraps each job. The consumer processes messages on
375
+ Bunny's worker thread, which Rails does not know about; without the executor, what Rails keeps per thread survives from
376
+ one message to the next:
377
+
378
+ - `ActiveSupport::CurrentAttributes` (and whatever lives in them: Sentry tags, trace context, per-run metrics);
379
+ - the ActiveRecord connection, which the thread keeps until it dies instead of returning it to the pool.
380
+
381
+ It goes first (`prepend`, not `use`) so that it also wraps the middlewares that load per-thread state, like ExisRay's
382
+ tracing middleware. Outside Rails nothing is registered and the consumer behaves as before.
383
+
384
+ #### ⚠️ Behavior change
385
+
386
+ **Errors that escape a message (Rails ≥ 7.0).**
387
+ An error that escapes the processing of a message (from a middleware, or from `handle_fatal_error`) still ends the same
388
+ way: the delivery is rejected without requeue. **It is now also reported to `Rails.error`** with `handled: false`, because that is what `executor.wrap` does before re-raising. If you have a `Rails.error` subscriber
389
+ (e.g. Sentry's), that error shows up there. Errors inside your controller actions are rescued by the consumer as
390
+ before and are not affected.
391
+
392
+ **The ActiveRecord query cache is on during each message**, and cleared when it ends: that is what the executor does
393
+ (`ActiveRecord::QueryCache::ExecutorHooks`). On Bunny's thread it used to be off. A handler that re-reads the same row
394
+ within one message, waiting for another process to change it (a poll), now gets the cached value; wrap that read in
395
+ `ActiveRecord::Base.uncached { … }`.
396
+
397
+ To turn it off (for example, if your consumer already wraps each message itself):
398
+
399
+ ```ruby
400
+ # config/initializers/bug_bunny.rb
401
+ BugBunny.configure { |config| config.consumer_rails_executor = false }
402
+ ```
403
+
365
404
  ---
366
405
 
367
406
  ## Observability
@@ -459,16 +498,16 @@ end
459
498
 
460
499
  Artefactos de detalle (modelo `dev-*`, RFC-001). El README indexa; no duplica.
461
500
 
462
- Anclado a `v5.2.0`.
501
+ Anclado a `v5.3.0`.
463
502
 
464
503
  | Capa | Artefacto | Estado |
465
504
  |---|---|---|
466
505
  | Glosario | [docs/glossary/glossary.md](docs/glossary/glossary.md) | parcial, acreta por PR |
467
- | Comportamiento | [docs/behavior/behavior.md](docs/behavior/behavior.md) | completa — 6 flujos (backfill on-demand) |
506
+ | Comportamiento | [docs/behavior/behavior.md](docs/behavior/behavior.md) | completa — 8 flujos (backfill on-demand) |
468
507
  | Configuración | [docs/config/configuracion.md](docs/config/configuracion.md) | §a-§e/§i estructura + §f/§g/§h enrich — completa |
469
508
  | Dependencias consumidas | [docs/consumed/rabbitmq.md](docs/consumed/rabbitmq.md) | §a/§b/§d estructura + §c/§e enrich |
470
509
  | Errores | [docs/errors/errors.md](docs/errors/errors.md) | §a/§b/§d completas; §c política inferida (verificación humana pendiente) |
471
- | Test | [docs/test/testing.md](docs/test/testing.md) | §a-§h — 16 unit / 7 integration |
510
+ | Test | [docs/test/testing.md](docs/test/testing.md) | §a-§h — 18 unit / 8 integration / 1 rails (`spec/rails/`, en su propio proceso) |
472
511
  | Release | [docs/release/release.md](docs/release/release.md) | completa (régimen gema: build→publish; §e/f/g n/a) |
473
512
  | Datos | — | n/a — gema sin DB (sin schema/models) |
474
513
  | Eventos | — | n/a — la gema **es** el transporte; no declara catálogo de eventos de dominio propio |
data/Rakefile CHANGED
@@ -4,6 +4,7 @@ require 'rspec/core/rake_task'
4
4
 
5
5
  RSpec::Core::RakeTask.new(:spec) do |t|
6
6
  t.rspec_opts = '--require spec_helper'
7
+ t.exclude_pattern = 'spec/rails/**/*_spec.rb'
7
8
  end
8
9
 
9
10
  RSpec::Core::RakeTask.new('spec:unit') do |t|
@@ -16,6 +17,12 @@ RSpec::Core::RakeTask.new('spec:integration') do |t|
16
17
  t.rspec_opts = '--require spec_helper'
17
18
  end
18
19
 
20
+ # Los specs que necesitan Rails (el Railtie) bootean una app propia, y Rails se inicializa una vez por
21
+ # proceso: corren aparte, para que el resto de la suite siga probando la gema fuera de Rails.
22
+ RSpec::Core::RakeTask.new('spec:rails') do |t|
23
+ t.pattern = 'spec/rails/**/*_spec.rb'
24
+ end
25
+
19
26
  # Mantiene la tarea :test apuntando a los tests de integración legacy de Minitest
20
27
  Rake::TestTask.new(:test) do |t|
21
28
  t.libs << 'test'
@@ -25,4 +32,5 @@ Rake::TestTask.new(:test) do |t|
25
32
  t.warning = false
26
33
  end
27
34
 
28
- task default: :spec
35
+ # Las dos suites, cada una en su proceso: es lo que corren `quality-code` y `gem-release`.
36
+ task default: [:spec, 'spec:rails']
@@ -1,6 +1,6 @@
1
1
  # Comportamiento — bug_bunny
2
2
 
3
- > meta: artefacto `comportamiento` · RFC-007 (cadencia incremental default / completo on-demand) · generado dev-enrich 1.3.0 (backfill on-demand) · anclado a `35b075a` (squash de #65 en `main`) · cobertura: completa (7 flujos) · verificado por humano 2026-05-18 (base) · 2026-05-26 (refresco scoped: contrato de error wrapping post-#49) — incremento 2026-09-28 (#64, flujo `drain` + re-mapeo de `file:line`): escrito por el agente, **sin verificación humana todavía**
3
+ > meta: artefacto `comportamiento` · RFC-007 (cadencia incremental default / completo on-demand) · generado dev-enrich 1.3.0 (backfill on-demand) · anclado a `67cca83` (squash de #71 en `main`; antes `35b075a`) · cobertura: completa (8 flujos) · verificado por humano 2026-05-18 (base) · 2026-05-26 (refresco scoped: contrato de error wrapping post-#49) — incremento 2026-09-28 (#64, flujo `drain` + re-mapeo de `file:line`): escrito por el agente, **sin verificación humana todavía** — incremento 2026-10-16 (#70, flujo «Executor de Rails alrededor de cada mensaje»; re-anclado a `67cca83` en el release 5.3.0): escrito por el agente, **sin verificación humana todavía**
4
4
 
5
5
  ## 1. Resumen
6
6
 
@@ -17,8 +17,9 @@ Flujos de ejecución de la gema. Generado en **modo completo on-demand** (RFC-00
17
17
  | Consumer drain (drenar y salir) | **documentado** | `consumer.rb:158-176,271-290`, `drain_tracker.rb:28-47` |
18
18
  | Error handling / RemoteError | **documentado** | `consumer.rb:473-482`, `remote_error.rb`, `raise_error.rb:32-65` |
19
19
  | Client middleware stack (onion) | **documentado** | `middleware/stack.rb:43-47`, `base.rb:35-43` |
20
+ | Executor de Rails alrededor de cada mensaje | **documentado** | `railtie.rb:44-51`, `consumer_middleware/rails_executor.rb:28-30`, `consumer.rb:227-238` |
20
21
 
21
- Cobertura completa a `35b075a`. Acreta incremental en cada PR que toque un flujo (default RFC-007). Ausencia futura ≠ inexistencia.
22
+ Cobertura completa a `67cca83`. Acreta incremental en cada PR que toque un flujo (default RFC-007). Ausencia futura ≠ inexistencia.
22
23
 
23
24
  ## 2. Cuerpo
24
25
 
@@ -110,7 +111,7 @@ sequenceDiagram
110
111
  C->>H: start_health_check (touch file)
111
112
  C->>BR: q.subscribe(manual_ack, block:true) — LOOP
112
113
  loop por mensaje
113
- BR->>C: deliver → consumer_middlewares → process_message
114
+ BR->>C: deliver → consumer_middlewares (en Rails: RailsExecutor primero) → process_message
114
115
  end
115
116
  H-->>S: cada interval: queue_declare(passive:true)
116
117
  H-->>S: falla → cierra session (fuerza reconnect)
@@ -146,6 +147,27 @@ sequenceDiagram
146
147
  ```
147
148
  Contexto: `consumer.rb:158-176` (`drain`), `consumer.rb:271-290` (`consume_until_idle`), `drain_tracker.rb:28-47`. **Mensajes que llegan mientras drena:** entran en esta vuelta si llegan antes de que venza la ventana; los posteriores quedan para la próxima corrida. Una entrega ya recibida al cancelar se procesa antes de volver (Bunny drena su work pool al cancelar — leído de la fuente de Bunny 2.24.0, no medido); si igual no se ack-eara, **vuelve a la cola** (at-least-once). El conteo devuelto incluye los rechazados. **Una entrega que falla fuera del rescue de `process_message`** (un middleware, `handle_fatal_error`) se rechaza sin requeue en `handle_delivery` y se loguea `consumer.delivery_failed`; antes quedaba sin ack, trababa el prefetch y `drain` volvía "con éxito" con la cola llena (medido en el review de #65). **Con un flujo sostenido no retorna:** si los mensajes llegan más seguido que `drain_idle_timeout` la ventana no vence — la duración la decide el productor; se acota desde afuera (timeout del job). **La conexión es del llamador:** `drain` cierra el canal, no la conexión. **Distinto de `subscribe(block: false)`:** ese modo retorna al instante y el `ensure shutdown` cierra el canal, así que no consume nada (medido en #64).
148
149
 
150
+ ### Flujo: Executor de Rails alrededor de cada mensaje
151
+ Dentro de Rails, cada entrega corre dentro de `Rails.application.executor.wrap`, como un job de Sidekiq. El Consumer procesa en el hilo de trabajo de Bunny, que Rails no conoce: sin esto, el estado por hilo pasa de un mensaje al siguiente (#70).
152
+
153
+ ```mermaid
154
+ sequenceDiagram
155
+ participant BR as RabbitMQ
156
+ participant C as Consumer#handle_delivery
157
+ participant X as RailsExecutor
158
+ participant M as otros middlewares (p. ej. tracing de ExisRay)
159
+ participant P as process_message
160
+ BR->>C: deliver (hilo de Bunny)
161
+ C->>X: consumer_middlewares.call
162
+ X->>X: executor.wrap — al entrar: reset de CurrentAttributes · prende la caché de queries de AR
163
+ X->>M: @app.call
164
+ M->>P: core: rutea, llama al controller, ack/reject, reply RPC
165
+ P-->>X: vuelve
166
+ X->>X: al salir: reset de CurrentAttributes · la conexión de AR vuelve al pool · se apaga y limpia la caché de queries
167
+ Note over C: un error que escapa: wrap lo relanza (en Rails ≥ 7.0, antes lo reporta a Rails.error) → rescue de handle_delivery → reject sin requeue
168
+ ```
169
+ Contexto: `railtie.rb:44-51` (registro en `after_initialize`, sólo si `config.consumer_rails_executor`), `consumer_middleware.rb:72-75` (`Stack#prepend`), `consumer_middleware/rails_executor.rb:28-30` (`wrap`), `consumer.rb:227-238` (`handle_delivery`: la cadena y su `rescue`). **Va primero** (`prepend`) porque el reset al entrar borraría el estado que un middleware anterior ya cargó: el de tracing de ExisRay carga `ExisRay::Tracer` (un `CurrentAttributes`) y se registra en su propio `after_initialize`, en un orden que no controlamos. El ack/reject queda adentro del wrap (vive en `process_message`); el `rescue` de `handle_delivery`, afuera. Si el hilo ya está dentro del executor, `wrap` no abre otro. **Fuera de Rails** el Railtie no se carga y la cadena no cambia. Probado con Rails de verdad en `spec/rails/consumer_rails_executor_spec.rb` (Rails 8.1.4, la versión que corre la suite). **Cambio de conducta:** la caché de queries, que en el hilo de Bunny estaba apagada, queda prendida durante cada mensaje (`ActiveRecord::QueryCache::ExecutorHooks`).
170
+
149
171
  ### Flujo: Error handling / RemoteError
150
172
  Excepción no manejada en controller → serializada (clase/mensaje/backtrace[0..25]) → reply 500 → reconstruida client-side por `Middleware::RaiseError`.
151
173
 
@@ -1,7 +1,7 @@
1
1
  # Configuración — bug_bunny
2
2
 
3
3
  > meta: artefacto configuración · RFC-012 · generado `arch-structure` (inventario
4
- > §a-§e/§i) + `arch-enrich` (§f/§g/§h/§j) · anclado a `35b075a` (squash de #65 en `main`),
4
+ > §a-§e/§i) + `arch-enrich` (§f/§g/§h/§j) · anclado a `67cca83` (squash de #71 en `main`),
5
5
  > `lib/bug_bunny/configuration.rb`, `lib/bug_bunny.rb`, `lib/bug_bunny/railtie.rb`,
6
6
  > `lib/bug_bunny/consumer.rb`,
7
7
  > `lib/generators/bug_bunny/install/templates/initializer.rb` · fecha 2026-06-30
@@ -9,6 +9,11 @@
9
9
  > §j n/a. Incremento 2026-09-28 (#64): `drain_idle_timeout`/`drain_poll_interval`,
10
10
  > anclados a `configuration.rb:37-38,99-105,273-275` y `consumer.rb:271-290`; todas las
11
11
  > citas `file:line` re-mapeadas al árbol de `35b075a` (el refactor de #64 las corrió).
12
+ > Incremento 2026-10-16 (#70): `consumer_rails_executor` (`configuration.rb:196,277`,
13
+ > `railtie.rb:44-51`); las citas de `configuration.rb` re-mapeadas por contenido (el atributo
14
+ > nuevo corrió +10/+11 todo lo que viene después) y las de `railtie.rb` (§i). Re-anclado a
15
+ > `67cca83` en el release 5.3.0: entre `35b075a` y `67cca83` sólo cambiaron los archivos de #70
16
+ > (re-mapeados) y `version.rb` (no citado). Escrito por el agente, sin verificación humana todavía.
12
17
 
13
18
  ## 1. Resumen
14
19
 
@@ -22,12 +27,12 @@ en `lib/`**; el `ENV.fetch` vive en el **template** que sugiere al consumidor
22
27
 
23
28
  ### a. Hecho verificable
24
29
 
25
- - **Total opciones (`Configuration`):** 32 (31 `attr_accessor` + 1 `attr_reader`; medido
26
- 2026-09-28 — el `29` anterior ya estaba desfasado del código antes de #64).
30
+ - **Total opciones (`Configuration`):** 33 (32 `attr_accessor` + 1 `attr_reader`; medido
31
+ 2026-10-16, con `consumer_rails_executor` de #70 — el `29` anterior ya estaba desfasado del código antes de #64).
27
32
  - **Validadas requeridas (`VALIDATIONS`, `configuration.rb:25-39`):** 5 (`host`,
28
33
  `port`, `username`, `password`, `vhost` — `required: true`; igual tienen
29
34
  default, `validate!` exige no-vacío).
30
- - **Con default:** 32 (todas; defaults en `initialize`/`init_callback_defaults`/`init_drain_defaults`).
35
+ - **Con default:** 33 (todas; defaults en `initialize`/`init_callback_defaults`/`init_drain_defaults`).
31
36
  - **Con rango validado:** 9 (`port`, `heartbeat`, `connection_timeout`,
32
37
  `read_timeout`, `write_timeout`, `rpc_timeout`, `channel_prefetch`,
33
38
  `drain_idle_timeout`, `drain_poll_interval`).
@@ -41,38 +46,39 @@ Origen `code-default` salvo nota. Consumidor = `configuration.rb` (default en
41
46
 
42
47
  | nombre | tipo | requerida | default | origen | consumidor | secret? |
43
48
  |---|---|---|---|---|---|---|
44
- | `host` | String | sí (validate!) | `'127.0.0.1'` | code-default | `configuration.rb:192` | no |
45
- | `port` | Integer | sí (validate!, 1..65535) | `5672` | code-default | `:193` | no |
46
- | `username` | String | sí (validate!) | `'guest'` | code-default | `:194` | no |
47
- | `password` | String | sí (validate!) | `'guest'` | code-default | `:195` | **sí** |
48
- | `vhost` | String | sí (validate!) | `'/'` | code-default | `:196` | no |
49
- | `logger` | Logger | no | `Logger.new($stdout)` INFO | code-default | `:198` | no |
50
- | `bunny_logger` | Logger | no | `Logger.new($stdout)` WARN | code-default | `:201` | no |
51
- | `automatically_recover` | Boolean | no | `true` | code-default | `:203` | no |
52
- | `network_recovery_interval` | Integer | no | `5` | code-default | `:204` | no |
53
- | `max_reconnect_attempts` | Integer/nil | no | `nil` (reintenta ∞) | code-default | `:205` | no |
54
- | `max_reconnect_interval` | Integer | no | `60` | code-default | `:206` | no |
55
- | `connection_timeout` | Integer | no (1..300) | `10` | code-default | `:207` | no |
56
- | `read_timeout` | Integer | no (1..300) | `30` | code-default | `:208` | no |
57
- | `write_timeout` | Integer | no (1..300) | `30` | code-default | `:209` | no |
58
- | `heartbeat` | Integer | no (0..3600) | `15` | code-default | `:210` | no |
59
- | `continuation_timeout` | Integer (ms) | no | `15000` | code-default | `:211` | no |
60
- | `channel_prefetch` | Integer | no (1..10000) | `1` | code-default | `:212` | no |
61
- | `rpc_timeout` | Integer | no (1..3600) | `10` | code-default | `:213` | no |
62
- | `health_check_interval` | Integer | no | `60` | code-default | `:214` | no |
63
- | `health_check_file` | String/nil | no | `nil` (desactivado) | code-default | `:217` | no |
64
- | `drain_idle_timeout` | Integer (s) | no (1..3600) | `5` | code-default | `:274` | no |
65
- | `drain_poll_interval` | Numeric (s) | no (0.01..10) | `0.1` | code-default | `:275` | no |
66
- | `controller_namespace` | String | no | `'BugBunny::Controllers'` | code-default | `:220` | no |
67
- | `log_tags` | Array | no | `[:uuid]` | code-default | `:222` | no |
68
- | `exchange_options` | Hash | no | `{}` | code-default | `:225` | no |
69
- | `queue_options` | Hash | no | `{}` | code-default | `:226` | no |
70
- | `consumer_middlewares` | Stack (attr_reader) | no | `Stack.new` | code-default | `:228` | no |
71
- | `rpc_reply_headers` | Proc/nil | no | `nil` | code-default | `:262` | no |
72
- | `on_rpc_reply` | Proc/nil | no | `nil` | code-default | `:263` | no |
73
- | `on_return` | Proc/nil | no | `nil` | code-default | `:264` | no |
74
- | `nack_raise` | Boolean | no | `true` | code-default | `:265` | no |
75
- | `return_raise` | Boolean | no | `true` | code-default | `:266` | no |
49
+ | `host` | String | sí (validate!) | `'127.0.0.1'` | code-default | `configuration.rb:202` | no |
50
+ | `port` | Integer | sí (validate!, 1..65535) | `5672` | code-default | `:203` | no |
51
+ | `username` | String | sí (validate!) | `'guest'` | code-default | `:204` | no |
52
+ | `password` | String | sí (validate!) | `'guest'` | code-default | `:205` | **sí** |
53
+ | `vhost` | String | sí (validate!) | `'/'` | code-default | `:206` | no |
54
+ | `logger` | Logger | no | `Logger.new($stdout)` INFO | code-default | `:208` | no |
55
+ | `bunny_logger` | Logger | no | `Logger.new($stdout)` WARN | code-default | `:211` | no |
56
+ | `automatically_recover` | Boolean | no | `true` | code-default | `:213` | no |
57
+ | `network_recovery_interval` | Integer | no | `5` | code-default | `:214` | no |
58
+ | `max_reconnect_attempts` | Integer/nil | no | `nil` (reintenta ∞) | code-default | `:215` | no |
59
+ | `max_reconnect_interval` | Integer | no | `60` | code-default | `:216` | no |
60
+ | `connection_timeout` | Integer | no (1..300) | `10` | code-default | `:217` | no |
61
+ | `read_timeout` | Integer | no (1..300) | `30` | code-default | `:218` | no |
62
+ | `write_timeout` | Integer | no (1..300) | `30` | code-default | `:219` | no |
63
+ | `heartbeat` | Integer | no (0..3600) | `15` | code-default | `:220` | no |
64
+ | `continuation_timeout` | Integer (ms) | no | `15000` | code-default | `:221` | no |
65
+ | `channel_prefetch` | Integer | no (1..10000) | `1` | code-default | `:222` | no |
66
+ | `rpc_timeout` | Integer | no (1..3600) | `10` | code-default | `:223` | no |
67
+ | `health_check_interval` | Integer | no | `60` | code-default | `:224` | no |
68
+ | `health_check_file` | String/nil | no | `nil` (desactivado) | code-default | `:227` | no |
69
+ | `drain_idle_timeout` | Integer (s) | no (1..3600) | `5` | code-default | `:285` | no |
70
+ | `drain_poll_interval` | Numeric (s) | no (0.01..10) | `0.1` | code-default | `:286` | no |
71
+ | `controller_namespace` | String | no | `'BugBunny::Controllers'` | code-default | `:230` | no |
72
+ | `log_tags` | Array | no | `[:uuid]` | code-default | `:232` | no |
73
+ | `exchange_options` | Hash | no | `{}` | code-default | `:235` | no |
74
+ | `queue_options` | Hash | no | `{}` | code-default | `:236` | no |
75
+ | `consumer_middlewares` | Stack (attr_reader) | no | `Stack.new` | code-default | `:238` | no |
76
+ | `rpc_reply_headers` | Proc/nil | no | `nil` | code-default | `:272` | no |
77
+ | `on_rpc_reply` | Proc/nil | no | `nil` | code-default | `:273` | no |
78
+ | `on_return` | Proc/nil | no | `nil` | code-default | `:274` | no |
79
+ | `nack_raise` | Boolean | no | `true` | code-default | `:275` | no |
80
+ | `return_raise` | Boolean | no | `true` | code-default | `:276` | no |
81
+ | `consumer_rails_executor` | Boolean | no | `true` | code-default | `:277` | no |
76
82
 
77
83
  > **Override por request:** `nack_raise` y `return_raise` se sobreescriben por
78
84
  > llamada con `nack_raise:` / `return_raise:` en `Client#publish` (scope-override
@@ -93,7 +99,7 @@ El install template (`initializer.rb`) **sugiere al consumidor** wirear 4 ENV
93
99
  ### d. Derivaciones simples
94
100
 
95
101
  - `url` ← `"amqp://#{username}:#{password}@#{host}:#{port}/#{vhost}"`
96
- (`configuration.rb:236`).
102
+ (`configuration.rb:246`).
97
103
  - `create_connection(**options)` ← `merge_connection_options(options)` sobre la
98
104
  config global; las options explícitas pisan los defaults (`bug_bunny.rb:88`).
99
105
 
@@ -106,11 +112,12 @@ El install template (`initializer.rb`) **sugiere al consumidor** wirear 4 ENV
106
112
 
107
113
  | inyección | qué hace | ancla |
108
114
  |---|---|---|
109
- | `initializer 'bug_bunny.add_autoload_paths'` | registra `app/rabbit` en el autoloader (Zeitwerk, `eager_load: true`) si existe | `:15-18` |
110
- | `config.after_initialize` → `ForkTracker.after_fork` | cierra la conexión heredada en cada fork (Rails 7.1+) | `:24-26` |
111
- | `config.after_initialize` → `Puma.events.on_worker_boot` | mismo cierre, hook legacy Puma <5 | `:30-34` |
112
- | `rake_tasks` | carga `tasks/bug_bunny.rake` (`bug_bunny:sync`) | `:38-40` |
113
- | `Spring.after_fork` | cierra conexión en preloader Spring | `:43-47` |
115
+ | `initializer 'bug_bunny.add_autoload_paths'` | registra `app/rabbit` en el autoloader (Zeitwerk, `eager_load: true`) si existe | `:16-19` |
116
+ | `config.after_initialize` → `ForkTracker.after_fork` | cierra la conexión heredada en cada fork (Rails 7.1+) | `:25-27` |
117
+ | `config.after_initialize` → `Puma.events.on_worker_boot` | mismo cierre, hook legacy Puma <5 | `:31-35` |
118
+ | `config.after_initialize` → `consumer_middlewares.prepend(RailsExecutor)` | si `consumer_rails_executor` (default `true`), pone `ConsumerMiddleware::RailsExecutor` **primero** en la cadena del Consumer: cada mensaje corre dentro de `Rails.application.executor.wrap` (#70) | `:44-51` |
119
+ | `rake_tasks` | carga `tasks/bug_bunny.rake` (`bug_bunny:sync`) | `:54-56` |
120
+ | `Spring.after_fork` | cierra conexión en preloader Spring | `:59-63` |
114
121
 
115
122
  ### f. Enriquecimiento semántico
116
123
 
@@ -128,6 +135,7 @@ del código.
128
135
  | Drain (`drain_idle_timeout`/`drain_poll_interval`) | latencia | `drain_idle_timeout` muy bajo → un productor lento deja mensajes para la próxima corrida (no se pierden); muy alto → el job tarda más en terminar tras vaciar la cola | ninguno: sólo acota cuánto espera `Consumer#drain` (`consumer.rb:285`) | correr un consumidor como job que termina (#64) |
129
136
  | Callbacks (`on_return`/`on_rpc_reply`/`rpc_reply_headers`) | extensibilidad | una excepción en `on_return` se captura pero **degrada visibilidad** (YARD `configuration.rb:157`) | corren en hilos sensibles (ver §h) | propagar trace-context / alertar unroutable |
130
137
  | Confirms (`nack_raise`/`return_raise`) | integridad de entrega | `false` → NACK/return solo se logea, la llamada retorna `202` (modo legacy, posible pérdida silenciosa) | habilitan el raise de `PublishNacked`/`PublishUnroutable` | elegir entre fail-fast vs best-effort en publish confirmado |
138
+ | Executor de Rails (`consumer_rails_executor`) | aislamiento entre mensajes | `false` dentro de Rails → el estado por hilo (`CurrentAttributes`, conexión de ActiveRecord) pasa de un mensaje al siguiente, salvo que el consumidor envuelva cada mensaje por su cuenta; fuera de Rails no tiene efecto | `true` → el Railtie registra `ConsumerMiddleware::RailsExecutor` **primero** en la cadena (`railtie.rb:44-51`); en Rails ≥ 7.0, un error que escapa del mensaje además se reporta a `Rails.error` (`handled: false`), y la caché de queries de AR queda prendida durante cada mensaje | que cada mensaje empiece limpio, como un job de Sidekiq (#70) |
131
139
  | Routing (`controller_namespace`) | seguridad | clase resuelta no subclase de `BugBunny::Controller` → el worker responde **403** + reject (guard anti-RCE, `consumer.rb:375-381`) | acota qué clases son enrutables | superficie de control de RCE |
132
140
  | Logging (`logger`/`bunny_logger`/`log_tags`) | observabilidad | — | salida a `$stdout` por default | trazabilidad estructurada |
133
141
  | Infra (`exchange_options`/`queue_options`) | infraestructura | options incompatibles con el broker → `PreconditionFailed` (vía `CommunicationError`) | defaults globales mergeados por recurso | declaración AMQP por default |
@@ -135,7 +143,7 @@ del código.
135
143
  ### g. Ramificadores intra-config
136
144
 
137
145
  - `health_check_file = nil` (default) **desactiva** el touchfile aunque
138
- `health_check_interval` siga corriendo (`configuration.rb:109-111,217`).
146
+ `health_check_interval` siga corriendo (`configuration.rb:109-111,227`).
139
147
  - `return_raise` es **inerte cuando `mandatory: false`** — sin `mandatory` el
140
148
  broker nunca retorna, así que el flag no tiene efecto (`configuration.rb:181`).
141
149
  - `nack_raise`/`return_raise` se sobreescriben **por request** (`Client#publish`),
@@ -5,7 +5,9 @@
5
5
  > `remote_error.rb`, `middleware/raise_error.rb`, `controller.rb`, `consumer.rb`,
6
6
  > `observability.rb` · fecha 2026-07-31 · cobertura: §a/§b/§d completas
7
7
  > (estructura); §c política completa (enrich, **inferida** de HTTP/AMQP —
8
- > verificación humana pendiente).
8
+ > verificación humana pendiente). Incremento 2026-10-16 (#70): las citas de `configuration.rb`
9
+ > (`:293,300,308`, los `raise` de `ConfigurationError`) son del árbol de `67cca83`; el resto sigue
10
+ > anclado a `5697541`.
9
11
 
10
12
  ## 1. Resumen
11
13
 
@@ -46,7 +48,7 @@ Jerarquía (todas bajo `BugBunny::Error < ::StandardError`):
46
48
  |---|---|---|
47
49
  | `Error` | `::StandardError` | base — no se levanta directa salvo `resource.rb:122` (pool ausente) |
48
50
  | `CommunicationError` | `Error` | `bug_bunny.rb:96`, `session.rb:177,285`, `producer.rb:90`, `client.rb:168` — envuelve cualquier `Bunny::Exception` en la frontera del gem; original en `.cause` |
49
- | `ConfigurationError` | `Error` | `configuration.rb:282,289,297` — validación al final de `BugBunny.configure` |
51
+ | `ConfigurationError` | `Error` | `configuration.rb:293,300,308` — validación al final de `BugBunny.configure` |
50
52
  | `PublishNacked` | `Error` | `producer.rb:235` — NACK del broker en modo `:confirmed`. Attrs: `path`, `nacked_count`. Opt-out `nack_raise: false` |
51
53
  | `PublishUnroutable` | `Error` | `producer.rb:325` — `basic.return` con `mandatory: true`. Attrs: `path`, `exchange`, `routing_key`, `reply_code`, `reply_text`, `correlation_id`. Opt-out `return_raise: false` |
52
54
  | `ClientError` | `Error` | `raise_error.rb:170` — 4xx no mapeado explícito |
@@ -5,7 +5,9 @@
5
5
  > a la RFC vigente) · anclado a `5697541`
6
6
  > (`.github/workflows/release.yml`, `*.gemspec`, `lib/bug_bunny/version.rb`,
7
7
  > `CHANGELOG.md`) · fecha 2026-07-31 · cobertura:
8
- > completa (régimen gema: build→publish, §e/f/g n/a).
8
+ > completa (régimen gema: build→publish, §e/f/g n/a). Incremento 2026-10-16 (release 5.3.0):
9
+ > versión actual y los pines de los consumidores (§h), re-medidos; escrito por el agente, sin
10
+ > verificación humana todavía.
9
11
 
10
12
  ## 1. Resumen
11
13
 
@@ -21,9 +23,9 @@ out-of-repo).
21
23
 
22
24
  ### a. Hecho verificable
23
25
 
24
- - **Convención de versión:** SemVer `vX.X.X`. Actual: **5.2.0**.
25
- - **Source of truth:** tag remoto (`v5.2.0`) + **triple mirror**
26
- `lib/bug_bunny/version.rb` (`VERSION = '5.2.0'`) ← `bug_bunny.gemspec:7`
26
+ - **Convención de versión:** SemVer `vX.X.X`. Actual: **5.3.0**.
27
+ - **Source of truth:** tag remoto (`v5.3.0`) + **triple mirror**
28
+ `lib/bug_bunny/version.rb` (`VERSION = '5.3.0'`) ← `bug_bunny.gemspec:7`
27
29
  (`spec.version = BugBunny::VERSION`).
28
30
  - **Changelog canónico:** `CHANGELOG.md` único.
29
31
  - **Patrón de trigger:** `gema-tag` (patrón 1).
@@ -34,7 +36,7 @@ out-of-repo).
34
36
 
35
37
  - **Convención:** SemVer `vX.X.X` (**con `v`** — distinto al servicio).
36
38
  - **Source of truth:** tag remoto canónico (`git tag --sort=-v:refname` →
37
- `v5.2.0`).
39
+ `v5.3.0`).
38
40
  - **Mirror:** `lib/bug_bunny/version.rb` (`VERSION`), leído por
39
41
  `bug_bunny.gemspec:7` (`spec.version = BugBunny::VERSION`).
40
42
  `required_ruby_version >= 2.6.0` (`bug_bunny.gemspec:17`).
@@ -85,11 +87,12 @@ procedimiento per-repo porque no vive acá. Una versión yankeada se anotaría e
85
87
  `>= 5.1.0, < 5.2.0`, así que un minor **no entra** sin editar el `Gemfile`.
86
88
  Consecuencia operativa: un fix publicado como **patch** lo toman con
87
89
  `bundle update bug_bunny`; uno publicado como **minor** requiere tocar el pin
88
- en cada consumidor. Estado medido el 2026-09-29 (Gemfiles de los clones del
90
+ en cada consumidor. Estado medido el 2026-10-16 (Gemfile y Gemfile.lock del default de cada clon del
89
91
  workspace): `box_manager_service` en `~> 5.1.1`; `box_acs_manager`,
90
- `box_cluster_manager` y `box_radius_manager` en `~> 5.1.0` — o sea que
91
- **5.2.0 no le entra a ninguno** sin tocar el pin. *(El estado anterior decía
92
- `box_acs_manager`/`box_cluster_manager` en `~> 4.19.0`: ya no es así.)* Un cambio de contrato del gem (ej. el
92
+ `box_cluster_manager` y `box_radius_manager` en `~> 5.1.0`, los cuatro con 5.1.1 en el lock — o sea que
93
+ **ni 5.2.0 ni 5.3.0 le entran a ninguno** sin tocar el pin. `agent` sigue en `~> 4.11.1`, y el resto de
94
+ los consumidores (`olt_*`, `*_invoice_service`, `notification_sms_service`, `wispro_box_server`,
95
+ `wispro_lan`) en la línea 0.2.x. *(El 2026-09-29 la medición daba lo mismo para los cuatro de 5.x.)* Un cambio de contrato del gem (ej. el
93
96
  behavior-change de `4.18.0` — `Bunny::Exception` → `CommunicationError`, o la
94
97
  eliminación de `BugBunny::SecurityError` en `5.0.0`) obliga a los consumidores
95
98
  a migrar; el `CHANGELOG.md` lo documenta como breaking note. **Orden de
data/docs/test/testing.md CHANGED
@@ -1,17 +1,22 @@
1
1
  # Test — bug_bunny
2
2
 
3
3
  > meta: artefacto test · RFC-013 · generado `arch-structure` (§a-§d) +
4
- > `arch-enrich` (§e-§h) · anclado a `35b075a` (squash de #65 en `main`; el ancla anterior `7bf1da7` no era ancestro de `main`), `Rakefile`, `bug_bunny.gemspec`,
4
+ > `arch-enrich` (§e-§h) · anclado a `67cca83` (squash de #71 en `main`; antes `35b075a`, y antes `7bf1da7`, que no era ancestro de `main`), `Rakefile`, `bug_bunny.gemspec`,
5
5
  > `spec/spec_helper.rb`, `spec/support/integration_helper.rb`,
6
6
  > `.github/workflows/main.yml`, `CHANGELOG.md` · fecha 2026-07-22 · cobertura:
7
7
  > §a-§d (estructura) + §e-§h (enrich, anclado a specs/CHANGELOG) completas.
8
8
  > Incremento 2026-09-28 (#64): conteos, `drain_*_spec` y el gap de aislamiento.
9
+ > Incremento 2026-10-16 (#70): `spec/rails/` (Railtie con Rails de verdad), tarea
10
+ > `spec:rails` y conteos; re-anclado a `67cca83` en el release 5.3.0. Escrito por el agente, sin
11
+ > verificación humana todavía.
9
12
 
10
13
  ## 1. Resumen
11
14
 
12
- Suite principal **RSpec** (`spec/`, 25 specs: 17 unit + 8 integration). Tarea
15
+ Suite principal **RSpec** (`spec/`, 27 specs: 18 unit + 8 integration + 1 rails). Tarea
13
16
  `:test` legacy de **Minitest** (`test/`, 2 archivos) fuera del default y del CI.
14
- CI corre `bundle exec rake` (= `:spec`) en Ruby 3.4.4. Sin coverage tool
17
+ CI corre `bundle exec rake` en Ruby 3.4.4: el default corre `:spec` y `spec:rails`, cada una en
18
+ su proceso. `bundle exec rspec` pelado corre `spec/` sin Rails: `.rspec` excluye `spec/rails/` salvo que se lo pida
19
+ explícitamente (`bundle exec rspec spec/rails` la corre, en su propio proceso). Sin coverage tool
15
20
  configurado.
16
21
 
17
22
  ## 2. Cuerpo
@@ -20,7 +25,8 @@ configurado.
20
25
 
21
26
  | framework | dir | nivel | nº | propósito |
22
27
  |---|---|---|---|---|
23
- | **RSpec** `~> 3.0` | `spec/unit/` | unit | 17 | client/session pool, configuration, consumer, `drain_tracker`, producer, controller, raise_error, remote_error, request, route, observability, otel, resource, middleware, `extra_top_level_params` (hook de params hermanos en `Resource#save`) |
28
+ | **RSpec** `~> 3.0` | `spec/unit/` | unit | 18 | client/session pool, configuration, consumer, `drain_tracker`, producer, controller, raise_error, remote_error, request, route, observability, otel, resource, middleware, `extra_top_level_params` (hook de params hermanos en `Resource#save`), `consumer_rails_executor_outside_rails` (fuera de Rails no se carga el Railtie ni el middleware del executor) |
29
+ | **RSpec** + `railties`/`activerecord`/`sqlite3` | `spec/rails/` | integration con Rails (sin broker) | 1 | `consumer_rails_executor`: una app Rails mínima (`spec/rails/app.rb`, ActiveRecord sobre SQLite en memoria) prueba que cada mensaje corre dentro del executor — el `CurrentAttributes` no pasa al siguiente, la conexión vuelve al pool, el orden frente a un middleware de tracing, el error que escapa (reject + `Rails.error`) y `consumer_rails_executor = false` (en subproceso) |
24
30
  | **RSpec** | `spec/integration/` | integration | 8 | client, consumer_middleware, controller, drain, error_handling, infrastructure, publisher_confirms, resource — **requieren RabbitMQ real** (usan `BugBunny.create_connection` + pool) |
25
31
  | **Minitest** `~> 5.0` (+ `mocha`, `minitest-reporters`) | `test/integration/` | integration (legacy) | 2 | `manual_client_test.rb`, `infrastructure_test.rb` — tarea `:test`, **no** en default ni CI |
26
32
 
@@ -30,13 +36,16 @@ Sin tags declarados (`:slow`/`:js`) en la config de RSpec.
30
36
 
31
37
  | objetivo | comando | qué corre |
32
38
  |---|---|---|
33
- | default / CI | `bundle exec rake` | tarea `:spec` (toda la suite RSpec) — `Rakefile:` `task default: :spec` |
39
+ | default / CI | `bundle exec rake` | `:spec` (la suite RSpec menos `spec/rails/`) y después `spec:rails`, cada una en su proceso — `Rakefile:` `task default: [:spec, 'spec:rails']` |
40
+ | `spec/` sin Rails | `bundle exec rspec` | todo `spec/` menos `spec/rails/` (lo excluye `.rspec` cuando no se pide `spec/rails` explícitamente) |
34
41
  | solo unit | `bundle exec rake spec:unit` | `spec/unit/**/*_spec.rb` |
35
42
  | solo integration | `bundle exec rake spec:integration` | `spec/integration/**/*_spec.rb` (requiere broker) |
43
+ | Railtie (Rails) | `bundle exec rake spec:rails` | `spec/rails/**/*_spec.rb`, en su propio proceso (Rails se inicializa una vez por proceso); equivale a `bundle exec rspec spec/rails` |
36
44
  | Minitest legacy | `bundle exec rake test` | `test/**/*_test.rb` |
37
45
  | CI | `.github/workflows/main.yml` → `bundle exec rake` | matrix Ruby `3.4.4`, `ruby/setup-ruby@v1` + `bundler-cache`, on push `main` + PR |
38
46
 
39
- > Todas las tareas RSpec corren con `--require spec_helper` (`Rakefile`).
47
+ > Las tareas RSpec corren con `--require spec_helper` (`Rakefile`), salvo `spec:rails`, que
48
+ > carga su propio `spec/rails/rails_helper.rb`.
40
49
 
41
50
  ### c. Fixtures / Factories
42
51
 
@@ -185,6 +185,16 @@ module BugBunny
185
185
  # `Client#publish`.
186
186
  attr_accessor :return_raise
187
187
 
188
+ # @return [Boolean] Si `true` (default) y la gema corre dentro de Rails, el Railtie envuelve
189
+ # cada mensaje del Consumer con `Rails.application.executor.wrap`
190
+ # ({BugBunny::ConsumerMiddleware::RailsExecutor}): el estado por hilo (`CurrentAttributes`,
191
+ # la conexión de ActiveRecord) no pasa de un mensaje al siguiente.
192
+ # Fuera de Rails no tiene efecto.
193
+ #
194
+ # Se lee al terminar de inicializar la app, así que se apaga desde `config/initializers`.
195
+ # Apagalo si el consumidor ya envuelve cada mensaje por su cuenta.
196
+ attr_accessor :consumer_rails_executor
197
+
188
198
  # @!endgroup
189
199
 
190
200
  # Inicializa la configuración con valores por defecto seguros.
@@ -264,6 +274,7 @@ module BugBunny
264
274
  @on_return = nil
265
275
  @nack_raise = true
266
276
  @return_raise = true
277
+ @consumer_rails_executor = true
267
278
  end
268
279
 
269
280
  # Defaults de {BugBunny::Consumer#drain}.
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugBunny
4
+ module ConsumerMiddleware
5
+ # Envuelve cada mensaje con el executor de Rails, como hace Sidekiq con cada job.
6
+ #
7
+ # El Consumer procesa los mensajes en el hilo de trabajo de Bunny, que Rails no conoce. Sin
8
+ # el executor, lo que Rails guarda por hilo pasa de un mensaje al siguiente:
9
+ # - los `ActiveSupport::CurrentAttributes` (y lo que vive en ellos: tags, trazas, métricas);
10
+ # - la conexión de ActiveRecord, que el hilo retiene hasta morir en vez de devolverla al pool.
11
+ #
12
+ # Además, el executor prende la caché de queries de ActiveRecord durante el mensaje y la limpia al
13
+ # terminarlo (en el hilo de Bunny estaba apagada).
14
+ #
15
+ # Lo registra el Railtie al principio de la cadena (ver {Stack#prepend}) salvo que
16
+ # `config.consumer_rails_executor` sea `false`. Fuera de Rails no se carga.
17
+ #
18
+ # **Errores:** `executor.wrap` relanza lo que escapa del mensaje, así que sigue el camino de
19
+ # siempre (el Consumer lo rechaza sin requeue). En Rails >= 7.0, antes de relanzar, además lo
20
+ # reporta a `Rails.error` con `handled: false`.
21
+ #
22
+ # Si el hilo ya está dentro del executor, `wrap` no abre otro: sólo ejecuta el bloque.
23
+ class RailsExecutor < Base
24
+ # @param delivery_info [Bunny::DeliveryInfo]
25
+ # @param properties [Bunny::MessageProperties]
26
+ # @param body [String]
27
+ # @return [void]
28
+ def call(delivery_info, properties, body)
29
+ ::Rails.application.executor.wrap { @app.call(delivery_info, properties, body) }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -61,6 +61,19 @@ module BugBunny
61
61
  self
62
62
  end
63
63
 
64
+ # Registra un middleware al principio de la cadena, para que envuelva a todos los demás. Thread-safe.
65
+ #
66
+ # Es para lo que tiene que quedar afuera de todo sin depender del orden en que se registran los
67
+ # demás: {RailsExecutor} va primero porque limpia el estado por hilo al arrancar, y un middleware
68
+ # que carga ese estado antes que él (el de tracing de ExisRay) lo perdería.
69
+ #
70
+ # @param middleware_class [Class] Clase que hereda de {Base}.
71
+ # @return [self]
72
+ def prepend(middleware_class)
73
+ @mutex.synchronize { @middlewares.unshift(middleware_class) }
74
+ self
75
+ end
76
+
64
77
  # @return [Boolean] `true` si no hay middlewares registrados. Thread-safe.
65
78
  def empty?
66
79
  @mutex.synchronize { @middlewares.empty? }
@@ -8,6 +8,7 @@ module BugBunny
8
8
  # Esta clase es responsable de:
9
9
  # 1. Registrar la carpeta `app/rabbit` en el autoloader de Rails (Zeitwerk).
10
10
  # 2. Gestionar la seguridad de las conexiones durante el "forking" de procesos (Puma/Spring).
11
+ # 3. Envolver cada mensaje del Consumer con el executor de Rails (ver {ConsumerMiddleware::RailsExecutor}).
11
12
  #
12
13
  # @see https://guides.rubyonrails.org/engines.html#railtie
13
14
  class Railtie < ::Rails::Railtie
@@ -34,12 +35,27 @@ module BugBunny
34
35
  end
35
36
  end
36
37
 
37
- # 3. Rake tasks (bug_bunny:sync)
38
+ # 3. Executor de Rails alrededor de cada mensaje del Consumer
39
+ #
40
+ # El Consumer procesa en el hilo de Bunny, que Rails no conoce: sin esto, el estado por hilo
41
+ # pasa de un mensaje al siguiente. Va al principio de la cadena (`prepend`, no `use`) para que
42
+ # envuelva también a los middlewares que cargan ese estado, como el de tracing de ExisRay, que
43
+ # se registra en su propio `after_initialize` y en un orden que no controlamos.
44
+ config.after_initialize do
45
+ # Sin un `BugBunny.configure` previo, vale la configuración por default; un `configure` posterior la reusa.
46
+ BugBunny.configuration ||= BugBunny::Configuration.new
47
+ if BugBunny.configuration.consumer_rails_executor
48
+ require_relative 'consumer_middleware/rails_executor'
49
+ BugBunny.consumer_middlewares.prepend(BugBunny::ConsumerMiddleware::RailsExecutor)
50
+ end
51
+ end
52
+
53
+ # 4. Rake tasks (bug_bunny:sync)
38
54
  rake_tasks do
39
55
  load File.expand_path('../tasks/bug_bunny.rake', __dir__)
40
56
  end
41
57
 
42
- # 4. Hook de Spring (Preloader)
58
+ # 5. Hook de Spring (Preloader)
43
59
  if defined?(Spring)
44
60
  Spring.after_fork do
45
61
  BugBunny.disconnect
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = '5.2.0'
4
+ VERSION = '5.3.0'
5
5
  end
data/skill/SKILL.md CHANGED
@@ -19,10 +19,12 @@ 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.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).
22
+ > Resume el contrato de **`bug_bunny` 5.3.0** (anclado a `v5.3.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
23
  >
24
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).
25
25
  >
26
+ > **Si venís de 5.2.x, dos cambios de conducta dentro de Rails (5.3.0):** cada mensaje del Consumer corre dentro de `Rails.application.executor.wrap`. En Rails ≥ 7.0, un error que escapa del mensaje además se reporta a `Rails.error` (el mensaje se sigue rechazando sin requeue), y la caché de queries de ActiveRecord queda prendida durante cada mensaje. Se apagan con `config.consumer_rails_executor = false`. Detalle en [`docs/behavior/behavior.md`](../docs/behavior/behavior.md) y [`docs/config/configuracion.md`](../docs/config/configuracion.md).
27
+ >
26
28
  > **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`.
27
29
 
28
30
  **Símbolos públicos clave**
@@ -63,16 +65,16 @@ client.publish('events', body: { type: 'x' }) # => { 'status' => 202 }
63
65
 
64
66
  ## Índice de artefactos (fuente de verdad)
65
67
 
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).
68
+ El detalle vive en `docs/<capa>/` (modelo `dev-*`); esta skill **indexa y resume**, no duplica. Links relativos = version-locked (mismo tag del release, `v5.3.0`; `gemspec.files` incluye `docs/**`, así que estos archivos viajan dentro del `.gem` que ya tenés instalado).
67
69
 
68
70
  | Capa | Artefacto | Estado |
69
71
  |---|---|---|
70
72
  | Glosario de dominio | [docs/glossary/glossary.md](../docs/glossary/glossary.md) | parcial, acreta por PR |
71
- | Comportamiento (flujos) | [docs/behavior/behavior.md](../docs/behavior/behavior.md) | completa — 6 flujos |
73
+ | Comportamiento (flujos) | [docs/behavior/behavior.md](../docs/behavior/behavior.md) | completa — 8 flujos |
72
74
  | Configuración | [docs/config/configuracion.md](../docs/config/configuracion.md) | §a-§e/§i estructura + §f/§g/§h enrich — completa |
73
75
  | Dependencias consumidas | [docs/consumed/rabbitmq.md](../docs/consumed/rabbitmq.md) | §a/§b/§d estructura + §c/§e enrich |
74
76
  | Errores | [docs/errors/errors.md](../docs/errors/errors.md) | §a/§b/§d completas; §c política inferida (verificación humana pendiente) |
75
- | Test | [docs/test/testing.md](../docs/test/testing.md) | §a-§h — 16 unit / 7 integration |
77
+ | Test | [docs/test/testing.md](../docs/test/testing.md) | §a-§h — 18 unit / 8 integration / 1 rails (`spec/rails/`, en su propio proceso) |
76
78
  | Release | [docs/release/release.md](../docs/release/release.md) | completa (régimen gema: build→publish; §e/f/g n/a) |
77
79
  | Datos | — | n/a — gema sin DB |
78
80
  | Eventos | — | n/a — la gema **es** el transporte; no declara catálogo de eventos de dominio propio |
@@ -139,7 +141,8 @@ Por RFC-008 §2: no se fabrica la capa, no se borra contrato sin destino, no se
139
141
  | `BugBunny::Producer` | Publica mensajes. Implementa tres modos: `#fire` (async), `#confirmed` (sync con `wait_for_confirms`) y `#rpc` (direct reply-to + `Concurrent::IVar`). |
140
142
  | `BugBunny::Client` | API de alto nivel. Pool de conexiones y middleware stack (onion architecture). |
141
143
  | `BugBunny::Consumer` | Subscribe loop con health check. Rutea mensajes via `BugBunny.routes`. |
142
- | `BugBunny::ConsumerMiddleware::Stack` | Pipeline de middlewares antes de `process_message`. Thread-safe. |
144
+ | `BugBunny::ConsumerMiddleware::Stack` | Pipeline de middlewares antes de `process_message`. Thread-safe. `use` agrega al final; `prepend` pone al principio (envuelve a todos). |
145
+ | `BugBunny::ConsumerMiddleware::RailsExecutor` | Sólo en Rails: envuelve cada mensaje con `Rails.application.executor.wrap`, para que el estado por hilo (`CurrentAttributes`, conexión de AR) no pase al siguiente. Lo registra el Railtie primero; se apaga con `config.consumer_rails_executor = false`. |
143
146
  | `BugBunny::Controller` | Base class tipo Rails. `before_action`, `around_action`, `after_action`, `rescue_from`, `render`. |
144
147
  | `BugBunny::Resource` | ORM sobre AMQP. `find`, `where`, `create`, `save`, `destroy`. ActiveModel validations y callbacks. |
145
148
  | `BugBunny::Routing::RouteSet` | DSL de rutas: `resources`, `namespace`, `member`, `collection`. |
@@ -147,7 +150,7 @@ Por RFC-008 §2: no se fabrica la capa, no se borra contrato sin destino, no se
147
150
  | `BugBunny::Middleware::Stack` | Builder de middlewares client-side (onion architecture tipo Faraday). |
148
151
  | BugBunny::Request | Value object del mensaje saliente con metadata AMQP completa. |
149
152
  | BugBunny::OTel | Helpers para emitir campos siguiendo las OTel semantic conventions for messaging. |
150
- | BugBunny::Railtie | Integración Rails: autoload de `app/rabbit`, fork safety (Puma, Spring). |
153
+ | BugBunny::Railtie | Integración Rails: autoload de `app/rabbit`, fork safety (Puma, Spring), executor de Rails alrededor de cada mensaje del Consumer. |
151
154
 
152
155
  ---
153
156
 
@@ -386,6 +389,9 @@ Cada slot del pool cachea su `Session` y `Producer` durante su vida útil. Esto
386
389
  ### ¿Cómo funciona fork safety?
387
390
  `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.
388
391
 
392
+ ### ¿Por qué el estado de un mensaje no pasa al siguiente?
393
+ El Consumer procesa en el hilo de trabajo de Bunny, que Rails no conoce. En Rails, el Railtie registra `ConsumerMiddleware::RailsExecutor` al principio de la cadena (`prepend`), que envuelve cada mensaje con `Rails.application.executor.wrap`: al entrar y al salir se resetean los `CurrentAttributes`, y al salir la conexión de ActiveRecord vuelve al pool. Va primero para envolver también a los middlewares que cargan estado por hilo (el de tracing de ExisRay). **Cambios de conducta:** en Rails ≥ 7.0, un error que escapa del mensaje además se reporta a `Rails.error` con `handled: false` (el mensaje se sigue rechazando sin requeue); y la caché de queries de AR queda prendida durante cada mensaje (en el hilo de Bunny estaba apagada). Apagado: `config.consumer_rails_executor = false`.
394
+
389
395
  ---
390
396
 
391
397
  ## Antipatrones
@@ -87,6 +87,15 @@ BugBunny.configuration.consumer_middlewares.use MyTracing::Middleware
87
87
  BugBunny.configuration.consumer_middlewares.use MyAuth::Middleware
88
88
  ```
89
89
 
90
+ `use` agrega al final de la cadena; `prepend` pone al principio, envolviendo a todos.
91
+
92
+ ### Executor de Rails (automático en Rails)
93
+
94
+ En Rails, el Railtie registra `BugBunny::ConsumerMiddleware::RailsExecutor` **primero**: cada mensaje corre dentro
95
+ de `Rails.application.executor.wrap`, así que los `CurrentAttributes` no pasan de un mensaje al siguiente y la
96
+ conexión de ActiveRecord vuelve al pool. Cambios de conducta: en Rails ≥ 7.0, un error que escapa del mensaje además
97
+ se reporta a `Rails.error`, y la caché de queries de AR queda prendida durante cada mensaje. Se apaga con `config.consumer_rails_executor = false` (desde `config/initializers`).
98
+
90
99
  ### Crear Middleware
91
100
 
92
101
  ```ruby
@@ -131,7 +131,10 @@ TEST_POOL = ConnectionPool.new(size: 5) { BugBunny.create_connection }
131
131
  ## Ejecutar Tests
132
132
 
133
133
  ```bash
134
- bundle exec rspec # Todos
134
+ bundle exec rake # Todos: spec/ y spec/rails/ (cada una en su proceso)
135
+ bundle exec rspec # spec/, sin Rails (.rspec excluye spec/rails/ si no se la pide)
136
+ bundle exec rspec spec/rails # Solo el Railtie (igual que rake spec:rails)
137
+ bundle exec rake spec:rails # Solo el Railtie, con una app Rails mínima
135
138
  bundle exec rspec spec/unit/ # Solo unit
136
139
  bundle exec rspec spec/integration/ # Solo integration (requiere RabbitMQ)
137
140
  bundle exec rspec spec/unit/session_spec.rb # Archivo específico
data/spec/rails/app.rb ADDED
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # App Rails mínima para probar el Railtie de BugBunny con Rails de verdad (ActiveRecord sobre SQLite
4
+ # en memoria).
5
+ #
6
+ # Rails se inicializa una vez por proceso, así que estos specs corren en su propio proceso
7
+ # (`rake spec:rails`): la suite de `spec/` sigue probando la gema fuera de Rails. Para bootear con
8
+ # otra configuración, el spec corre este archivo solo, en un subproceso (`ruby spec/rails/app.rb`),
9
+ # y lee la cadena de middlewares que imprime.
10
+ #
11
+ # `BUG_BUNNY_SPEC_EXECUTOR=off` bootea con `config.consumer_rails_executor = false`.
12
+ # `BUG_BUNNY_SPEC_CONFIGURE=none` bootea sin llamar a `BugBunny.configure` ni registrar otros middlewares:
13
+ # la configuración queda la de default.
14
+
15
+ ENV['RAILS_ENV'] = 'test'
16
+ ENV['DATABASE_URL'] = 'sqlite3::memory:'
17
+
18
+ require 'bundler/setup'
19
+ require 'logger'
20
+ require 'rails'
21
+ require 'active_record/railtie'
22
+ require 'bug_bunny'
23
+
24
+ module BugBunnyRailsSpec
25
+ # La app: sin archivos de configuración propios, todo acá.
26
+ class Application < Rails::Application
27
+ config.root = __dir__
28
+ config.eager_load = false
29
+ config.logger = Logger.new(nil)
30
+ config.secret_key_base = 'bug_bunny-rails-spec'
31
+ end
32
+
33
+ # Lo que un mensaje deja por hilo: lo que los specs miran pasar (o no) al siguiente.
34
+ class LeftoverState < ActiveSupport::CurrentAttributes
35
+ attribute :value
36
+ end
37
+
38
+ # Contexto de traza, como el `ExisRay::Tracer` de ExisRay: un `CurrentAttributes`.
39
+ class TraceContext < ActiveSupport::CurrentAttributes
40
+ attribute :trace_id
41
+ end
42
+
43
+ # Un middleware que carga estado por hilo antes de procesar el mensaje y lo limpia al salir, como
44
+ # el de tracing de ExisRay. Se registra ANTES de que el Railtie de BugBunny registre el suyo: es el
45
+ # orden que hace falta para ver si el executor lo borra.
46
+ class TracingMiddleware < BugBunny::ConsumerMiddleware::Base
47
+ def call(delivery_info, properties, body)
48
+ TraceContext.trace_id = 'from-tracing'
49
+ @app.call(delivery_info, properties, body)
50
+ ensure
51
+ TraceContext.reset
52
+ end
53
+ end
54
+ end
55
+
56
+ unless ENV['BUG_BUNNY_SPEC_CONFIGURE'] == 'none'
57
+ BugBunny.configure do |config|
58
+ config.logger = Logger.new(nil)
59
+ # El `respond_to?` deja correr estos specs contra una versión de la gema sin la opción, para ver
60
+ # que fallan sin el cambio.
61
+ if config.respond_to?(:consumer_rails_executor=)
62
+ config.consumer_rails_executor = ENV['BUG_BUNNY_SPEC_EXECUTOR'] != 'off'
63
+ end
64
+ end
65
+ BugBunny.consumer_middlewares.use(BugBunnyRailsSpec::TracingMiddleware)
66
+ end
67
+
68
+ BugBunnyRailsSpec::Application.initialize!
69
+
70
+ puts BugBunny.consumer_middlewares.instance_variable_get(:@middlewares).map(&:name) if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rails_helper'
4
+ require 'open3'
5
+
6
+ # Cada mensaje del Consumer corre dentro del executor de Rails (sequre/bug_bunny#70).
7
+ #
8
+ # Las entregas se procesan en un hilo propio, como el hilo de trabajo de Bunny: Rails no lo conoce.
9
+ RSpec.describe 'BugBunny::ConsumerMiddleware::RailsExecutor' do
10
+ let(:channel) do
11
+ double('channel').tap do |ch|
12
+ allow(ch).to receive(:ack)
13
+ allow(ch).to receive(:reject)
14
+ allow(ch).to receive(:open?).and_return(true)
15
+ end
16
+ end
17
+
18
+ let(:consumer) do
19
+ session = instance_double(BugBunny::Session, channel: channel, close: nil)
20
+ described_consumer = BugBunny::Consumer.new(double('connection'))
21
+ described_consumer.instance_variable_set(:@session, session)
22
+ described_consumer
23
+ end
24
+
25
+ let(:properties) do
26
+ double('properties', type: 'probe/show', headers: { 'x-http-method' => 'GET' },
27
+ correlation_id: 'corr-1', reply_to: nil, content_type: 'application/json')
28
+ end
29
+
30
+ # Lo que hace la acción del controller en cada entrega.
31
+ let(:on_message) { -> {} }
32
+
33
+ before do
34
+ allow(BugBunny.routes).to receive(:recognize)
35
+ .and_return({ controller: 'probe', action: 'show', params: {}, namespace: nil })
36
+
37
+ handler = on_message
38
+ stub_const('BugBunny::Controllers::ProbeController', Class.new(BugBunny::Controller) do
39
+ define_method(:show) do
40
+ handler.call
41
+ render status: 200, json: {}
42
+ end
43
+ end)
44
+ end
45
+
46
+ # Procesa las entregas en orden, en un hilo propio (como el de Bunny).
47
+ def deliver_on_a_bunny_like_thread(*tags)
48
+ Thread.new do
49
+ tags.each do |tag|
50
+ delivery_info = double('delivery_info', exchange: 'x', routing_key: 'probe', delivery_tag: tag,
51
+ redelivered?: false)
52
+ yield(:before, tag) if block_given?
53
+ consumer.send(:handle_delivery, delivery_info, properties, '{}')
54
+ yield(:after, tag) if block_given?
55
+ end
56
+ end.join
57
+ end
58
+
59
+ it 'queda primero en la cadena, antes del middleware de tracing registrado antes que él' do
60
+ middlewares = BugBunny.consumer_middlewares.instance_variable_get(:@middlewares).map(&:name)
61
+
62
+ expect(middlewares).to eq(%w[BugBunny::ConsumerMiddleware::RailsExecutor BugBunnyRailsSpec::TracingMiddleware])
63
+ end
64
+
65
+ describe 'el estado por hilo no pasa de un mensaje al siguiente' do
66
+ let(:seen) { [] }
67
+ let(:on_message) do
68
+ lambda do
69
+ seen << BugBunnyRailsSpec::LeftoverState.value
70
+ BugBunnyRailsSpec::LeftoverState.value = 'left by the previous message'
71
+ end
72
+ end
73
+
74
+ it 'el segundo mensaje arranca sin lo que el primero dejó en un CurrentAttributes' do
75
+ deliver_on_a_bunny_like_thread(1, 2)
76
+
77
+ expect(seen).to eq([nil, nil])
78
+ end
79
+ end
80
+
81
+ describe 'la conexión de ActiveRecord' do
82
+ # Si el hilo tiene una conexión tomada del pool (en Rails 8.1, `active_connection?` devuelve la
83
+ # conexión, o nil).
84
+ def connection_leased?
85
+ ActiveRecord::Base.connection_pool.active_connection?.present?
86
+ end
87
+
88
+ let(:inside) { [] }
89
+ let(:on_message) do
90
+ lambda do
91
+ ActiveRecord::Base.connection.execute('SELECT 1')
92
+ inside << connection_leased?
93
+ end
94
+ end
95
+
96
+ it 'vuelve al pool al terminar el mensaje que la usó' do
97
+ after = []
98
+ deliver_on_a_bunny_like_thread(1) do |moment, _tag|
99
+ after << connection_leased? if moment == :after
100
+ end
101
+
102
+ expect(inside).to eq([true]) # el mensaje la usó
103
+ expect(after).to eq([false]) # y al terminar ya no la tiene el hilo
104
+ end
105
+ end
106
+
107
+ describe 'un middleware que carga estado antes que el executor' do
108
+ let(:seen) { [] }
109
+ let(:on_message) { -> { seen << BugBunnyRailsSpec::TraceContext.trace_id } }
110
+
111
+ it 'conserva ese estado dentro del mensaje: el executor lo envuelve y no lo borra' do
112
+ deliver_on_a_bunny_like_thread(1)
113
+
114
+ expect(seen).to eq(['from-tracing'])
115
+ end
116
+ end
117
+
118
+ describe 'un error que escapa del procesamiento del mensaje' do
119
+ let(:reports) { [] }
120
+ let(:subscriber) do
121
+ collected = reports
122
+ Class.new do
123
+ define_method(:report) { |error, handled:, **| collected << [error.message, handled] }
124
+ end.new
125
+ end
126
+
127
+ before { Rails.error.subscribe(subscriber) }
128
+ after { Rails.error.unsubscribe(subscriber) if Rails.error.respond_to?(:unsubscribe) } # no existe en Rails 7.0
129
+
130
+ it 'termina en un reject sin requeue, como hoy, y además se reporta a Rails.error' do
131
+ allow(consumer).to receive(:process_message).and_raise(RuntimeError, 'boom')
132
+
133
+ deliver_on_a_bunny_like_thread(7)
134
+
135
+ expect(channel).to have_received(:reject).with(7, false)
136
+ expect(reports).to eq([['boom', false]])
137
+ end
138
+ end
139
+
140
+ describe 'config.consumer_rails_executor' do
141
+ # Rails se inicializa una vez por proceso: la app se bootea de nuevo en un subproceso.
142
+ # Las líneas de stdout que nombran un middleware: un warning del entorno no las rompe.
143
+ def middlewares_after_boot(env)
144
+ app = File.expand_path('app.rb', __dir__)
145
+ stdout, stderr, status = Open3.capture3(env, RbConfig.ruby, app)
146
+ raise "la app no booteó:\n#{stderr}" unless status.success?
147
+
148
+ stdout.lines.map(&:strip).grep(/\ABugBunny/)
149
+ end
150
+
151
+ it 'con false, no registra el middleware' do
152
+ expect(middlewares_after_boot('BUG_BUNNY_SPEC_EXECUTOR' => 'off')).to eq(['BugBunnyRailsSpec::TracingMiddleware'])
153
+ end
154
+
155
+ it 'con true, lo registra primero' do
156
+ expect(middlewares_after_boot('BUG_BUNNY_SPEC_EXECUTOR' => 'on'))
157
+ .to eq(%w[BugBunny::ConsumerMiddleware::RailsExecutor BugBunnyRailsSpec::TracingMiddleware])
158
+ end
159
+
160
+ it 'sin BugBunny.configure previo, bootea y lo registra: la opción ausente vale su default' do
161
+ expect(middlewares_after_boot('BUG_BUNNY_SPEC_CONFIGURE' => 'none'))
162
+ .to eq(['BugBunny::ConsumerMiddleware::RailsExecutor'])
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helper de los specs que corren dentro de Rails. Se carga sólo desde `rake spec:rails`, en un
4
+ # proceso aparte del resto de la suite (ver spec/rails/app.rb).
5
+
6
+ require_relative 'app'
@@ -188,6 +188,12 @@ RSpec.describe BugBunny::Configuration do
188
188
  end
189
189
  end
190
190
 
191
+ describe 'consumer_rails_executor flag' do
192
+ it 'tiene default true (el Railtie envuelve cada mensaje con el executor de Rails)' do
193
+ expect(BugBunny::Configuration.new.consumer_rails_executor).to be(true)
194
+ end
195
+ end
196
+
191
197
  describe 'nack_raise flag' do
192
198
  it 'tiene default true (raise PublishNacked en NACK)' do
193
199
  expect(BugBunny::Configuration.new.nack_raise).to be(true)
@@ -47,6 +47,19 @@ RSpec.describe BugBunny::ConsumerMiddleware::Stack do
47
47
  end
48
48
  end
49
49
 
50
+ describe '#prepend' do
51
+ it 'registra el middleware al principio: envuelve a los ya registrados' do
52
+ log = []
53
+ stack.use(tracking_middleware(log, :a))
54
+ result = stack.prepend(tracking_middleware(log, :first))
55
+
56
+ stack.call(delivery_info, properties, body) { log << :core }
57
+
58
+ expect(result).to be(stack)
59
+ expect(log).to eq(%i[first_before a_before core a_after first_after])
60
+ end
61
+ end
62
+
50
63
  describe '#empty?' do
51
64
  it 'retorna true cuando no hay middlewares' do
52
65
  expect(stack.empty?).to be(true)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # Fuera de Rails la gema se comporta como antes de sequre/bug_bunny#70: el middleware del executor
6
+ # lo registra el Railtie, y el Railtie no se carga.
7
+ RSpec.describe 'BugBunny fuera de Rails' do
8
+ it 'no carga el Railtie' do
9
+ expect(defined?(Rails)).to be_nil
10
+ expect(defined?(BugBunny::Railtie)).to be_nil
11
+ end
12
+
13
+ it 'no carga el middleware del executor ni lo pone en la cadena del Consumer' do
14
+ middlewares = BugBunny.consumer_middlewares.instance_variable_get(:@middlewares).map(&:name)
15
+
16
+ expect(defined?(BugBunny::ConsumerMiddleware::RailsExecutor)).to be_nil
17
+ expect(middlewares).not_to include('BugBunny::ConsumerMiddleware::RailsExecutor')
18
+ end
19
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bug_bunny
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.2.0
4
+ version: 5.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - gabix
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-29 00:00:00.000000000 Z
11
+ date: 2026-10-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bunny
@@ -220,6 +220,48 @@ dependencies:
220
220
  - - "~>"
221
221
  - !ruby/object:Gem::Version
222
222
  version: '1.6'
223
+ - !ruby/object:Gem::Dependency
224
+ name: activerecord
225
+ requirement: !ruby/object:Gem::Requirement
226
+ requirements:
227
+ - - ">="
228
+ - !ruby/object:Gem::Version
229
+ version: '6.1'
230
+ type: :development
231
+ prerelease: false
232
+ version_requirements: !ruby/object:Gem::Requirement
233
+ requirements:
234
+ - - ">="
235
+ - !ruby/object:Gem::Version
236
+ version: '6.1'
237
+ - !ruby/object:Gem::Dependency
238
+ name: railties
239
+ requirement: !ruby/object:Gem::Requirement
240
+ requirements:
241
+ - - ">="
242
+ - !ruby/object:Gem::Version
243
+ version: '6.1'
244
+ type: :development
245
+ prerelease: false
246
+ version_requirements: !ruby/object:Gem::Requirement
247
+ requirements:
248
+ - - ">="
249
+ - !ruby/object:Gem::Version
250
+ version: '6.1'
251
+ - !ruby/object:Gem::Dependency
252
+ name: sqlite3
253
+ requirement: !ruby/object:Gem::Requirement
254
+ requirements:
255
+ - - ">="
256
+ - !ruby/object:Gem::Version
257
+ version: '1.4'
258
+ type: :development
259
+ prerelease: false
260
+ version_requirements: !ruby/object:Gem::Requirement
261
+ requirements:
262
+ - - ">="
263
+ - !ruby/object:Gem::Version
264
+ version: '1.4'
223
265
  description: BugBunny is a lightweight RPC framework for Ruby on Rails over RabbitMQ.
224
266
  It simulates a RESTful architecture with an intelligent router, Active Record-like
225
267
  resources, and middleware support.
@@ -229,6 +271,7 @@ executables: []
229
271
  extensions: []
230
272
  extra_rdoc_files: []
231
273
  files:
274
+ - ".rspec"
232
275
  - AGENTS.md
233
276
  - CHANGELOG.md
234
277
  - CLAUDE.md
@@ -247,6 +290,7 @@ files:
247
290
  - lib/bug_bunny/configuration.rb
248
291
  - lib/bug_bunny/consumer.rb
249
292
  - lib/bug_bunny/consumer_middleware.rb
293
+ - lib/bug_bunny/consumer_middleware/rails_executor.rb
250
294
  - lib/bug_bunny/controller.rb
251
295
  - lib/bug_bunny/drain_tracker.rb
252
296
  - lib/bug_bunny/exception.rb
@@ -287,6 +331,9 @@ files:
287
331
  - spec/integration/infrastructure_spec.rb
288
332
  - spec/integration/publisher_confirms_spec.rb
289
333
  - spec/integration/resource_spec.rb
334
+ - spec/rails/app.rb
335
+ - spec/rails/consumer_rails_executor_spec.rb
336
+ - spec/rails/rails_helper.rb
290
337
  - spec/spec_helper.rb
291
338
  - spec/support/bunny_mocks.rb
292
339
  - spec/support/integration_helper.rb
@@ -294,6 +341,7 @@ files:
294
341
  - spec/unit/communication_error_wrapping_spec.rb
295
342
  - spec/unit/configuration_spec.rb
296
343
  - spec/unit/consumer_middleware_spec.rb
344
+ - spec/unit/consumer_rails_executor_outside_rails_spec.rb
297
345
  - spec/unit/consumer_spec.rb
298
346
  - spec/unit/controller_after_action_spec.rb
299
347
  - spec/unit/drain_tracker_spec.rb
@@ -317,7 +365,7 @@ metadata:
317
365
  homepage_uri: https://github.com/gedera/bug_bunny
318
366
  source_code_uri: https://github.com/gedera/bug_bunny
319
367
  changelog_uri: https://github.com/gedera/bug_bunny/blob/main/CHANGELOG.md
320
- documentation_uri: https://github.com/gedera/bug_bunny/blob/v5.2.0/skill
368
+ documentation_uri: https://github.com/gedera/bug_bunny/blob/v5.3.0/skill
321
369
  post_install_message:
322
370
  rdoc_options: []
323
371
  require_paths: