bug_bunny 3.0.6 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +228 -4
- data/Rakefile +10 -6
- data/lib/bug_bunny/configuration.rb +13 -2
- data/lib/bug_bunny/consumer.rb +53 -25
- data/lib/bug_bunny/controller.rb +135 -91
- data/lib/bug_bunny/exception.rb +4 -0
- data/lib/bug_bunny/producer.rb +10 -3
- data/lib/bug_bunny/resource.rb +69 -150
- data/lib/bug_bunny/session.rb +65 -44
- data/lib/bug_bunny/version.rb +1 -1
- data/test/integration/fire_and_forget_test.rb +76 -0
- data/test/integration/rpc_flow_test.rb +78 -0
- data/test/test_helper.rb +24 -0
- data/test/unit/configuration_test.rb +40 -0
- data/test/unit/consumer_test.rb +44 -0
- data/test/unit/controller_headers_test.rb +38 -0
- data/test/unit/hybrid_resource_test.rb +60 -0
- data/test/unit/middleware_test.rb +61 -0
- data/test/unit/resource_test.rb +49 -0
- metadata +39 -2
data/lib/bug_bunny/resource.rb
CHANGED
|
@@ -1,71 +1,44 @@
|
|
|
1
|
-
#
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require 'active_model'
|
|
3
4
|
require 'active_support/core_ext/string/inflections'
|
|
4
5
|
require 'uri'
|
|
6
|
+
require 'set' # Necesario para el tracking manual
|
|
5
7
|
require 'rack/utils'
|
|
6
8
|
|
|
7
9
|
module BugBunny
|
|
8
10
|
# Clase base para modelos remotos que implementan **Active Record over AMQP (RESTful)**.
|
|
9
11
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# También gestiona la serialización automática de parámetros ("wrapping") para
|
|
14
|
-
# compatibilidad con Strong Parameters de Rails.
|
|
12
|
+
# Soporta un esquema híbrido de datos:
|
|
13
|
+
# 1. **Atributos Tipados:** Definidos con `attribute :name, :type`.
|
|
14
|
+
# 2. **Atributos Dinámicos:** Asignados al vuelo sin definición previa.
|
|
15
15
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# self.exchange = 'app.topic'
|
|
19
|
-
# self.resource_name = 'users'
|
|
20
|
-
# # Opcional: Personalizar la clave raíz del JSON
|
|
21
|
-
# self.param_key = 'user_data'
|
|
22
|
-
# end
|
|
16
|
+
# Implementa un sistema de "Dirty Tracking" híbrido para detectar cambios
|
|
17
|
+
# tanto en atributos tipados (via ActiveModel) como dinámicos (via Set manual).
|
|
23
18
|
#
|
|
24
|
-
# @
|
|
25
|
-
#
|
|
26
|
-
# user = User.with(routing_key: 'urgent').new(name: 'Gaby')
|
|
27
|
-
# user.save # Enviará a la cola 'urgent' aunque estemos fuera del bloque .with
|
|
19
|
+
# @author Gabriel
|
|
20
|
+
# @since 3.0.6
|
|
28
21
|
class Resource
|
|
29
22
|
include ActiveModel::API
|
|
23
|
+
include ActiveModel::Attributes
|
|
30
24
|
include ActiveModel::Dirty
|
|
31
25
|
include ActiveModel::Validations
|
|
32
26
|
extend ActiveModel::Callbacks
|
|
33
27
|
|
|
34
28
|
define_model_callbacks :save, :create, :update, :destroy
|
|
35
29
|
|
|
36
|
-
# @return [HashWithIndifferentAccess] Contenedor de los atributos remotos (JSON crudo).
|
|
37
30
|
attr_reader :remote_attributes
|
|
38
|
-
|
|
39
|
-
# @return [Boolean] Indica si el objeto ha sido guardado en el servicio remoto.
|
|
40
31
|
attr_accessor :persisted
|
|
41
|
-
|
|
42
|
-
# @return [String, nil] Routing Key capturada en el momento de la instanciación.
|
|
43
|
-
attr_accessor :routing_key
|
|
44
|
-
|
|
45
|
-
# @return [String, nil] Exchange capturado en el momento de la instanciación.
|
|
46
|
-
attr_accessor :exchange
|
|
47
|
-
|
|
48
|
-
# @return [String, nil] Tipo de Exchange capturado en el momento de la instanciación.
|
|
49
|
-
attr_accessor :exchange_type
|
|
32
|
+
attr_accessor :routing_key, :exchange, :exchange_type
|
|
50
33
|
|
|
51
34
|
class << self
|
|
52
|
-
# Configuración heredable
|
|
53
35
|
attr_writer :connection_pool, :exchange, :exchange_type, :resource_name, :routing_key, :param_key
|
|
54
36
|
|
|
55
|
-
|
|
56
|
-
# @api private
|
|
57
|
-
def thread_config(key)
|
|
58
|
-
Thread.current["bb_#{object_id}_#{key}"]
|
|
59
|
-
end
|
|
37
|
+
def thread_config(key); Thread.current["bb_#{object_id}_#{key}"]; end
|
|
60
38
|
|
|
61
|
-
# Resuelve la configuración buscando en: 1. Thread (Scope), 2. Clase, 3. Herencia.
|
|
62
|
-
# @api private
|
|
63
39
|
def resolve_config(key, instance_var)
|
|
64
|
-
# 1. Prioridad: Contexto de hilo (.with)
|
|
65
40
|
val = thread_config(key)
|
|
66
41
|
return val if val
|
|
67
|
-
|
|
68
|
-
# 2. Prioridad: Jerarquía de clases
|
|
69
42
|
target = self
|
|
70
43
|
while target <= BugBunny::Resource
|
|
71
44
|
value = target.instance_variable_get(instance_var)
|
|
@@ -75,38 +48,23 @@ module BugBunny
|
|
|
75
48
|
nil
|
|
76
49
|
end
|
|
77
50
|
|
|
78
|
-
# @return [ConnectionPool] El pool de conexiones asignado.
|
|
79
51
|
def connection_pool; resolve_config(:pool, :@connection_pool); end
|
|
80
|
-
|
|
81
|
-
# @return [String] El exchange configurado.
|
|
82
|
-
# @raise [ArgumentError] Si no se ha definido un exchange.
|
|
83
52
|
def current_exchange; resolve_config(:exchange, :@exchange) || raise(ArgumentError, "Exchange not defined"); end
|
|
84
|
-
|
|
85
|
-
# @return [String] El tipo de exchange (default: direct).
|
|
86
53
|
def current_exchange_type; resolve_config(:exchange_type, :@exchange_type) || 'direct'; end
|
|
87
54
|
|
|
88
|
-
# @return [String] El nombre del recurso (ej: 'users'). Se infiere del nombre de la clase si no existe.
|
|
89
55
|
def resource_name
|
|
90
56
|
resolve_config(:resource_name, :@resource_name) || name.demodulize.underscore.pluralize
|
|
91
57
|
end
|
|
92
58
|
|
|
93
|
-
# Define la clave raíz para envolver el payload JSON (Wrapping).
|
|
94
|
-
#
|
|
95
|
-
# Por defecto utiliza `model_name.element`, lo que elimina los namespaces.
|
|
96
|
-
# Ej: `Manager::Service` -> `'service'`.
|
|
97
|
-
#
|
|
98
|
-
# @return [String] La clave paramétrica.
|
|
99
59
|
def param_key
|
|
100
60
|
resolve_config(:param_key, :@param_key) || model_name.element
|
|
101
61
|
end
|
|
102
62
|
|
|
103
|
-
# Define un middleware para el cliente HTTP/AMQP de este recurso.
|
|
104
63
|
def client_middleware(&block)
|
|
105
64
|
@client_middleware_stack ||= []
|
|
106
65
|
@client_middleware_stack << block
|
|
107
66
|
end
|
|
108
67
|
|
|
109
|
-
# @api private
|
|
110
68
|
def resolve_middleware_stack
|
|
111
69
|
stack = []
|
|
112
70
|
target = self
|
|
@@ -118,28 +76,19 @@ module BugBunny
|
|
|
118
76
|
stack
|
|
119
77
|
end
|
|
120
78
|
|
|
121
|
-
# Instancia un cliente configurado con el pool y middlewares del recurso.
|
|
122
|
-
# @return [BugBunny::Client]
|
|
123
79
|
def bug_bunny_client
|
|
124
80
|
pool = connection_pool
|
|
125
81
|
raise BugBunny::Error, "Connection pool missing for #{name}" unless pool
|
|
126
|
-
|
|
127
82
|
BugBunny::Client.new(pool: pool) do |conn|
|
|
128
83
|
resolve_middleware_stack.each { |block| block.call(conn) }
|
|
129
84
|
end
|
|
130
85
|
end
|
|
131
86
|
|
|
132
|
-
# Ejecuta un bloque (o retorna un Proxy) con una configuración temporal.
|
|
133
|
-
# Útil para cambiar de exchange o routing_key para una operación específica.
|
|
134
|
-
#
|
|
135
|
-
# @example
|
|
136
|
-
# User.with(routing_key: 'urgent').create(params)
|
|
137
87
|
def with(exchange: nil, routing_key: nil, exchange_type: nil, pool: nil)
|
|
138
88
|
keys = { exchange: "bb_#{object_id}_exchange", exchange_type: "bb_#{object_id}_exchange_type", pool: "bb_#{object_id}_pool", routing_key: "bb_#{object_id}_routing_key" }
|
|
139
89
|
old_values = {}
|
|
140
90
|
keys.each { |k, v| old_values[k] = Thread.current[v] }
|
|
141
91
|
|
|
142
|
-
# Seteamos valores temporales
|
|
143
92
|
Thread.current[keys[:exchange]] = exchange if exchange
|
|
144
93
|
Thread.current[keys[:exchange_type]] = exchange_type if exchange_type
|
|
145
94
|
Thread.current[keys[:pool]] = pool if pool
|
|
@@ -152,40 +101,25 @@ module BugBunny
|
|
|
152
101
|
end
|
|
153
102
|
end
|
|
154
103
|
|
|
155
|
-
# Proxy para permitir encadenamiento: User.with(...).find(1)
|
|
156
104
|
class ScopeProxy < BasicObject
|
|
157
105
|
def initialize(target, keys, old_values); @target = target; @keys = keys; @old_values = old_values; end
|
|
158
106
|
def method_missing(method, *args, &block); @target.public_send(method, *args, &block); ensure; @keys.each { |k, v| ::Thread.current[v] = @old_values[k] }; end
|
|
159
107
|
end
|
|
160
108
|
|
|
161
|
-
# Calcula la Routing Key.
|
|
162
|
-
# @return [String]
|
|
163
109
|
def calculate_routing_key(id = nil)
|
|
164
|
-
# 1. Contexto .with
|
|
165
110
|
manual_rk = thread_config(:routing_key)
|
|
166
111
|
return manual_rk if manual_rk
|
|
167
|
-
|
|
168
|
-
# 2. Configuración estática
|
|
169
112
|
static_rk = resolve_config(:routing_key, :@routing_key)
|
|
170
113
|
return static_rk if static_rk.present?
|
|
171
|
-
|
|
172
|
-
# 3. Default: Resource name
|
|
173
114
|
resource_name
|
|
174
115
|
end
|
|
175
116
|
|
|
176
|
-
# @!group Acciones CRUD RESTful
|
|
177
|
-
|
|
178
|
-
# Busca recursos que coincidan con los filtros.
|
|
179
|
-
# Envía: GET resource?query
|
|
117
|
+
# @!group Acciones CRUD RESTful
|
|
180
118
|
def where(filters = {})
|
|
181
119
|
rk = calculate_routing_key
|
|
182
120
|
path = resource_name
|
|
183
|
-
|
|
184
|
-
# Usamos Rack para serializar anidamiento (q[service]=val)
|
|
185
121
|
path += "?#{Rack::Utils.build_nested_query(filters)}" if filters.present?
|
|
186
|
-
|
|
187
122
|
response = bug_bunny_client.request(path, method: :get, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk)
|
|
188
|
-
|
|
189
123
|
return [] unless response['body'].is_a?(Array)
|
|
190
124
|
response['body'].map do |attrs|
|
|
191
125
|
inst = new(attrs)
|
|
@@ -197,26 +131,18 @@ module BugBunny
|
|
|
197
131
|
|
|
198
132
|
def all; where({}); end
|
|
199
133
|
|
|
200
|
-
# Busca un recurso por ID.
|
|
201
|
-
# Envía: GET resource/id
|
|
202
134
|
def find(id)
|
|
203
135
|
rk = calculate_routing_key(id)
|
|
204
136
|
path = "#{resource_name}/#{id}"
|
|
205
|
-
|
|
206
137
|
response = bug_bunny_client.request(path, method: :get, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk)
|
|
207
|
-
|
|
208
138
|
return nil if response.nil? || response['status'] == 404
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
return nil unless attributes.is_a?(Hash)
|
|
212
|
-
|
|
213
|
-
instance = new(attributes)
|
|
139
|
+
return nil unless response['body'].is_a?(Hash)
|
|
140
|
+
instance = new(response['body'])
|
|
214
141
|
instance.persisted = true
|
|
215
142
|
instance.send(:clear_changes_information)
|
|
216
143
|
instance
|
|
217
144
|
end
|
|
218
145
|
|
|
219
|
-
# Crea un nuevo recurso.
|
|
220
146
|
def create(payload)
|
|
221
147
|
instance = new(payload)
|
|
222
148
|
instance.save
|
|
@@ -226,45 +152,32 @@ module BugBunny
|
|
|
226
152
|
|
|
227
153
|
# @!group Instancia
|
|
228
154
|
|
|
229
|
-
# Inicializa una nueva instancia del recurso.
|
|
230
|
-
#
|
|
231
|
-
# **IMPORTANTE:** Captura la configuración del contexto actual (`.with`)
|
|
232
|
-
# y la guarda en la instancia. Esto permite que objetos creados dentro de un bloque `with`
|
|
233
|
-
# mantengan esa configuración (routing_key, exchange) durante todo su ciclo de vida,
|
|
234
|
-
# incluso si `save` se llama fuera del bloque.
|
|
235
|
-
#
|
|
236
|
-
# @param attributes [Hash] Atributos iniciales.
|
|
237
155
|
def initialize(attributes = {})
|
|
238
156
|
@remote_attributes = {}.with_indifferent_access
|
|
157
|
+
@dynamic_changes = Set.new # Rastreo manual para atributos dinámicos
|
|
239
158
|
@persisted = false
|
|
240
|
-
|
|
241
|
-
# === CAPTURA DE CONTEXTO ===
|
|
242
159
|
@routing_key = self.class.thread_config(:routing_key)
|
|
243
160
|
@exchange = self.class.thread_config(:exchange)
|
|
244
161
|
@exchange_type = self.class.thread_config(:exchange_type)
|
|
245
162
|
|
|
246
|
-
assign_attributes(attributes)
|
|
247
163
|
super()
|
|
164
|
+
assign_attributes(attributes)
|
|
248
165
|
end
|
|
249
166
|
|
|
250
|
-
#
|
|
251
|
-
def
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
# Prioridad Exchange: 1. Instancia (Capturada), 2. Clase
|
|
257
|
-
def current_exchange
|
|
258
|
-
@exchange || self.class.current_exchange
|
|
167
|
+
# Limpia tanto el rastreo de ActiveModel como nuestro rastreo dinámico.
|
|
168
|
+
def clear_changes_information
|
|
169
|
+
super
|
|
170
|
+
@dynamic_changes.clear
|
|
259
171
|
end
|
|
260
172
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
@exchange_type || self.class.current_exchange_type
|
|
173
|
+
def attributes_for_serialization
|
|
174
|
+
@remote_attributes.merge(attributes)
|
|
264
175
|
end
|
|
265
176
|
|
|
177
|
+
def calculate_routing_key(id=nil); @routing_key || self.class.calculate_routing_key(id); end
|
|
178
|
+
def current_exchange; @exchange || self.class.current_exchange; end
|
|
179
|
+
def current_exchange_type; @exchange_type || self.class.current_exchange_type; end
|
|
266
180
|
def bug_bunny_client; self.class.bug_bunny_client; end
|
|
267
|
-
|
|
268
181
|
def persisted?; !!@persisted; end
|
|
269
182
|
|
|
270
183
|
def assign_attributes(new_attributes)
|
|
@@ -277,19 +190,36 @@ module BugBunny
|
|
|
277
190
|
save
|
|
278
191
|
end
|
|
279
192
|
|
|
280
|
-
# Retorna
|
|
193
|
+
# Retorna el hash combinado de cambios (Tipados + Dinámicos).
|
|
281
194
|
def changes_to_send
|
|
282
|
-
|
|
283
|
-
|
|
195
|
+
# 1. Cambios de ActiveModel (Tipados)
|
|
196
|
+
# changes returns { 'attr' => [old, new] } -> nos quedamos con new
|
|
197
|
+
payload = changes.transform_values(&:last)
|
|
198
|
+
|
|
199
|
+
# 2. Cambios Dinámicos (Manuales)
|
|
200
|
+
@dynamic_changes.each do |key|
|
|
201
|
+
payload[key] = @remote_attributes[key]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
return payload unless payload.empty?
|
|
205
|
+
|
|
206
|
+
# Fallback: Si no hay cambios detectados, enviamos todo (útil para create)
|
|
207
|
+
attributes_for_serialization.except('id', 'ID', 'Id', '_id')
|
|
284
208
|
end
|
|
285
209
|
|
|
286
|
-
#
|
|
210
|
+
# Intercepta asignaciones dinámicas y las marca como sucias.
|
|
287
211
|
def method_missing(method_name, *args, &block)
|
|
288
212
|
attribute_name = method_name.to_s
|
|
289
213
|
if attribute_name.end_with?('=')
|
|
290
214
|
key = attribute_name.chop
|
|
291
215
|
val = args.first
|
|
292
|
-
|
|
216
|
+
|
|
217
|
+
# Dirty Tracking Manual
|
|
218
|
+
# Si el valor cambia, lo marcamos en nuestro Set
|
|
219
|
+
if @remote_attributes[key] != val
|
|
220
|
+
@dynamic_changes << key
|
|
221
|
+
end
|
|
222
|
+
|
|
293
223
|
@remote_attributes[key] = val
|
|
294
224
|
else
|
|
295
225
|
@remote_attributes.key?(attribute_name) ? @remote_attributes[attribute_name] : super
|
|
@@ -301,50 +231,45 @@ module BugBunny
|
|
|
301
231
|
end
|
|
302
232
|
|
|
303
233
|
def id
|
|
304
|
-
@remote_attributes['id'] || @remote_attributes['ID'] || @remote_attributes['Id'] || @remote_attributes['_id']
|
|
234
|
+
attributes['id'] || @remote_attributes['id'] || @remote_attributes['ID'] || @remote_attributes['Id'] || @remote_attributes['_id']
|
|
305
235
|
end
|
|
306
236
|
|
|
307
237
|
def id=(value)
|
|
308
|
-
|
|
238
|
+
if self.class.attribute_names.include?('id')
|
|
239
|
+
super(value)
|
|
240
|
+
else
|
|
241
|
+
@remote_attributes['id'] = value
|
|
242
|
+
end
|
|
309
243
|
end
|
|
310
244
|
|
|
311
245
|
def read_attribute_for_validation(attr)
|
|
312
|
-
|
|
246
|
+
attr_s = attr.to_s
|
|
247
|
+
self.class.attribute_names.include?(attr_s) ? attribute(attr_s) : @remote_attributes[attr_s]
|
|
313
248
|
end
|
|
314
249
|
|
|
315
|
-
# @!group Persistencia
|
|
250
|
+
# @!group Persistencia
|
|
316
251
|
|
|
317
|
-
# Guarda el registro.
|
|
318
|
-
# Envía POST si es nuevo, PUT si ya existe.
|
|
319
|
-
#
|
|
320
|
-
# **AUTOMÁTICO:** Envuelve los parámetros en la clave del modelo (`param_key`).
|
|
321
|
-
# Ej: Manager::Service -> "service". Esto facilita `params.require(:service)`.
|
|
322
|
-
#
|
|
323
|
-
# @return [Boolean] true si se guardó correctamente.
|
|
324
252
|
def save
|
|
325
253
|
return false unless valid?
|
|
326
254
|
|
|
327
255
|
run_callbacks(:save) do
|
|
328
256
|
is_new = !persisted?
|
|
329
257
|
rk = calculate_routing_key(id)
|
|
330
|
-
|
|
331
|
-
# 1. Obtenemos el payload plano (atributos modificados)
|
|
332
258
|
flat_payload = changes_to_send
|
|
333
|
-
|
|
334
|
-
# 2. Wrappeamos automáticamente en la clave del modelo
|
|
335
259
|
key = self.class.param_key
|
|
336
260
|
wrapped_payload = { key => flat_payload }
|
|
337
261
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
262
|
+
path = is_new ? self.class.resource_name : "#{self.class.resource_name}/#{id}"
|
|
263
|
+
method = is_new ? :post : :put
|
|
264
|
+
|
|
265
|
+
response = bug_bunny_client.request(
|
|
266
|
+
path,
|
|
267
|
+
method: method,
|
|
268
|
+
exchange: current_exchange,
|
|
269
|
+
exchange_type: current_exchange_type,
|
|
270
|
+
routing_key: rk,
|
|
271
|
+
body: wrapped_payload
|
|
272
|
+
)
|
|
348
273
|
|
|
349
274
|
handle_save_response(response)
|
|
350
275
|
end
|
|
@@ -353,17 +278,12 @@ module BugBunny
|
|
|
353
278
|
false
|
|
354
279
|
end
|
|
355
280
|
|
|
356
|
-
# Elimina el registro.
|
|
357
|
-
# Envía DELETE resource/id.
|
|
358
281
|
def destroy
|
|
359
282
|
return false unless persisted?
|
|
360
|
-
|
|
361
283
|
run_callbacks(:destroy) do
|
|
362
284
|
path = "#{self.class.resource_name}/#{id}"
|
|
363
285
|
rk = calculate_routing_key(id)
|
|
364
|
-
|
|
365
286
|
bug_bunny_client.request(path, method: :delete, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk)
|
|
366
|
-
|
|
367
287
|
self.persisted = false
|
|
368
288
|
end
|
|
369
289
|
true
|
|
@@ -381,7 +301,6 @@ module BugBunny
|
|
|
381
301
|
elsif response['status'] >= 400
|
|
382
302
|
raise BugBunny::ClientError
|
|
383
303
|
end
|
|
384
|
-
|
|
385
304
|
assign_attributes(response['body'])
|
|
386
305
|
self.persisted = true
|
|
387
306
|
clear_changes_information
|
data/lib/bug_bunny/session.rb
CHANGED
|
@@ -3,80 +3,101 @@
|
|
|
3
3
|
module BugBunny
|
|
4
4
|
# Clase interna que encapsula una unidad de trabajo sobre una conexión RabbitMQ.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# configura el QoS y facilita la creación de Exchanges y Colas.
|
|
6
|
+
# Gestiona el ciclo de vida de un `Bunny::Channel` implementando:
|
|
7
|
+
# 1. Carga Perezosa (Lazy Loading): El canal solo se abre al usarse.
|
|
8
|
+
# 2. Resiliencia: Intenta recuperar la conexión TCP si está cerrada.
|
|
10
9
|
#
|
|
11
10
|
# @api private
|
|
12
11
|
class Session
|
|
13
|
-
# Opciones por defecto
|
|
12
|
+
# Opciones por defecto (Mantenemos las que tenías en tu repo)
|
|
14
13
|
DEFAULT_EXCHANGE_OPTIONS = { durable: false, auto_delete: false }.freeze
|
|
15
|
-
|
|
16
|
-
# Opciones por defecto para Colas: No exclusivas, No durables, Auto-borrables.
|
|
17
|
-
# @note Por defecto las colas son volátiles (`auto_delete: true`). Para workers persistentes,
|
|
18
|
-
# se debe pasar explícitamente `durable: true, auto_delete: false`.
|
|
19
14
|
DEFAULT_QUEUE_OPTIONS = { exclusive: false, durable: false, auto_delete: true }.freeze
|
|
20
15
|
|
|
21
16
|
# @return [Bunny::Session] La conexión TCP subyacente.
|
|
22
17
|
attr_reader :connection
|
|
23
18
|
|
|
24
|
-
#
|
|
25
|
-
|
|
19
|
+
# Inicializa una nueva sesión sin abrir canales todavía.
|
|
20
|
+
#
|
|
21
|
+
# @param connection [Bunny::Session] Una conexión (puede estar abierta o cerrada temporalmente).
|
|
22
|
+
def initialize(connection)
|
|
23
|
+
@connection = connection
|
|
24
|
+
@channel = nil
|
|
25
|
+
end
|
|
26
26
|
|
|
27
|
-
#
|
|
27
|
+
# Obtiene el canal actual o crea uno nuevo si es necesario.
|
|
28
28
|
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# 3. Habilita "Publisher Confirms" para garantizar que los mensajes lleguen al broker.
|
|
32
|
-
# 4. Configura el "Prefetch" (QoS) global para este canal.
|
|
29
|
+
# Este método es el punto central de la robustez. Verifica la salud
|
|
30
|
+
# de la conexión y del canal antes de devolverlo.
|
|
33
31
|
#
|
|
34
|
-
# @
|
|
35
|
-
# @raise [BugBunny::
|
|
36
|
-
def
|
|
37
|
-
|
|
32
|
+
# @return [Bunny::Channel] Un canal abierto y configurado.
|
|
33
|
+
# @raise [BugBunny::CommunicationError] Si no se puede restablecer la conexión.
|
|
34
|
+
def channel
|
|
35
|
+
# Si el canal existe y está abierto, lo devolvemos rápido.
|
|
36
|
+
return @channel if @channel&.open?
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@channel
|
|
38
|
+
# Si no, intentamos asegurar la conexión y crear el canal.
|
|
39
|
+
ensure_connection!
|
|
40
|
+
create_channel!
|
|
41
|
+
|
|
42
|
+
@channel
|
|
44
43
|
end
|
|
45
44
|
|
|
46
45
|
# Factory method para declarar o recuperar un Exchange.
|
|
46
|
+
# Usa el método robusto `channel` internamente.
|
|
47
47
|
#
|
|
48
|
-
# @param name [String, nil]
|
|
49
|
-
# @param type [String, Symbol]
|
|
50
|
-
# @param opts [Hash] Opciones
|
|
51
|
-
# @return [Bunny::Exchange] La instancia del exchange.
|
|
48
|
+
# @param name [String, nil] Nombre del exchange.
|
|
49
|
+
# @param type [String, Symbol] Tipo de exchange.
|
|
50
|
+
# @param opts [Hash] Opciones adicionales.
|
|
52
51
|
def exchange(name: nil, type: 'direct', opts: {})
|
|
53
52
|
return channel.default_exchange if name.nil? || name.empty?
|
|
54
53
|
|
|
55
54
|
merged_opts = DEFAULT_EXCHANGE_OPTIONS.merge(opts)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
when :direct then channel.direct(name, merged_opts)
|
|
59
|
-
when :fanout then channel.fanout(name, merged_opts)
|
|
60
|
-
when :headers then channel.headers(name, merged_opts)
|
|
61
|
-
else channel.direct(name, merged_opts)
|
|
62
|
-
end
|
|
55
|
+
# public_send permite llamar a :topic, :direct, etc. dinámicamente
|
|
56
|
+
channel.public_send(type, name, merged_opts)
|
|
63
57
|
end
|
|
64
58
|
|
|
65
59
|
# Factory method para declarar o recuperar una Cola.
|
|
60
|
+
# Usa el método robusto `channel` internamente.
|
|
66
61
|
#
|
|
67
|
-
# @param name [String]
|
|
68
|
-
# @param opts [Hash] Opciones
|
|
69
|
-
# @return [Bunny::Queue] La instancia de la cola.
|
|
62
|
+
# @param name [String] Nombre de la cola.
|
|
63
|
+
# @param opts [Hash] Opciones adicionales.
|
|
70
64
|
def queue(name, opts = {})
|
|
71
65
|
channel.queue(name.to_s, DEFAULT_QUEUE_OPTIONS.merge(opts))
|
|
72
66
|
end
|
|
73
67
|
|
|
74
|
-
# Cierra el canal asociado a esta sesión.
|
|
75
|
-
# No cierra la conexión TCP (ya que esta pertenece al Pool), solo libera el canal virtual.
|
|
76
|
-
#
|
|
77
|
-
# @return [void]
|
|
68
|
+
# Cierra el canal asociado a esta sesión de forma segura.
|
|
78
69
|
def close
|
|
79
|
-
@channel
|
|
70
|
+
@channel&.close if @channel&.open?
|
|
71
|
+
@channel = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Crea y configura un nuevo canal.
|
|
77
|
+
# Asume que la conexión ya ha sido verificada por `ensure_connection!`.
|
|
78
|
+
def create_channel!
|
|
79
|
+
@channel = @connection.create_channel
|
|
80
|
+
|
|
81
|
+
# Configuraciones globales de BugBunny
|
|
82
|
+
@channel.confirm_select
|
|
83
|
+
|
|
84
|
+
if BugBunny.configuration.channel_prefetch
|
|
85
|
+
@channel.prefetch(BugBunny.configuration.channel_prefetch)
|
|
86
|
+
end
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
raise BugBunny::CommunicationError, "Failed to create channel: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Garantiza que la conexión TCP esté abierta.
|
|
92
|
+
# Si está cerrada, intenta reconectarla (Reconexión Transparente).
|
|
93
|
+
def ensure_connection!
|
|
94
|
+
return if @connection.open?
|
|
95
|
+
|
|
96
|
+
BugBunny.configuration.logger.warn("[BugBunny] Connection lost. Attempting to reconnect...")
|
|
97
|
+
@connection.start
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
BugBunny.configuration.logger.error("[BugBunny] Critical connection failure: #{e.message}")
|
|
100
|
+
raise BugBunny::CommunicationError, "Could not reconnect to RabbitMQ: #{e.message}"
|
|
80
101
|
end
|
|
81
102
|
end
|
|
82
103
|
end
|
data/lib/bug_bunny/version.rb
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../test_helper'
|
|
4
|
+
require 'connection_pool'
|
|
5
|
+
|
|
6
|
+
class FireAndForgetTest < Minitest::Test
|
|
7
|
+
def setup
|
|
8
|
+
skip "RabbitMQ no disponible" unless TestHelper.rabbitmq_available?
|
|
9
|
+
|
|
10
|
+
# 1. Configuración
|
|
11
|
+
BugBunny.configure do |c|
|
|
12
|
+
c.host = 'localhost'
|
|
13
|
+
c.username = 'wisproMQ'
|
|
14
|
+
c.password = 'wisproMQ'
|
|
15
|
+
c.port = 5672
|
|
16
|
+
c.logger = Logger.new(nil)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@pool = ConnectionPool.new(size: 1, timeout: 5) { BugBunny.create_connection }
|
|
20
|
+
|
|
21
|
+
# 2. Infraestructura de Test
|
|
22
|
+
@queue_name = 'test_fire_queue'
|
|
23
|
+
@exchange_name = 'test_fire_exchange'
|
|
24
|
+
@routing_key = 'logs.error'
|
|
25
|
+
|
|
26
|
+
# Usamos una Queue de Ruby para pasar el mensaje del Consumer al Test
|
|
27
|
+
@message_bucket = Queue.new
|
|
28
|
+
|
|
29
|
+
# 3. Consumidor "Espía"
|
|
30
|
+
@conn_consumer = BugBunny.create_connection
|
|
31
|
+
@consumer_thread = Thread.new do
|
|
32
|
+
ch = @conn_consumer.create_channel
|
|
33
|
+
# IMPORTANTE: Aquí declaramos el exchange como TOPIC
|
|
34
|
+
x = ch.topic(@exchange_name)
|
|
35
|
+
q = ch.queue(@queue_name).bind(x, routing_key: @routing_key)
|
|
36
|
+
|
|
37
|
+
q.subscribe(block: true) do |delivery_info, properties, body|
|
|
38
|
+
@message_bucket << {
|
|
39
|
+
body: body,
|
|
40
|
+
routing_key: delivery_info.routing_key
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
sleep 0.5 # Wait boot
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def teardown
|
|
48
|
+
return unless @conn_consumer
|
|
49
|
+
@conn_consumer.close
|
|
50
|
+
@consumer_thread.kill
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_publish_directly
|
|
54
|
+
client = BugBunny::Client.new(pool: @pool)
|
|
55
|
+
payload = { system: 'payment', error: 'timeout' }
|
|
56
|
+
|
|
57
|
+
# 1. Disparamos (Fire)
|
|
58
|
+
client.publish(
|
|
59
|
+
'logs/error',
|
|
60
|
+
body: payload,
|
|
61
|
+
exchange: @exchange_name,
|
|
62
|
+
exchange_type: 'topic',
|
|
63
|
+
routing_key: @routing_key
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# 2. Verificamos asíncronamente
|
|
67
|
+
received = nil
|
|
68
|
+
Timeout.timeout(2) do
|
|
69
|
+
received = @message_bucket.pop
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# 3. Aserciones
|
|
73
|
+
assert_equal payload.to_json, received[:body]
|
|
74
|
+
assert_equal @routing_key, received[:routing_key]
|
|
75
|
+
end
|
|
76
|
+
end
|