startback 2.1.0 → 2.1.1

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: 844c222d0b34ee64835caafe69e4a52b0fad2176653b007208fb16feba998556
4
- data.tar.gz: c7a8ff0dd59aa367d4de6fdf4f23c4c916ac52e98e935316ca267af834ef7655
3
+ metadata.gz: 618541ed038c6a619973934e3430a23eebd0ac9606f91d2535fbd7354e2835b1
4
+ data.tar.gz: 0161c46f4272637e7ae3862eca10d5098c2f109cc28ef9e019826ca2d627aa79
5
5
  SHA512:
6
- metadata.gz: e928708f893554970de832c1b0c1f1235134614f0fe7e6b82ed8208502f423c6f911d0ad3898f3ddd8185d0bdc85634d03ec40e9b169164100e64c72b1ca824e
7
- data.tar.gz: ab333a49176eda2128b6cc1c502135be60c4cbc9479f05afef983186452f412c9c388f12cd2cece970e3da5a2a39da05e24dd7c2793f5d57e53d2365d90847d0
6
+ metadata.gz: a12d45c8d149de30c27658638572bc32d9d5ba6d8fb0fd92a5a94a14681be318ce79473463476e5cd67c750639affbdb5d25aafc20ae4b3accd6a649a2762d20
7
+ data.tar.gz: 8b3e16023edb27a7969984576b512974ad1a68277e9d08e94b8399b1c9c475a1063f0e5afd4b49141577a58ab00853ac53193bfa3d018875b87e3dec1a21d839
data/CHANGELOG.md CHANGED
@@ -1,3 +1,40 @@
1
+ ## 2.1.1 - 2026-10-23
2
+
3
+ The Bunny event bus keeps consuming across a broker restart. **Nothing to do
4
+ to get the fix**; `alive?` is there for whoever wants a liveness probe.
5
+
6
+ ### Listeners survive a broker restart
7
+
8
+ 2.1.0 adopts a pre-existing exchange or queue passively when its properties
9
+ differ from the requested ones -- what an older Startback left behind, a
10
+ transient topology. Bunny does not record passive declarations for topology
11
+ recovery. After a broker restart the transient objects are gone, Bunny's
12
+ replay of the queue binding fails with `NOT_FOUND`, the broker closes the
13
+ channel the consumer lived on, and the application keeps running without
14
+ consuming anything, with nothing louder than an error line in Bunny's log.
15
+ It took a manual restart to get it back.
16
+
17
+ Once Bunny reports the connection recovered, the bus now forgets what it
18
+ knew of the topology and subscribes again every listener whose consumer did
19
+ not come back, declaring the exchange and queue as requested -- durable,
20
+ since the transient ones are gone. Listeners whose consumer survived are left
21
+ alone. An application's own `recovery_completed` connection option is still
22
+ called, first.
23
+
24
+ ### `Bus::Bunny::Async#alive?`
25
+
26
+ True when the bus is connected and every listener still has a consumer on an
27
+ open channel. A recovery that fails to subscribe a listener again leaves it
28
+ dead and logs the error; Bunny's own recovery may also never complete at all.
29
+ `alive?` reports both, which makes it the right thing for a liveness probe to
30
+ check:
31
+
32
+ ```ruby
33
+ map '/health/bus' do
34
+ run ->(_env) { bus.async.alive? ? [204, {}, []] : [503, {}, ["Bus is not consuming"]] }
35
+ end
36
+ ```
37
+
1
38
  ## 2.1.0 - 2026-09-30
2
39
 
3
40
  Ruby 4.0 support, docker images for several ruby versions, and the Bunny
@@ -28,6 +28,15 @@ module Startback
28
28
  # A warning is logged, and the durable declaration takes effect on its
29
29
  # own at the next broker restart, transient objects not surviving one.
30
30
  #
31
+ # Listeners **survive a broker restart**. Bunny recovers the topology
32
+ # it recorded, but not what was adopted passively: rebinding such a
33
+ # queue fails with NOT_FOUND and closes the channel its consumer lives
34
+ # on. Once Bunny reports the connection recovered, the bus therefore
35
+ # subscribes again every listener whose consumer did not come back.
36
+ # `alive?` tells whether every listener still consumes, which is what
37
+ # a liveness probe wants to ask: Bunny's own recovery may also never
38
+ # complete at all.
39
+ #
31
40
  # Examples:
32
41
  #
33
42
  # # Connects to RabbitMQ using all default options
@@ -102,6 +111,8 @@ module Startback
102
111
  @options = DEFAULT_OPTIONS.merge(options)
103
112
  @topology = {}
104
113
  @topology_lock = Mutex.new
114
+ @listeners = []
115
+ @listeners_lock = Mutex.new
105
116
  connect if @options[:autoconnect]
106
117
  end
107
118
  attr_reader :options
@@ -114,6 +125,7 @@ module Startback
114
125
  conn = options[:connection_options] || options[:url]
115
126
  try_max_times(10) do
116
127
  @bunny = ::Bunny.new(conn)
128
+ @bunny.after_recovery_completed { recovery_completed(conn) }
117
129
  @bunny.start
118
130
  channel # make sure we already create the channel
119
131
  log(:info, {op: "#{self.class.name}#connect", op_data: conn}, options[:context])
@@ -132,6 +144,13 @@ module Startback
132
144
  @bunny && @bunny.connected?
133
145
  end
134
146
 
147
+ # Whether the bus is connected and every listener still consumes:
148
+ # its channel is open and the broker did not cancel its consumer.
149
+ # False is what a liveness probe should restart the process on.
150
+ def alive?
151
+ !!connected? && listeners.all?(&:alive?)
152
+ end
153
+
135
154
  def channel
136
155
  unless @bunny
137
156
  raise Startback::Errors::Error, "Please connect your bus first, or use autoconnect: true"
@@ -163,19 +182,67 @@ module Startback
163
182
  def listen(type, processor = nil, listener = nil, &bl)
164
183
  raise ArgumentError, "A listener must be provided" unless listener || bl
165
184
 
166
- fanout = declare(:fanout, type.to_s, fanout_options)
167
- queue = declare(:queue, (processor || "main").to_s, queue_options)
185
+ subscription = Subscription.new(type.to_s, (processor || "main").to_s, listener || bl)
186
+ subscribe(subscription)
187
+ @listeners_lock.synchronize { @listeners << subscription }
188
+ subscription.consumer
189
+ end
190
+
191
+ protected
192
+
193
+ # What `listen` was asked for, kept to subscribe again after a
194
+ # broker restart, along with the consumer currently serving it.
195
+ class Subscription < Struct.new(:type, :processor, :listener, :consumer)
196
+ def alive?
197
+ channel = consumer && consumer.channel
198
+ !!channel && channel.open? && channel.consumers.key?(consumer.consumer_tag)
199
+ end
200
+ end
201
+
202
+ def listeners
203
+ @listeners_lock.synchronize { @listeners.dup }
204
+ end
205
+
206
+ def subscribe(subscription)
207
+ fanout = declare(:fanout, subscription.type, fanout_options)
208
+ queue = declare(:queue, subscription.processor, queue_options)
168
209
  # Bound by name on purpose: declaring the queue may have renewed
169
210
  # the channel, in which case `fanout` belongs to a closed one.
170
211
  queue.bind(fanout.name)
171
- queue.subscribe do |delivery_info, properties, body|
212
+ subscription.consumer = queue.subscribe do |delivery_info, properties, body|
172
213
  stop_errors(self, "listen") do
173
- (listener || bl).call(body)
214
+ subscription.listener.call(body)
174
215
  end
175
216
  end
176
217
  end
177
218
 
178
- protected
219
+ # Called by Bunny once it reconnected and replayed the topology it
220
+ # recorded, after the application's own `recovery_completed`
221
+ # callback, if any.
222
+ #
223
+ # What Bunny replayed may no longer match the broker -- objects
224
+ # adopted passively are not replayed at all -- so the topology is
225
+ # probed anew, and the listeners left without a consumer subscribe
226
+ # again. One that fails stays dead, and `alive?` reports it.
227
+ def recovery_completed(conn)
228
+ conn[:recovery_completed].call if conn.is_a?(Hash) && conn[:recovery_completed]
229
+
230
+ @topology_lock.synchronize { @topology = {} }
231
+ listeners.reject(&:alive?).each do |subscription|
232
+ renew_channel!
233
+ subscribe(subscription)
234
+ log(:info, {
235
+ op: "#{self.class.name}#recovery_completed",
236
+ op_data: { type: subscription.type, processor: subscription.processor, msg: "Subscribed again" }
237
+ }, options[:context])
238
+ rescue StandardError => ex
239
+ log(:error, {
240
+ op: "#{self.class.name}#recovery_completed",
241
+ op_data: { type: subscription.type, processor: subscription.processor },
242
+ error: ex
243
+ }, options[:context])
244
+ end
245
+ end
179
246
 
180
247
  # Declares an exchange (`kind` = :fanout) or a queue (:queue) with
181
248
  # `options`, adopting one that already exists with other properties
@@ -2,7 +2,7 @@ module Startback
2
2
  module Version
3
3
  MAJOR = 2
4
4
  MINOR = 1
5
- TINY = 0
5
+ TINY = 1
6
6
  end
7
7
  VERSION = "#{Version::MAJOR}.#{Version::MINOR}.#{Version::TINY}"
8
8
  end
@@ -259,6 +259,105 @@ module Startback
259
259
 
260
260
  end
261
261
 
262
+ describe "staying alive" do
263
+
264
+ # What a broker restart leaves behind when the queue was adopted
265
+ # passively: Bunny does not replay it, rebinding fails, and the
266
+ # consumer is gone. Deleting the queue from elsewhere gets the same
267
+ # outcome -- the broker cancels the consumer -- without a restart.
268
+ def kill_consumer!
269
+ BunnyBroker.delete_topology(queues: [processor])
270
+ end
271
+
272
+ # Bunny calls this once it reconnected and replayed its topology.
273
+ def complete_recovery!(b)
274
+ b.instance_variable_get(:@bunny).send(:notify_of_recovery_completion)
275
+ end
276
+
277
+ it 'is not alive before connecting' do
278
+ expect(bus.alive?).to eql(false)
279
+ end
280
+
281
+ it 'is alive once connected and listening' do
282
+ b = connected_bus
283
+ b.listen(type, processor) { |body| }
284
+ expect(b.alive?).to eql(true)
285
+ end
286
+
287
+ it 'is not alive once disconnected' do
288
+ b = connected_bus
289
+ b.listen(type, processor) { |body| }
290
+ b.disconnect
291
+ expect(b.alive?).to eql(false)
292
+ end
293
+
294
+ it 'is not alive when a consumer is gone' do
295
+ b = connected_bus
296
+ b.listen(type, processor) { |body| }
297
+ kill_consumer!
298
+
299
+ expect(BunnyBroker.wait_for { !b.alive? }).to eql(true)
300
+ end
301
+
302
+ it 'subscribes dead listeners again once a recovery completed' do
303
+ b = connected_bus
304
+ seen = Queue.new
305
+ b.listen(type, processor) { |body| seen << body }
306
+ kill_consumer!
307
+ BunnyBroker.wait_for { !b.alive? }
308
+
309
+ complete_recovery!(b)
310
+
311
+ expect(b.alive?).to eql(true)
312
+ b.emit(Event.new(type, { id: 12 }))
313
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
314
+ expect(received).not_to be_nil
315
+ expect(JSON.parse(received)["data"]).to eql({ "id" => 12 })
316
+ end
317
+
318
+ it 'declares the topology as requested when subscribing again' do
319
+ # An exchange adopted passively before the restart is gone after
320
+ # it: the durable declaration must win then, not the memoized
321
+ # `passive: true`, which would fail with NOT_FOUND.
322
+ b = connected_bus
323
+ b.listen(type, processor) { |body| }
324
+ b.instance_variable_get(:@topology)[[:fanout, type]] = { passive: true }
325
+ BunnyBroker.delete_topology(exchanges: [type], queues: [processor])
326
+ BunnyBroker.wait_for { !b.alive? }
327
+
328
+ complete_recovery!(b)
329
+
330
+ expect(b.alive?).to eql(true)
331
+ expect {
332
+ ch = b.channel
333
+ ch.fanout(type, durable: true, passive: true)
334
+ ch.queue(processor, durable: true, passive: true)
335
+ }.not_to raise_error
336
+ end
337
+
338
+ it 'leaves live listeners alone' do
339
+ b = connected_bus
340
+ consumer = b.listen(type, processor) { |body| }
341
+
342
+ complete_recovery!(b)
343
+
344
+ expect(b.send(:listeners).first.consumer).to equal(consumer)
345
+ end
346
+
347
+ it "still calls the application's own recovery_completed" do
348
+ called = Queue.new
349
+ b = connected_bus(connection_options: ::Bunny::Session.parse_uri(url).merge(
350
+ recovery_completed: -> { called << true }
351
+ ))
352
+ b.listen(type, processor) { |body| }
353
+
354
+ complete_recovery!(b)
355
+
356
+ expect(called.empty?).to eql(false)
357
+ end
358
+
359
+ end
360
+
262
361
  describe "the asynchronous contract" do
263
362
 
264
363
  it 'hides emit errors from the emitter' do
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: startback
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bernard Lambeau
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-30 00:00:00.000000000 Z
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec