message_bus 4.6.0 → 5.0.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/.github/workflows/ci.yml +9 -1
- data/CHANGELOG +16 -0
- data/Dockerfile +1 -1
- data/README.md +2 -2
- data/lib/message_bus/backends/base.rb +12 -0
- data/lib/message_bus/backends/postgres.rb +65 -33
- data/lib/message_bus/backends/redis.rb +22 -5
- data/lib/message_bus/version.rb +1 -1
- data/lib/message_bus.rb +21 -6
- data/message_bus.gemspec +3 -2
- data/spec/helpers.rb +61 -1
- data/spec/lib/message_bus/backend_spec.rb +290 -97
- data/spec/lib/message_bus/rack/middleware_spec.rb +5 -4
- data/spec/lib/message_bus_spec.rb +106 -0
- data/spec/spec_helper.rb +3 -1
- metadata +18 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 83e05555f3305e70e3a20f0e6d8e7d62b100581e12c7353df3e59550d191045d
|
|
4
|
+
data.tar.gz: 74517b6497d898f3f19a0fe43235e72952c43ecd65107b6f3ad10336da83ad65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1dd4b38ed1ed3458f5858130e0fb74575321819220b53579c06c7350a21595061b82189e4ce78ffd0c7248c1c39b7412499e36d8dd01ca7fbb0d85c0cbb5ec18
|
|
7
|
+
data.tar.gz: d6a13a35bc7a89f423950ee0162cea651d48a5bc858468653995e3bcaaff9062baa972dcc842dd102a82120984915643b94a9de339c8d08cc145e4d0b0b17193
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -50,9 +50,15 @@ jobs:
|
|
|
50
50
|
strategy:
|
|
51
51
|
fail-fast: false
|
|
52
52
|
matrix:
|
|
53
|
-
ruby: ["3.
|
|
53
|
+
ruby: ["3.3", "3.4", "4.0"]
|
|
54
54
|
rack: ["rack2", "rack3"]
|
|
55
55
|
redis: ["redis:7.4", "valkey/valkey"]
|
|
56
|
+
experimental: [false]
|
|
57
|
+
include:
|
|
58
|
+
- ruby: ruby-head
|
|
59
|
+
rack: rack3
|
|
60
|
+
redis: redis:7.4
|
|
61
|
+
experimental: true
|
|
56
62
|
|
|
57
63
|
services:
|
|
58
64
|
postgres:
|
|
@@ -77,6 +83,7 @@ jobs:
|
|
|
77
83
|
- uses: actions/checkout@v4
|
|
78
84
|
|
|
79
85
|
- uses: ruby/setup-ruby@v1
|
|
86
|
+
continue-on-error: ${{ matrix.experimental }}
|
|
80
87
|
with:
|
|
81
88
|
ruby-version: ${{ matrix.ruby }}
|
|
82
89
|
bundler-cache: true
|
|
@@ -92,6 +99,7 @@ jobs:
|
|
|
92
99
|
run: pnpm install
|
|
93
100
|
|
|
94
101
|
- name: Tests
|
|
102
|
+
continue-on-error: ${{ matrix.experimental }}
|
|
95
103
|
env:
|
|
96
104
|
TESTOPTS: --verbose
|
|
97
105
|
run: bundle exec rake
|
data/CHANGELOG
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
|
+
04-09-2026
|
|
2
|
+
|
|
3
|
+
- Version 5.0.0
|
|
4
|
+
|
|
5
|
+
- BREAKING: Drop support for EOL Ruby 3.2; Ruby 3.3 or newer is now required
|
|
6
|
+
- DEV: Test against all maintained Ruby versions: 3.3, 3.4, and 4.0, plus Ruby head as a non-blocking compatibility check
|
|
7
|
+
- DEV: Keep the Rack 2 test suite compatible with Ruby 4.0
|
|
8
|
+
- DEV: Stabilize time-sensitive backlog expiry and long-polling tests on busy CI runners
|
|
9
|
+
- FIX: Recover Redis and PostgreSQL backend subscribers from half-open connections without restarting the process
|
|
10
|
+
- FIX: Keep the keepalive watchdog active if a subscriber reconnect request fails
|
|
11
|
+
- FIX: Replay missed global messages after Redis and PostgreSQL subscriber reconnects without delivering duplicates
|
|
12
|
+
- FIX: Request PostgreSQL reconnects on the subscriber thread to respect libpq thread ownership
|
|
13
|
+
|
|
1
14
|
03-08-2026
|
|
2
15
|
|
|
3
16
|
- Version 4.6.0
|
|
4
17
|
|
|
5
18
|
- DEV: Export the JavaScript client as a CommonJS/ES module so it can be imported directly by bundlers and ESM projects, while still assigning the `MessageBus` global for existing consumers
|
|
19
|
+
- DEV: Add `logger` as a runtime dependency
|
|
20
|
+
- DEV: Make `MessageBus::Message` a regular class instead of a `Struct`
|
|
21
|
+
- PERF: Use `Set#intersect?` when checking allowed groups
|
|
6
22
|
|
|
7
23
|
13-02-2026
|
|
8
24
|
|
data/Dockerfile
CHANGED
data/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Read the generated docs: <https://www.rubydoc.info/gems/message_bus>
|
|
|
12
12
|
|
|
13
13
|
## Ruby version support
|
|
14
14
|
|
|
15
|
-
MessageBus only
|
|
15
|
+
MessageBus only supports Ruby versions currently maintained by the Ruby core team. As of [2026-09-04](https://www.ruby-lang.org/en/downloads/branches/), the supported versions are Ruby 3.3, 3.4, and 4.0; the minimum required version is Ruby 3.3.
|
|
16
16
|
|
|
17
17
|
## Can you handle concurrent requests?
|
|
18
18
|
|
|
@@ -368,7 +368,7 @@ message_bus can be configured to use one of several available storage backends,
|
|
|
368
368
|
|
|
369
369
|
### Keepalive
|
|
370
370
|
|
|
371
|
-
To ensure correct operation of message_bus, every 60 seconds a message is broadcast to itself. If for any reason the message is not consumed by the same process within 3 keepalive intervals a warning log message is raised.
|
|
371
|
+
To ensure correct operation of message_bus, every 60 seconds a message is broadcast to itself. If for any reason the message is not consumed by the same process within 3 keepalive intervals, a warning log message is raised and the backend subscriber is asked to reconnect, so a wedged connection (for example, a half-open socket) can self-heal without a process restart.
|
|
372
372
|
|
|
373
373
|
To control keepalive interval use
|
|
374
374
|
|
|
@@ -175,6 +175,18 @@ module MessageBus
|
|
|
175
175
|
raise ConcreteClassMustImplementError
|
|
176
176
|
end
|
|
177
177
|
|
|
178
|
+
# Asks the subscriber to drop its connection and re-establish it, so a
|
|
179
|
+
# wedged connection (a half-open socket, for example) recovers without a
|
|
180
|
+
# process restart. The backend's rescue/retry around {#global_subscribe}
|
|
181
|
+
# performs the actual reconnection.
|
|
182
|
+
#
|
|
183
|
+
# Called from the keepalive watchdog thread, never from the subscriber
|
|
184
|
+
# thread; implementations must not touch anything requiring
|
|
185
|
+
# single-thread ownership (see the Postgres backend). Backends with no
|
|
186
|
+
# way to detach a stuck connection may leave this as the inherited no-op.
|
|
187
|
+
def request_reconnect
|
|
188
|
+
end
|
|
189
|
+
|
|
178
190
|
# Subscribe to messages on all channels. Each message since the last ID
|
|
179
191
|
# specified will be delivered by yielding to the passed block as soon as
|
|
180
192
|
# it is available. This will block until subscription is terminated.
|
|
@@ -20,6 +20,10 @@ module MessageBus
|
|
|
20
20
|
#
|
|
21
21
|
# @see Base general information about message_bus backends
|
|
22
22
|
class Postgres < Base
|
|
23
|
+
# Raised inside the subscriber thread to make it drop its LISTEN
|
|
24
|
+
# connection and reconnect. See {Postgres#request_reconnect}.
|
|
25
|
+
ReconnectRequested = Class.new(StandardError)
|
|
26
|
+
|
|
23
27
|
class Client
|
|
24
28
|
INHERITED_CONNECTIONS = []
|
|
25
29
|
|
|
@@ -45,6 +49,7 @@ module MessageBus
|
|
|
45
49
|
@available = []
|
|
46
50
|
@allocated = {}
|
|
47
51
|
@subscribe_connection = nil
|
|
52
|
+
@reconnect_requested = false
|
|
48
53
|
@subscribed = false
|
|
49
54
|
@mutex = Mutex.new
|
|
50
55
|
@pid = Process.pid
|
|
@@ -153,35 +158,55 @@ module MessageBus
|
|
|
153
158
|
|
|
154
159
|
def subscribe(channel)
|
|
155
160
|
obj = Object.new
|
|
156
|
-
sync
|
|
161
|
+
sync do
|
|
162
|
+
@listening_on[channel] = obj
|
|
163
|
+
@reconnect_requested = false
|
|
164
|
+
end
|
|
157
165
|
listener = Listener.new
|
|
158
166
|
yield listener
|
|
159
167
|
|
|
160
168
|
conn = @subscribe_connection = raw_pg_connection
|
|
161
|
-
conn.exec "LISTEN #{channel}"
|
|
162
|
-
listener.do_sub.call
|
|
163
|
-
while listening_on?(channel, obj)
|
|
164
|
-
conn.wait_for_notify(10) do |_, _, payload|
|
|
165
|
-
break unless listening_on?(channel, obj)
|
|
166
169
|
|
|
167
|
-
|
|
170
|
+
begin
|
|
171
|
+
conn.exec "LISTEN #{channel}"
|
|
172
|
+
listener.do_sub.call
|
|
173
|
+
while listening_on?(channel, obj)
|
|
174
|
+
raise ReconnectRequested if reconnect_requested?
|
|
175
|
+
|
|
176
|
+
conn.wait_for_notify(10) do |_, _, payload|
|
|
177
|
+
break unless listening_on?(channel, obj)
|
|
178
|
+
|
|
179
|
+
listener.do_message.call(nil, payload)
|
|
180
|
+
end
|
|
168
181
|
end
|
|
169
|
-
|
|
170
|
-
listener.do_unsub.call
|
|
182
|
+
listener.do_unsub.call
|
|
171
183
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
184
|
+
conn.exec "UNLISTEN #{channel}"
|
|
185
|
+
nil
|
|
186
|
+
ensure
|
|
187
|
+
@subscribe_connection&.close
|
|
188
|
+
@subscribe_connection = nil
|
|
189
|
+
end
|
|
177
190
|
end
|
|
178
191
|
|
|
179
192
|
def unsubscribe
|
|
180
193
|
sync { @listening_on.clear }
|
|
181
194
|
end
|
|
182
195
|
|
|
196
|
+
# Sets a flag rather than closing the connection directly, since only
|
|
197
|
+
# the owning thread may touch a PGconn. Picked up by {#subscribe}'s
|
|
198
|
+
# wait_for_notify loop, which raises to trigger the retry in
|
|
199
|
+
# {Postgres#global_subscribe}.
|
|
200
|
+
def request_reconnect
|
|
201
|
+
sync { @reconnect_requested = true }
|
|
202
|
+
end
|
|
203
|
+
|
|
183
204
|
private
|
|
184
205
|
|
|
206
|
+
def reconnect_requested?
|
|
207
|
+
sync { @reconnect_requested }
|
|
208
|
+
end
|
|
209
|
+
|
|
185
210
|
def exec_prepared(conn, *a)
|
|
186
211
|
r = conn.exec_prepared(*a)
|
|
187
212
|
yield r if block_given?
|
|
@@ -378,6 +403,16 @@ module MessageBus
|
|
|
378
403
|
@subscribed = false
|
|
379
404
|
end
|
|
380
405
|
|
|
406
|
+
# (see Base#request_reconnect)
|
|
407
|
+
#
|
|
408
|
+
# Flags the subscriber thread instead of closing its connection: a
|
|
409
|
+
# PG::Connection may only be used by its owning thread. The reconnect
|
|
410
|
+
# therefore takes effect when the subscriber's `wait_for_notify` call
|
|
411
|
+
# next returns, so within 10 seconds.
|
|
412
|
+
def request_reconnect
|
|
413
|
+
client.request_reconnect
|
|
414
|
+
end
|
|
415
|
+
|
|
381
416
|
# (see Base#global_subscribe)
|
|
382
417
|
def global_subscribe(last_id = nil)
|
|
383
418
|
raise ArgumentError unless block_given?
|
|
@@ -385,15 +420,18 @@ module MessageBus
|
|
|
385
420
|
highest_id = last_id
|
|
386
421
|
|
|
387
422
|
begin
|
|
423
|
+
# Seed a cursor if we don't already have one, so that a reconnect
|
|
424
|
+
# (triggered by ReconnectRequested below) always has a replay
|
|
425
|
+
# point and never treats "no explicit last_id" as "never replay".
|
|
426
|
+
highest_id ||= client.max_id
|
|
427
|
+
|
|
388
428
|
client.subscribe(postgresql_channel_name) do |on|
|
|
389
429
|
h = {}
|
|
390
430
|
|
|
391
431
|
on.subscribe do
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
yield m
|
|
396
|
-
end
|
|
432
|
+
highest_id = process_global_backlog(highest_id) do |m|
|
|
433
|
+
h[m.global_id] = true
|
|
434
|
+
yield m
|
|
397
435
|
end
|
|
398
436
|
h = nil if h.empty?
|
|
399
437
|
@subscribed = true
|
|
@@ -410,25 +448,19 @@ module MessageBus
|
|
|
410
448
|
end
|
|
411
449
|
m = MessageBus::Message.decode m
|
|
412
450
|
|
|
413
|
-
#
|
|
414
|
-
#
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
# If already yielded during the clear backlog when subscribing,
|
|
421
|
-
# don't yield a duplicate copy.
|
|
422
|
-
unless h.delete(m.global_id)
|
|
423
|
-
h = nil if h.empty?
|
|
424
|
-
yield m
|
|
425
|
-
end
|
|
426
|
-
else
|
|
451
|
+
# If already yielded during the clear backlog when subscribing,
|
|
452
|
+
# don't yield a duplicate copy.
|
|
453
|
+
duplicate = h && h.delete(m.global_id)
|
|
454
|
+
h = nil if h&.empty?
|
|
455
|
+
|
|
456
|
+
unless duplicate
|
|
457
|
+
highest_id = m.global_id if m.global_id > highest_id
|
|
427
458
|
yield m
|
|
428
459
|
end
|
|
429
460
|
end
|
|
430
461
|
end
|
|
431
462
|
rescue => error
|
|
463
|
+
@subscribed = false
|
|
432
464
|
@config[:logger].warn "#{error} subscribe failed, reconnecting in 1 second. Call stack\n#{error.backtrace.join("\n")}"
|
|
433
465
|
sleep 1
|
|
434
466
|
retry
|
|
@@ -60,6 +60,7 @@ module MessageBus
|
|
|
60
60
|
@flush_backlog_thread = nil
|
|
61
61
|
@pub_redis = nil
|
|
62
62
|
@subscribed = false
|
|
63
|
+
@subscriber_connection = nil
|
|
63
64
|
# after 7 days inactive backlogs will be removed
|
|
64
65
|
@max_backlog_age = 604_800
|
|
65
66
|
end
|
|
@@ -286,13 +287,18 @@ LUA
|
|
|
286
287
|
end
|
|
287
288
|
|
|
288
289
|
begin
|
|
289
|
-
global_redis = new_redis_connection
|
|
290
|
+
global_redis = @subscriber_connection = new_redis_connection
|
|
290
291
|
|
|
291
|
-
|
|
292
|
+
# Seed a cursor if we don't already have one, so that a reconnect
|
|
293
|
+
# (triggered by request_reconnect below) always has a replay point
|
|
294
|
+
# and never treats "no explicit last_id" as "never replay".
|
|
295
|
+
highest_id ||= current_global_id
|
|
296
|
+
|
|
297
|
+
clear_backlog.call(&blk)
|
|
292
298
|
|
|
293
299
|
global_redis.subscribe(redis_channel_name) do |on|
|
|
294
300
|
on.subscribe do
|
|
295
|
-
clear_backlog.call(&blk)
|
|
301
|
+
clear_backlog.call(&blk)
|
|
296
302
|
@subscribed = true
|
|
297
303
|
end
|
|
298
304
|
|
|
@@ -312,7 +318,7 @@ LUA
|
|
|
312
318
|
# 2. message came in the incorrect order COMPLICATED, wait a tiny bit and clear backlog
|
|
313
319
|
# 3. message came in the incorrect order and is lowest than current highest id, reset
|
|
314
320
|
|
|
315
|
-
if
|
|
321
|
+
if m.global_id == highest_id + 1
|
|
316
322
|
highest_id = m.global_id
|
|
317
323
|
yield m
|
|
318
324
|
else
|
|
@@ -321,15 +327,22 @@ LUA
|
|
|
321
327
|
end
|
|
322
328
|
end
|
|
323
329
|
rescue => error
|
|
330
|
+
@subscribed = false
|
|
324
331
|
@logger.warn "#{error} subscribe failed, reconnecting in 1 second. Call stack #{error.backtrace.join("\n")}"
|
|
325
332
|
sleep 1
|
|
326
333
|
global_redis&.disconnect!
|
|
327
334
|
retry
|
|
328
335
|
ensure
|
|
329
336
|
global_redis&.disconnect!
|
|
337
|
+
@subscriber_connection = nil
|
|
330
338
|
end
|
|
331
339
|
end
|
|
332
340
|
|
|
341
|
+
# (see Base#request_reconnect)
|
|
342
|
+
def request_reconnect
|
|
343
|
+
@subscriber_connection&.disconnect!
|
|
344
|
+
end
|
|
345
|
+
|
|
333
346
|
private
|
|
334
347
|
|
|
335
348
|
def new_redis_connection
|
|
@@ -362,8 +375,12 @@ LUA
|
|
|
362
375
|
"__mb_global_backlog_n"
|
|
363
376
|
end
|
|
364
377
|
|
|
378
|
+
def current_global_id
|
|
379
|
+
pub_redis.get(global_id_key).to_i
|
|
380
|
+
end
|
|
381
|
+
|
|
365
382
|
def process_global_backlog(highest_id, raise_error)
|
|
366
|
-
highest_id = 0 if highest_id >
|
|
383
|
+
highest_id = 0 if highest_id > current_global_id
|
|
367
384
|
|
|
368
385
|
global_backlog(highest_id).each do |old|
|
|
369
386
|
if highest_id + 1 == old.global_id
|
data/lib/message_bus/version.rb
CHANGED
data/lib/message_bus.rb
CHANGED
|
@@ -591,7 +591,9 @@ module MessageBus::Implementation
|
|
|
591
591
|
|
|
592
592
|
# @param [Integer] interval the keepalive interval in seconds.
|
|
593
593
|
# Set to 0 to disable; anything higher and a keepalive will run every N
|
|
594
|
-
# seconds. If
|
|
594
|
+
# seconds. If a keepalive message is not received within 3 intervals, a
|
|
595
|
+
# warning is logged and the backend subscriber is asked to reconnect
|
|
596
|
+
# (see {Backends::Base#request_reconnect}).
|
|
595
597
|
def keepalive_interval=(interval)
|
|
596
598
|
configure(keepalive_interval: interval)
|
|
597
599
|
end
|
|
@@ -748,13 +750,26 @@ module MessageBus::Implementation
|
|
|
748
750
|
|
|
749
751
|
blk = proc do
|
|
750
752
|
if !@destroyed && thread.alive? && keepalive_interval > MIN_KEEPALIVE
|
|
753
|
+
begin
|
|
754
|
+
publish("/__mb_keepalive__/", Process.pid, user_ids: [-1])
|
|
751
755
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
+
if (Time.now - (@last_message || Time.now)) > keepalive_interval * 3
|
|
757
|
+
logger.warn "Global messages on #{Process.pid} timed out, message bus is no longer functioning correctly"
|
|
758
|
+
|
|
759
|
+
# Reset first so a slow reconnect isn't re-triggered on the next tick.
|
|
760
|
+
@last_message = Time.now
|
|
756
761
|
|
|
757
|
-
|
|
762
|
+
begin
|
|
763
|
+
backend_instance.request_reconnect
|
|
764
|
+
rescue => e
|
|
765
|
+
logger.warn "Failed to request subscriber reconnect on #{Process.pid}: #{e}"
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
ensure
|
|
769
|
+
# This proc is the only thing that re-schedules itself; an exception
|
|
770
|
+
# escaping here would disable the watchdog permanently.
|
|
771
|
+
timer.queue(keepalive_interval, &blk) if keepalive_interval > MIN_KEEPALIVE
|
|
772
|
+
end
|
|
758
773
|
end
|
|
759
774
|
end
|
|
760
775
|
|
data/message_bus.gemspec
CHANGED
|
@@ -14,7 +14,7 @@ Gem::Specification.new do |gem|
|
|
|
14
14
|
gem.name = "message_bus"
|
|
15
15
|
gem.require_paths = ["lib"]
|
|
16
16
|
gem.version = MessageBus::VERSION
|
|
17
|
-
gem.required_ruby_version = ">= 3.
|
|
17
|
+
gem.required_ruby_version = ">= 3.3.0"
|
|
18
18
|
|
|
19
19
|
gem.add_runtime_dependency 'rack', '> 2', '< 4'
|
|
20
20
|
gem.add_runtime_dependency 'logger'
|
|
@@ -24,9 +24,10 @@ Gem::Specification.new do |gem|
|
|
|
24
24
|
gem.add_development_dependency 'pg'
|
|
25
25
|
gem.add_development_dependency 'concurrent-ruby' # for distributed-cache
|
|
26
26
|
|
|
27
|
-
gem.add_development_dependency 'minitest'
|
|
27
|
+
gem.add_development_dependency 'minitest', '< 6'
|
|
28
28
|
gem.add_development_dependency 'minitest-hooks'
|
|
29
29
|
gem.add_development_dependency 'minitest-global_expectations'
|
|
30
|
+
gem.add_development_dependency 'cgi' # Rack 2 tests on Ruby 4+
|
|
30
31
|
gem.add_development_dependency 'rake'
|
|
31
32
|
gem.add_development_dependency 'http_parser.rb'
|
|
32
33
|
gem.add_development_dependency 'thin'
|
data/spec/helpers.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'logger'
|
|
4
4
|
require 'method_source'
|
|
5
|
+
require 'stringio'
|
|
5
6
|
|
|
6
7
|
def wait_for(timeout_milliseconds = 2000, &blk)
|
|
7
8
|
timeout = timeout_milliseconds / 1000.0
|
|
@@ -23,7 +24,7 @@ def test_config_for_backend(backend)
|
|
|
23
24
|
|
|
24
25
|
case backend
|
|
25
26
|
when :redis
|
|
26
|
-
config[:
|
|
27
|
+
config[:redis_config] = { url: ENV['REDISURL'] }
|
|
27
28
|
when :postgres
|
|
28
29
|
config[:backend_options] = {
|
|
29
30
|
host: ENV['PGHOST'],
|
|
@@ -34,3 +35,62 @@ def test_config_for_backend(backend)
|
|
|
34
35
|
end
|
|
35
36
|
config
|
|
36
37
|
end
|
|
38
|
+
|
|
39
|
+
# Captures logger output regardless of where each backend stashes its
|
|
40
|
+
# logger instance, so shared reconnect-continuity tests can detect the
|
|
41
|
+
# "subscribe failed, reconnecting in 1 second" line that both network
|
|
42
|
+
# backends emit right before their retry sleep.
|
|
43
|
+
def capture_backend_log(bus)
|
|
44
|
+
log_output = StringIO.new
|
|
45
|
+
logger = Logger.new(log_output)
|
|
46
|
+
|
|
47
|
+
case CURRENT_BACKEND
|
|
48
|
+
when :redis
|
|
49
|
+
bus.instance_variable_set(:@logger, logger)
|
|
50
|
+
when :postgres
|
|
51
|
+
bus.instance_variable_get(:@config)[:logger] = logger
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
log_output
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Postgres polls wait_for_notify(10) by default; shorten that only for
|
|
58
|
+
# Postgres so reconnect-continuity assertions run quickly without
|
|
59
|
+
# affecting Redis, which reacts to disconnect immediately.
|
|
60
|
+
def speed_up_reconnect_detection(bus)
|
|
61
|
+
instrument_postgres_client(bus) if CURRENT_BACKEND == :postgres
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Records LISTEN attempts and which thread closed each subscriber
|
|
65
|
+
# connection (pool connections are excluded), and shortens the notify poll
|
|
66
|
+
# so a reconnect request is picked up quickly.
|
|
67
|
+
def instrument_postgres_client(backend)
|
|
68
|
+
recorded = { subscribes: [], close_threads: [] }
|
|
69
|
+
client = backend.send(:client)
|
|
70
|
+
real_subscribe = client.method(:subscribe)
|
|
71
|
+
real_raw_pg_connection = client.method(:raw_pg_connection)
|
|
72
|
+
|
|
73
|
+
client.define_singleton_method(:subscribe) do |channel, &blk|
|
|
74
|
+
recorded[:subscribes] << channel
|
|
75
|
+
real_subscribe.call(channel, &blk)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
client.define_singleton_method(:raw_pg_connection) do
|
|
79
|
+
conn = real_raw_pg_connection.call
|
|
80
|
+
subscriber = false
|
|
81
|
+
|
|
82
|
+
conn.define_singleton_method(:wait_for_notify) do |_timeout = nil, &blk|
|
|
83
|
+
subscriber = true
|
|
84
|
+
super(0.05, &blk)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
conn.define_singleton_method(:close) do
|
|
88
|
+
recorded[:close_threads] << Thread.current if subscriber
|
|
89
|
+
super()
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
conn
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
recorded
|
|
96
|
+
end
|
|
@@ -17,6 +17,10 @@ describe BACKEND_CLASS do
|
|
|
17
17
|
it "has the same public methods as the base class" do
|
|
18
18
|
@bus.public_methods.sort.must_equal MessageBus::Backends::Base.new(test_config_for_backend(CURRENT_BACKEND)).public_methods.sort
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
it "treats request_reconnect as an optional no-op rather than an error" do
|
|
22
|
+
MessageBus::Backends::Base.new.request_reconnect.must_be_nil
|
|
23
|
+
end
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
it "should be able to access the backlog" do
|
|
@@ -108,114 +112,30 @@ describe BACKEND_CLASS do
|
|
|
108
112
|
end
|
|
109
113
|
|
|
110
114
|
it "can set backlog age" do
|
|
111
|
-
@bus.max_backlog_age =
|
|
112
|
-
|
|
113
|
-
expected_backlog_size = 0
|
|
114
|
-
|
|
115
|
-
# Start at time = 0s
|
|
115
|
+
@bus.max_backlog_age = 0
|
|
116
116
|
@bus.publish "/foo", "bar"
|
|
117
|
-
expected_backlog_size += 1
|
|
118
|
-
|
|
119
|
-
@bus.global_backlog.length.must_equal expected_backlog_size
|
|
120
|
-
@bus.backlog("/foo", 0).length.must_equal expected_backlog_size
|
|
121
|
-
|
|
122
|
-
sleep 1.25 # Should now be at time =~ 1.25s. Our backlog should have expired by now.
|
|
123
|
-
expected_backlog_size = 0
|
|
124
|
-
|
|
125
|
-
case CURRENT_BACKEND
|
|
126
|
-
when :postgres
|
|
127
|
-
# Force triggering backlog expiry: postgres backend doesn't expire backlogs on a timer, but at publication time.
|
|
128
|
-
@bus.global_backlog.length.wont_equal expected_backlog_size
|
|
129
|
-
@bus.backlog("/foo", 0).length.wont_equal expected_backlog_size
|
|
130
|
-
@bus.publish "/foo", "baz"
|
|
131
|
-
expected_backlog_size += 1
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
# Assert that the backlog did expire, and now has only the new publication in it.
|
|
135
|
-
@bus.global_backlog.length.must_equal expected_backlog_size
|
|
136
|
-
@bus.backlog("/foo", 0).length.must_equal expected_backlog_size
|
|
137
|
-
|
|
138
|
-
sleep 0.75 # Should now be at time =~ 2s
|
|
139
117
|
|
|
140
|
-
|
|
141
|
-
|
|
118
|
+
# Redis and PostgreSQL expire synchronously during publication. The memory
|
|
119
|
+
# backend normally expires on a timer, so invoke the same expiry operation
|
|
120
|
+
# directly rather than making the test wait for that timer to run.
|
|
121
|
+
@bus.send(:client).expire if CURRENT_BACKEND == :memory
|
|
142
122
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
@bus.publish "/foo", "baz" # Publish something else to ward off another expiry
|
|
147
|
-
expected_backlog_size += 1
|
|
148
|
-
|
|
149
|
-
case CURRENT_BACKEND
|
|
150
|
-
when :postgres
|
|
151
|
-
# Postgres expires individual messages that have lived longer than the TTL, not whole backlogs
|
|
152
|
-
expected_backlog_size -= 1
|
|
153
|
-
else
|
|
154
|
-
# Assert that the backlog did not expire, and has all of our publications since the last expiry.
|
|
155
|
-
end
|
|
156
|
-
@bus.global_backlog.length.must_equal expected_backlog_size
|
|
157
|
-
@bus.backlog("/foo", 0).length.must_equal expected_backlog_size
|
|
123
|
+
@bus.global_backlog.must_be_empty
|
|
124
|
+
@bus.backlog("/foo", 0).must_be_empty
|
|
158
125
|
end
|
|
159
126
|
|
|
160
127
|
it "can set backlog age on publish" do
|
|
161
128
|
@bus.max_backlog_age = 100
|
|
162
|
-
|
|
163
|
-
expected_backlog_size = 0
|
|
164
|
-
|
|
165
129
|
initial_id = @bus.last_id("/foo")
|
|
166
130
|
|
|
167
|
-
|
|
168
|
-
@bus.
|
|
169
|
-
expected_backlog_size += 1
|
|
131
|
+
@bus.publish "/foo", "bar", max_backlog_age: 0
|
|
132
|
+
@bus.send(:client).expire if CURRENT_BACKEND == :memory
|
|
170
133
|
|
|
171
|
-
@bus.global_backlog.
|
|
172
|
-
@bus.backlog("/foo", 0).
|
|
173
|
-
|
|
174
|
-
sleep 1.25 # Should now be at time =~ 1.25s. Our backlog should have expired by now.
|
|
175
|
-
expected_backlog_size = 0
|
|
176
|
-
|
|
177
|
-
case CURRENT_BACKEND
|
|
178
|
-
when :postgres
|
|
179
|
-
# Force triggering backlog expiry: postgres backend doesn't expire backlogs on a timer, but at publication time.
|
|
180
|
-
@bus.global_backlog.length.wont_equal expected_backlog_size
|
|
181
|
-
@bus.backlog("/foo", 0).length.wont_equal expected_backlog_size
|
|
182
|
-
@bus.publish "/foo", "baz", max_backlog_age: 1
|
|
183
|
-
expected_backlog_size += 1
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# Assert that the backlog did expire, and now has only the new publication in it.
|
|
187
|
-
@bus.global_backlog.length.must_equal expected_backlog_size
|
|
188
|
-
@bus.backlog("/foo", 0).length.must_equal expected_backlog_size
|
|
189
|
-
|
|
190
|
-
# for the time being we can give pg a pass here
|
|
191
|
-
# TODO: make the implementation here consistent
|
|
192
|
-
if CURRENT_BACKEND != :postgres
|
|
193
|
-
# ids are not opaque we expect them to be reset on our channel if it
|
|
194
|
-
# got cleared due to an expire, the reason for this is cause we will leak entries due to tracking
|
|
195
|
-
# this in turn can bloat storage for the backend
|
|
196
|
-
@bus.last_id("/foo").must_equal initial_id
|
|
197
|
-
end
|
|
134
|
+
@bus.global_backlog.must_be_empty
|
|
135
|
+
@bus.backlog("/foo", 0).must_be_empty
|
|
198
136
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
@bus.publish "/foo", "baz", max_backlog_age: 1 # Publish something else before another expiry
|
|
202
|
-
expected_backlog_size += 1
|
|
203
|
-
|
|
204
|
-
sleep 0.75 # Should now be at time =~ 2.75s
|
|
205
|
-
# Our oldest message is now 1.5s old, but we didn't cease publishing for a period of 1s at a time, so we should not have expired the backlog.
|
|
206
|
-
|
|
207
|
-
@bus.publish "/foo", "baz", max_backlog_age: 1 # Publish something else to ward off another expiry
|
|
208
|
-
expected_backlog_size += 1
|
|
209
|
-
|
|
210
|
-
case CURRENT_BACKEND
|
|
211
|
-
when :postgres
|
|
212
|
-
# Postgres expires individual messages that have lived longer than the TTL, not whole backlogs
|
|
213
|
-
expected_backlog_size -= 1
|
|
214
|
-
else
|
|
215
|
-
# Assert that the backlog did not expire, and has all of our publications since the last expiry.
|
|
216
|
-
end
|
|
217
|
-
@bus.global_backlog.length.must_equal expected_backlog_size
|
|
218
|
-
@bus.backlog("/foo", 0).length.must_equal expected_backlog_size
|
|
137
|
+
# The memory and Redis backends discard channel ID state on expiry.
|
|
138
|
+
@bus.last_id("/foo").must_equal initial_id if CURRENT_BACKEND != :postgres
|
|
219
139
|
end
|
|
220
140
|
|
|
221
141
|
it "can set backlog size on publish" do
|
|
@@ -392,4 +312,277 @@ describe BACKEND_CLASS do
|
|
|
392
312
|
got.map { |m| m.data }.must_equal ["12"]
|
|
393
313
|
end
|
|
394
314
|
|
|
315
|
+
it "tolerates request_reconnect when nothing is subscribed" do
|
|
316
|
+
@bus.request_reconnect
|
|
317
|
+
|
|
318
|
+
@bus.publish("/reconnect-noop", "bar")
|
|
319
|
+
|
|
320
|
+
@bus.backlog("/reconnect-noop", 0).map(&:data).must_equal ["bar"]
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
it "retries global_subscribe after a redis subscribe failure" do
|
|
324
|
+
test_only(:redis)
|
|
325
|
+
|
|
326
|
+
log_output = StringIO.new
|
|
327
|
+
@bus.instance_variable_set(:@logger, Logger.new(log_output))
|
|
328
|
+
|
|
329
|
+
fail_first_subscribe = true
|
|
330
|
+
real_new_redis_connection = @bus.method(:new_redis_connection)
|
|
331
|
+
|
|
332
|
+
@bus.define_singleton_method(:new_redis_connection) do
|
|
333
|
+
redis = real_new_redis_connection.call
|
|
334
|
+
|
|
335
|
+
if fail_first_subscribe
|
|
336
|
+
fail_first_subscribe = false
|
|
337
|
+
redis.define_singleton_method(:subscribe) do |_channel, &_blk|
|
|
338
|
+
raise IOError, "forced subscribe failure"
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
redis
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
got = []
|
|
346
|
+
|
|
347
|
+
t = Thread.new do
|
|
348
|
+
@bus.global_subscribe(0) do |msg|
|
|
349
|
+
got << msg
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
wait_for(4000) { log_output.string.include?("forced subscribe failure") }
|
|
354
|
+
wait_for(5000) { @bus.subscribed }
|
|
355
|
+
|
|
356
|
+
@bus.publish("/redis-retry", "delivered-after-retry")
|
|
357
|
+
wait_for(3000) { got.any? { |m| m.data == "delivered-after-retry" } }
|
|
358
|
+
|
|
359
|
+
@bus.global_unsubscribe
|
|
360
|
+
t.join(2)
|
|
361
|
+
t.kill if t.alive?
|
|
362
|
+
|
|
363
|
+
log_output.string.must_include "subscribe failed, reconnecting in 1 second"
|
|
364
|
+
got.map(&:data).must_include "delivered-after-retry"
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
it "retries global_subscribe after request_reconnect disconnects redis" do
|
|
368
|
+
test_only(:redis)
|
|
369
|
+
|
|
370
|
+
log_output = StringIO.new
|
|
371
|
+
@bus.instance_variable_set(:@logger, Logger.new(log_output))
|
|
372
|
+
subscribe_attempts = 0
|
|
373
|
+
real_new_redis_connection = @bus.method(:new_redis_connection)
|
|
374
|
+
|
|
375
|
+
@bus.define_singleton_method(:new_redis_connection) do
|
|
376
|
+
redis = real_new_redis_connection.call
|
|
377
|
+
real_subscribe = redis.method(:subscribe)
|
|
378
|
+
redis.define_singleton_method(:subscribe) do |*args, &blk|
|
|
379
|
+
subscribe_attempts += 1
|
|
380
|
+
real_subscribe.call(*args, &blk)
|
|
381
|
+
end
|
|
382
|
+
redis
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
got = []
|
|
386
|
+
|
|
387
|
+
t = Thread.new do
|
|
388
|
+
@bus.global_subscribe(0) do |msg|
|
|
389
|
+
got << msg
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
wait_for(5000) { @bus.subscribed }
|
|
394
|
+
wait_for(5000) { subscribe_attempts >= 1 }
|
|
395
|
+
|
|
396
|
+
@bus.publish("/redis-reconnect", "before-reconnect")
|
|
397
|
+
wait_for(3000) { got.any? { |m| m.data == "before-reconnect" } }
|
|
398
|
+
|
|
399
|
+
@bus.request_reconnect
|
|
400
|
+
|
|
401
|
+
wait_for(7000) { log_output.string.include?("subscribe failed, reconnecting in 1 second") }
|
|
402
|
+
wait_for(7000) { subscribe_attempts >= 2 }
|
|
403
|
+
|
|
404
|
+
@bus.publish("/redis-reconnect", "after-reconnect")
|
|
405
|
+
wait_for(5000) { got.any? { |m| m.data == "after-reconnect" } }
|
|
406
|
+
|
|
407
|
+
@bus.global_unsubscribe
|
|
408
|
+
t.join(2)
|
|
409
|
+
t.kill if t.alive?
|
|
410
|
+
|
|
411
|
+
got.map(&:data).must_include "after-reconnect"
|
|
412
|
+
subscribe_attempts.must_be :>=, 2
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
it "replays messages published during a reconnect window without loss or duplication" do
|
|
416
|
+
test_never(:memory)
|
|
417
|
+
|
|
418
|
+
log_output = capture_backend_log(@bus)
|
|
419
|
+
speed_up_reconnect_detection(@bus)
|
|
420
|
+
|
|
421
|
+
got = []
|
|
422
|
+
|
|
423
|
+
t = Thread.new do
|
|
424
|
+
@bus.global_subscribe do |msg|
|
|
425
|
+
got << msg
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
wait_for(5000) { @bus.subscribed }
|
|
430
|
+
|
|
431
|
+
@bus.publish("/reconnect-continuity", "before")
|
|
432
|
+
wait_for(3000) { got.any? { |m| m.data == "before" } }
|
|
433
|
+
|
|
434
|
+
@bus.request_reconnect
|
|
435
|
+
wait_for(7000) { log_output.string.include?("subscribe failed, reconnecting in 1 second") }
|
|
436
|
+
|
|
437
|
+
@bus.publish("/reconnect-continuity", "during")
|
|
438
|
+
|
|
439
|
+
wait_for(7000) { @bus.subscribed }
|
|
440
|
+
|
|
441
|
+
@bus.publish("/reconnect-continuity", "after")
|
|
442
|
+
wait_for(5000) { got.any? { |m| m.data == "after" } }
|
|
443
|
+
|
|
444
|
+
@bus.global_unsubscribe
|
|
445
|
+
t.join(3)
|
|
446
|
+
t.kill if t.alive?
|
|
447
|
+
|
|
448
|
+
got.map(&:data).must_equal ["before", "during", "after"]
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
it "replays a message published during a reconnect window that opened before any message was received" do
|
|
452
|
+
test_never(:memory)
|
|
453
|
+
|
|
454
|
+
log_output = capture_backend_log(@bus)
|
|
455
|
+
speed_up_reconnect_detection(@bus)
|
|
456
|
+
|
|
457
|
+
got = []
|
|
458
|
+
|
|
459
|
+
t = Thread.new do
|
|
460
|
+
@bus.global_subscribe do |msg|
|
|
461
|
+
got << msg
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
wait_for(5000) { @bus.subscribed }
|
|
466
|
+
|
|
467
|
+
# Request reconnect before any live message has been received, so the
|
|
468
|
+
# backend has never had a chance to derive a cursor from a message: it
|
|
469
|
+
# must fall back to the cursor seeded at subscription start.
|
|
470
|
+
@bus.request_reconnect
|
|
471
|
+
wait_for(7000) { log_output.string.include?("subscribe failed, reconnecting in 1 second") }
|
|
472
|
+
|
|
473
|
+
@bus.publish("/reconnect-continuity-cold", "during")
|
|
474
|
+
|
|
475
|
+
wait_for(7000) { @bus.subscribed }
|
|
476
|
+
|
|
477
|
+
@bus.publish("/reconnect-continuity-cold", "after")
|
|
478
|
+
wait_for(5000) { got.any? { |m| m.data == "after" } }
|
|
479
|
+
|
|
480
|
+
@bus.global_unsubscribe
|
|
481
|
+
t.join(3)
|
|
482
|
+
t.kill if t.alive?
|
|
483
|
+
|
|
484
|
+
got.map(&:data).must_equal ["during", "after"]
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
it "reports subscribed as false during a reconnect window and true once replaced" do
|
|
488
|
+
test_never(:memory)
|
|
489
|
+
|
|
490
|
+
log_output = capture_backend_log(@bus)
|
|
491
|
+
speed_up_reconnect_detection(@bus)
|
|
492
|
+
|
|
493
|
+
got = []
|
|
494
|
+
|
|
495
|
+
t = Thread.new do
|
|
496
|
+
@bus.global_subscribe do |msg|
|
|
497
|
+
got << msg
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
wait_for(5000) { @bus.subscribed }
|
|
502
|
+
|
|
503
|
+
@bus.request_reconnect
|
|
504
|
+
wait_for(7000) { log_output.string.include?("subscribe failed, reconnecting in 1 second") }
|
|
505
|
+
|
|
506
|
+
@bus.subscribed.must_equal false
|
|
507
|
+
|
|
508
|
+
wait_for(7000) { @bus.subscribed }
|
|
509
|
+
@bus.subscribed.must_equal true
|
|
510
|
+
|
|
511
|
+
@bus.global_unsubscribe
|
|
512
|
+
t.join(3)
|
|
513
|
+
t.kill if t.alive?
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
it "reconnects the postgres subscriber from its own thread" do
|
|
517
|
+
test_only(:postgres)
|
|
518
|
+
|
|
519
|
+
log_output = StringIO.new
|
|
520
|
+
@bus.instance_variable_get(:@config)[:logger] = Logger.new(log_output)
|
|
521
|
+
recorded = instrument_postgres_client(@bus)
|
|
522
|
+
|
|
523
|
+
got = []
|
|
524
|
+
|
|
525
|
+
t = Thread.new do
|
|
526
|
+
@bus.global_subscribe(0) do |msg|
|
|
527
|
+
got << msg
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
wait_for(5000) { @bus.subscribed }
|
|
532
|
+
|
|
533
|
+
@bus.publish("/pg-reconnect", "before-reconnect")
|
|
534
|
+
wait_for(3000) { got.any? { |m| m.data == "before-reconnect" } }
|
|
535
|
+
|
|
536
|
+
@bus.request_reconnect
|
|
537
|
+
wait_for(7000) { recorded[:subscribes].length >= 2 }
|
|
538
|
+
|
|
539
|
+
# ReconnectRequested is raised before on.unsubscribe fires, so @subscribed
|
|
540
|
+
# must already have been reset explicitly by the rescue path.
|
|
541
|
+
@bus.subscribed.must_equal false
|
|
542
|
+
|
|
543
|
+
@bus.publish("/pg-reconnect", "during-reconnect")
|
|
544
|
+
|
|
545
|
+
wait_for(5000) { @bus.subscribed }
|
|
546
|
+
|
|
547
|
+
@bus.publish("/pg-reconnect", "after-reconnect")
|
|
548
|
+
wait_for(5000) { got.any? { |m| m.data == "after-reconnect" } }
|
|
549
|
+
|
|
550
|
+
@bus.global_unsubscribe
|
|
551
|
+
t.join(3)
|
|
552
|
+
t.kill if t.alive?
|
|
553
|
+
|
|
554
|
+
# Exact order, no duplicates, no loss: the cursor must be advanced by
|
|
555
|
+
# every accepted live message and carried across the reconnect.
|
|
556
|
+
got.map(&:data).must_equal ["before-reconnect", "during-reconnect", "after-reconnect"]
|
|
557
|
+
log_output.string.must_include "subscribe failed, reconnecting in 1 second"
|
|
558
|
+
|
|
559
|
+
# Regression check: only the subscriber thread may close its own PGconn.
|
|
560
|
+
recorded[:close_threads].uniq.must_equal [t]
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
it "ignores a postgres reconnect request made while nothing was subscribed" do
|
|
564
|
+
test_only(:postgres)
|
|
565
|
+
|
|
566
|
+
recorded = instrument_postgres_client(@bus)
|
|
567
|
+
@bus.request_reconnect
|
|
568
|
+
|
|
569
|
+
got = []
|
|
570
|
+
|
|
571
|
+
t = Thread.new do
|
|
572
|
+
@bus.global_subscribe(0) do |msg|
|
|
573
|
+
got << msg
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
wait_for(5000) { @bus.subscribed }
|
|
578
|
+
|
|
579
|
+
@bus.publish("/pg-stale-reconnect", "delivered")
|
|
580
|
+
wait_for(3000) { got.any? { |m| m.data == "delivered" } }
|
|
581
|
+
|
|
582
|
+
@bus.global_unsubscribe
|
|
583
|
+
t.join(3)
|
|
584
|
+
t.kill if t.alive?
|
|
585
|
+
|
|
586
|
+
recorded[:subscribes].length.must_equal 1
|
|
587
|
+
end
|
|
395
588
|
end
|
|
@@ -99,13 +99,14 @@ describe MessageBus::Rack::Middleware do
|
|
|
99
99
|
t.join
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
-
it "should timeout within its
|
|
102
|
+
it "should timeout within its allotted slot" do
|
|
103
103
|
begin
|
|
104
104
|
@bus.long_polling_interval = 10
|
|
105
|
-
|
|
105
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
106
106
|
post "/message-bus/ABC", '/foo' => nil
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000
|
|
108
|
+
# Leave enough headroom for scheduler jitter on busy CI runners.
|
|
109
|
+
elapsed_ms.must_be :<, 250
|
|
109
110
|
ensure
|
|
110
111
|
@bus.long_polling_interval = 5000
|
|
111
112
|
end
|
|
@@ -398,4 +398,110 @@ describe MessageBus do
|
|
|
398
398
|
channel.must_equal('/test')
|
|
399
399
|
end
|
|
400
400
|
end
|
|
401
|
+
|
|
402
|
+
describe "keepalive reconnect watchdog" do
|
|
403
|
+
def fast_keepalive
|
|
404
|
+
0.5
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
before do
|
|
408
|
+
@original_min_keepalive = MessageBus::Implementation::MIN_KEEPALIVE
|
|
409
|
+
MessageBus::Implementation.send(:remove_const, :MIN_KEEPALIVE)
|
|
410
|
+
MessageBus::Implementation.const_set(:MIN_KEEPALIVE, 0)
|
|
411
|
+
|
|
412
|
+
@log_output = StringIO.new
|
|
413
|
+
@bus.configure(keepalive_interval: fast_keepalive, logger: Logger.new(@log_output))
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
after do
|
|
417
|
+
MessageBus::Implementation.send(:remove_const, :MIN_KEEPALIVE)
|
|
418
|
+
MessageBus::Implementation.const_set(:MIN_KEEPALIVE, @original_min_keepalive)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def stall_global_subscribe(backend)
|
|
422
|
+
backend.define_singleton_method(:global_subscribe) do |_last_id = nil, &_blk|
|
|
423
|
+
@subscribed = true
|
|
424
|
+
sleep(0.001) while @subscribed
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
it "invokes request_reconnect and logs a warning when subscriber reads stall" do
|
|
429
|
+
backend = @bus.backend_instance
|
|
430
|
+
reconnect_calls = 0
|
|
431
|
+
stall_global_subscribe(backend)
|
|
432
|
+
|
|
433
|
+
backend.define_singleton_method(:request_reconnect) do
|
|
434
|
+
reconnect_calls += 1
|
|
435
|
+
@subscribed = false
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
@bus.after_fork
|
|
439
|
+
wait_for(1000) { @bus.listening? }
|
|
440
|
+
|
|
441
|
+
wait_for(4000) { reconnect_calls == 1 }
|
|
442
|
+
wait_for(4000) { @log_output.string.include?("no longer functioning correctly") }
|
|
443
|
+
|
|
444
|
+
reconnect_calls.must_equal 1
|
|
445
|
+
@log_output.string.must_include "timed out"
|
|
446
|
+
@log_output.string.must_include "no longer functioning correctly"
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
it "resets @last_message so reconnect attempts are not immediate cascades" do
|
|
450
|
+
backend = @bus.backend_instance
|
|
451
|
+
reconnect_times = []
|
|
452
|
+
stall_global_subscribe(backend)
|
|
453
|
+
|
|
454
|
+
backend.define_singleton_method(:request_reconnect) do
|
|
455
|
+
reconnect_times << Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
@bus.after_fork
|
|
459
|
+
wait_for(1000) { @bus.listening? }
|
|
460
|
+
wait_for(7000) { reconnect_times.length >= 2 }
|
|
461
|
+
|
|
462
|
+
first_gap = reconnect_times[1] - reconnect_times[0]
|
|
463
|
+
first_gap.must_be :>=, fast_keepalive * 2.8
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
it "stays armed and tries again when request_reconnect raises" do
|
|
467
|
+
backend = @bus.backend_instance
|
|
468
|
+
reconnect_calls = 0
|
|
469
|
+
stall_global_subscribe(backend)
|
|
470
|
+
|
|
471
|
+
backend.define_singleton_method(:request_reconnect) do
|
|
472
|
+
reconnect_calls += 1
|
|
473
|
+
raise IOError, "simulated connection error"
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
@bus.after_fork
|
|
477
|
+
wait_for(1000) { @bus.listening? }
|
|
478
|
+
|
|
479
|
+
wait_for(8000) { reconnect_calls >= 2 }
|
|
480
|
+
|
|
481
|
+
reconnect_calls.must_be :>=, 2
|
|
482
|
+
@log_output.string.must_include "Failed to request subscriber reconnect"
|
|
483
|
+
@log_output.string.must_include "simulated connection error"
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
it "stays armed when the keepalive publish raises" do
|
|
487
|
+
backend = @bus.backend_instance
|
|
488
|
+
publish_calls = 0
|
|
489
|
+
real_publish = backend.method(:publish)
|
|
490
|
+
stall_global_subscribe(backend)
|
|
491
|
+
|
|
492
|
+
backend.define_singleton_method(:publish) do |*args, &blk|
|
|
493
|
+
publish_calls += 1
|
|
494
|
+
raise IOError, "simulated publish failure" if publish_calls == 1
|
|
495
|
+
|
|
496
|
+
real_publish.call(*args, &blk)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
@bus.after_fork
|
|
500
|
+
wait_for(1000) { @bus.listening? }
|
|
501
|
+
wait_for(5000) { publish_calls >= 3 }
|
|
502
|
+
|
|
503
|
+
publish_calls.must_be :>=, 3
|
|
504
|
+
@log_output.string.must_include "simulated publish failure"
|
|
505
|
+
end
|
|
506
|
+
end
|
|
401
507
|
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -19,7 +19,9 @@ BACKEND_CLASS = MessageBus::BACKENDS.fetch(CURRENT_BACKEND)
|
|
|
19
19
|
puts "Running with backend: #{CURRENT_BACKEND}"
|
|
20
20
|
|
|
21
21
|
def test_only(*backends)
|
|
22
|
-
|
|
22
|
+
return if backends.include?(CURRENT_BACKEND)
|
|
23
|
+
|
|
24
|
+
skip "Test doesn't apply to #{CURRENT_BACKEND}"
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def test_never(*backends)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: message_bus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 5.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sam Saffron
|
|
@@ -87,6 +87,20 @@ dependencies:
|
|
|
87
87
|
version: '0'
|
|
88
88
|
- !ruby/object:Gem::Dependency
|
|
89
89
|
name: minitest
|
|
90
|
+
requirement: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - "<"
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '6'
|
|
95
|
+
type: :development
|
|
96
|
+
prerelease: false
|
|
97
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - "<"
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '6'
|
|
102
|
+
- !ruby/object:Gem::Dependency
|
|
103
|
+
name: minitest-hooks
|
|
90
104
|
requirement: !ruby/object:Gem::Requirement
|
|
91
105
|
requirements:
|
|
92
106
|
- - ">="
|
|
@@ -100,7 +114,7 @@ dependencies:
|
|
|
100
114
|
- !ruby/object:Gem::Version
|
|
101
115
|
version: '0'
|
|
102
116
|
- !ruby/object:Gem::Dependency
|
|
103
|
-
name: minitest-
|
|
117
|
+
name: minitest-global_expectations
|
|
104
118
|
requirement: !ruby/object:Gem::Requirement
|
|
105
119
|
requirements:
|
|
106
120
|
- - ">="
|
|
@@ -114,7 +128,7 @@ dependencies:
|
|
|
114
128
|
- !ruby/object:Gem::Version
|
|
115
129
|
version: '0'
|
|
116
130
|
- !ruby/object:Gem::Dependency
|
|
117
|
-
name:
|
|
131
|
+
name: cgi
|
|
118
132
|
requirement: !ruby/object:Gem::Requirement
|
|
119
133
|
requirements:
|
|
120
134
|
- - ">="
|
|
@@ -382,7 +396,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
382
396
|
requirements:
|
|
383
397
|
- - ">="
|
|
384
398
|
- !ruby/object:Gem::Version
|
|
385
|
-
version: 3.
|
|
399
|
+
version: 3.3.0
|
|
386
400
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
387
401
|
requirements:
|
|
388
402
|
- - ">="
|