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 +5 -5
- data/.github/workflows/ci.yml +55 -0
- data/.gitignore +1 -0
- data/README.md +87 -5
- data/Rakefile +7 -5
- data/broadcaster.gemspec +15 -5
- data/lib/broadcaster/version.rb +3 -0
- data/lib/broadcaster/watchdog.rb +47 -0
- data/lib/broadcaster.rb +95 -25
- data/spec/broadcast_spec.rb +83 -19
- data/spec/minitest_helper.rb +96 -3
- metadata +12 -54
- data/.coveralls.yml +0 -2
- data/.travis.yml +0 -27
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8db15deb65dc4589963f402e1db172de1fd5cc31c8e46f99e34897a5dc1f3409
|
|
4
|
+
data.tar.gz: 57fb1bf72218bbfe631fd8547488b4408921ccec0cce2d09d389eb1cc5996f6b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
data/README.md
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
# Broadcaster
|
|
2
2
|
|
|
3
3
|
[](https://rubygems.org/gems/broadcaster)
|
|
4
|
-
[](https://coveralls.io/r/gabynaiman/broadcaster?branch=master)
|
|
6
|
-
[](https://codeclimate.com/github/gabynaiman/broadcaster)
|
|
7
|
-
[](https://gemnasium.com/gabynaiman/broadcaster)
|
|
4
|
+
[](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')
|
|
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 '
|
|
21
|
+
require 'irb'
|
|
18
22
|
require 'broadcaster'
|
|
19
23
|
ARGV.clear
|
|
20
|
-
|
|
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 =
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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,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
|
-
@
|
|
20
|
-
@
|
|
21
|
-
@subscriptions =
|
|
27
|
+
@redis_settings = options.fetch(:redis_settings, Broadcaster.redis_settings)
|
|
28
|
+
@publisher = establish_connection
|
|
29
|
+
@subscriptions = {}
|
|
22
30
|
@mutex = Mutex.new
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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(
|
|
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
|
|
68
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
data/spec/broadcast_spec.rb
CHANGED
|
@@ -2,26 +2,14 @@ require 'minitest_helper'
|
|
|
2
2
|
|
|
3
3
|
describe Broadcaster do
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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 '
|
|
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
|
data/spec/minitest_helper.rb
CHANGED
|
@@ -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
|
|
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:
|
|
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: '
|
|
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: '
|
|
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
|
-
- ".
|
|
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:
|
|
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
|
-
|
|
199
|
-
|
|
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
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
|