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
@@ -0,0 +1,88 @@
|
|
1
|
+
module Pubsubstub
|
2
|
+
class Subscriber
|
3
|
+
include Logging
|
4
|
+
include Mutex_m
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
super
|
8
|
+
@subscribed = false
|
9
|
+
@listeners = {}
|
10
|
+
end
|
11
|
+
|
12
|
+
def subscribed?
|
13
|
+
@subscribed
|
14
|
+
end
|
15
|
+
|
16
|
+
def add_event_listener(channel_key, callback)
|
17
|
+
synchronize do
|
18
|
+
@listeners[channel_key] ||= Set.new
|
19
|
+
!!@listeners[channel_key].add?(callback)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def remove_event_listener(channel_key, callback)
|
24
|
+
synchronize do
|
25
|
+
return unless @listeners[channel_key]
|
26
|
+
!!@listeners[channel_key].delete?(callback)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def stop
|
31
|
+
# redis.client.call allow to bypass the client mutex
|
32
|
+
# Since we now that the only other possible caller is blocking on reading the socket this is safe
|
33
|
+
synchronize do
|
34
|
+
redis.client.call(['punsubscribe', pubsub_pattern])
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def start
|
39
|
+
redis.psubscribe(pubsub_pattern) do |on|
|
40
|
+
on.psubscribe do
|
41
|
+
info { "Subscribed to #{pubsub_pattern}" }
|
42
|
+
@subscribed = true
|
43
|
+
end
|
44
|
+
|
45
|
+
on.punsubscribe do
|
46
|
+
info { "Unsubscribed from #{pubsub_pattern}" }
|
47
|
+
@subscribed = false
|
48
|
+
end
|
49
|
+
|
50
|
+
on.pmessage do |pattern, pubsub_key, message|
|
51
|
+
process_message(pubsub_key, message)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
ensure
|
55
|
+
info { "Terminated" }
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def pubsub_pattern
|
61
|
+
'*.pubsub'
|
62
|
+
end
|
63
|
+
|
64
|
+
def process_message(pubsub_key, message)
|
65
|
+
channel_name = Channel.name_from_pubsub_key(pubsub_key)
|
66
|
+
event = Event.from_json(message)
|
67
|
+
dispatch_event(channel_name, event)
|
68
|
+
end
|
69
|
+
|
70
|
+
def dispatch_event(channel_name, event)
|
71
|
+
listeners = listeners_for(channel_name)
|
72
|
+
info { "Dispatching event ##{event.id} from #{channel_name} to #{listeners.size} listeners" }
|
73
|
+
listeners.each do |listener|
|
74
|
+
listener.call(event)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def listeners_for(channel_name)
|
79
|
+
@listeners.fetch(channel_name) { [] }
|
80
|
+
end
|
81
|
+
|
82
|
+
# The Redis client suround all calls with a mutex.
|
83
|
+
# As such it is crucial to use a dedicated Redis client when blocking on a `subscribe` call.
|
84
|
+
def redis
|
85
|
+
@redis ||= Pubsubstub.new_redis
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module Pubsubstub
|
2
|
+
class Subscription
|
3
|
+
include Logging
|
4
|
+
|
5
|
+
attr_reader :channels, :connection, :queue, :id
|
6
|
+
|
7
|
+
def initialize(channels, connection)
|
8
|
+
@id = Random.rand(2 ** 64)
|
9
|
+
@connection = connection
|
10
|
+
@channels = channels
|
11
|
+
@queue = Queue.new
|
12
|
+
end
|
13
|
+
|
14
|
+
def stream(last_event_id)
|
15
|
+
info { "Connecting client ##{id} (#{channels.map(&:name).join(', ')})" }
|
16
|
+
subscribe
|
17
|
+
fetch_scrollback(last_event_id)
|
18
|
+
while event = queue.pop
|
19
|
+
debug { "Sending event ##{event.id} to client ##{id}"}
|
20
|
+
connection << event.to_message
|
21
|
+
end
|
22
|
+
ensure
|
23
|
+
info { "Disconnecting client ##{id}" }
|
24
|
+
unsubscribe
|
25
|
+
end
|
26
|
+
|
27
|
+
def push(event)
|
28
|
+
queue.push(event)
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def subscribe
|
34
|
+
channels.each { |c| Pubsubstub.subscriber.add_event_listener(c.name, callback) }
|
35
|
+
end
|
36
|
+
|
37
|
+
def unsubscribe
|
38
|
+
channels.each { |c| Pubsubstub.subscriber.remove_event_listener(c.name, callback) }
|
39
|
+
end
|
40
|
+
|
41
|
+
# This method is not ideal as it doesn't guarantee order in case of multi-channel subscription
|
42
|
+
def fetch_scrollback(last_event_id)
|
43
|
+
event_sent = false
|
44
|
+
if last_event_id
|
45
|
+
channels.each do |channel|
|
46
|
+
channel.scrollback(since: last_event_id).each do |event|
|
47
|
+
event_sent = true
|
48
|
+
queue.push(event)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
queue.push(Pubsubstub.heartbeat_event) unless event_sent
|
54
|
+
end
|
55
|
+
|
56
|
+
# We use store the callback so that the object_id stays the same.
|
57
|
+
# Otherwise we wouldn't be able to unsubscribe
|
58
|
+
def callback
|
59
|
+
@callback ||= method(:push)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
data/lib/pubsubstub/version.rb
CHANGED
data/pubsubstub.gemspec
CHANGED
@@ -19,16 +19,7 @@ Gem::Specification.new do |spec|
|
|
19
19
|
spec.require_paths = ["lib"]
|
20
20
|
|
21
21
|
spec.add_dependency 'sinatra', "~> 1.4"
|
22
|
-
spec.add_dependency 'em-hiredis', "~> 0.2"
|
23
22
|
spec.add_dependency 'redis', "~> 3.0"
|
24
|
-
spec.add_dependency 'eventmachine', "~> 1.0"
|
25
23
|
|
26
24
|
spec.add_development_dependency "bundler", "~> 1.5"
|
27
|
-
spec.add_development_dependency "rake", "~> 10.2"
|
28
|
-
spec.add_development_dependency "rspec", "3.1.0"
|
29
|
-
spec.add_development_dependency "pry-byebug"
|
30
|
-
spec.add_development_dependency "thin", "~> 1.6"
|
31
|
-
spec.add_development_dependency "rack-test"
|
32
|
-
spec.add_development_dependency "timecop"
|
33
|
-
spec.add_development_dependency "em-spec"
|
34
25
|
end
|
data/spec/channel_spec.rb
CHANGED
@@ -1,93 +1,39 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
3
|
describe Pubsubstub::Channel do
|
4
|
-
|
5
|
-
subject { Pubsubstub::Channel.new("test") }
|
6
|
-
before { allow(subject).to receive(:pubsub) { pubsub } }
|
4
|
+
subject { described_class.new('foobar') }
|
7
5
|
|
8
|
-
|
9
|
-
|
10
|
-
expect(pubsub).not_to receive(:subscribe)
|
11
|
-
Pubsubstub::Channel.new('test')
|
12
|
-
end
|
6
|
+
it "has a name" do
|
7
|
+
expect(subject.name).to be == 'foobar'
|
13
8
|
end
|
14
9
|
|
15
|
-
|
16
|
-
|
17
|
-
it "subscribes the client" do
|
18
|
-
subject.subscribe(connection)
|
19
|
-
expect(subject.subscribed?(connection)).to be true
|
20
|
-
end
|
21
|
-
|
22
|
-
it "starts subscribing to the channel if it's the first client" do
|
23
|
-
expect(pubsub).to receive(:subscribe)
|
24
|
-
subject.subscribe(connection)
|
25
|
-
end
|
26
|
-
|
27
|
-
it "does not starts subscribing to the channel if it's not the first client" do
|
28
|
-
subject.subscribe(connection)
|
29
|
-
expect(pubsub).not_to receive(:subscribe)
|
30
|
-
subject.subscribe(double('connection'))
|
31
|
-
end
|
32
|
-
|
33
|
-
it "sends the scrollback if a last_event_id is passed" do
|
34
|
-
event = Pubsubstub::Event.new("event")
|
35
|
-
expect(pubsub).to receive(:scrollback).with(1234).and_yield(event)
|
36
|
-
expect(connection).to receive(:<<).with(event.to_message)
|
37
|
-
subject.subscribe(connection, last_event_id: 1234)
|
38
|
-
end
|
10
|
+
it "has a scrollback key derived from the name" do
|
11
|
+
expect(subject.scrollback_key).to be == 'foobar.scrollback'
|
39
12
|
end
|
40
13
|
|
41
|
-
|
42
|
-
|
43
|
-
before { subject.subscribe(connection) }
|
44
|
-
|
45
|
-
it "unsubscribes the client" do
|
46
|
-
expect(subject.subscribed?(connection)).to be true
|
47
|
-
subject.unsubscribe(connection)
|
48
|
-
expect(subject.subscribed?(connection)).to be false
|
49
|
-
end
|
50
|
-
|
51
|
-
it "does not stop listening if it's not the last client" do
|
52
|
-
subject.subscribe(double('connection'))
|
53
|
-
expect(pubsub).not_to receive(:unsubscribe)
|
54
|
-
subject.unsubscribe(connection)
|
55
|
-
end
|
56
|
-
|
57
|
-
it "stops listening if it's the last client" do
|
58
|
-
expect(pubsub).to receive(:unsubscribe)
|
59
|
-
subject.unsubscribe(connection)
|
60
|
-
end
|
14
|
+
it "has a pubsub key derived from the name" do
|
15
|
+
expect(subject.pubsub_key).to be == 'foobar.pubsub'
|
61
16
|
end
|
62
17
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
18
|
+
describe "#publish" do
|
19
|
+
let(:event) { Pubsubstub::Event.new("refresh #1", id: 1) }
|
20
|
+
|
21
|
+
it "records the event in the scrollback" do
|
22
|
+
expect {
|
23
|
+
subject.publish(event)
|
24
|
+
}.to change { subject.scrollback(since: 0) }.from([]).to([event])
|
68
25
|
end
|
69
26
|
end
|
70
27
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
}
|
77
|
-
|
78
|
-
it "sends the events to the clients" do
|
79
|
-
expect(connection).to receive(:<<).with(event.to_message)
|
80
|
-
subject.send(:broadcast, event.to_json)
|
28
|
+
describe "#scrollback" do
|
29
|
+
before do
|
30
|
+
10.times do |i|
|
31
|
+
subject.publish(Pubsubstub::Event.new("refresh ##{i}", id: i))
|
32
|
+
end
|
81
33
|
end
|
82
|
-
end
|
83
34
|
|
84
|
-
|
85
|
-
|
86
|
-
event = Pubsubstub::Event.new("message")
|
87
|
-
expect(pubsub).to receive(:scrollback).and_yield(event)
|
88
|
-
connection = ""
|
89
|
-
subject.scrollback(connection, 1)
|
90
|
-
expect(connection).to eq(event.to_message)
|
35
|
+
it "returns the events with an id greater than the `since` parameter" do
|
36
|
+
expect(subject.scrollback(since: 5).map(&:id)).to be == [6, 7, 8, 9]
|
91
37
|
end
|
92
38
|
end
|
93
39
|
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Pubsubstub::StreamAction do
|
4
|
+
let(:app) { Pubsubstub::PublishAction.new }
|
5
|
+
let(:channel) { Pubsubstub::Channel.new('foo') }
|
6
|
+
|
7
|
+
it "adds the event to the scrollback" do
|
8
|
+
expect {
|
9
|
+
post '/?channels[]=foo', {'event' => 'hello', 'data' => 'world!'}
|
10
|
+
expect(last_response.status).to eq 200
|
11
|
+
}.to change { channel.scrollback(since: 0).size }.from(0).to(1)
|
12
|
+
end
|
13
|
+
end
|
data/spec/spec_helper.rb
CHANGED
@@ -1,14 +1,33 @@
|
|
1
|
+
require 'open3'
|
1
2
|
require 'rack/test'
|
2
3
|
require 'pry'
|
3
4
|
require 'pry-byebug'
|
4
5
|
require 'timecop'
|
5
|
-
require '
|
6
|
+
require 'thread'
|
7
|
+
|
8
|
+
require_relative 'support/threading_matchers'
|
9
|
+
require_relative 'support/http_helpers'
|
10
|
+
|
11
|
+
Thread.abort_on_exception = true # ensure no exception stays hidden in threads
|
6
12
|
|
7
13
|
ENV['RACK_ENV'] = 'test'
|
8
14
|
require_relative '../lib/pubsubstub'
|
9
15
|
|
16
|
+
Pubsubstub.logger = Logger.new(nil)
|
17
|
+
Pubsubstub.logger.level = Logger::DEBUG
|
18
|
+
|
19
|
+
# Fake EM
|
20
|
+
module EventMachine
|
21
|
+
extend self
|
22
|
+
|
23
|
+
def reactor_running?
|
24
|
+
false
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
10
28
|
RSpec.configure do |config|
|
11
29
|
config.include Rack::Test::Methods
|
30
|
+
config.include HTTPHelpers
|
12
31
|
|
13
32
|
config.run_all_when_everything_filtered = true
|
14
33
|
config.filter_run :focus
|
@@ -16,6 +35,5 @@ RSpec.configure do |config|
|
|
16
35
|
|
17
36
|
config.order = 'random'
|
18
37
|
|
19
|
-
config.before(:each) { Pubsubstub
|
38
|
+
config.before(:each) { Redis.new(url: Pubsubstub.redis_url).flushdb }
|
20
39
|
end
|
21
|
-
|
data/spec/stream_action_spec.rb
CHANGED
@@ -1,119 +1,56 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
|
-
describe
|
4
|
-
before {
|
5
|
-
allow(EventMachine).to receive(:reactor_running?).and_return(false)
|
6
|
-
}
|
7
|
-
|
3
|
+
describe Pubsubstub::StreamAction do
|
8
4
|
let(:app) { Pubsubstub::StreamAction.new }
|
9
|
-
it "returns a heartbeat if there is no LAST_EVENT_ID" do
|
10
|
-
Timecop.freeze(DateTime.parse("2015-01-01T00:00:00+00:00")) do
|
11
|
-
event = Pubsubstub::Event.new(
|
12
|
-
'ping',
|
13
|
-
name: 'heartbeat',
|
14
|
-
retry_after: Pubsubstub::StreamAction::RECONNECT_TIMEOUT,
|
15
|
-
)
|
16
|
-
get "/"
|
17
|
-
expect(last_response.body).to eq(event.to_message)
|
18
|
-
end
|
19
|
-
end
|
20
|
-
|
21
|
-
it "returns an empty body if a LAST_EVENT_ID is provided and there is no scrollback" do
|
22
|
-
get "/", {}, 'HTTP_LAST_EVENT_ID' => 1
|
23
|
-
expect(last_response.body).to eq('')
|
24
|
-
end
|
25
|
-
|
26
|
-
it "returns the content of the scrollback" do
|
27
|
-
event = Pubsubstub::Event.new("test")
|
28
|
-
Pubsubstub::RedisPubSub.publish(:default, event)
|
29
|
-
|
30
|
-
get "/", {}, 'HTTP_LAST_EVENT_ID' => 1
|
31
|
-
expect(last_response.body).to eq(event.to_message)
|
32
|
-
end
|
33
|
-
end
|
34
|
-
|
35
|
-
describe "Pubsubstub::StreamAction with EventMachine" do
|
36
|
-
include EventMachine::SpecHelper
|
37
5
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
it "returns a heartbeat if there is no LAST_EVENT_ID" do
|
43
|
-
Timecop.freeze(DateTime.parse("2015-01-01T00:00:00+00:00")) do
|
44
|
-
event = Pubsubstub::Event.new(
|
45
|
-
'ping',
|
46
|
-
name: 'heartbeat',
|
47
|
-
retry_after: Pubsubstub::StreamAction::RECONNECT_TIMEOUT,
|
48
|
-
)
|
49
|
-
get "/"
|
50
|
-
expect(last_response.body).to eq(event.to_message)
|
6
|
+
context "with EventMachine" do
|
7
|
+
before do
|
8
|
+
allow(EventMachine).to receive(:reactor_running?).and_return(true)
|
51
9
|
end
|
52
|
-
end
|
53
|
-
|
54
|
-
it "returns the content of the scrollback right away" do
|
55
|
-
event = Pubsubstub::Event.new("test")
|
56
|
-
Pubsubstub::RedisPubSub.publish(:default, event)
|
57
|
-
expect_any_instance_of(EventMachine::Hiredis::Client).to receive(:zrangebyscore).and_yield([event.to_json])
|
58
10
|
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
status, headers, body = app.call(request.env)
|
11
|
+
it "immediately returns the scrollback" do
|
12
|
+
Pubsubstub.publish('foo', 'bar', id: 1)
|
13
|
+
Pubsubstub.publish('foo', 'baz', id: 2)
|
63
14
|
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
body.close
|
68
|
-
response.finish
|
15
|
+
get '/?channels[]=foo', {}, 'HTTP_LAST_EVENT_ID' => 1
|
16
|
+
expect(last_response.body).to eq("id: 2\ndata: baz\n\n")
|
17
|
+
end
|
69
18
|
|
70
|
-
|
71
|
-
|
72
|
-
|
19
|
+
it "returns and heartbeat if scrollback is empty" do
|
20
|
+
Timecop.freeze('2015-01-01T00:00:00+00:00') do
|
21
|
+
get '/'
|
22
|
+
message = "id: 1420070400000\nevent: heartbeat\nretry: #{Pubsubstub.reconnect_timeout}\ndata: ping\n\n"
|
23
|
+
expect(last_response.body).to eq(message)
|
24
|
+
end
|
73
25
|
end
|
74
26
|
end
|
75
27
|
|
76
|
-
it "
|
77
|
-
|
78
|
-
|
79
|
-
allow(Pubsubstub::RedisPubSub).to receive(:sub).and_return(redis)
|
80
|
-
expect(redis).to receive(:subscribe).with("default.pubsub", anything)
|
28
|
+
it "immediately send a heartbeat event if there is no scrollback" do
|
29
|
+
with_background_server do
|
30
|
+
expect(9292).to listen.in_under(5)
|
81
31
|
|
82
|
-
|
83
|
-
|
84
|
-
status, headers, body = app.call(request.env)
|
32
|
+
chunks = async_get('http://localhost:9292/?channels[]=foo', 'Last-Event-Id' => '0')
|
33
|
+
expect(chunks.pop).to include("event: heartbeat\n")
|
85
34
|
|
86
|
-
|
35
|
+
Pubsubstub.publish('foo', 'bar', id: 1)
|
36
|
+
expect(chunks.pop).to include("id: 1\n")
|
87
37
|
|
88
|
-
|
89
|
-
|
90
|
-
response.finish
|
91
|
-
EM.stop
|
92
|
-
}
|
38
|
+
Pubsubstub.publish('foo', 'baz', id: 2)
|
39
|
+
expect(chunks.pop).to include("id: 2\n")
|
93
40
|
end
|
94
41
|
end
|
95
42
|
|
96
|
-
it "sends
|
97
|
-
|
98
|
-
|
99
|
-
Timecop.freeze do
|
100
|
-
em do
|
101
|
-
env = current_session.send(:env_for, "/", 'HTTP_LAST_EVENT_ID' => 1)
|
102
|
-
request = Rack::Request.new(env)
|
103
|
-
status, headers, body = app.call(request.env)
|
43
|
+
it "sends the scrollback if there is some" do
|
44
|
+
Pubsubstub.publish('foo', 'bar', id: 1)
|
104
45
|
|
105
|
-
|
46
|
+
with_background_server do
|
47
|
+
expect(9292).to listen.in_under(5)
|
106
48
|
|
107
|
-
|
49
|
+
chunks = async_get('http://localhost:9292/?channels[]=foo', 'Last-Event-Id' => '0')
|
50
|
+
expect(chunks.pop).to include("id: 1\n")
|
108
51
|
|
109
|
-
|
110
|
-
|
111
|
-
response.finish
|
112
|
-
|
113
|
-
expect(response.body).to start_with(event.to_message)
|
114
|
-
EM.stop
|
115
|
-
}
|
116
|
-
end
|
52
|
+
Pubsubstub.publish('foo', 'baz', id: 2)
|
53
|
+
expect(chunks.pop).to include("id: 2\n")
|
117
54
|
end
|
118
55
|
end
|
119
|
-
end
|
56
|
+
end
|