pubsubstub 0.0.15 → 0.1.0.beta1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.travis.yml +3 -1
- data/Gemfile +8 -0
- data/README.md +10 -4
- data/Rakefile +6 -1
- data/bin/console +16 -0
- data/bin/server +3 -0
- data/example/Gemfile +1 -1
- data/example/config.ru +2 -0
- data/example/puma_config.rb +19 -0
- data/lib/pubsubstub.rb +65 -4
- data/lib/pubsubstub/action.rb +0 -10
- data/lib/pubsubstub/channel.rb +21 -35
- data/lib/pubsubstub/event.rb +10 -6
- data/lib/pubsubstub/logging.rb +15 -0
- data/lib/pubsubstub/publish_action.rb +1 -2
- data/lib/pubsubstub/stream_action.rb +74 -40
- data/lib/pubsubstub/subscriber.rb +88 -0
- data/lib/pubsubstub/subscription.rb +62 -0
- data/lib/pubsubstub/version.rb +1 -1
- data/pubsubstub.gemspec +0 -9
- data/spec/channel_spec.rb +21 -75
- data/spec/publish_action_spec.rb +13 -0
- data/spec/spec_helper.rb +21 -3
- data/spec/stream_action_spec.rb +34 -97
- data/spec/subscriber_spec.rb +45 -0
- data/spec/subscription_spec.rb +70 -0
- data/spec/support/http_helpers.rb +52 -0
- data/spec/support/threading_matchers.rb +58 -0
- metadata +24 -135
- data/lib/pubsubstub/redis_pub_sub.rb +0 -73
- data/spec/redis_pub_sub_spec.rb +0 -121
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 66e90ee4856de125eb629c5d0b5eac58fde45493
|
4
|
+
data.tar.gz: f3a7ff09c124a70524fc1d8e31615a51ab53c1e2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1d2004519f159a56587cef4a57dd88ff7eec7215a27a247a04ca30442821dc82ec0528e28743b198ae233cd04f1eba9c4df98bee4033f6a0c1646a50c565dc6f
|
7
|
+
data.tar.gz: b27b55471cf1ece69e956c6eb2e8606bfe65576bfdaa9236d02007b1ce371e1985d94e7b4de74035b6d1d7d6e69d23c395e052db020bdd6efc529042e1a55a0a
|
data/.travis.yml
CHANGED
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -56,12 +56,18 @@ run Pubsubstub::Application
|
|
56
56
|
### Sending an event
|
57
57
|
|
58
58
|
```ruby
|
59
|
-
|
60
|
-
event = Pubsubstub::Event.new(payload, name: "user.update")
|
61
|
-
Pubsubstub::RedisPubSub.publish("user.#{user_id}", event)
|
59
|
+
Pubsubstub.publish("user.#{user.id}", user.to_json, name: "user.update")
|
62
60
|
```
|
63
61
|
|
64
|
-
To start the application, run `bundle exec
|
62
|
+
To start the application, run `bundle exec puma config.ru`
|
63
|
+
|
64
|
+
### HTTP Server
|
65
|
+
|
66
|
+
It is heavilly recommended to deploy Pubsubstub with `puma >= 3.4.0`. As of April 2016, it is the only ruby server that properly handle persistent connections.
|
67
|
+
|
68
|
+
Other servers like thin will either require you to set a connection timeout otherwise you won't be able to shutdown the server properly.
|
69
|
+
|
70
|
+
See the [/example/puma_config.rb](example puma config) for how to configure puma properly.
|
65
71
|
|
66
72
|
## Contributing
|
67
73
|
|
data/Rakefile
CHANGED
data/bin/console
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
#
|
3
|
+
# This file was generated by Bundler.
|
4
|
+
#
|
5
|
+
# The application 'console' is installed as part of a gem, and
|
6
|
+
# this file is here to facilitate running it.
|
7
|
+
#
|
8
|
+
|
9
|
+
require "pathname"
|
10
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
|
11
|
+
Pathname.new(__FILE__).realpath)
|
12
|
+
|
13
|
+
require "rubygems"
|
14
|
+
require "bundler/setup"
|
15
|
+
|
16
|
+
load Gem.bin_path("pubsubstub", "console")
|
data/bin/server
ADDED
data/example/Gemfile
CHANGED
@@ -1,2 +1,2 @@
|
|
1
1
|
gem 'pubsubstub', path: '../'
|
2
|
-
gem 'puma'
|
2
|
+
gem 'puma', '~> 3.4'
|
data/example/config.ru
CHANGED
@@ -0,0 +1,19 @@
|
|
1
|
+
#!/usr/bin/env puma
|
2
|
+
|
3
|
+
# This option is crucial, otherwise puma would wait for all the clients to disconnect before accepting to restart
|
4
|
+
# If you run Pubsubstub as a standalone server 0 seconds is recomended since the clients will reconnect
|
5
|
+
# and receive the scrollback anyway.
|
6
|
+
#
|
7
|
+
# But if you run Pubsubstub as an embeded Rack application, you want this to consider what is a
|
8
|
+
# decent timeout for the application.
|
9
|
+
|
10
|
+
force_shutdown_after 0
|
11
|
+
|
12
|
+
|
13
|
+
# min, max thread. The max define how much conccurent clients you can handle.
|
14
|
+
# If all threads are occupied the client will be stuck. Make sure to configure this appropriately.
|
15
|
+
# If you run Pubsubstub as a standalone, the cost of each thread is really low, so you can easilly have this quite high.
|
16
|
+
threads 0, 16
|
17
|
+
|
18
|
+
# If you notice your standalone pubsubstub server is starving for CPU, you can run puma in multi-process mode.
|
19
|
+
# workers 2
|
data/lib/pubsubstub.rb
CHANGED
@@ -1,10 +1,15 @@
|
|
1
|
+
require "logger"
|
2
|
+
require "mutex_m"
|
1
3
|
require "json"
|
4
|
+
require "set"
|
5
|
+
|
2
6
|
require "sinatra"
|
3
|
-
require "em-hiredis"
|
4
7
|
require "redis"
|
5
8
|
require "pubsubstub/version"
|
6
|
-
require "pubsubstub/
|
9
|
+
require "pubsubstub/logging"
|
7
10
|
require "pubsubstub/channel"
|
11
|
+
require "pubsubstub/subscriber"
|
12
|
+
require "pubsubstub/subscription"
|
8
13
|
require "pubsubstub/event"
|
9
14
|
require "pubsubstub/action"
|
10
15
|
require "pubsubstub/stream_action"
|
@@ -12,9 +17,65 @@ require "pubsubstub/publish_action"
|
|
12
17
|
require "pubsubstub/application"
|
13
18
|
|
14
19
|
module Pubsubstub
|
20
|
+
extend Mutex_m
|
21
|
+
|
15
22
|
class << self
|
16
|
-
attr_accessor :heartbeat_frequency, :redis_url
|
23
|
+
attr_accessor :heartbeat_frequency, :redis_url, :channels_scrollback_size,
|
24
|
+
:channels_scrollback_ttl, :logger, :reconnect_timeout, :error_handler
|
25
|
+
|
26
|
+
def publish(channel_name, *args)
|
27
|
+
Channel.new(channel_name).publish(Event.new(*args))
|
28
|
+
nil
|
29
|
+
end
|
30
|
+
|
31
|
+
def redis_url=(url)
|
32
|
+
@redis_url = url.to_s
|
33
|
+
@redis = nil
|
34
|
+
end
|
35
|
+
|
36
|
+
def redis
|
37
|
+
@redis || synchronize { @redis ||= new_redis }
|
38
|
+
end
|
39
|
+
|
40
|
+
def new_redis
|
41
|
+
Redis.new(url: redis_url)
|
42
|
+
end
|
43
|
+
|
44
|
+
def subscriber
|
45
|
+
@subscriber || synchronize { @subscriber ||= Subscriber.new }
|
46
|
+
end
|
47
|
+
|
48
|
+
def heartbeat_event
|
49
|
+
Event.new('ping', name: 'heartbeat', retry_after: reconnect_timeout)
|
50
|
+
end
|
51
|
+
|
52
|
+
def handle_error(error)
|
53
|
+
logger.error("Uncaught exception: #{error.class}: #{error.message}\n#{error.backtrace.join("\n\t")}\n")
|
54
|
+
error_handler && error_handler.call(error)
|
55
|
+
end
|
56
|
+
|
57
|
+
def report_errors
|
58
|
+
yield
|
59
|
+
rescue => error
|
60
|
+
handle_error(error)
|
61
|
+
raise
|
62
|
+
end
|
17
63
|
end
|
64
|
+
|
65
|
+
self.logger = Logger.new(STDOUT)
|
66
|
+
self.logger.level = Logger::DEBUG
|
18
67
|
self.heartbeat_frequency = 15
|
19
|
-
|
68
|
+
self.redis_url = ENV.fetch('REDIS_URL', 'redis://localhost:6379/0')
|
69
|
+
self.channels_scrollback_size = 1000
|
70
|
+
self.channels_scrollback_ttl = 24 * 60 * 60
|
71
|
+
self.reconnect_timeout = 10_000
|
20
72
|
|
73
|
+
# Deprecated. Use Pubsubstub.publish instead
|
74
|
+
module RedisPubSub
|
75
|
+
extend self
|
76
|
+
|
77
|
+
def publish(channel_name, event)
|
78
|
+
Channel.new(channel_name).publish(event)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
data/lib/pubsubstub/action.rb
CHANGED
@@ -9,15 +9,5 @@ module Pubsubstub
|
|
9
9
|
set :raise_errors, true
|
10
10
|
set :show_exceptions, false
|
11
11
|
end
|
12
|
-
|
13
|
-
def initialize(*)
|
14
|
-
@channels = Hash.new { |h, k| h[k] = Channel.new(k) }
|
15
|
-
@connections = []
|
16
|
-
super
|
17
|
-
end
|
18
|
-
|
19
|
-
def channel(name)
|
20
|
-
@channels[name]
|
21
|
-
end
|
22
12
|
end
|
23
13
|
end
|
data/lib/pubsubstub/channel.rb
CHANGED
@@ -1,54 +1,40 @@
|
|
1
1
|
module Pubsubstub
|
2
2
|
class Channel
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
@pubsub = RedisPubSub.new(name)
|
8
|
-
@connections = []
|
9
|
-
end
|
10
|
-
|
11
|
-
def subscribe(connection, options = {})
|
12
|
-
listen if @connections.empty?
|
13
|
-
@connections << connection
|
14
|
-
scrollback(connection, options[:last_event_id])
|
3
|
+
class << self
|
4
|
+
def name_from_pubsub_key(key)
|
5
|
+
key.sub(/\.pubsub$/, '')
|
6
|
+
end
|
15
7
|
end
|
16
8
|
|
17
|
-
|
18
|
-
@connections.include?(connection)
|
19
|
-
end
|
9
|
+
attr_reader :name
|
20
10
|
|
21
|
-
def
|
22
|
-
@
|
23
|
-
stop_listening if @connections.empty?
|
11
|
+
def initialize(name)
|
12
|
+
@name = name.to_s
|
24
13
|
end
|
25
14
|
|
26
15
|
def publish(event)
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
pubsub.scrollback(last_event_id) do |event|
|
33
|
-
connection << event.to_message
|
16
|
+
redis.pipelined do
|
17
|
+
redis.zadd(scrollback_key, event.id, event.to_json)
|
18
|
+
redis.zremrangebyrank(scrollback_key, 0, -Pubsubstub.channels_scrollback_size)
|
19
|
+
redis.expire(scrollback_key, Pubsubstub.channels_scrollback_ttl)
|
20
|
+
redis.publish(pubsub_key, event.to_json)
|
34
21
|
end
|
35
22
|
end
|
36
23
|
|
37
|
-
|
24
|
+
def scrollback(since: )
|
25
|
+
redis.zrangebyscore(scrollback_key, Integer(since) + 1, '+inf').map(&Event.method(:from_json))
|
26
|
+
end
|
38
27
|
|
39
|
-
def
|
40
|
-
|
41
|
-
@connections.each do |connection|
|
42
|
-
connection << string
|
43
|
-
end
|
28
|
+
def scrollback_key
|
29
|
+
"#{name}.scrollback"
|
44
30
|
end
|
45
31
|
|
46
|
-
def
|
47
|
-
pubsub
|
32
|
+
def pubsub_key
|
33
|
+
"#{name}.pubsub"
|
48
34
|
end
|
49
35
|
|
50
|
-
def
|
51
|
-
|
36
|
+
def redis
|
37
|
+
Pubsubstub.redis
|
52
38
|
end
|
53
39
|
end
|
54
40
|
end
|
data/lib/pubsubstub/event.rb
CHANGED
@@ -14,12 +14,7 @@ module Pubsubstub
|
|
14
14
|
end
|
15
15
|
|
16
16
|
def to_message
|
17
|
-
|
18
|
-
message = "id: #{id}" << "\n"
|
19
|
-
message << "event: #{name}" << "\n" if name
|
20
|
-
message << "retry: #{retry_after}" << "\n" if retry_after
|
21
|
-
message << data << "\n\n"
|
22
|
-
message
|
17
|
+
@message ||= build_message
|
23
18
|
end
|
24
19
|
|
25
20
|
def self.from_json(json)
|
@@ -33,6 +28,15 @@ module Pubsubstub
|
|
33
28
|
|
34
29
|
private
|
35
30
|
|
31
|
+
def build_message
|
32
|
+
data = @data.lines.map{ |segment| "data: #{segment}" }.join
|
33
|
+
message = "id: #{id}\n"
|
34
|
+
message << "event: #{name}\n" if name
|
35
|
+
message << "retry: #{retry_after}\n" if retry_after
|
36
|
+
message << data << "\n\n"
|
37
|
+
message
|
38
|
+
end
|
39
|
+
|
36
40
|
def time_now
|
37
41
|
(Time.now.to_f * 1000).to_i
|
38
42
|
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Pubsubstub
|
2
|
+
module Logging
|
3
|
+
def error
|
4
|
+
Pubsubstub.logger.error { "[#{self.class.name}] #{yield}" }
|
5
|
+
end
|
6
|
+
|
7
|
+
def info
|
8
|
+
Pubsubstub.logger.info { "[#{self.class.name}] #{yield}" }
|
9
|
+
end
|
10
|
+
|
11
|
+
def debug
|
12
|
+
Pubsubstub.logger.debug { "[#{self.class.name}] #{yield}" }
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -1,9 +1,8 @@
|
|
1
1
|
module Pubsubstub
|
2
2
|
class PublishAction < Pubsubstub::Action
|
3
3
|
post '/' do
|
4
|
-
event = Event.new(params[:data], name: params[:event])
|
5
4
|
(params[:channels] || [:default]).each do |channel_name|
|
6
|
-
|
5
|
+
Pubsubstub.publish(channel_name, params[:data], name: params[:event])
|
7
6
|
end
|
8
7
|
""
|
9
8
|
end
|
@@ -1,10 +1,11 @@
|
|
1
1
|
module Pubsubstub
|
2
2
|
class StreamAction < Pubsubstub::Action
|
3
|
-
|
3
|
+
include Logging
|
4
4
|
|
5
5
|
def initialize(*)
|
6
6
|
super
|
7
|
-
|
7
|
+
@subscriptions = Set.new
|
8
|
+
@mutex = Mutex.new
|
8
9
|
end
|
9
10
|
|
10
11
|
get '/', provides: 'text/event-stream' do
|
@@ -15,71 +16,104 @@ module Pubsubstub
|
|
15
16
|
'Connection' => 'keep-alive',
|
16
17
|
})
|
17
18
|
|
18
|
-
if
|
19
|
-
|
19
|
+
if event_machine?
|
20
|
+
send_scrollback
|
20
21
|
else
|
21
|
-
|
22
|
+
subscribe_connection
|
22
23
|
end
|
23
24
|
end
|
24
25
|
|
26
|
+
def call(*)
|
27
|
+
spawn_helper_threads
|
28
|
+
super
|
29
|
+
end
|
30
|
+
|
25
31
|
private
|
26
32
|
|
27
|
-
def
|
28
|
-
|
29
|
-
|
33
|
+
def last_event_id
|
34
|
+
request.env['HTTP_LAST_EVENT_ID']
|
35
|
+
end
|
30
36
|
|
31
|
-
|
32
|
-
|
37
|
+
def send_scrollback
|
38
|
+
scrollback_events = []
|
39
|
+
scrollback_events = channels.flat_map { |c| c.scrollback(since: last_event_id) } if last_event_id
|
40
|
+
scrollback_events = [Pubsubstub.heartbeat_event] if scrollback_events.empty?
|
41
|
+
stream do |connection|
|
42
|
+
scrollback_events.each do |event|
|
43
|
+
connection << event.to_message
|
44
|
+
end
|
33
45
|
end
|
46
|
+
end
|
34
47
|
|
35
|
-
|
48
|
+
def event_machine?
|
49
|
+
defined?(EventMachine) && EventMachine.reactor_running?
|
36
50
|
end
|
37
51
|
|
38
|
-
def
|
39
|
-
|
52
|
+
def channels
|
53
|
+
(params[:channels] || [:default]).map(&Channel.method(:new))
|
40
54
|
end
|
41
55
|
|
42
56
|
def subscribe_connection
|
43
|
-
stream
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
connection.callback do
|
51
|
-
@connections.delete(connection)
|
52
|
-
with_each_channel do |channel|
|
53
|
-
channel.unsubscribe(connection)
|
54
|
-
end
|
57
|
+
stream do |connection|
|
58
|
+
subscription = register(channels, connection)
|
59
|
+
begin
|
60
|
+
subscription.stream(last_event_id)
|
61
|
+
ensure
|
62
|
+
release(subscription)
|
55
63
|
end
|
56
64
|
end
|
57
65
|
end
|
58
66
|
|
59
|
-
def
|
60
|
-
|
61
|
-
|
67
|
+
def register(*args)
|
68
|
+
new_subscription = Subscription.new(*args)
|
69
|
+
@mutex.synchronize { @subscriptions << new_subscription }
|
70
|
+
new_subscription
|
62
71
|
end
|
63
72
|
|
64
|
-
def
|
65
|
-
@
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
73
|
+
def release(subscription)
|
74
|
+
@mutex.synchronize { @subscriptions.delete(subscription) }
|
75
|
+
end
|
76
|
+
|
77
|
+
def spawn_helper_threads
|
78
|
+
return if defined? @helper_threads_initialized
|
79
|
+
@mutex.synchronize do
|
80
|
+
return if defined? @helper_threads_initialized
|
81
|
+
@helper_threads_initialized = true
|
82
|
+
if event_machine?
|
83
|
+
error { "EventMachine is loaded, running in degraded mode :/"}
|
84
|
+
else
|
85
|
+
start_subscriber
|
86
|
+
start_heartbeat
|
70
87
|
end
|
71
88
|
end
|
72
89
|
end
|
73
90
|
|
74
|
-
def
|
75
|
-
|
76
|
-
|
77
|
-
|
91
|
+
def start_subscriber
|
92
|
+
Thread.start do
|
93
|
+
info { "Starting subscriber" }
|
94
|
+
Pubsubstub.report_errors do
|
95
|
+
begin
|
96
|
+
Pubsubstub.subscriber.start
|
97
|
+
rescue Redis::BaseConnectionError => error
|
98
|
+
error { "Can't subscribe to Redis (#{error.class}: #{error.message}). Retrying in 1 second" }
|
99
|
+
sleep 1
|
100
|
+
retry
|
101
|
+
end
|
102
|
+
end
|
78
103
|
end
|
79
104
|
end
|
80
105
|
|
81
|
-
def
|
82
|
-
|
106
|
+
def start_heartbeat
|
107
|
+
Thread.start do
|
108
|
+
info { "Starting heartbeat" }
|
109
|
+
Pubsubstub.report_errors do
|
110
|
+
loop do
|
111
|
+
sleep Pubsubstub.heartbeat_frequency
|
112
|
+
event = Pubsubstub.heartbeat_event
|
113
|
+
@subscriptions.each { |subscription| subscription.push(event) }
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
83
117
|
end
|
84
118
|
end
|
85
119
|
end
|