fairway 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.rbenv-gemsets ADDED
@@ -0,0 +1 @@
1
+ fairway
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ source :rubygems
2
+
3
+ # Specify your gem's dependencies in fairway.gemspec
4
+ gemspec
5
+
6
+ group :test do
7
+ gem "sidekiq"
8
+ gem "rspec"
9
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,50 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ fairway (0.0.1)
5
+ activesupport
6
+ hiredis
7
+ redis
8
+ redis-namespace
9
+
10
+ GEM
11
+ remote: http://rubygems.org/
12
+ specs:
13
+ activesupport (3.2.11)
14
+ i18n (~> 0.6)
15
+ multi_json (~> 1.0)
16
+ celluloid (0.12.4)
17
+ facter (>= 1.6.12)
18
+ timers (>= 1.0.0)
19
+ connection_pool (1.0.0)
20
+ diff-lcs (1.1.3)
21
+ facter (1.6.17)
22
+ hiredis (0.4.5)
23
+ i18n (0.6.1)
24
+ multi_json (1.5.0)
25
+ redis (3.0.2)
26
+ redis-namespace (1.2.1)
27
+ redis (~> 3.0.0)
28
+ rspec (2.12.0)
29
+ rspec-core (~> 2.12.0)
30
+ rspec-expectations (~> 2.12.0)
31
+ rspec-mocks (~> 2.12.0)
32
+ rspec-core (2.12.2)
33
+ rspec-expectations (2.12.1)
34
+ diff-lcs (~> 1.1.3)
35
+ rspec-mocks (2.12.2)
36
+ sidekiq (2.7.2)
37
+ celluloid (~> 0.12.0)
38
+ connection_pool (~> 1.0)
39
+ multi_json (~> 1)
40
+ redis (~> 3)
41
+ redis-namespace
42
+ timers (1.1.0)
43
+
44
+ PLATFORMS
45
+ ruby
46
+
47
+ DEPENDENCIES
48
+ fairway!
49
+ rspec
50
+ sidekiq
data/README.markdown ADDED
@@ -0,0 +1,123 @@
1
+ # Fairway (redis backbone)
2
+
3
+ ## Responsibilities
4
+
5
+ * Allow consumption of messages from various sources
6
+ * Publish messages for services who are listening
7
+ * adds messages to queues which services have registered
8
+ * keeps track of which facets have messages queued
9
+ * allows messages to be pulled off of queues in round-robin by facet
10
+
11
+ ### Queue structure
12
+
13
+ Once a service registers a queue, with a regex for what messages should be
14
+ added to the queue, the backbone will begin pushing matching messages onto the queue.
15
+
16
+ In some cases, queuing systems can have problems in multi-user systems. If one user
17
+ queues up a ton of messages, messages for other users may be delayed.
18
+
19
+ Driver solves that by allowing "facets" for each queue, which can be set to a facet
20
+ for each user.
21
+
22
+ Each facet is added to a list if it has messages waiting to be processed. This list
23
+ is used to enforce a round-robin stategy for pulling messages off of the queue. This
24
+ means we'll process one message for every facet which has messages queued, before
25
+ looping back and processing additional messages.
26
+
27
+ ### Redis LUA scripting
28
+
29
+ Driver uses [LUA scripts](http://redis.io/commands/eval) inside of redis heavily. This is for a few reasons:
30
+
31
+ * There is complex logic that can't be expressed in normal redis commands.
32
+ * Each script contains many redis commands and it's important that these
33
+ commands are processed atomically. A LUA script does that.
34
+ * Since the script is run inside of redis, once the script has started,
35
+ there's very low latency for each redis command. So, the script executes
36
+ much faster than if we made each call independantly over the network.
37
+
38
+ This means your Redis version must be `>= 2.6.0`
39
+
40
+ ### Usage
41
+
42
+ Add driver to your Gemfile
43
+
44
+ gem 'driver', git: 'git@github.com:customerio/driver.git'
45
+
46
+ Make sure to `bundle install`.
47
+
48
+ ##### Configure driver
49
+
50
+ Driver.configure do |config|
51
+ config.redis = { host: "yourserver.com", port: 6379 }
52
+ config.namespace = "letsdrive"
53
+
54
+ config.facet do |message|
55
+ message[:user_id]
56
+ end
57
+
58
+ config.topic do |message|
59
+ "#{message[:user_id]}:#{message[:type]}"
60
+ end
61
+ end
62
+
63
+ If you don't configure, it'll default to:
64
+
65
+ Driver.configure do |config|
66
+ config.redis = { host: "localhost", port: 6379 }
67
+ config.namespace = nil
68
+
69
+ config.facet do |message|
70
+ message[:facet]
71
+ end
72
+
73
+ config.topic do |message|
74
+ message[:topic]
75
+ end
76
+ end
77
+
78
+ ##### Create an instance of driver
79
+
80
+ driver = Driver::Client.new
81
+
82
+ ##### Send messages
83
+
84
+ driver.deliver(facet: 1, type: :page, name: "http://customer.io/blog", referrer: "http://customer.io")
85
+
86
+ You can pass any hash of data you'd like. Using the default configuration, this message will have a topic
87
+ of `page`, which can useful if you'd like to listen for, or process, messages.
88
+
89
+ ##### Listen for messages
90
+
91
+ If a message is sent in the middle of the forest, and no one is listening, was it ever really sent?
92
+
93
+ You can listen for messages that are delivered by driver by subscribing to message topics:
94
+
95
+ driver.redis.psubscribe("page:*google*") do |on|
96
+ on.pmessage do |pattern, channel, message|
97
+ puts "[#{channel}] #{message}"
98
+ end
99
+ end
100
+
101
+ If you've configured your topic to be `"#{message[:type]}:#{message[:name]}`, this will listen for any page events with google in the name.
102
+
103
+ Now, if you deliver a message, it'll be printed out on the console.
104
+
105
+ *Note:* redis psubscribe is blocking. So, you'll need multiple console windows open.
106
+ One to deliver the message, and one to listen for them.
107
+
108
+ ##### Create a queue
109
+
110
+ Ok, so now you can listen to messages, but what if your listener dies and you miss all your important messages?
111
+
112
+ Not to worry, you can tell driver to queue up any messages you want to know about.
113
+
114
+ driver.register_queue("myqueue", ".*:page:.*google.*")
115
+
116
+ Now driver will deliver all page events with google in the name to the queue named `myqueue`. To retrieve messages
117
+ from your queue:
118
+
119
+ message = driver.pull("myqueue")
120
+
121
+ This will return a message or nil (if no messages are queued).
122
+
123
+ *Note:* `pull` is facet aware, and will rotate through all facets with queued messages.
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/boot.rb ADDED
@@ -0,0 +1,7 @@
1
+ require "rubygems"
2
+
3
+ # Set up gems listed in the Gemfile.
4
+ ENV["BUNDLE_GEMFILE"] ||= File.join(File.dirname(__FILE__), "Gemfile")
5
+
6
+ require "bundler/setup"
7
+ Bundler.require(:default)
data/fairway.gemspec ADDED
@@ -0,0 +1,24 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'fairway/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "fairway"
8
+ gem.version = Fairway::VERSION
9
+ gem.authors = ["John Allison"]
10
+ gem.email = ["john@customer.io"]
11
+ gem.description = "A fair way to queue work in multi-user systems."
12
+ gem.summary = "A fair way to queue work in multi-user systems."
13
+ gem.homepage = "https://github.com/customerio/fairway"
14
+
15
+ gem.files = `git ls-files`.split($/)
16
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
+ gem.require_paths = ["lib"]
19
+
20
+ gem.add_dependency("activesupport")
21
+ gem.add_dependency("redis")
22
+ gem.add_dependency("hiredis")
23
+ gem.add_dependency("redis-namespace")
24
+ end
@@ -0,0 +1,17 @@
1
+ module Fairway
2
+ class ChanneledConnection
3
+ def initialize(connection, &block)
4
+ @connection = connection
5
+ @block = block
6
+ end
7
+
8
+ def deliver(message)
9
+ channel = @block.call(message)
10
+ @connection.deliver(message, channel)
11
+ end
12
+
13
+ def scripts
14
+ @connection.scripts
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,57 @@
1
+ module Fairway
2
+ class Config
3
+ attr_accessor :namespace
4
+ attr_reader :queues
5
+
6
+ DEFAULT_FACET = "default"
7
+
8
+ QueueDefinition = Struct.new(:name, :channel)
9
+
10
+ def initialize
11
+ @redis_options = {}
12
+ @namespace = nil
13
+ @facet = lambda { |message| DEFAULT_FACET }
14
+ @queues = []
15
+ yield self if block_given?
16
+ end
17
+
18
+ def facet(&block)
19
+ if block_given?
20
+ @facet = block
21
+ else
22
+ @facet
23
+ end
24
+ end
25
+
26
+ def register_queue(name, channel = Connection::DEFAULT_CHANNEL)
27
+ @queues << QueueDefinition.new(name, channel)
28
+ end
29
+
30
+ def redis=(options)
31
+ @redis_options = options
32
+ end
33
+
34
+ def redis
35
+ @redis ||= Redis::Namespace.new(@namespace, redis: raw_redis)
36
+ end
37
+
38
+ def scripts
39
+ @scripts ||= Scripts.new(raw_redis, scripts_namespace)
40
+ end
41
+
42
+ private
43
+
44
+ def scripts_namespace
45
+ if @namespace.blank?
46
+ ""
47
+ else
48
+ "#{@namespace}:"
49
+ end
50
+ end
51
+
52
+ def raw_redis
53
+ @raw_redis ||= Redis.new(@redis_options.merge(hiredis: true))
54
+ end
55
+
56
+ end
57
+ end
@@ -0,0 +1,28 @@
1
+ require "fairway/scripts"
2
+
3
+ module Fairway
4
+ class Connection
5
+ DEFAULT_CHANNEL = "default"
6
+
7
+ def initialize(config = Fairway.config)
8
+ @config = config
9
+
10
+ @config.queues.each do |queue|
11
+ scripts.fairway_register_queue(queue.name, queue.channel)
12
+ end
13
+ end
14
+
15
+ def deliver(message, channel = DEFAULT_CHANNEL)
16
+ scripts.fairway_deliver(
17
+ channel,
18
+ @config.facet.call(message),
19
+ message.to_json
20
+ )
21
+ end
22
+
23
+ def scripts
24
+ @config.scripts
25
+ end
26
+
27
+ end
28
+ end
@@ -0,0 +1,12 @@
1
+ module Fairway
2
+ class QueueReader
3
+ def initialize(connection, *queue_names)
4
+ @connection = connection
5
+ @queue_names = [queue_names].flatten!
6
+ end
7
+
8
+ def pull
9
+ @connection.scripts.fairway_pull(@queue_names)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,43 @@
1
+ require "pathname"
2
+ require "digest/sha1"
3
+
4
+ module Fairway
5
+ class Scripts
6
+ def self.script_shas
7
+ @script_shas ||= {}
8
+ end
9
+
10
+ def initialize(redis, namespace)
11
+ @redis = redis
12
+ @namespace = namespace
13
+ end
14
+
15
+ def method_missing(method_name, *args)
16
+ loaded = false
17
+ @redis.evalsha(script_sha(method_name), [@namespace], args)
18
+ rescue Redis::CommandError => ex
19
+ if ex.message.include?("NOSCRIPT") && !loaded
20
+ @redis.script(:load, script_source(method_name))
21
+ loaded = true
22
+ retry
23
+ else
24
+ raise
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def script_sha(name)
31
+ self.class.script_shas[name] ||= Digest::SHA1.hexdigest(script_source(name))
32
+ end
33
+
34
+ def script_source(name)
35
+ script_path(name).read
36
+ end
37
+
38
+ def script_path(name)
39
+ Pathname.new(__FILE__).dirname.join("../../redis/#{name}.lua")
40
+ end
41
+
42
+ end
43
+ end
@@ -0,0 +1,37 @@
1
+ module Fairway
2
+ module Sidekiq
3
+ class CompositeFetch
4
+ attr_reader :fetches
5
+
6
+ def initialize(fetches)
7
+ @fetches = []
8
+
9
+ fetches.each do |fetch, weight|
10
+ [weight.to_i, 1].max.times do
11
+ @fetches << fetch
12
+ end
13
+ end
14
+ end
15
+
16
+ def fetch_order
17
+ fetches.shuffle.uniq
18
+ end
19
+
20
+ def retrieve_work
21
+ ::Sidekiq.logger.debug "#{self.class.name}#retrieve_work"
22
+
23
+ fetch_order.each do |fetch|
24
+ work = fetch.retrieve_work
25
+
26
+ if work
27
+ ::Sidekiq.logger.debug "#{self.class.name}#retrieve_work got work"
28
+ return work
29
+ end
30
+ end
31
+
32
+ ::Sidekiq.logger.debug "#{self.class.name}#retrieve_work got nil"
33
+ return nil
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,37 @@
1
+ module Fairway
2
+ module Sidekiq
3
+ class Fetcher < ::Sidekiq::Fetcher
4
+ attr_reader :mgr, :strategy
5
+
6
+ def initialize(mgr, fetch)
7
+ @mgr = mgr
8
+ @strategy = fetch
9
+ end
10
+
11
+ def done!
12
+ @done = true
13
+ end
14
+
15
+ def fetch
16
+ watchdog('Fetcher#fetch died') do
17
+ return if @done
18
+
19
+ begin
20
+ work = @strategy.retrieve_work
21
+
22
+ if work
23
+ @mgr.async.assign(work)
24
+ else
25
+ after(TIMEOUT) { fetch }
26
+ end
27
+ rescue => ex
28
+ logger.error("Error fetching message: #{ex}")
29
+ logger.error(ex.backtrace.first)
30
+ sleep(TIMEOUT)
31
+ after(0) { fetch }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,23 @@
1
+ module Fairway
2
+ module Sidekiq
3
+ class FetcherFactory
4
+ def initialize(fetch)
5
+ @fetch = fetch
6
+ end
7
+
8
+ def done!
9
+ @fetcher.done!
10
+ end
11
+
12
+ def strategy
13
+ # This is only used for ::Sidekiq::BasicFetch.bulk_requeue
14
+ # which is the same for us.
15
+ ::Sidekiq::BasicFetch
16
+ end
17
+
18
+ def new(mgr, options)
19
+ @fetcher = Fetcher.new(mgr, @fetch)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,64 @@
1
+ module Fairway
2
+ module Sidekiq
3
+ class NonBlockingFetch < ::Sidekiq::BasicFetch
4
+ attr_reader :queues
5
+
6
+ def initialize(options)
7
+ @queues = options[:queues].map { |q| "queue:#{q}" }
8
+ end
9
+
10
+ def retrieve_work
11
+ ::Sidekiq.logger.debug "#{self.class.name}#retrieve_work #{queues_cmd}"
12
+
13
+ work = ::Sidekiq.redis do |conn|
14
+ script = <<-SCRIPT
15
+ -- take advantage of non-blocking scripts
16
+ for i = 1, #KEYS do
17
+ local work = redis.call('rpop', KEYS[i]);
18
+
19
+ if work then
20
+ return {KEYS[i], work};
21
+ end
22
+ end
23
+
24
+ return nil;
25
+ SCRIPT
26
+
27
+ conn.eval(script, queues_cmd.map{|q| "#{namespace}#{q}"})
28
+ end
29
+
30
+ if (work)
31
+ ::Sidekiq.logger.debug "#{self.class.name}#retrieve_work got work"
32
+ work = UnitOfWork.new(*work)
33
+ else
34
+ ::Sidekiq.logger.debug "#{self.class.name}#retrieve_work got nil"
35
+ end
36
+
37
+ work
38
+ end
39
+
40
+ def queues_cmd
41
+ @queues.shuffle.uniq
42
+ end
43
+
44
+ def namespace
45
+ @namespace ||= begin
46
+ namespaces = []
47
+
48
+ ::Sidekiq.redis do |conn|
49
+ wrapper = conn
50
+
51
+ while defined?(wrapper.redis)
52
+ namespaces.unshift(wrapper.namespace)
53
+ wrapper = wrapper.redis
54
+ end
55
+
56
+ namespace = namespaces.join(":")
57
+ namespace += ":" unless namespace.blank?
58
+ namespace
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,33 @@
1
+ require "sidekiq/fetch"
2
+
3
+ module Fairway
4
+ module Sidekiq
5
+ class QueueFetch < ::Sidekiq::BasicFetch
6
+ def initialize(queue_reader, &block)
7
+ @queue_reader = queue_reader
8
+ @message_to_job = block if block_given?
9
+ end
10
+
11
+ def retrieve_work
12
+ ::Sidekiq.logger.debug "#{self.class.name}#retrieve_work"
13
+ unit_of_work = nil
14
+
15
+ fairway_queue, work = @queue_reader.pull
16
+
17
+ if work
18
+ decoded_work = JSON.parse(work)
19
+ work = @message_to_job.call(fairway_queue, decoded_work).to_json if @message_to_job
20
+ unit_of_work = UnitOfWork.new(decoded_work["queue"], work)
21
+ end
22
+
23
+ if unit_of_work
24
+ ::Sidekiq.logger.debug "#{self.class.name}#retrieve_work got work"
25
+ else
26
+ ::Sidekiq.logger.debug "#{self.class.name}#retrieve_work got nil"
27
+ end
28
+
29
+ unit_of_work
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ require "sidekiq"
2
+ require "sidekiq/manager"
3
+
4
+ require "fairway/sidekiq/composite_fetch"
5
+ require "fairway/sidekiq/fetcher"
6
+ require "fairway/sidekiq/fetcher_factory"
7
+ require "fairway/sidekiq/non_blocking_fetch"
8
+ require "fairway/sidekiq/queue_fetch"
9
+
10
+ # conn = Fairway::Connection.new
11
+ # queue_reader = Fairway::QueueReader.new(conn, "fairway")
12
+ #
13
+ # queue_fetch = Fairway::Sidekiq::QueueFetch.new(queue_reader) do |message|
14
+ # # Transform message into a sidekiq job
15
+ # message
16
+ # end
17
+ #
18
+ # non_blocking_fetch = Fairway::Sidekiq::NonBlockingFetch.new(Sidekiq.options)
19
+ # fetch = Fairway::Sidekiq::CompositeFetch.new(queue_fetch => 1, non_blocking_fetch => 1)
20
+ # Sidekiq.options[:fetcher] = Fairway::Sidekiq::FetcherFactory.new(fetch)
@@ -0,0 +1,3 @@
1
+ module Fairway
2
+ VERSION = "0.0.1"
3
+ end
data/lib/fairway.rb ADDED
@@ -0,0 +1,22 @@
1
+ require "fairway/version"
2
+
3
+ require "active_support/core_ext"
4
+ require "redis"
5
+ require "hiredis"
6
+ require "redis-namespace"
7
+
8
+ require "fairway/config"
9
+ require "fairway/scripts"
10
+ require "fairway/channeled_connection"
11
+ require "fairway/connection"
12
+ require "fairway/queue_reader"
13
+
14
+ module Fairway
15
+ def self.config
16
+ @config ||= Config.new
17
+ end
18
+
19
+ def self.configure
20
+ yield(config) if block_given?
21
+ end
22
+ end
@@ -0,0 +1,25 @@
1
+ local namespace = KEYS[1];
2
+ local topic = ARGV[1];
3
+ local facet = ARGV[2];
4
+ local message = ARGV[3];
5
+
6
+ local registered_queues_key = namespace .. 'registered_queues';
7
+ local registered_queues = redis.call('hgetall', registered_queues_key);
8
+
9
+ for i = 1, #registered_queues, 2 do
10
+ local queue_name = registered_queues[i];
11
+ local queue_message = registered_queues[i+1];
12
+
13
+ if string.find(topic, queue_message) then
14
+ local active_facets = namespace .. queue_name .. ':active_facets';
15
+ local facet_queue = namespace .. queue_name .. ':facet_queue';
16
+
17
+ redis.call('lpush', namespace .. queue_name .. ':' .. facet, message)
18
+
19
+ if redis.call('sadd', active_facets, facet) == 1 then
20
+ redis.call('lpush', facet_queue, facet);
21
+ end
22
+ end
23
+ end
24
+
25
+ redis.call('publish', namespace .. topic, message);
@@ -0,0 +1,21 @@
1
+ local namespace = KEYS[1];
2
+
3
+ for index, queue_name in ipairs(ARGV) do
4
+ local active_facets = namespace .. queue_name .. ':active_facets';
5
+ local facet_queue = namespace .. queue_name .. ':facet_queue';
6
+
7
+ local facet = redis.call('rpop', facet_queue);
8
+
9
+ if facet then
10
+ local message_queue = namespace .. queue_name .. ':' .. facet;
11
+ local message = redis.call('rpop', message_queue);
12
+
13
+ if redis.call('llen', message_queue) == 0 then
14
+ redis.call('srem', active_facets, facet);
15
+ else
16
+ redis.call('lpush', facet_queue, facet);
17
+ end
18
+
19
+ return {queue_name, message};
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ local hash = KEYS[1] .. 'registered_queues';
2
+ local queue_name = ARGV[1];
3
+ local queue_message = ARGV[2];
4
+
5
+ redis.call('hset', hash, queue_name, queue_message);