roundhouse-x 0.1.0 → 0.2.0

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: 58561b1a622c1151e562d2b5663f76028f59499f
4
- data.tar.gz: 4ae16eb8c3c77399fb530560d12a1fbc39d0e695
3
+ metadata.gz: e419459121a0e984bdeb39afef3442ff962a37a9
4
+ data.tar.gz: 8781d7c6f13f23bdc40c2a30311ac73d26476b07
5
5
  SHA512:
6
- metadata.gz: 734f9b44dc5d7198264ea151163c0eb066d4ea151d88380b7060fd7a2d315251deb913a829de8cd496f95257f14f975d07fbaa62d31fece29aac2ab8a2bd6b1f
7
- data.tar.gz: a8de13c124fed036d51df2d58b02a60c07885d1849ef9bbd93d3b1d2fcc9582945fc7fa571cdeec00578354c41dd9b93b9b38d46c990057fe5f7ce4c6e8d0a4f
6
+ metadata.gz: 5648e6e5e86ab1e55b5c5e1a6e6f3eb2f660ed7013dc67b9e6b6ebf9910ebf9e2f2b2a5f375b94528c44c9b54d61d4b61e2a3c57510211e1d7aba38e2054ac1b
7
+ data.tar.gz: 0b8591db28a22914c1eb5e1c0ca65fc90988e6f3b91ac623f07c1c2cca86453f03396112696bce5b0ff64fb1d13592e061266407d30909114aa98258a210f63f
data/README.md CHANGED
@@ -1,23 +1,30 @@
1
1
  Roundhouse
2
2
  ==============
3
3
 
4
- Roundhouse is based on Sidekiq 3.5.0 (HEAD: f8ee0896076671b73739099e3c1eb7d8814e407d)
4
+ Roundhouse is based on Sidekiq 3.5.0 (`HEAD: f8ee0896076671b73739099e3c1eb7d8814e407d`). It
5
+ dispatches jobs in a very specialized way for a very narrow set of problems. For example,
6
+ if you don't need to manage a large number of rate-limited access to an external resource,
7
+ you probably want to use Sidekiq and not Roundhouse.
5
8
 
6
- It uses the basic framework of a multi-threaded workers and managers to solve a different
7
- kind of problem. This problem resembles that of a load balancing problem: only one worker
8
- should be working on a work for a given user. This requires:
9
+ The problem Roundhouse solves resembles that of a load balancing problem: only one worker
10
+ should be working on a work for a given user. Any other worker can work on any other user's
11
+ work, as only as no other worker is working on it. We also want to do this fairly so that every
12
+ user has a chance at getting work done steadily. This requires:
9
13
 
10
- 1. A queue for each user
11
- 2. A circular buffer to cycle through all user queues
12
- 3. A way to track how many pieces of jobs to process per user, before moving to the next user's queue.
14
+ 1. A queue for each user. (Which often means a lot of queues)
15
+ 2. A turntable semaphore to control access to queues in a round-robin fashion
16
+ 3. A way to track the status of each queue (active, empty, suspended) and when to push a queue back into the turntable
13
17
 
14
18
  Other things Roundhouse should be able to do:
15
19
 
16
- 1. It should be able to run side-by side with Sidekiq client
20
+ 1. It should be able to run side-by side with Sidekiq client.
17
21
  2. Be able to suspend, resume, and defer work
18
22
  3. Metrics to measure lead times and give estimates on when work might be completed
19
23
  4. Coordinate across a number of worker processes, across machines
20
24
 
25
+ Roundhouse is in pre-alpha right now, designed for a specialized need we have. You probably don't want to
26
+ try this in production.
27
+
21
28
  Install
22
29
  ----------------
23
30
 
@@ -40,6 +47,30 @@ end
40
47
  APIWorker.perform_async(api_token.id, item.id)
41
48
  ```
42
49
 
50
+ Why the name 'Roundhouse'?
51
+ --------------------------
52
+
53
+ The name 'Sidekiq' is obviously a play on the word, 'sidekick', both to mean a companion that
54
+ helps a hero out in the background, and the name of a martial arts kicking technique. It
55
+ is a great name for a background processing manager that works well with Rails apps.
56
+
57
+ When Roundhouse was first concieved, it was meant as a play on the word 'sidekick'. Since Roundhouse
58
+ controls access to a queue in a round-robin fashion, I thought of a 'roundhouse kick'. Later, while
59
+ implementing the code, I found out that a 'roundhouse' is an old term in the railway industry.
60
+ In the old days, you needed a turntable to move trains around, or to reverse their direction.
61
+ Although in modern times, 'Roundhouse' has come to mean the general facilities to maintain
62
+ trains, this idea of a turntable at the center of a roundhouse is a powerful metaphor for the way
63
+ Roundhouse dispatches work to background workers.
64
+
65
+ The structure that actually controls access is neither a counting or a binary semaphore. I
66
+ had originally thought to call it a "queing semaphore' but that confuses a lot of people. The
67
+ term 'turntable semaphore' better describes this.
68
+
69
+ Unfortunately, someone had written a Ruby gem several years ago called RoundhousE. I have no
70
+ idea what that project does, other than that it has something to do with Ruby and .NET. So for
71
+ now, this gem is available as `roundhouse-x`. (Yes, that is quite a creative name).
72
+
73
+
43
74
  Sidekiq License
44
75
  -----------------
45
76
 
data/lib/roundhouse.rb CHANGED
@@ -14,7 +14,6 @@ module Roundhouse
14
14
  LICENSE = 'See LICENSE and the LGPL-3.0 for licensing details.'
15
15
 
16
16
  DEFAULTS = {
17
- queues: [],
18
17
  labels: [],
19
18
  concurrency: 25,
20
19
  require: '.',
@@ -33,8 +32,7 @@ module Roundhouse
33
32
  }
34
33
 
35
34
  DEFAULT_WORKER_OPTIONS = {
36
- 'retry' => true,
37
- 'queue' => 'default'
35
+ 'retry' => true
38
36
  }
39
37
 
40
38
  def self.❨╯°□°❩╯︵┻━┻
@@ -93,6 +91,16 @@ module Roundhouse
93
91
  end
94
92
  end
95
93
 
94
+ # Suspends a queue
95
+ def self.suspend_queue(queue_id)
96
+ self.redis { |conn| Roundhouse::Monitor.suspend(conn, queue_id) }
97
+ end
98
+
99
+ # Resumes a queue
100
+ def self.resume_queue(queue_id)
101
+ self.redis { |conn| Roundhouse::Monitor.resume(conn, queue_id) }
102
+ end
103
+
96
104
  def self.client_middleware
97
105
  @client_chain ||= Middleware::Chain.new
98
106
  yield @client_chain if block_given?
@@ -62,7 +62,7 @@ module Roundhouse
62
62
  conn.zcard('retry'.freeze)
63
63
  conn.zcard('dead'.freeze)
64
64
  conn.scard('processes'.freeze)
65
- conn.llen('semaphore'.freeze)
65
+ conn.llen(Roundhouse::Monitor::TURNTABLE.freeze)
66
66
  conn.smembers('processes'.freeze)
67
67
  conn.smembers(Roundhouse::Monitor::BUCKETS)
68
68
  end
@@ -64,7 +64,6 @@ module Roundhouse
64
64
 
65
65
  logger.info "Running in #{RUBY_DESCRIPTION}"
66
66
  logger.info Roundhouse::LICENSE
67
- logger.info "Upgrade to Roundhouse Pro for more features and support: http://roundhouse.org" unless defined?(::Roundhouse::Pro)
68
67
 
69
68
  fire_event(:startup)
70
69
 
@@ -102,18 +101,16 @@ module Roundhouse
102
101
  end
103
102
 
104
103
  def self.banner
105
- %q{ s
106
- ss
107
- sss sss ss
108
- s sss s ssss sss ____ _ _ _ _
109
- s sssss ssss / ___|(_) __| | ___| | _(_) __ _
110
- s sss \___ \| |/ _` |/ _ \ |/ / |/ _` |
111
- s sssss s ___) | | (_| | __/ <| | (_| |
112
- ss s s |____/|_|\__,_|\___|_|\_\_|\__, |
113
- s s s |_|
114
- s s
115
- sss
116
- sss }
104
+ %q{
105
+ ______ _ _
106
+ | ___ \ | | |
107
+ | |_/ /___ _ _ _ __ __| | |__ ___ _ _ ___ ___
108
+ | // _ \| | | | '_ \ / _` | '_ \ / _ \| | | / __|/ _ \
109
+ | |\ \ (_) | |_| | | | | (_| | | | | (_) | |_| \__ \ __/
110
+ \_| \_\___/ \__,_|_| |_|\__,_|_| |_|\___/ \__,_|___/\___|
111
+
112
+
113
+ }
117
114
  end
118
115
 
119
116
  def handle_signal(sig)
@@ -262,8 +259,6 @@ module Roundhouse
262
259
  end
263
260
 
264
261
  def validate!
265
- options[:queues] << 'default' if options[:queues].empty?
266
-
267
262
  if !File.exist?(options[:require]) ||
268
263
  (File.directory?(options[:require]) && !File.exist?("#{options[:require]}/config/application.rb"))
269
264
  logger.info "=================================================================="
@@ -303,11 +298,6 @@ module Roundhouse
303
298
  opts[:index] = Integer(arg.match(/\d+/)[0])
304
299
  end
305
300
 
306
- o.on "-q", "--queue QUEUE[,WEIGHT]", "Queues to process with optional weights" do |arg|
307
- queue, weight = arg.split(",")
308
- parse_queue opts, queue, weight
309
- end
310
-
311
301
  o.on '-r', '--require [PATH|DIR]', "Location of Rails application with workers or file to require" do |arg|
312
302
  opts[:require] = arg
313
303
  end
@@ -368,7 +358,6 @@ module Roundhouse
368
358
  if File.exist?(cfile)
369
359
  opts = YAML.load(ERB.new(IO.read(cfile)).result) || opts
370
360
  opts = opts.merge(opts.delete(environment) || {})
371
- parse_queues(opts, opts.delete(:queues) || [])
372
361
  else
373
362
  # allow a non-existent config file so Roundhouse
374
363
  # can be deployed by cap with just the defaults.
@@ -381,16 +370,5 @@ module Roundhouse
381
370
  end
382
371
  opts
383
372
  end
384
-
385
- def parse_queues(opts, queues_and_weights)
386
- queues_and_weights.each { |queue_and_weight| parse_queue(opts, *queue_and_weight) }
387
- end
388
-
389
- def parse_queue(opts, q, weight=nil)
390
- [weight.to_i, 1].max.times do
391
- (opts[:queues] ||= []) << q
392
- end
393
- opts[:strict] = false if weight.to_i > 0
394
- end
395
373
  end
396
374
  end
@@ -32,7 +32,7 @@ module Roundhouse
32
32
  # a new fetch if the current fetch turned up nothing.
33
33
  def fetch
34
34
  watchdog('Fetcher#fetch died') do
35
- return if Roundhouse::Fetcher.done?
35
+ logger.debug 'Fetcher terminating in #fetch' and return if Roundhouse::Fetcher.done?
36
36
 
37
37
  begin
38
38
  work = @strategy.retrieve_work
@@ -77,6 +77,7 @@ module Roundhouse
77
77
  # its mailbox when shutdown starts.
78
78
  def self.done!
79
79
  @done = true
80
+ Roundhouse.logger.debug 'Fetcher: setting done'
80
81
  end
81
82
 
82
83
  def self.reset # testing only
@@ -94,10 +95,11 @@ module Roundhouse
94
95
 
95
96
  class RoundRobinFetch
96
97
  def initialize(options = nil)
98
+ # ignore options
97
99
  end
98
100
 
99
101
  def retrieve_work
100
- work = Roundhouse.redis { |conn| Roundhouse::Monitor.await_next_job(conn) }
102
+ work = Roundhouse.redis { |conn| Roundhouse::Monitor.maybe_next_job(conn) }
101
103
  UnitOfWork.new(*work) if work
102
104
  end
103
105
 
@@ -46,6 +46,7 @@ module Roundhouse
46
46
 
47
47
  def stop
48
48
  watchdog('Launcher#stop') do
49
+ logger.debug 'Stopping launcher'
49
50
  @done = true
50
51
  Roundhouse::Fetcher.done!
51
52
  fetcher.terminate if fetcher.alive?
@@ -73,7 +74,6 @@ module Roundhouse
73
74
  'pid' => $$,
74
75
  'tag' => @options[:tag] || '',
75
76
  'concurrency' => @options[:concurrency],
76
- 'queues' => @options[:queues].uniq,
77
77
  'labels' => Roundhouse.options[:labels],
78
78
  'identity' => identity,
79
79
  }
@@ -1,6 +1,8 @@
1
+ require 'roundhouse/script'
2
+
1
3
  module Roundhouse
2
4
  # This class implements two things:
3
- # 1. A queueing semaphore - the fetcher can pop the next available
5
+ # 1. A turntable semaphore - the fetcher can pop the next available
4
6
  # exclusive right to something (such as API request with a given
5
7
  # auth token)
6
8
  # 2. Track which access right is temporarily suspended
@@ -10,25 +12,32 @@ module Roundhouse
10
12
  SUSPENDED = -1
11
13
 
12
14
  # This helps catch problems with key names at runtime
13
- SEMAPHORE = 'semaphore'.freeze
14
- BUCKETS = 'buckets'.freeze
15
- QUEUE = 'queue'.freeze
16
- SCHEDULE = 'schedule'.freeze
17
- STATUS = 'status'.freeze
15
+ TURNTABLE = 'turntable'.freeze
16
+ IN_ROTATION = 'inrotation'.freeze
17
+ BUCKETS = 'buckets'.freeze
18
+ QUEUE = 'queue'.freeze
19
+ SCHEDULE = 'schedule'.freeze
20
+ STATUS = 'status'.freeze
21
+
22
+ # Number of seconds to block on turntable
23
+ TURNTABLE_TIMEOUT = 5
18
24
 
19
25
  class << self
20
26
  # Find the first active queue
21
- # If nothing is in the rotation, then block
27
+ # Return nil if nothing is there. The fetcher is responsible
28
+ # for polling.
22
29
  def pop(conn)
23
- loop do
24
- _, q_id = conn.brpop(SEMAPHORE)
25
- return q_id if queue_status(conn, q_id) == ACTIVE
26
- end
30
+ _, q_id = conn.brpop(TURNTABLE, TURNTABLE_TIMEOUT)
31
+ conn.srem(IN_ROTATION, q_id)
32
+ return q_id if queue_status(conn, q_id) == ACTIVE
33
+ return nil
27
34
  end
28
35
 
36
+ # Atomic push
37
+ # Push queue into turntable if and only if queue status is active
29
38
  def push(conn, q_id)
30
- return unless queue_status(conn, q_id) == ACTIVE
31
- conn.lpush(SEMAPHORE, q_id)
39
+ # NOTE: this version of redis-namespace has a bug when you do keys: argv: params
40
+ TURNTABLE_PUSH.call conn, [status_bucket(q_id), TURNTABLE, IN_ROTATION], [q_id]
32
41
  end
33
42
 
34
43
  # Bulk requeue (push from right). Usually done
@@ -37,17 +46,19 @@ module Roundhouse
37
46
  conn.rpush("#{QUEUE}:#{q_id}", jobs)
38
47
  end
39
48
 
40
- def await_next_job(conn)
41
- loop do
42
- queue_id = pop(conn)
43
- job = pop_job(conn, queue_id)
44
- return queue_id, job if job
45
- Roundhouse::Monitor.set_queue_is_empty(conn, queue_id)
46
- end
49
+ def maybe_next_job(conn)
50
+ queue_id = pop(conn)
51
+ return nil unless queue_id
52
+
53
+ job = pop_job(conn, queue_id)
54
+ return queue_id, job if job
55
+ return nil
47
56
  end
48
57
 
58
+ # Atomically pop job
59
+ # If nothing is in the queue, set queue status to empty
49
60
  def pop_job(conn, q_id)
50
- conn.rpop("#{QUEUE}:#{q_id}")
61
+ POP_JOB.call conn, ["#{QUEUE}:#{q_id}", status_bucket(q_id)], [q_id]
51
62
  end
52
63
 
53
64
  def push_job(conn, payloads)
@@ -77,25 +88,20 @@ module Roundhouse
77
88
  end
78
89
 
79
90
  def resume(conn, q_id)
80
- return unless queue_status(conn, q_id) == SUSPENDED
81
- set_queue_status(conn, q_id, ACTIVE)
82
- conn.lpush(SEMAPHORE, q_id)
91
+ RESUME_QUEUE.call conn, [status_bucket(q_id), TURNTABLE, IN_ROTATION], [q_id]
83
92
  end
84
93
 
85
94
  def queue_status(conn, q_id)
86
95
  conn.hget(status_bucket(q_id), q_id).to_i || EMPTY
87
96
  end
88
97
 
98
+ # Only push onto turntable is status is empty
99
+ # If empty, push into turntable and set to active
100
+ # Subsequent job pushes would see the active queue and
101
+ # not push into turntable
102
+ # Used after pushing a job, and conditionally putting it into rotation
89
103
  def maybe_add_to_rotation(conn, q_id)
90
- # NOTE: this really should be written in LUA to make
91
- # sure this is set to ACTIVE after pushing it into the
92
- # queuing semaphore. Otherwise, race conditions might
93
- # creep in giving this queue an unfair advantage.
94
- # See: https://github.com/resque/redis-namespace/blob/master/lib/redis/namespace.rb#L403-L413
95
- # See: https://www.redisgreen.net/blog/intro-to-lua-for-redis-programmers/
96
- return false unless queue_status(conn, q_id) == EMPTY
97
- activate(conn, q_id)
98
- conn.lpush(SEMAPHORE, q_id)
104
+ MAYBE_TURNTABLE_PUSH.call conn, [status_bucket(q_id), TURNTABLE, BUCKETS, IN_ROTATION], [q_id, bucket_num(q_id)]
99
105
  end
100
106
 
101
107
  def status_bucket(q_id)
@@ -106,6 +112,51 @@ module Roundhouse
106
112
  q_id.to_i / 1000
107
113
  end
108
114
 
115
+ # TODO: This needs to be in a lua script and atomic
116
+ # Need to test how well this scales too. Should be testing about
117
+ # 10,000 queues
118
+ def rebuild_turntable!
119
+ Roundhouse.redis do |conn|
120
+ buckets = conn.smembers(BUCKETS)
121
+ queues = conn.pipelined do
122
+ buckets.each { |bucket| conn.hgetall("#{STATUS}:#{bucket}") }
123
+ end
124
+
125
+ all_queue_ids = queues.map(&:keys).flatten
126
+ queue_len_res = conn.pipelined do
127
+ all_queue_ids.each { |queue| conn.llen("#{QUEUE}:#{queue}") }
128
+ end
129
+
130
+ queue_len = all_queue_ids.zip(queue_len_res)
131
+ status = queues.inject({}) { |a,x| a.merge(x) }
132
+
133
+ conn.multi do
134
+ conn.del(TURNTABLE)
135
+ queue_len.each do |(q_id, len)|
136
+ s = status[q_id].to_i
137
+
138
+ case s
139
+ when SUSPENDED then next
140
+ when ACTIVE then
141
+ if len > 0
142
+ conn.lpush(TURNTABLE, q_id)
143
+ conn.sadd(IN_ROTATION, q_id)
144
+ else
145
+ set_queue_status(conn, q_id, EMPTY, false)
146
+ end
147
+ when EMPTY then
148
+ next if len <= 0
149
+ conn.lpush(TURNTABLE, q_id)
150
+ conn.sadd(IN_ROTATION, q_id)
151
+ set_queue_status(conn, q_id, ACTIVE, false)
152
+ else
153
+ set_queue_status(conn, q_id, SUSPENDED, false)
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+
109
160
  private
110
161
 
111
162
  def schedule(conn, payloads)
@@ -115,10 +166,60 @@ module Roundhouse
115
166
  end )
116
167
  end
117
168
 
118
- def set_queue_status(conn, q_id, status)
119
- conn.sadd(BUCKETS, bucket_num(q_id))
169
+ def set_queue_status(conn, q_id, status, add_bucket = true)
170
+ conn.sadd(BUCKETS, bucket_num(q_id)) if add_bucket
120
171
  conn.hset(status_bucket(q_id), q_id, status)
121
172
  end
122
- end
173
+ end # class << self
174
+
175
+ WOLVERINE_CONFIG = Roundhouse::Script::Configuration.new
176
+
177
+ LUA_TURNTABLE_PUSH = <<END
178
+ local status = redis.call("HGET", KEYS[1], ARGV[1])
179
+ local in_rotation = redis.call("SISMEMBER", KEYS[3], ARGV[1])
180
+ if status == "#{ACTIVE}" and in_rotation == 0 then
181
+ redis.call("SADD", KEYS[3], ARGV[1])
182
+ return redis.call("LPUSH", KEYS[2], ARGV[1])
183
+ end
184
+ return nil
185
+ END
186
+ LUA_MAYBE_TURNTABLE_PUSH = <<END
187
+ local status = redis.call("HGET", KEYS[1], ARGV[1])
188
+ if status == "#{EMPTY}" or status == nil or status == false then
189
+ redis.call('SADD', KEYS[3], ARGV[2])
190
+ redis.call("HSET", KEYS[1], ARGV[1], #{ACTIVE})
191
+ local in_rotation = redis.call("SISMEMBER", KEYS[4], ARGV[1])
192
+ if in_rotation == 0 then
193
+ redis.call('SADD', KEYS[4], ARGV[1])
194
+ return redis.call("LPUSH", KEYS[2], ARGV[1])
123
195
  end
124
196
  end
197
+ return nil
198
+ END
199
+ LUA_POP_JOB = <<END
200
+ local job = redis.call("RPOP", KEYS[1])
201
+ if type(job) == "string" then
202
+ return job
203
+ end
204
+ redis.call("HSET", KEYS[2], ARGV[1], #{EMPTY})
205
+ return nil
206
+ END
207
+ LUA_RESUME_QUEUE = <<END
208
+ local status = redis.call("HGET", KEYS[1], ARGV[1])
209
+ if status == "#{SUSPENDED}" then
210
+ redis.call("HSET", KEYS[1], ARGV[1], #{ACTIVE})
211
+ local in_rotation = redis.call("SISMEMBER", KEYS[3], ARGV[1])
212
+ if in_rotation == 0 then
213
+ redis.call('SADD', KEYS[4], ARGV[1])
214
+ return redis.call("LPUSH", KEYS[2], ARGV[1])
215
+ end
216
+ end
217
+ return nil
218
+ END
219
+ TURNTABLE_PUSH = Roundhouse::Script.new(LUA_TURNTABLE_PUSH.freeze, name: :turntable_push, config: WOLVERINE_CONFIG)
220
+ MAYBE_TURNTABLE_PUSH = Roundhouse::Script.new(LUA_MAYBE_TURNTABLE_PUSH.freeze, name: :maybe_turntable_push, config: WOLVERINE_CONFIG)
221
+ POP_JOB = Roundhouse::Script.new(LUA_POP_JOB.freeze, name: :pop_job, config: WOLVERINE_CONFIG)
222
+ RESUME_QUEUE = Roundhouse::Script.new(LUA_RESUME_QUEUE.freeze, name: :resume_queue, config: WOLVERINE_CONFIG)
223
+
224
+ end # Monitor
225
+ end