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.
@@ -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