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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ac878307b45991461b9a20bf6a4f31935c164aa6abce9ba8d931ae291869dbb9
4
- data.tar.gz: 201fd5f776e4fb437eb3f2be49961d9469f81745b60b6f84dcccb332212cdd5d
3
+ metadata.gz: 83e05555f3305e70e3a20f0e6d8e7d62b100581e12c7353df3e59550d191045d
4
+ data.tar.gz: 74517b6497d898f3f19a0fe43235e72952c43ecd65107b6f3ad10336da83ad65
5
5
  SHA512:
6
- metadata.gz: f54fb643cb48006c33585e2af967b9e0ae9bb69c5c034b3e454cbe82b9330e1fbbfdb3d58bf60cf6056522e67a59c2b519f18dd851d0fe3fe62e4d96dfe559e5
7
- data.tar.gz: 9c358176d7f5441931f1d0505b2c9e17c330806af06fa310393ba321e9db0b0041c433cc12c79e5247d85f85983ffc592b45ee8be4cd939297e7e8411360e6ae
6
+ metadata.gz: 1dd4b38ed1ed3458f5858130e0fb74575321819220b53579c06c7350a21595061b82189e4ce78ffd0c7248c1c39b7412499e36d8dd01ca7fbb0d85c0cbb5ec18
7
+ data.tar.gz: d6a13a35bc7a89f423950ee0162cea651d48a5bc858468653995e3bcaaff9062baa972dcc842dd102a82120984915643b94a9de339c8d08cc145e4d0b0b17193
@@ -50,9 +50,15 @@ jobs:
50
50
  strategy:
51
51
  fail-fast: false
52
52
  matrix:
53
- ruby: ["3.2", "3.3", "3.4"]
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
@@ -1,4 +1,4 @@
1
- FROM ruby:2.7
1
+ FROM ruby:3.3
2
2
 
3
3
  RUN cd /tmp && \
4
4
  wget --quiet https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 && \
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 support officially supported versions of Ruby; as of [2025-03-14](https://www.ruby-lang.org/en/downloads/branches/) this means we only support Ruby version 3.2 and up.
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 { @listening_on[channel] = obj }
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
- listener.do_message.call(nil, payload)
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
- end
170
- listener.do_unsub.call
182
+ listener.do_unsub.call
171
183
 
172
- conn.exec "UNLISTEN #{channel}"
173
- nil
174
- ensure
175
- @subscribe_connection&.close
176
- @subscribe_connection = nil
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
- if highest_id
393
- process_global_backlog(highest_id) do |m|
394
- h[m.global_id] = true
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
- # we have 3 options
414
- #
415
- # 1. message came in the correct order GREAT, just deal with it
416
- # 2. message came in the incorrect order COMPLICATED, wait a tiny bit and clear backlog
417
- # 3. message came in the incorrect order and is lowest than current highest id, reset
418
-
419
- if h
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
- clear_backlog.call(&blk) if highest_id
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) if highest_id
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 highest_id.nil? || m.global_id == highest_id + 1
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 > pub_redis.get(global_id_key).to_i
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MessageBus
4
- VERSION = "4.6.0"
4
+ VERSION = "5.0.0"
5
5
  end
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 it fails, the process is killed.
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
- publish("/__mb_keepalive__/", Process.pid, user_ids: [-1])
753
- if (Time.now - (@last_message || Time.now)) > keepalive_interval * 3
754
- logger.warn "Global messages on #{Process.pid} timed out, message bus is no longer functioning correctly"
755
- end
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
- timer.queue(keepalive_interval, &blk) if keepalive_interval > MIN_KEEPALIVE
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.2.0"
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[:url] = ENV['REDISURL']
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 = 1
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
- @bus.publish "/foo", "baz" # Publish something else before another expiry
141
- expected_backlog_size += 1
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
- sleep 0.75 # Should now be at time =~ 2.75s
144
- # 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.
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
- # Start at time = 0s
168
- @bus.publish "/foo", "bar", max_backlog_age: 1
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.length.must_equal expected_backlog_size
172
- @bus.backlog("/foo", 0).length.must_equal expected_backlog_size
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
- sleep 0.75 # Should now be at time =~ 2s
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 alloted slot" do
102
+ it "should timeout within its allotted slot" do
103
103
  begin
104
104
  @bus.long_polling_interval = 10
105
- s = Time.now.to_f * 1000
105
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
106
106
  post "/message-bus/ABC", '/foo' => nil
107
- # allow for some jitter
108
- (Time.now.to_f * 1000 - s).must_be :<, 100
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
- skip "Test doesn't apply to #{CURRENT_BACKEND}" if backends.exclude?(CURRENT_BACKEND)
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.6.0
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-hooks
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: minitest-global_expectations
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.2.0
399
+ version: 3.3.0
386
400
  required_rubygems_version: !ruby/object:Gem::Requirement
387
401
  requirements:
388
402
  - - ">="