broadcaster 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 90a6948f7efefca09f7a8dae08c85402844b7cdc
4
- data.tar.gz: 27d4e8d9c47555c9b0a2e5153702eb96486e6a87
2
+ SHA256:
3
+ metadata.gz: 8db15deb65dc4589963f402e1db172de1fd5cc31c8e46f99e34897a5dc1f3409
4
+ data.tar.gz: 57fb1bf72218bbfe631fd8547488b4408921ccec0cce2d09d389eb1cc5996f6b
5
5
  SHA512:
6
- metadata.gz: 201f6d54a4208e4c30456102d6bcd39bc07e5769976957eeee807372939e6d48cb66cbb6a7df3028ea25fc8b05af0a2aa7748b98a2a3d11ee1df40147e2f0ab9
7
- data.tar.gz: ae3d206cee7a2517e48dd1af011ba8f7db7a2d6bebf2cd41bea69a75b27402a0a9da1ad15f32d0362a8a0928f72ad9648fb9c7426304c4a6c73902d008a7c8d5
6
+ metadata.gz: 581d6bcc885ee1366c70e73bd70fad9ba4072b525046e6dde9bb0e0d7091c47edaacc643ee31b06658591c53b41d2a212be04ca7479f77b948f0da8b423e3bed
7
+ data.tar.gz: 012557be64bfdcda584ac04cea11f456cbc2c0f3f5eab7e83659260288259be837c8bf76e5dc548da1b5fe4d31aa5f11f2afae410651d20815358dfe77b99920
@@ -0,0 +1,55 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+ branches: [master]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ ruby-version: ['2.3', '2.4', '2.5', '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4']
17
+ include:
18
+ - ruby-version: '4.0'
19
+ allow-failure: true
20
+ - ruby-version: ruby-head
21
+ allow-failure: true
22
+ - ruby-version: jruby-head
23
+ allow-failure: true
24
+
25
+ continue-on-error: ${{ matrix.allow-failure || false }}
26
+
27
+ services:
28
+ redis:
29
+ image: redis
30
+ ports:
31
+ - 6379:6379
32
+ options: >-
33
+ --health-cmd "redis-cli ping"
34
+ --health-interval 10s
35
+ --health-timeout 5s
36
+ --health-retries 5
37
+
38
+ steps:
39
+ - uses: actions/checkout@v4
40
+
41
+ - name: Set up Ruby ${{ matrix.ruby-version }}
42
+ uses: ruby/setup-ruby@v1
43
+ with:
44
+ ruby-version: ${{ matrix.ruby-version }}
45
+ bundler-cache: true
46
+
47
+ - name: Run tests
48
+ run: bundle exec rake spec
49
+
50
+ - name: Upload coverage to Codecov
51
+ if: matrix.ruby-version == '3.4'
52
+ uses: codecov/codecov-action@v5
53
+ with:
54
+ files: coverage/.resultset.json
55
+ token: ${{ secrets.CODECOV_TOKEN }}
data/.gitignore CHANGED
@@ -7,3 +7,4 @@
7
7
  /pkg/
8
8
  /spec/reports/
9
9
  /tmp/
10
+ *.gem
data/README.md CHANGED
@@ -1,13 +1,12 @@
1
1
  # Broadcaster
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/broadcaster.svg)](https://rubygems.org/gems/broadcaster)
4
- [![Build Status](https://travis-ci.org/gabynaiman/broadcaster.svg?branch=master)](https://travis-ci.org/gabynaiman/broadcaster)
5
- [![Coverage Status](https://coveralls.io/repos/gabynaiman/broadcaster/badge.svg?branch=master)](https://coveralls.io/r/gabynaiman/broadcaster?branch=master)
6
- [![Code Climate](https://codeclimate.com/github/gabynaiman/broadcaster.svg)](https://codeclimate.com/github/gabynaiman/broadcaster)
7
- [![Dependency Status](https://gemnasium.com/gabynaiman/broadcaster.svg)](https://gemnasium.com/gabynaiman/broadcaster)
4
+ [![CI](https://github.com/gabynaiman/broadcaster/actions/workflows/ci.yml/badge.svg)](https://github.com/gabynaiman/broadcaster/actions/workflows/ci.yml)
8
5
 
9
6
  Broadcasting based on Redis PubSub
10
7
 
8
+ Requires a running Redis server.
9
+
11
10
  ## Installation
12
11
 
13
12
  Add this line to your application's Gemfile:
@@ -41,6 +40,46 @@ broadcaster.unsubscribe subscription_id
41
40
  broadcaster.unsubscribe_all
42
41
  ```
43
42
 
43
+ Any callable object can be used instead of a block:
44
+
45
+ ```ruby
46
+ class Receiver
47
+ def call(message)
48
+ puts message
49
+ end
50
+ end
51
+
52
+ broadcaster.subscribe 'channel_1', Receiver.new
53
+ ```
54
+
55
+ Messages are serialized with `Marshal`, so any serializable Ruby object can be published.
56
+
57
+ Each subscriber runs isolated from the others: if one raises an error it is logged
58
+ and the message is still delivered to the remaining subscribers.
59
+
60
+ ## Scoping
61
+
62
+ Channels are scoped by the broadcaster `id`. Without an explicit `id` each instance
63
+ gets a random one, so instances are isolated from each other:
64
+
65
+ ```ruby
66
+ broadcaster_1 = Broadcaster.new
67
+ broadcaster_2 = Broadcaster.new
68
+
69
+ broadcaster_2.subscribe('channel_1') { |message| puts message }
70
+ broadcaster_1.publish 'channel_1', 'text' # not received by broadcaster_2
71
+ ```
72
+
73
+ Sharing the same `id` makes them see each other's messages, even across processes:
74
+
75
+ ```ruby
76
+ broadcaster_1 = Broadcaster.new id: 'my_app'
77
+ broadcaster_2 = Broadcaster.new id: 'my_app'
78
+
79
+ broadcaster_2.subscribe('channel_1') { |message| puts message }
80
+ broadcaster_1.publish 'channel_1', 'text' # received by broadcaster_2
81
+ ```
82
+
44
83
  ## Global configuration
45
84
 
46
85
  ```ruby
@@ -53,13 +92,24 @@ Broadcaster.configure do |config|
53
92
  end
54
93
  ```
55
94
 
95
+ | Setting | Default | Description |
96
+ |---|---|---|
97
+ | `redis_client` | `Redic` | Redis client class |
98
+ | `redis_settings` | `'redis://localhost:6379'` | Connection settings passed to the client |
99
+ | `logger` | `Logger.new(STDOUT)` | Logger used by every broadcaster |
100
+ | `reconnection_timeout` | `10` | Seconds to wait before retrying a broken listener connection |
101
+ | `watchdog_interval` | `30` | Seconds between watchdog checks |
102
+ | `watchdog_timeout` | `120` | Seconds without listener activity before restarting it |
103
+
56
104
  ## Options
57
105
 
58
106
  ```ruby
59
107
  options = {
60
108
  id: 'my_app', # Shared broadcaster for multiple processes
61
109
  redis_settings: 'redis://host_name:6379', # Custom redis connection
62
- logger: Logger.new('/file.log') # Custom logger
110
+ logger: Logger.new('/file.log'), # Custom logger
111
+ watchdog_interval: 30, # Watchdog tuning for this instance
112
+ watchdog_timeout: 120
63
113
  # or
64
114
  redis_client: Redic::Sentinels,
65
115
  redis_settings: hosts: [sentinel_1, sentinel_2], master_name: 'mymaster'
@@ -68,6 +118,38 @@ options = {
68
118
  Broadcaster.new options
69
119
  ```
70
120
 
121
+ `reconnection_timeout` can only be set globally.
122
+
123
+ ## Reconnection
124
+
125
+ The listener runs on its own thread. If the connection to Redis drops, it is
126
+ re-established automatically after `reconnection_timeout` seconds, keeping the
127
+ current subscriptions. Publishing while the connection is down raises an error.
128
+
129
+ ## Watchdog
130
+
131
+ Reconnection relies on the connection raising an error. A half-open socket never
132
+ does: if the TCP connection is silently dropped, or a Redis Sentinel failover
133
+ promotes a new master, the listener stays blocked reading from a socket that will
134
+ never deliver another message. The process looks healthy while no message is
135
+ received, and nothing recovers on its own.
136
+
137
+ The watchdog covers that case. It publishes a ping on its own channel every
138
+ `watchdog_interval` seconds, and if no message reaches the listener for
139
+ `watchdog_timeout` seconds it restarts the listener over a new connection —
140
+ which also re-resolves the current master when using `Redic::Sentinels`.
141
+
142
+ `watchdog_timeout` must be comfortably greater than `watchdog_interval`, so that a
143
+ single lost ping does not trigger a restart.
144
+
145
+ ## Shutdown
146
+
147
+ Stops the watchdog and the listener, and closes both connections:
148
+
149
+ ```ruby
150
+ broadcaster.shutdown
151
+ ```
152
+
71
153
  ## Contributing
72
154
 
73
155
  Bug reports and pull requests are welcome on GitHub at https://github.com/gabynaiman/broadcaster.
data/Rakefile CHANGED
@@ -3,6 +3,7 @@ require 'rake/testtask'
3
3
 
4
4
  Rake::TestTask.new(:spec) do |t|
5
5
  t.libs << 'spec'
6
+ t.libs << 'lib'
6
7
  t.pattern = 'spec/**/*_spec.rb'
7
8
  t.verbose = false
8
9
  t.warning = false
@@ -13,11 +14,12 @@ Rake::TestTask.new(:spec) do |t|
13
14
  t.options << "-l #{ENV['LINE']} " if ENV['LINE'] && ENV['TEST']
14
15
  end
15
16
 
17
+ task default: :spec
18
+
19
+ desc 'IRB console'
16
20
  task :console do
17
- require 'pry'
21
+ require 'irb'
18
22
  require 'broadcaster'
19
23
  ARGV.clear
20
- Pry.start
21
- end
22
-
23
- task default: :spec
24
+ IRB.start
25
+ end
data/broadcaster.gemspec CHANGED
@@ -1,10 +1,11 @@
1
1
  # coding: utf-8
2
2
  lib = File.expand_path('../lib', __FILE__)
3
3
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'broadcaster/version'
4
5
 
5
6
  Gem::Specification.new do |spec|
6
7
  spec.name = 'broadcaster'
7
- spec.version = '1.0.1'
8
+ spec.version = Broadcaster::VERSION
8
9
  spec.authors = ['Gabriel Naiman']
9
10
  spec.email = ['gabynaiman@gmail.com']
10
11
  spec.summary = 'Broadcasting based on Redis PubSub'
@@ -12,6 +13,8 @@ Gem::Specification.new do |spec|
12
13
  spec.homepage = 'https://github.com/gabynaiman/broadcaster'
13
14
  spec.license = 'MIT'
14
15
 
16
+ spec.required_ruby_version = '>= 2.3.0'
17
+
15
18
  spec.files = `git ls-files -z`.split("\x0")
16
19
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
20
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
@@ -20,12 +23,19 @@ Gem::Specification.new do |spec|
20
23
  spec.add_dependency 'redic', '~> 1.5'
21
24
  spec.add_dependency 'class_config', '~> 0.0'
22
25
 
23
- spec.add_development_dependency 'bundler', '~> 1.12'
24
- spec.add_development_dependency 'rake', '~> 11.0'
26
+ if RUBY_VERSION >= '4.0'
27
+ spec.add_dependency 'logger'
28
+ spec.add_dependency 'securerandom'
29
+ end
30
+
31
+ spec.add_development_dependency 'rake', '~> 12.0'
25
32
  spec.add_development_dependency 'minitest', '~> 5.0', '< 5.11'
26
33
  spec.add_development_dependency 'minitest-colorin', '~> 0.1'
27
34
  spec.add_development_dependency 'minitest-line', '~> 0.6'
28
35
  spec.add_development_dependency 'simplecov', '~> 0.12'
29
- spec.add_development_dependency 'coveralls', '~> 0.8'
30
- spec.add_development_dependency 'pry-nav', '~> 0.2'
36
+
37
+ if RUBY_VERSION >= '3.3'
38
+ spec.add_development_dependency 'mutex_m'
39
+ spec.add_development_dependency 'ostruct'
40
+ end
31
41
  end
@@ -0,0 +1,3 @@
1
+ class Broadcaster
2
+ VERSION = '1.1.0'
3
+ end
@@ -0,0 +1,47 @@
1
+ class Broadcaster
2
+ class Watchdog
3
+
4
+ CHANNEL = '__watchdog__'.freeze
5
+
6
+ def initialize(broadcaster, interval:, timeout:, logger:)
7
+ @broadcaster = broadcaster
8
+ @interval = interval
9
+ @timeout = timeout
10
+ @logger = logger
11
+ end
12
+
13
+ def start
14
+ @thread = Thread.new do
15
+ loop do
16
+ sleep interval
17
+ check
18
+ end
19
+ end
20
+ end
21
+
22
+ def stop
23
+ thread.kill
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :broadcaster, :interval, :timeout, :logger, :thread
29
+
30
+ def check
31
+ broadcaster.publish CHANNEL, ping: Time.now.to_f
32
+
33
+ return unless unresponsive?
34
+
35
+ logger.warn(self.class.name) { "No notifications received for #{timeout}s" }
36
+ broadcaster.restart_listener
37
+
38
+ rescue => ex
39
+ logger.error(self.class.name) { "#{ex.class}: #{ex.message}" }
40
+ end
41
+
42
+ def unresponsive?
43
+ Time.now - broadcaster.last_notification_at > timeout
44
+ end
45
+
46
+ end
47
+ end
data/lib/broadcaster.rb CHANGED
@@ -1,6 +1,10 @@
1
1
  require 'redic'
2
2
  require 'class_config'
3
3
  require 'logger'
4
+ require 'securerandom'
5
+
6
+ require_relative 'broadcaster/version'
7
+ require_relative 'broadcaster/watchdog'
4
8
 
5
9
  class Broadcaster
6
10
 
@@ -9,30 +13,40 @@ class Broadcaster
9
13
  attr_config :redis_client, Redic
10
14
  attr_config :redis_settings, 'redis://localhost:6379'
11
15
  attr_config :logger, Logger.new(STDOUT)
16
+ attr_config :reconnection_timeout, 10
17
+ attr_config :watchdog_interval, 30
18
+ attr_config :watchdog_timeout, 120
12
19
 
13
- attr_reader :id
20
+ attr_reader :id, :last_notification_at
14
21
 
15
22
  def initialize(options={})
16
23
  @id = options.fetch(:id, SecureRandom.uuid)
17
24
  @logger = options.fetch(:logger, Broadcaster.logger)
25
+ @logger_name = "Broadcaster (#{@id})"
18
26
  @redis_client = options.fetch(:redis_client, Broadcaster.redis_client)
19
- @publisher = @redis_client.new options.fetch(:redis_settings, Broadcaster.redis_settings)
20
- @subscriber = @redis_client.new options.fetch(:redis_settings, Broadcaster.redis_settings)
21
- @subscriptions = Hash.new { |h,k| h[k] = {} }
27
+ @redis_settings = options.fetch(:redis_settings, Broadcaster.redis_settings)
28
+ @publisher = establish_connection
29
+ @subscriptions = {}
22
30
  @mutex = Mutex.new
23
- listen
31
+ @watchdog = Watchdog.new self, logger: logger,
32
+ interval: options.fetch(:watchdog_interval, Broadcaster.watchdog_interval),
33
+ timeout: options.fetch(:watchdog_timeout, Broadcaster.watchdog_timeout)
34
+
35
+ start_listener
36
+ watchdog.start
24
37
  end
25
38
 
26
39
  def publish(channel, message)
27
- logger.debug(self.class) { "Published | #{scoped(channel)} | #{message}" }
28
- publisher.call 'PUBLISH', scoped(channel), Marshal.dump(message)
40
+ publisher.call! 'PUBLISH', scoped(channel), Marshal.dump(message)
41
+ logger.debug(logger_name) { "Published | #{scoped(channel)} | #{message}" }
29
42
  end
30
43
 
31
44
  def subscribe(channel, callable=nil, &block)
32
45
  mutex.synchronize do
33
46
  SecureRandom.uuid.tap do |subscription_id|
34
- logger.debug(self.class) { "Subscribed | #{scoped(channel)} | #{subscription_id}" }
35
- subscriptions[scoped(channel)][subscription_id] = callable || block
47
+ channel_subscriptions = subscriptions[scoped(channel)] ||= {}
48
+ channel_subscriptions[subscription_id] = callable || block
49
+ logger.debug(logger_name) { "Subscribed | #{scoped(channel)} | #{subscription_id}" }
36
50
  end
37
51
  end
38
52
  end
@@ -41,9 +55,9 @@ class Broadcaster
41
55
  mutex.synchronize do
42
56
  channel, _ = subscriptions.detect { |k,v| v.key? subscription_id }
43
57
  if channel
44
- logger.debug(self.class) { "Unsubscribed | #{channel} | #{subscription_id}" }
45
58
  block = subscriptions[channel].delete subscription_id
46
59
  subscriptions.delete_if { |k,v| v.empty? }
60
+ logger.debug(logger_name) { "Unsubscribed | #{channel} | #{subscription_id}" }
47
61
  block
48
62
  end
49
63
  end
@@ -51,42 +65,98 @@ class Broadcaster
51
65
 
52
66
  def unsubscribe_all
53
67
  mutex.synchronize do
54
- logger.debug(self.class) { 'Unsubscribed all' }
68
+ logger.debug(logger_name) { 'Unsubscribed all' }
55
69
  subscriptions.clear
56
70
  end
57
71
  end
58
72
 
73
+ def restart_listener
74
+ logger.warn(logger_name) { 'Restarting listener' }
75
+
76
+ listener.kill if listener && listener != Thread.current
77
+ disconnect subscriber
78
+
79
+ start_listener
80
+ end
81
+
82
+ def shutdown
83
+ watchdog.stop
84
+ listener.kill
85
+
86
+ disconnect subscriber
87
+ disconnect publisher
88
+
89
+ logger.info(logger_name) { 'Shutdown' }
90
+ end
91
+
59
92
  private
60
93
 
61
- attr_reader :publisher, :subscriber, :subscriptions, :mutex, :logger
94
+ attr_reader :publisher, :subscriber, :listener, :watchdog, :subscriptions, :mutex, :logger, :logger_name,
95
+ :redis_client, :redis_settings
96
+
97
+ def establish_connection
98
+ redis_client.new(redis_settings).tap { |redis| redis.call! 'PING' }
99
+ end
100
+
101
+ def disconnect(connection)
102
+ connection.client.disconnect!
103
+ rescue => ex
104
+ logger.error(logger_name) { ex }
105
+ end
62
106
 
63
107
  def scoped(channel)
64
108
  "#{id}:#{channel}"
65
109
  end
66
110
 
67
- def listen
68
- logger.debug(self.class) { 'Start listening' }
69
- subscriber.call 'PSUBSCRIBE', scoped('*')
111
+ def start_listener
112
+ @subscriber = establish_connection
113
+ subscriber.call! 'PSUBSCRIBE', scoped('*')
114
+
115
+ logger.info(logger_name) { 'Listener started' }
116
+
117
+ @last_notification_at = Time.now
70
118
 
71
- Thread.new do
119
+ @listener = Thread.new do
72
120
  loop do
73
121
  begin
74
122
  notification = subscriber.client.read
123
+ @last_notification_at = Time.now
124
+
75
125
  channel = notification[2]
76
126
  message = Marshal.load notification[3]
77
- logger.debug(self.class) { "Broadcasting (#{subscriptions[channel].count}) | #{channel} | #{message}" }
78
- subscriptions[channel].each do |subscription_id, block|
79
- begin
80
- block.call message
81
- rescue => ex
82
- logger.error(self.class) { "Failed | #{channel} | #{subscription_id} | #{message}\n#{ex.class}: #{ex.message}\n#{ex.backtrace.join("\n")}" }
127
+
128
+ current_subscriptions = mutex.synchronize do
129
+ subscriptions.key?(channel) ? subscriptions[channel].dup : nil
130
+ end
131
+
132
+ if current_subscriptions
133
+ logger.debug(logger_name) { "Broadcasting (#{current_subscriptions.count}) | #{channel} | #{message}" }
134
+
135
+ current_subscriptions.each do |subscription_id, block|
136
+ begin
137
+ block.call message
138
+ rescue => ex
139
+ logger.error(logger_name) { "Failed | #{channel} | #{subscription_id} | #{message}\n#{ex.class}: #{ex.message}\n#{ex.backtrace.join("\n")}" }
140
+ end
83
141
  end
84
142
  end
85
- rescue java.nio.channels.ClosedSelectorException
86
- # JRuby - Safe closing thread
143
+
144
+ rescue => ex
145
+ logger.error(logger_name) { ex }
146
+ break
87
147
  end
88
148
  end
149
+
150
+ logger.warn(logger_name) { 'Listener broken' }
151
+ restart_listener
89
152
  end
153
+
154
+ rescue => ex
155
+ logger.error(logger_name) { ex }
156
+ sleep Broadcaster.reconnection_timeout
157
+
158
+ logger.info(logger_name) { 'Listener reconnecting' }
159
+ retry
90
160
  end
91
161
 
92
- end
162
+ end
@@ -2,26 +2,14 @@ require 'minitest_helper'
2
2
 
3
3
  describe Broadcaster do
4
4
 
5
- class Receiver
6
- attr_reader :messages
7
-
8
- def initialize
9
- @messages = []
10
- end
11
-
12
- def call(message)
13
- messages << message
14
- end
15
- end
16
-
17
- def wait_for(&block)
18
- Timeout.timeout(3) do
5
+ def wait_for(timeout=1, &block)
6
+ Timeout.timeout(timeout) do
19
7
  while !block.call
20
8
  sleep 0.001
21
9
  end
22
10
  end
23
11
  end
24
-
12
+
25
13
  it 'Multiple channels' do
26
14
  broadcaster = Broadcaster.new
27
15
 
@@ -40,7 +28,7 @@ describe Broadcaster do
40
28
 
41
29
  wait_for { received_messages.values.flatten.count == 4 }
42
30
 
43
- received_messages.must_equal 'channel_1' => ['message 1', 'message 4'],
31
+ received_messages.must_equal 'channel_1' => ['message 1', 'message 4'],
44
32
  'channel_2' => ['message 2', 'message 3']
45
33
  end
46
34
 
@@ -69,7 +57,7 @@ describe Broadcaster do
69
57
 
70
58
  subscriptions = 2.times.map do |i|
71
59
  broadcaster.subscribe 'channel_1' do |message|
72
- received_messages[i] << message
60
+ received_messages[i] << message
73
61
  end
74
62
  end
75
63
 
@@ -94,7 +82,7 @@ describe Broadcaster do
94
82
 
95
83
  2.times.map do
96
84
  broadcaster.subscribe 'channel_1' do |message|
97
- received_messages << message
85
+ received_messages << message
98
86
  end
99
87
  end
100
88
 
@@ -149,7 +137,7 @@ describe Broadcaster do
149
137
  1 => ['message 1', 'message 2']
150
138
  end
151
139
 
152
- it 'Custom redis connection' do
140
+ it 'Invalid redis connection' do
153
141
  error = proc { Broadcaster.new redis_settings: 'redis://invalid_host:6379' }.must_raise StandardError
154
142
  error.message.must_match 'invalid_host'
155
143
  end
@@ -170,4 +158,80 @@ describe Broadcaster do
170
158
  receiver.messages.must_equal ['message 1', 'message 2', 'message 3']
171
159
  end
172
160
 
161
+ it 'Automatic reconnection' do
162
+ broadcaster = Broadcaster.new redis_client: MockRedis
163
+
164
+ received_messages = []
165
+
166
+ broadcaster.subscribe 'channel_1' do |message|
167
+ received_messages << message
168
+ end
169
+
170
+ broadcaster.publish 'channel_1', 'message 1'
171
+
172
+ MockRedis.stop
173
+
174
+ error = proc { broadcaster.publish 'channel_1', 'message 2' }.must_raise RuntimeError
175
+ error.message.must_equal 'Redis connection error'
176
+
177
+ MockRedis.start
178
+
179
+ broadcaster.publish 'channel_1', 'message 3'
180
+
181
+ wait_for { received_messages.count == 2 }
182
+
183
+ received_messages.must_equal ['message 1', 'message 3']
184
+ end
185
+
186
+ describe 'Watchdog' do
187
+
188
+ def with_blocked_listener(options={})
189
+ HangingRedis.reset
190
+
191
+ broadcaster = Broadcaster.new options.merge(redis_client: HangingRedis)
192
+ wait_for { HangingRedis.blocked_reads > 0 }
193
+ HangingRedis.hanging = false
194
+
195
+ yield broadcaster
196
+
197
+ ensure
198
+ broadcaster.shutdown if broadcaster
199
+ HangingRedis.reset
200
+ end
201
+
202
+ it 'Restarts a listener blocked on a silent connection' do
203
+ with_blocked_listener watchdog_interval: 0.05, watchdog_timeout: 0.2 do |broadcaster|
204
+ received_messages = []
205
+
206
+ broadcaster.subscribe 'channel_1' do |message|
207
+ received_messages << message
208
+ end
209
+
210
+ wait_for(10) do
211
+ broadcaster.publish 'channel_1', 'message 1'
212
+ sleep 0.1
213
+ received_messages.any?
214
+ end
215
+
216
+ received_messages.must_include 'message 1'
217
+ end
218
+ end
219
+
220
+ it 'Does not restart the listener before the timeout elapses' do
221
+ with_blocked_listener watchdog_interval: 0.05, watchdog_timeout: 60 do |broadcaster|
222
+ received_messages = []
223
+
224
+ broadcaster.subscribe 'channel_1' do |message|
225
+ received_messages << message
226
+ end
227
+
228
+ broadcaster.publish 'channel_1', 'message 1'
229
+ sleep 0.5
230
+
231
+ received_messages.must_be_empty
232
+ end
233
+ end
234
+
235
+ end
236
+
173
237
  end
@@ -1,15 +1,108 @@
1
1
  require 'simplecov'
2
- require 'coveralls'
3
2
 
4
- SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new [SimpleCov::Formatter::HTMLFormatter, Coveralls::SimpleCov::Formatter]
5
3
  SimpleCov.start
6
4
 
7
5
  require 'broadcaster'
8
6
  require 'minitest/autorun'
9
7
  require 'minitest/colorin'
10
- require 'pry-nav'
11
8
  require 'timeout'
12
9
 
13
10
  Broadcaster.configure do |config|
14
11
  config.logger = Logger.new '/dev/null'
12
+ config.reconnection_timeout = 0.001
13
+ end
14
+
15
+ class Receiver
16
+ attr_reader :messages
17
+
18
+ def initialize
19
+ @messages = []
20
+ end
21
+
22
+ def call(message)
23
+ messages << message
24
+ end
25
+ end
26
+
27
+ class MockRedis
28
+
29
+ class << self
30
+
31
+ def start
32
+ @running = true
33
+ sleep 0.05
34
+ end
35
+
36
+ def stop
37
+ @running = false
38
+ Redic.new.call 'CLIENT', 'KILL', 'TYPE', 'pubsub'
39
+ sleep 0.05
40
+ end
41
+
42
+ def running?
43
+ @running.nil? ? true : @running
44
+ end
45
+
46
+ end
47
+
48
+ def initialize(*args)
49
+ @redis = Redic.new(*args)
50
+ end
51
+
52
+ def call!(*args)
53
+ raise 'Redis connection error' unless self.class.running?
54
+ @redis.call!(*args)
55
+ end
56
+
57
+ def client
58
+ @redis.client
59
+ end
60
+
61
+ end
62
+
63
+ class HangingRedis
64
+
65
+ class << self
66
+
67
+ attr_writer :hanging
68
+ attr_accessor :blocked_reads
69
+
70
+ def hanging?
71
+ @hanging.nil? ? true : @hanging
72
+ end
73
+
74
+ def reset
75
+ @hanging = nil
76
+ @blocked_reads = 0
77
+ end
78
+
79
+ def blocked_reads
80
+ @blocked_reads ||= 0
81
+ end
82
+
83
+ end
84
+
85
+ def initialize(*args)
86
+ @redis = Redic.new(*args)
87
+ end
88
+
89
+ def call!(*args)
90
+ @redis.call!(*args)
91
+ end
92
+
93
+ def client
94
+ self
95
+ end
96
+
97
+ def read
98
+ return @redis.client.read unless self.class.hanging?
99
+
100
+ self.class.blocked_reads += 1
101
+ sleep
102
+ end
103
+
104
+ def disconnect!
105
+ @redis.client.disconnect!
106
+ end
107
+
15
108
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: broadcaster
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriel Naiman
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-06-25 00:00:00.000000000 Z
11
+ date: 2026-08-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redic
@@ -38,34 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0.0'
41
- - !ruby/object:Gem::Dependency
42
- name: bundler
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - "~>"
46
- - !ruby/object:Gem::Version
47
- version: '1.12'
48
- type: :development
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - "~>"
53
- - !ruby/object:Gem::Version
54
- version: '1.12'
55
41
  - !ruby/object:Gem::Dependency
56
42
  name: rake
57
43
  requirement: !ruby/object:Gem::Requirement
58
44
  requirements:
59
45
  - - "~>"
60
46
  - !ruby/object:Gem::Version
61
- version: '11.0'
47
+ version: '12.0'
62
48
  type: :development
63
49
  prerelease: false
64
50
  version_requirements: !ruby/object:Gem::Requirement
65
51
  requirements:
66
52
  - - "~>"
67
53
  - !ruby/object:Gem::Version
68
- version: '11.0'
54
+ version: '12.0'
69
55
  - !ruby/object:Gem::Dependency
70
56
  name: minitest
71
57
  requirement: !ruby/object:Gem::Requirement
@@ -128,34 +114,6 @@ dependencies:
128
114
  - - "~>"
129
115
  - !ruby/object:Gem::Version
130
116
  version: '0.12'
131
- - !ruby/object:Gem::Dependency
132
- name: coveralls
133
- requirement: !ruby/object:Gem::Requirement
134
- requirements:
135
- - - "~>"
136
- - !ruby/object:Gem::Version
137
- version: '0.8'
138
- type: :development
139
- prerelease: false
140
- version_requirements: !ruby/object:Gem::Requirement
141
- requirements:
142
- - - "~>"
143
- - !ruby/object:Gem::Version
144
- version: '0.8'
145
- - !ruby/object:Gem::Dependency
146
- name: pry-nav
147
- requirement: !ruby/object:Gem::Requirement
148
- requirements:
149
- - - "~>"
150
- - !ruby/object:Gem::Version
151
- version: '0.2'
152
- type: :development
153
- prerelease: false
154
- version_requirements: !ruby/object:Gem::Requirement
155
- requirements:
156
- - - "~>"
157
- - !ruby/object:Gem::Version
158
- version: '0.2'
159
117
  description: Broadcasting based on Redis PubSub
160
118
  email:
161
119
  - gabynaiman@gmail.com
@@ -163,24 +121,25 @@ executables: []
163
121
  extensions: []
164
122
  extra_rdoc_files: []
165
123
  files:
166
- - ".coveralls.yml"
124
+ - ".github/workflows/ci.yml"
167
125
  - ".gitignore"
168
126
  - ".ruby-gemset"
169
127
  - ".ruby-version"
170
- - ".travis.yml"
171
128
  - Gemfile
172
129
  - LICENSE.txt
173
130
  - README.md
174
131
  - Rakefile
175
132
  - broadcaster.gemspec
176
133
  - lib/broadcaster.rb
134
+ - lib/broadcaster/version.rb
135
+ - lib/broadcaster/watchdog.rb
177
136
  - spec/broadcast_spec.rb
178
137
  - spec/minitest_helper.rb
179
138
  homepage: https://github.com/gabynaiman/broadcaster
180
139
  licenses:
181
140
  - MIT
182
141
  metadata: {}
183
- post_install_message:
142
+ post_install_message:
184
143
  rdoc_options: []
185
144
  require_paths:
186
145
  - lib
@@ -188,16 +147,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
188
147
  requirements:
189
148
  - - ">="
190
149
  - !ruby/object:Gem::Version
191
- version: '0'
150
+ version: 2.3.0
192
151
  required_rubygems_version: !ruby/object:Gem::Requirement
193
152
  requirements:
194
153
  - - ">="
195
154
  - !ruby/object:Gem::Version
196
155
  version: '0'
197
156
  requirements: []
198
- rubyforge_project:
199
- rubygems_version: 2.5.1
200
- signing_key:
157
+ rubygems_version: 3.0.9
158
+ signing_key:
201
159
  specification_version: 4
202
160
  summary: Broadcasting based on Redis PubSub
203
161
  test_files:
data/.coveralls.yml DELETED
@@ -1,2 +0,0 @@
1
- service_name: travis-ci
2
- repo_token: DIwiPcMHvmwcB0uOFrsaZ3ftClakHZSCM
data/.travis.yml DELETED
@@ -1,27 +0,0 @@
1
- language: ruby
2
-
3
- rvm:
4
- - 2.0
5
- - 2.1
6
- - 2.2
7
- - 2.3.0
8
- - 2.4.0
9
- - 2.5.0
10
- - 2.6.0
11
- - jruby-9.1.17.0
12
- - jruby-9.2.7.0
13
- - ruby-head
14
- - jruby-head
15
-
16
- matrix:
17
- fast_finish: true
18
- allow_failures:
19
- - rvm: ruby-head
20
- - rvm: jruby-head
21
-
22
- before_install:
23
- - rvm all-gemsets do gem uninstall bundler -ax || true
24
- - gem install bundler -v "< 2"
25
-
26
- services:
27
- - redis-server