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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: e14d40f41e5f06347d7f552009f53d29bb3d4942
4
- data.tar.gz: e1fca82791cb2f1769aa8bf3a6076736b70f8e4c
3
+ metadata.gz: 66e90ee4856de125eb629c5d0b5eac58fde45493
4
+ data.tar.gz: f3a7ff09c124a70524fc1d8e31615a51ab53c1e2
5
5
  SHA512:
6
- metadata.gz: dad828aea534513d6acef76af10e057e8ad21f8804c1fccc9102ce1c1db8149dedd5e74f9730d4a4d64a67b1815fdc05b70149e2f9f561b90582cd9ac1c3fc7d
7
- data.tar.gz: a9afa981566c4320fd578e99dbb843e537e2e1f10d668ab735929ec96dcc34a9a99eb0bad3c013594a378f544c130cb8ffbfce6700b42270005e5a03218d79e6
6
+ metadata.gz: 1d2004519f159a56587cef4a57dd88ff7eec7215a27a247a04ca30442821dc82ec0528e28743b198ae233cd04f1eba9c4df98bee4033f6a0c1646a50c565dc6f
7
+ data.tar.gz: b27b55471cf1ece69e956c6eb2e8606bfe65576bfdaa9236d02007b1ce371e1985d94e7b4de74035b6d1d7d6e69d23c395e052db020bdd6efc529042e1a55a0a
@@ -1,6 +1,8 @@
1
1
  language: ruby
2
2
  rvm:
3
3
  - 2.2.2
4
- script: rspec spec
5
4
  services:
6
5
  - redis-server
6
+
7
+ after_failure:
8
+ - cat tmp/puma.log
data/Gemfile CHANGED
@@ -2,3 +2,11 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in pubsubstub.gemspec
4
4
  gemspec
5
+
6
+ gem "rspec", "3.1.0"
7
+ gem "pry-byebug"
8
+ gem "puma", "~> 3.4"
9
+ gem "thin", "~> 1.6"
10
+ gem "rack-test"
11
+ gem "timecop"
12
+ gem "rake", "~> 10.0"
data/README.md CHANGED
@@ -56,12 +56,18 @@ run Pubsubstub::Application
56
56
  ### Sending an event
57
57
 
58
58
  ```ruby
59
- payload = user.to_json
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 thin start --timeout 0 --max-conns 1024`
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
@@ -1 +1,6 @@
1
- require "bundler/gem_tasks"
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -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")
@@ -0,0 +1,3 @@
1
+ #!/bin/bash
2
+
3
+ exec bundle exec puma --config example/puma_config.rb example/config.ru
@@ -1,2 +1,2 @@
1
1
  gem 'pubsubstub', path: '../'
2
- gem 'puma'
2
+ gem 'puma', '~> 3.4'
@@ -1,3 +1,5 @@
1
+ p File.expand_path('../lib', __dir__)
2
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
1
3
  require 'pubsubstub'
2
4
 
3
5
  run Pubsubstub::Application
@@ -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
@@ -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/redis_pub_sub"
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
- end
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
@@ -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
@@ -1,54 +1,40 @@
1
1
  module Pubsubstub
2
2
  class Channel
3
- attr_reader :name, :pubsub
4
-
5
- def initialize(name)
6
- @name = name
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
- def subscribed?(connection)
18
- @connections.include?(connection)
19
- end
9
+ attr_reader :name
20
10
 
21
- def unsubscribe(connection)
22
- @connections.delete(connection)
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
- pubsub.publish(event)
28
- end
29
-
30
- def scrollback(connection, last_event_id)
31
- return unless last_event_id
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
- private
24
+ def scrollback(since: )
25
+ redis.zrangebyscore(scrollback_key, Integer(since) + 1, '+inf').map(&Event.method(:from_json))
26
+ end
38
27
 
39
- def broadcast(json)
40
- string = Event.from_json(json).to_message
41
- @connections.each do |connection|
42
- connection << string
43
- end
28
+ def scrollback_key
29
+ "#{name}.scrollback"
44
30
  end
45
31
 
46
- def listen
47
- pubsub.subscribe(method(:broadcast))
32
+ def pubsub_key
33
+ "#{name}.pubsub"
48
34
  end
49
35
 
50
- def stop_listening
51
- pubsub.unsubscribe(method(:broadcast))
36
+ def redis
37
+ Pubsubstub.redis
52
38
  end
53
39
  end
54
40
  end
@@ -14,12 +14,7 @@ module Pubsubstub
14
14
  end
15
15
 
16
16
  def to_message
17
- data = @data.split("\n").map{ |segment| "data: #{segment}" }.join("\n")
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
- channel(channel_name).publish(event)
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
- RECONNECT_TIMEOUT = 10_000
3
+ include Logging
4
4
 
5
5
  def initialize(*)
6
6
  super
7
- start_heartbeat
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 EventMachine.reactor_running?
19
- subscribe_connection
19
+ if event_machine?
20
+ send_scrollback
20
21
  else
21
- return_scrollback
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 return_scrollback
28
- buffer = ''
29
- ensure_connection_has_event(buffer)
33
+ def last_event_id
34
+ request.env['HTTP_LAST_EVENT_ID']
35
+ end
30
36
 
31
- with_each_channel do |channel|
32
- channel.scrollback(buffer, last_event_id)
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
- buffer
48
+ def event_machine?
49
+ defined?(EventMachine) && EventMachine.reactor_running?
36
50
  end
37
51
 
38
- def last_event_id
39
- request.env['HTTP_LAST_EVENT_ID']
52
+ def channels
53
+ (params[:channels] || [:default]).map(&Channel.method(:new))
40
54
  end
41
55
 
42
56
  def subscribe_connection
43
- stream(:keep_open) do |connection|
44
- @connections << connection
45
- ensure_connection_has_event(connection)
46
- with_each_channel do |channel|
47
- channel.subscribe(connection, last_event_id: last_event_id)
48
- end
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 ensure_connection_has_event(connection)
60
- return if last_event_id
61
- connection << heartbeat_event.to_message
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 start_heartbeat
65
- @heartbeat = Thread.new do
66
- loop do
67
- sleep Pubsubstub.heartbeat_frequency
68
- event = heartbeat_event.to_message
69
- @connections.each { |connection| connection << event }
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 with_each_channel(&block)
75
- channels = params[:channels] || [:default]
76
- channels.each do |channel_name|
77
- yield channel(channel_name)
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 heartbeat_event
82
- Event.new('ping', name: 'heartbeat', retry_after: RECONNECT_TIMEOUT)
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