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 +4 -4
- data/CHANGELOG.md +37 -0
- data/lib/startback/event/bus/bunny/async.rb +72 -5
- data/lib/startback/version.rb +1 -1
- data/spec/unit/event/bus/bunny/test_async.rb +99 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 618541ed038c6a619973934e3430a23eebd0ac9606f91d2535fbd7354e2835b1
|
|
4
|
+
data.tar.gz: 0161c46f4272637e7ae3862eca10d5098c2f109cc28ef9e019826ca2d627aa79
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
214
|
+
subscription.listener.call(body)
|
|
174
215
|
end
|
|
175
216
|
end
|
|
176
217
|
end
|
|
177
218
|
|
|
178
|
-
|
|
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
|
data/lib/startback/version.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
11
|
+
date: 2026-10-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rspec
|