fairway 0.0.1

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.
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);