pubsubstub 0.0.15 → 0.1.0.beta1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|