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.
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Pubsubstub
2
- VERSION = "0.0.15"
2
+ VERSION = "0.1.0.beta1"
3
3
  end
@@ -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
@@ -1,93 +1,39 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe Pubsubstub::Channel do
4
- let(:pubsub) { double(Pubsubstub::RedisPubSub, publish: true, subscribe: true, unsubscribe: true) }
5
- subject { Pubsubstub::Channel.new("test") }
6
- before { allow(subject).to receive(:pubsub) { pubsub } }
4
+ subject { described_class.new('foobar') }
7
5
 
8
- context "#initialize" do
9
- it "does not subscribe immediately" do
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
- context "#subscribe" do
16
- let(:connection) { double('connection') }
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
- context "#unsubscribe" do
42
- let(:connection) { double('connection') }
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
- context "#publish" do
64
- it "forwards to the pubsub" do
65
- event = double('event')
66
- expect(pubsub).to receive(:publish).with(event)
67
- subject.publish(event)
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
- context "broadcasting events from redis" do
72
- let(:event) { Pubsubstub::Event.new("message", name: "toto") }
73
- let(:connection) { double('connection') }
74
- before {
75
- subject.subscribe(connection)
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
- context "#scrollback" do
85
- it "sends events to the connection buffer" do
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
@@ -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 'em-spec/rspec'
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::RedisPubSub.blocking_redis.flushdb }
38
+ config.before(:each) { Redis.new(url: Pubsubstub.redis_url).flushdb }
20
39
  end
21
-
@@ -1,119 +1,56 @@
1
1
  require 'spec_helper'
2
2
 
3
- describe "Pubsubstub::StreamAction without EventMachine" do
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
- let(:app) {
39
- Pubsubstub::StreamAction.new
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
- em do
60
- env = current_session.send(:env_for, "/", 'HTTP_LAST_EVENT_ID' => 1)
61
- request = Rack::Request.new(env)
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
- response = Rack::MockResponse.new(status, headers, body, env["rack.errors"].flush)
65
-
66
- EM.next_tick {
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
- expect(response.body).to eq(event.to_message)
71
- EM.stop
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 "subscribes the connection to the channel" do
77
- em do
78
- redis = spy('Redis Pubsub')
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
- env = current_session.send(:env_for, "/", 'HTTP_LAST_EVENT_ID' => 1)
83
- request = Rack::Request.new(env)
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
- response = Rack::MockResponse.new(status, headers, body, env["rack.errors"].flush)
35
+ Pubsubstub.publish('foo', 'bar', id: 1)
36
+ expect(chunks.pop).to include("id: 1\n")
87
37
 
88
- EM.next_tick {
89
- body.close
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 heartbeat events every now and then" do
97
- allow(Pubsubstub).to receive(:heartbeat_frequency).and_return(0.001)
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
- response = Rack::MockResponse.new(status, headers, body, env["rack.errors"].flush)
46
+ with_background_server do
47
+ expect(9292).to listen.in_under(5)
106
48
 
107
- event = Pubsubstub::Event.new('ping', name: 'heartbeat', retry_after: Pubsubstub::StreamAction::RECONNECT_TIMEOUT)
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
- EM.add_timer(0.002) {
110
- body.close
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