firehose 1.3.6 → 2.1.0
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 +5 -5
- data/CHANGELOG.md +31 -10
- data/{LICENSE.txt → LICENSE} +6 -6
- data/README.md +379 -187
- data/app/assets/javascripts/firehose.js +258 -0
- data/config/importmap.rb +1 -0
- data/db/migrate/20260214185244_create_firehose_events.rb +23 -0
- data/lib/commands/firehose_command.rb +119 -0
- data/lib/firehose/channel.rb +40 -0
- data/lib/firehose/cleanup_job.rb +21 -0
- data/lib/firehose/engine.rb +36 -0
- data/lib/firehose/helper.rb +40 -0
- data/lib/firehose/models/channel.rb +12 -0
- data/lib/firehose/models/message.rb +11 -0
- data/lib/firehose/queue.rb +44 -0
- data/lib/firehose/server.rb +930 -17
- data/lib/firehose/sse.rb +187 -0
- data/lib/firehose/stream.rb +32 -0
- data/lib/firehose/streamable.rb +25 -0
- data/lib/firehose/websocket.rb +242 -0
- data/lib/firehose.rb +67 -15
- data/lib/generators/firehose/install/install_generator.rb +43 -0
- data/lib/generators/firehose/install/templates/firehose_controller.rb +16 -0
- metadata +39 -366
- data/.codeclimate.yml +0 -29
- data/.dockerignore +0 -2
- data/.env.sample +0 -17
- data/.gitignore +0 -14
- data/.rspec +0 -1
- data/.rubocop.yml +0 -1156
- data/.ruby-version +0 -1
- data/.travis.yml +0 -12
- data/Dockerfile +0 -11
- data/Gemfile +0 -6
- data/Procfile +0 -1
- data/Procfile.dev +0 -3
- data/Rakefile +0 -64
- data/bin/firehose +0 -9
- data/coffeelint.json +0 -129
- data/config/rainbows.rb +0 -17
- data/docker-compose.yml +0 -17
- data/firehose.gemspec +0 -45
- data/karma.config.coffee +0 -89
- data/lib/assets/javascripts/firehose/base.js.coffee +0 -4
- data/lib/assets/javascripts/firehose/consumer.js.coffee +0 -86
- data/lib/assets/javascripts/firehose/core.js.coffee +0 -6
- data/lib/assets/javascripts/firehose/long_poll.js.coffee +0 -210
- data/lib/assets/javascripts/firehose/multiplexed_consumer.js.coffee +0 -74
- data/lib/assets/javascripts/firehose/transport.js.coffee +0 -44
- data/lib/assets/javascripts/firehose/version.js.coffee.erb +0 -2
- data/lib/assets/javascripts/firehose/web_socket.js.coffee +0 -148
- data/lib/assets/javascripts/firehose.js.coffee +0 -2
- data/lib/firehose/assets.rb +0 -56
- data/lib/firehose/cli.rb +0 -79
- data/lib/firehose/client/consumer.rb +0 -94
- data/lib/firehose/client/producer.rb +0 -114
- data/lib/firehose/client.rb +0 -10
- data/lib/firehose/logging.rb +0 -32
- data/lib/firehose/rack/app.rb +0 -49
- data/lib/firehose/rack/consumer/http_long_poll.rb +0 -159
- data/lib/firehose/rack/consumer/web_socket.rb +0 -200
- data/lib/firehose/rack/consumer.rb +0 -78
- data/lib/firehose/rack/ping.rb +0 -88
- data/lib/firehose/rack/publisher.rb +0 -59
- data/lib/firehose/rack.rb +0 -32
- data/lib/firehose/rails.rb +0 -7
- data/lib/firehose/server/app.rb +0 -52
- data/lib/firehose/server/channel.rb +0 -72
- data/lib/firehose/server/message_buffer.rb +0 -59
- data/lib/firehose/server/publisher.rb +0 -133
- data/lib/firehose/server/redis.rb +0 -32
- data/lib/firehose/server/subscriber.rb +0 -51
- data/lib/firehose/version.rb +0 -4
- data/package.json +0 -17
- data/spec/integrations/integration_test_helper.rb +0 -22
- data/spec/integrations/rainbows_spec.rb +0 -7
- data/spec/integrations/shared_examples.rb +0 -202
- data/spec/integrations/thin_spec.rb +0 -7
- data/spec/javascripts/firehose/base_spec.coffee +0 -2
- data/spec/javascripts/firehose/consumer_spec.coffee +0 -45
- data/spec/javascripts/firehose/long_poll_spec.coffee +0 -44
- data/spec/javascripts/firehose/multiplexed_consumer_spec.coffee +0 -72
- data/spec/javascripts/firehose/transport_spec.coffee +0 -30
- data/spec/javascripts/firehose/websocket_spec.coffee +0 -27
- data/spec/javascripts/helpers/spec_helper.js +0 -31
- data/spec/javascripts/support/jquery-1.11.1.js +0 -10308
- data/spec/javascripts/support/json2.js +0 -487
- data/spec/javascripts/support/sinon-1.7.3.js +0 -4290
- data/spec/javascripts/support/spec_helper.coffee +0 -3
- data/spec/lib/assets_spec.rb +0 -35
- data/spec/lib/client/consumer_spec.rb +0 -13
- data/spec/lib/client/producer_spec.rb +0 -71
- data/spec/lib/firehose_spec.rb +0 -7
- data/spec/lib/rack/consumer/http_long_poll_spec.rb +0 -30
- data/spec/lib/rack/consumer_spec.rb +0 -19
- data/spec/lib/rack/ping_spec.rb +0 -28
- data/spec/lib/rack/publisher_spec.rb +0 -34
- data/spec/lib/server/app_spec.rb +0 -15
- data/spec/lib/server/channel_spec.rb +0 -178
- data/spec/lib/server/message_buffer_spec.rb +0 -148
- data/spec/lib/server/publisher_spec.rb +0 -91
- data/spec/lib/server/redis_spec.rb +0 -13
- data/spec/lib/server/subscriber_spec.rb +0 -70
- data/spec/spec_helper.rb +0 -84
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
require 'json'
|
|
2
|
-
|
|
3
|
-
module Firehose
|
|
4
|
-
module Rack
|
|
5
|
-
# Handles a subscription request over HTTP or WebSockets depeding on its abilities and
|
|
6
|
-
# binds that to the Firehose::Server::Subscription class, which is bound to a channel that
|
|
7
|
-
# gets published to.
|
|
8
|
-
class Consumer
|
|
9
|
-
# Rack consumer transports
|
|
10
|
-
autoload :HttpLongPoll, 'firehose/rack/consumer/http_long_poll'
|
|
11
|
-
autoload :WebSocket, 'firehose/rack/consumer/web_socket'
|
|
12
|
-
|
|
13
|
-
MULTIPLEX_CHANNEL = "channels@firehose"
|
|
14
|
-
|
|
15
|
-
def self.multiplexing_request?(env)
|
|
16
|
-
env["PATH_INFO"].include? MULTIPLEX_CHANNEL
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def self.multiplex_subscriptions(request)
|
|
20
|
-
if request.get?
|
|
21
|
-
query_string_subscriptions(request.env)
|
|
22
|
-
elsif request.post?
|
|
23
|
-
post_subscriptions(request)
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def self.query_string_subscriptions(env)
|
|
28
|
-
query_params = ::Rack::Utils.parse_query(env["QUERY_STRING"])
|
|
29
|
-
|
|
30
|
-
query_params["subscribe"].to_s.split(",").map do |sub|
|
|
31
|
-
chan, last_sequence = sub.split("!")
|
|
32
|
-
last_sequence = last_sequence.to_i
|
|
33
|
-
last_sequence = 0 if last_sequence < 0
|
|
34
|
-
{
|
|
35
|
-
channel: chan,
|
|
36
|
-
message_sequence: last_sequence
|
|
37
|
-
}
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def self.post_subscriptions(request)
|
|
42
|
-
body = request.body.read
|
|
43
|
-
subs = JSON.parse(body).map do |chan, last_sequence|
|
|
44
|
-
last_sequence = 0 if last_sequence < 0
|
|
45
|
-
{
|
|
46
|
-
channel: chan,
|
|
47
|
-
message_sequence: last_sequence
|
|
48
|
-
}
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# Let the client configure the consumer on initialization.
|
|
53
|
-
def initialize
|
|
54
|
-
yield self if block_given?
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def call(env)
|
|
58
|
-
websocket_request?(env) ? websocket.call(env) : http_long_poll.call(env)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Memoized instance of web socket that can be configured from the rack app.
|
|
62
|
-
def websocket
|
|
63
|
-
@web_socket ||= WebSocket.new
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Memoized instance of http long poll handler that can be configured from the rack app.
|
|
67
|
-
def http_long_poll
|
|
68
|
-
@http_long_poll ||= HttpLongPoll.new
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
private
|
|
72
|
-
# Determine if the incoming request is a websocket request.
|
|
73
|
-
def websocket_request?(env)
|
|
74
|
-
Firehose::Rack::Consumer::WebSocket.request?(env)
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
end
|
data/lib/firehose/rack/ping.rb
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
require 'em-hiredis'
|
|
2
|
-
|
|
3
|
-
module Firehose
|
|
4
|
-
module Rack
|
|
5
|
-
# Allows the Firehose client to periodically "ping" the server
|
|
6
|
-
# so that the connection isn't timed out by browsers or proxies from
|
|
7
|
-
# inactivity.
|
|
8
|
-
class Ping
|
|
9
|
-
attr_reader :redis
|
|
10
|
-
|
|
11
|
-
def initialize(redis=nil)
|
|
12
|
-
@redis = redis
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def call(env)
|
|
16
|
-
PingCheck.new(env, redis).call
|
|
17
|
-
ASYNC_RESPONSE
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# Encapsulate this in a class so we aren't passing a bunch of variables around
|
|
21
|
-
class PingCheck
|
|
22
|
-
include Firehose::Rack::Helpers
|
|
23
|
-
|
|
24
|
-
attr_reader :req, :env, :key, :redis
|
|
25
|
-
|
|
26
|
-
TEST_VALUE = 'Firehose Healthcheck Test Value'
|
|
27
|
-
SECONDS_TO_EXPIRE = 60
|
|
28
|
-
|
|
29
|
-
def self.redis
|
|
30
|
-
@redis ||= Firehose::Server.redis.connection
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def initialize(env, redis=nil)
|
|
34
|
-
@redis = redis || self.class.redis
|
|
35
|
-
@env = env
|
|
36
|
-
@req = env['parsed_request'] ||= ::Rack::Request.new(env)
|
|
37
|
-
@key = "/firehose/ping/#{Time.now.to_i}/#{rand}"
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def call
|
|
41
|
-
log req, 'started'
|
|
42
|
-
test_redis
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def log(req, msg)
|
|
49
|
-
Firehose.logger.debug "HTTP PING request for path '#{req.path}': #{msg}"
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def test_redis
|
|
53
|
-
redis.set(key, TEST_VALUE).
|
|
54
|
-
callback { expire_key }.
|
|
55
|
-
callback { read_and_respond }.
|
|
56
|
-
errback do |e|
|
|
57
|
-
log req, "failed with write value to redis: #{e.inspect}"
|
|
58
|
-
env['async.callback'].call response(500)
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def expire_key
|
|
63
|
-
redis.expire(key, SECONDS_TO_EXPIRE).
|
|
64
|
-
errback do
|
|
65
|
-
log req, "failed to expire key #{key.inspect}. If this key is not manually deleted, it may cause a memory leak."
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def read_and_respond
|
|
70
|
-
redis.get(key).
|
|
71
|
-
callback do |val|
|
|
72
|
-
if val == TEST_VALUE
|
|
73
|
-
log req, 'succeeded'
|
|
74
|
-
env['async.callback'].call response(200)
|
|
75
|
-
else
|
|
76
|
-
log req, "failed with unexpected value retrieved from redis: #{val.inspect}"
|
|
77
|
-
env['async.callback'].call response(500)
|
|
78
|
-
end
|
|
79
|
-
end.
|
|
80
|
-
errback do |e|
|
|
81
|
-
log req, "failed with read value from redis: #{e.inspect}"
|
|
82
|
-
env['async.callback'].call response(500)
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
require "rack/utils"
|
|
2
|
-
|
|
3
|
-
module Firehose
|
|
4
|
-
module Rack
|
|
5
|
-
class Publisher
|
|
6
|
-
include Firehose::Rack::Helpers
|
|
7
|
-
|
|
8
|
-
def call(env)
|
|
9
|
-
req = env['parsed_request'] ||= ::Rack::Request.new(env)
|
|
10
|
-
path = req.path
|
|
11
|
-
method = req.request_method
|
|
12
|
-
cache_control = {}
|
|
13
|
-
|
|
14
|
-
# Parse out cache control directives from the Cache-Control header.
|
|
15
|
-
if cache_control_header = env['HTTP_CACHE_CONTROL']
|
|
16
|
-
cache_control = cache_control_header.split(',').map(&:strip).inject({}) do |memo, directive|
|
|
17
|
-
key, value = directive.split('=')
|
|
18
|
-
memo[key.downcase] = value
|
|
19
|
-
memo
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Read the max-age directive from the cache so that we can set a TTL on the redis key. This will
|
|
24
|
-
# prevent stale content from being served up to the client.
|
|
25
|
-
ttl = cache_control['max-age']
|
|
26
|
-
|
|
27
|
-
if method == 'PUT'
|
|
28
|
-
EM.next_tick do
|
|
29
|
-
body = env['rack.input'].read
|
|
30
|
-
Firehose.logger.debug "HTTP published #{body.inspect} to #{path.inspect} with ttl #{ttl.inspect}"
|
|
31
|
-
opts = { :ttl => ttl }
|
|
32
|
-
if buffer_size = env["HTTP_X_FIREHOSE_BUFFER_SIZE"]
|
|
33
|
-
opts[:buffer_size] = buffer_size.to_i
|
|
34
|
-
end
|
|
35
|
-
publisher.publish(path, body, opts).callback do
|
|
36
|
-
env['async.callback'].call [202, {'Content-Type' => 'text/plain', 'Content-Length' => '0'}, []]
|
|
37
|
-
env['async.callback'].call response(202, '', 'Content-Type' => 'text/plain')
|
|
38
|
-
end.errback do |e|
|
|
39
|
-
Firehose.logger.debug "Error publishing: #{e.inspect}"
|
|
40
|
-
env['async.callback'].call response(500, 'Error when trying to publish', 'Content-Type' => 'text/plain')
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# Tell the web server that this will be an async response.
|
|
45
|
-
ASYNC_RESPONSE
|
|
46
|
-
else
|
|
47
|
-
Firehose.logger.debug "HTTP #{method} not supported"
|
|
48
|
-
msg = "#{method} not supported."
|
|
49
|
-
[501, {'Content-Type' => 'text/plain', 'Content-Length' => msg.size.to_s}, [msg]]
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
private
|
|
54
|
-
def publisher
|
|
55
|
-
@publisher ||= Firehose::Server::Publisher.new
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|
data/lib/firehose/rack.rb
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
module Firehose
|
|
2
|
-
module Rack
|
|
3
|
-
autoload :Consumer, 'firehose/rack/consumer'
|
|
4
|
-
autoload :Publisher, 'firehose/rack/publisher'
|
|
5
|
-
autoload :Ping, 'firehose/rack/ping'
|
|
6
|
-
autoload :App, 'firehose/rack/app'
|
|
7
|
-
|
|
8
|
-
# Evented web servers recognize the -1 HTTP code as a response deferral, which
|
|
9
|
-
# is needed to stream responses via WebSockets or HTTP long polling.
|
|
10
|
-
ASYNC_RESPONSE = [-1, {}, []].freeze
|
|
11
|
-
|
|
12
|
-
# Normally we'd want to use a custom header to reduce the likelihood of some
|
|
13
|
-
# HTTP middleware clobbering the value. But Safari seems to ignore our CORS
|
|
14
|
-
# header instructions, so we are using 'pragma' because it is always allowed.
|
|
15
|
-
LAST_MESSAGE_SEQUENCE_HEADER = 'Pragma'
|
|
16
|
-
|
|
17
|
-
# Rack wants the header to start with HTTP, so we deal with that here.
|
|
18
|
-
RACK_LAST_MESSAGE_SEQUENCE_HEADER = "HTTP_#{LAST_MESSAGE_SEQUENCE_HEADER.upcase.gsub('-', '_')}"
|
|
19
|
-
|
|
20
|
-
# Disable CORS preflight caches for requests in development mode.
|
|
21
|
-
CORS_OPTIONS_MAX_AGE = ENV['RACK_ENV'] == 'development' ? '1' : '1728000'
|
|
22
|
-
|
|
23
|
-
module Helpers
|
|
24
|
-
# Calculates the content of a message body for the response so that HTTP Keep-Alive
|
|
25
|
-
# connections work.
|
|
26
|
-
def response(status, body='', headers={})
|
|
27
|
-
headers = {'Content-Length' => body.size.to_s}.merge(headers)
|
|
28
|
-
[status, headers, [body]]
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
data/lib/firehose/rails.rb
DELETED
data/lib/firehose/server/app.rb
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
module Firehose
|
|
2
|
-
module Server
|
|
3
|
-
# Configure servers that are booted with-out going through Rack. This is mostly used by
|
|
4
|
-
# the `firehose server` CLI command or for testing. Production configurations are likely
|
|
5
|
-
# to boot with custom rack configurations.
|
|
6
|
-
class App
|
|
7
|
-
def initialize(opts={})
|
|
8
|
-
@port = opts[:port] || Firehose::URI.port
|
|
9
|
-
@host = opts[:host] || Firehose::URI.host
|
|
10
|
-
@server = opts[:server] || :rainbows
|
|
11
|
-
|
|
12
|
-
Firehose.logger.info "Starting #{Firehose::VERSION} '#{Firehose::CODENAME}', in #{ENV['RACK_ENV']}"
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def start
|
|
16
|
-
self.send("start_#{@server}")
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
private
|
|
20
|
-
# Boot the Firehose server with the Rainbows app server.
|
|
21
|
-
def start_rainbows
|
|
22
|
-
require 'rainbows'
|
|
23
|
-
Faye::WebSocket.load_adapter('rainbows')
|
|
24
|
-
|
|
25
|
-
rackup = Unicorn::Configurator::RACKUP
|
|
26
|
-
rackup[:port] = @port if @port
|
|
27
|
-
rackup[:host] = @host if @host
|
|
28
|
-
rackup[:set_listener] = true
|
|
29
|
-
opts = rackup[:options]
|
|
30
|
-
opts[:config_file] = File.expand_path('../../../../config/rainbows.rb', __FILE__)
|
|
31
|
-
|
|
32
|
-
server = Rainbows::HttpServer.new(Firehose::Rack::App.new, opts)
|
|
33
|
-
server.start.join
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# Boot the Firehose server with the Thin app server.
|
|
37
|
-
def start_thin
|
|
38
|
-
require 'thin'
|
|
39
|
-
|
|
40
|
-
Faye::WebSocket.load_adapter('thin')
|
|
41
|
-
|
|
42
|
-
# TODO: See if we can just set Thin to use Firehose.logger instead of
|
|
43
|
-
# printing out messages by itself.
|
|
44
|
-
Thin::Logging.silent = true if Firehose.logger.level == Logger::ERROR
|
|
45
|
-
|
|
46
|
-
server = Thin::Server.new(@host, @port) do
|
|
47
|
-
run Firehose::Rack::App.new
|
|
48
|
-
end.start
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
module Firehose
|
|
2
|
-
module Server
|
|
3
|
-
# Connects to a specific channel on Redis and listens for messages to notify subscribers.
|
|
4
|
-
class Channel
|
|
5
|
-
attr_reader :channel_key, :list_key, :sequence_key
|
|
6
|
-
attr_reader :redis, :subscriber
|
|
7
|
-
|
|
8
|
-
def self.redis
|
|
9
|
-
@redis ||= Firehose::Server.redis.connection
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def self.subscriber
|
|
13
|
-
@subscriber ||= Server::Subscriber.new(Firehose::Server.redis.connection)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def initialize(channel_key, redis=self.class.redis, subscriber=self.class.subscriber)
|
|
17
|
-
@redis = redis
|
|
18
|
-
@subscriber = subscriber
|
|
19
|
-
@channel_key = channel_key
|
|
20
|
-
@list_key = Server::Redis.key(channel_key, :list)
|
|
21
|
-
@sequence_key = Server::Redis.key(channel_key, :sequence)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def next_messages(consumer_sequence=nil, options={})
|
|
25
|
-
deferrable = EM::DefaultDeferrable.new
|
|
26
|
-
deferrable.errback {|e| EM.next_tick { raise e } unless [:timeout, :disconnect].include?(e) }
|
|
27
|
-
|
|
28
|
-
redis.multi
|
|
29
|
-
redis.get(sequence_key).
|
|
30
|
-
errback {|e| deferrable.fail e }
|
|
31
|
-
# Fetch entire list: http://stackoverflow.com/questions/10703019/redis-fetch-all-value-of-list-without-iteration-and-without-popping
|
|
32
|
-
redis.lrange(list_key, 0, -1).
|
|
33
|
-
errback {|e| deferrable.fail e }
|
|
34
|
-
redis.exec.callback do |(channel_sequence, message_list)|
|
|
35
|
-
# Reverse the messages so they can be correctly procesed by the MessageBuffer class. There's
|
|
36
|
-
# a patch in the message-buffer-redis branch that moves this concern into the Publisher LUA
|
|
37
|
-
# script. We kept it out of this for now because it represents a deployment risk and `reverse!`
|
|
38
|
-
# is a cheap operation in Ruby.
|
|
39
|
-
message_list.reverse!
|
|
40
|
-
buffer = MessageBuffer.new(message_list, channel_sequence, consumer_sequence)
|
|
41
|
-
if buffer.remaining_messages.empty?
|
|
42
|
-
Firehose.logger.debug "No messages in buffer, subscribing. sequence: `#{channel_sequence}` consumer_sequence: #{consumer_sequence}"
|
|
43
|
-
# Either this resource has never been seen before or we are all caught up.
|
|
44
|
-
# Subscribe and hope something gets published to this end-point.
|
|
45
|
-
subscribe(deferrable, options[:timeout])
|
|
46
|
-
else # Either the client is under water or caught up to head.
|
|
47
|
-
deferrable.succeed buffer.remaining_messages
|
|
48
|
-
end
|
|
49
|
-
end.errback {|e| deferrable.fail e }
|
|
50
|
-
|
|
51
|
-
deferrable
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def unsubscribe(deferrable)
|
|
55
|
-
subscriber.unsubscribe channel_key, deferrable
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
private
|
|
59
|
-
def subscribe(deferrable, timeout=nil)
|
|
60
|
-
subscriber.subscribe(channel_key, deferrable)
|
|
61
|
-
if timeout
|
|
62
|
-
timer = EventMachine::Timer.new(timeout) do
|
|
63
|
-
deferrable.fail :timeout
|
|
64
|
-
unsubscribe deferrable
|
|
65
|
-
end
|
|
66
|
-
# Cancel the timer if when the deferrable succeeds
|
|
67
|
-
deferrable.callback { timer.cancel }
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
module Firehose
|
|
2
|
-
module Server
|
|
3
|
-
# Encapsulates a sequence of messages from the server along with their
|
|
4
|
-
# consumer_sequences calculate by offset.
|
|
5
|
-
class MessageBuffer
|
|
6
|
-
# Number of messages that Redis buffers for the client if its
|
|
7
|
-
# connection drops, then reconnects.
|
|
8
|
-
DEFAULT_SIZE = 100
|
|
9
|
-
|
|
10
|
-
Message = Struct.new(:payload, :sequence)
|
|
11
|
-
|
|
12
|
-
def initialize(message_list, channel_sequence, consumer_sequence = nil)
|
|
13
|
-
@message_list = message_list
|
|
14
|
-
@channel_sequence = channel_sequence.to_i
|
|
15
|
-
@consumer_sequence = consumer_sequence.to_i
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def remaining_messages
|
|
19
|
-
messages.last(remaining_message_count)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
def remaining_message_count
|
|
25
|
-
# Special case to always get the latest message.
|
|
26
|
-
return 1 unless @consumer_sequence > 0
|
|
27
|
-
|
|
28
|
-
count = @channel_sequence - @consumer_sequence
|
|
29
|
-
|
|
30
|
-
if count < 0
|
|
31
|
-
# UNEXPECTED: Somehow the sequence is ahead of the channel.
|
|
32
|
-
# It is likely a bug in the consumer, but we'll assume
|
|
33
|
-
# the consumer has all the messages.
|
|
34
|
-
0
|
|
35
|
-
elsif count > @message_list.size
|
|
36
|
-
# Consumer is under water since the last request. Just send the most recent message.
|
|
37
|
-
1
|
|
38
|
-
else
|
|
39
|
-
count
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Calculates the last_message_sequence per message.
|
|
44
|
-
# [a b c e f]
|
|
45
|
-
def messages
|
|
46
|
-
@messages ||= @message_list.map.with_index do |payload, index|
|
|
47
|
-
Message.new(payload, starting_channel_sequence + index)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Channel sequence is 10
|
|
52
|
-
# Buffer size of 5
|
|
53
|
-
# Start of sequence in buffer ... which would be 6
|
|
54
|
-
def starting_channel_sequence
|
|
55
|
-
@channel_sequence - @message_list.size + 1
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
module Firehose
|
|
2
|
-
module Server
|
|
3
|
-
class Publisher
|
|
4
|
-
# Seconds that the message buffer should live before Redis expires it.
|
|
5
|
-
TTL = 60*60*24
|
|
6
|
-
|
|
7
|
-
# Delimited used to frame different parts of a message that's published
|
|
8
|
-
# over Firehose.
|
|
9
|
-
PAYLOAD_DELIMITER = "\n"
|
|
10
|
-
|
|
11
|
-
# Publish a message to a Firehose channel via Redis.
|
|
12
|
-
def publish(channel_key, message, opts={})
|
|
13
|
-
# How long should we hang on to the resource once is published?
|
|
14
|
-
ttl = (opts[:ttl] || TTL).to_i
|
|
15
|
-
buffer_size = (opts[:buffer_size] || MessageBuffer::DEFAULT_SIZE).to_i
|
|
16
|
-
|
|
17
|
-
# TODO hi-redis isn't that awesome... we have to setup an errback per even for wrong
|
|
18
|
-
# commands because of the lack of a method_missing whitelist. Perhaps implement a whitelist in
|
|
19
|
-
# em-hiredis or us a diff lib?
|
|
20
|
-
if (deferrable = opts[:deferrable]).nil?
|
|
21
|
-
deferrable = EM::DefaultDeferrable.new
|
|
22
|
-
deferrable.errback do |e|
|
|
23
|
-
# Handle missing Lua publishing script in cache
|
|
24
|
-
# (such as Redis restarting or someone executing SCRIPT FLUSH)
|
|
25
|
-
if e.message =~ /NOSCRIPT/
|
|
26
|
-
deferrable.succeed
|
|
27
|
-
EM.next_tick do
|
|
28
|
-
@publish_script_digest = nil
|
|
29
|
-
combined_opts = opts.merge :deferrable => deferrable
|
|
30
|
-
self.publish channel_key, message, combined_opts
|
|
31
|
-
end
|
|
32
|
-
else
|
|
33
|
-
EM.next_tick { raise e }
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
if @publish_script_digest.nil?
|
|
39
|
-
register_publish_script.errback do |e|
|
|
40
|
-
deferrable.fail e
|
|
41
|
-
end.callback do |digest|
|
|
42
|
-
@publish_script_digest = digest
|
|
43
|
-
Firehose.logger.debug "Registered Lua publishing script with Redis => #{digest}"
|
|
44
|
-
eval_publish_script channel_key, message, ttl, buffer_size, deferrable
|
|
45
|
-
end
|
|
46
|
-
else
|
|
47
|
-
eval_publish_script channel_key, message, ttl, buffer_size, deferrable
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
deferrable
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
private
|
|
54
|
-
def redis
|
|
55
|
-
@redis ||= Firehose::Server.redis.connection
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# Serialize components of a message into something that can be dropped into Redis.
|
|
59
|
-
def self.to_payload(channel_key, sequence, message)
|
|
60
|
-
[channel_key, sequence, message].join(PAYLOAD_DELIMITER)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Deserialize components of a message back into Ruby.
|
|
64
|
-
def self.from_payload(payload)
|
|
65
|
-
@payload_size ||= method(:to_payload).arity
|
|
66
|
-
payload.split(PAYLOAD_DELIMITER, @payload_size)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# TODO: Make this FAR more robust. Ideally we'd whitelist the permitted
|
|
70
|
-
# characters and then escape or remove everything else.
|
|
71
|
-
# See: http://en.wikibooks.org/wiki/Lua_Programming/How_to_Lua/escape_sequence
|
|
72
|
-
def lua_escape(str)
|
|
73
|
-
str.gsub(/\\/,'\\\\\\').gsub(/"/,'\"').gsub(/\n/,'\n').gsub(/\r/,'\r')
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def register_publish_script
|
|
77
|
-
redis.script 'LOAD', REDIS_PUBLISH_SCRIPT
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def eval_publish_script(channel_key, message, ttl, buffer_size, deferrable)
|
|
81
|
-
list_key = Server::Redis.key(channel_key, :list)
|
|
82
|
-
script_args = [
|
|
83
|
-
Server::Redis.key(channel_key, :sequence),
|
|
84
|
-
list_key,
|
|
85
|
-
Server::Redis.key(:channel_updates),
|
|
86
|
-
ttl,
|
|
87
|
-
message,
|
|
88
|
-
buffer_size,
|
|
89
|
-
PAYLOAD_DELIMITER,
|
|
90
|
-
channel_key
|
|
91
|
-
]
|
|
92
|
-
|
|
93
|
-
redis.evalsha(
|
|
94
|
-
@publish_script_digest, script_args.length, *script_args
|
|
95
|
-
).errback do |e|
|
|
96
|
-
deferrable.fail e
|
|
97
|
-
end.callback do |sequence|
|
|
98
|
-
Firehose.logger.debug "Redis stored/published `#{message}` to list `#{list_key}` with sequence `#{sequence}`"
|
|
99
|
-
deferrable.succeed
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
REDIS_PUBLISH_SCRIPT = <<-LUA
|
|
104
|
-
local sequence_key = KEYS[1]
|
|
105
|
-
local list_key = KEYS[2]
|
|
106
|
-
local channel_key = KEYS[3]
|
|
107
|
-
local ttl = KEYS[4]
|
|
108
|
-
local message = KEYS[5]
|
|
109
|
-
local buffer_size = KEYS[6]
|
|
110
|
-
local payload_delimiter = KEYS[7]
|
|
111
|
-
local firehose_resource = KEYS[8]
|
|
112
|
-
|
|
113
|
-
local current_sequence = redis.call('get', sequence_key)
|
|
114
|
-
if current_sequence == nil or current_sequence == false then
|
|
115
|
-
current_sequence = 0
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
local sequence = current_sequence + 1
|
|
119
|
-
local message_payload = firehose_resource .. payload_delimiter .. sequence .. payload_delimiter .. message
|
|
120
|
-
|
|
121
|
-
redis.call('set', sequence_key, sequence)
|
|
122
|
-
redis.call('expire', sequence_key, ttl)
|
|
123
|
-
redis.call('lpush', list_key, message)
|
|
124
|
-
redis.call('ltrim', list_key, 0, buffer_size - 1)
|
|
125
|
-
redis.call('expire', list_key, ttl)
|
|
126
|
-
redis.call('publish', channel_key, message_payload)
|
|
127
|
-
|
|
128
|
-
return sequence
|
|
129
|
-
LUA
|
|
130
|
-
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
end
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
require "uri"
|
|
2
|
-
|
|
3
|
-
module Firehose
|
|
4
|
-
module Server
|
|
5
|
-
# Manages redis configuration and connections.
|
|
6
|
-
class Redis
|
|
7
|
-
DEFAULT_URL = "redis://127.0.0.1:6379/0".freeze
|
|
8
|
-
KEY_DELIMITER = ":".freeze
|
|
9
|
-
ROOT_KEY = "firehose".freeze
|
|
10
|
-
|
|
11
|
-
attr_reader :url
|
|
12
|
-
|
|
13
|
-
def initialize(url = self.class.url)
|
|
14
|
-
@url = URI(url)
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def connection
|
|
18
|
-
EM::Hiredis.connect(@url)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# Generates keys for all firehose interactions with Redis. Ensures a root
|
|
22
|
-
# key of `firehose`
|
|
23
|
-
def self.key(*segments)
|
|
24
|
-
segments.flatten.unshift(ROOT_KEY).join(KEY_DELIMITER)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def self.url
|
|
28
|
-
ENV.fetch("REDIS_URL", DEFAULT_URL)
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
module Firehose
|
|
2
|
-
module Server
|
|
3
|
-
# Setups a connetion to Redis to listen for new resources...
|
|
4
|
-
class Subscriber
|
|
5
|
-
attr_reader :pubsub
|
|
6
|
-
|
|
7
|
-
def initialize(redis)
|
|
8
|
-
@pubsub = redis.pubsub
|
|
9
|
-
# TODO: Instead of just raising an exception, it would probably be better
|
|
10
|
-
# for the errback to set some sort of 'disconnected' state. Then
|
|
11
|
-
# whenever a deferrable was 'subscribed' we could instantly fail
|
|
12
|
-
# the deferrable with whatever connection error we had.
|
|
13
|
-
# An alternative which would have a similar result would be to
|
|
14
|
-
# subscribe lazily (i.e. not until we have a deferrable to subscribe).
|
|
15
|
-
# Then, if connecting failed, it'd be super easy to fail the deferrable
|
|
16
|
-
# with the same error.
|
|
17
|
-
# The final goal is to allow the failed deferrable bubble back up
|
|
18
|
-
# so we can send back a nice, clean 500 error to the client.
|
|
19
|
-
channel_updates_key = Server::Redis.key('channel_updates')
|
|
20
|
-
pubsub.subscribe(channel_updates_key).
|
|
21
|
-
errback{|e| EM.next_tick { raise e } }.
|
|
22
|
-
callback { Firehose.logger.debug "Redis subscribed to `#{channel_updates_key}`" }
|
|
23
|
-
pubsub.on(:message) do |_, payload|
|
|
24
|
-
channel_key, channel_sequence, message = Server::Publisher.from_payload(payload)
|
|
25
|
-
messages = [ MessageBuffer::Message.new(message, channel_sequence.to_i) ]
|
|
26
|
-
if deferrables = subscriptions.delete(channel_key)
|
|
27
|
-
Firehose.logger.debug "Redis notifying #{deferrables.count} deferrable(s) at `#{channel_key}` with channel_sequence `#{channel_sequence}` and message `#{message}`"
|
|
28
|
-
deferrables.each do |deferrable|
|
|
29
|
-
Firehose.logger.debug "Sending message #{message} and channel_sequence #{channel_sequence} to client from subscriber"
|
|
30
|
-
deferrable.succeed messages
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def subscribe(channel_key, deferrable)
|
|
37
|
-
subscriptions[channel_key].push deferrable
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def unsubscribe(channel_key, deferrable)
|
|
41
|
-
subscriptions[channel_key].delete deferrable
|
|
42
|
-
subscriptions.delete(channel_key) if subscriptions[channel_key].empty?
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
private
|
|
46
|
-
def subscriptions
|
|
47
|
-
@subscriptions ||= Hash.new{|h,k| h[k] = []}
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
data/lib/firehose/version.rb
DELETED