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