reqless 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +8 -0
  3. data/README.md +648 -0
  4. data/Rakefile +117 -0
  5. data/bin/docker-build-and-test +22 -0
  6. data/exe/reqless-web +11 -0
  7. data/lib/reqless/config.rb +31 -0
  8. data/lib/reqless/failure_formatter.rb +43 -0
  9. data/lib/reqless/job.rb +496 -0
  10. data/lib/reqless/job_reservers/ordered.rb +29 -0
  11. data/lib/reqless/job_reservers/round_robin.rb +46 -0
  12. data/lib/reqless/job_reservers/shuffled_round_robin.rb +21 -0
  13. data/lib/reqless/lua/reqless-lib.lua +2965 -0
  14. data/lib/reqless/lua/reqless.lua +2545 -0
  15. data/lib/reqless/lua_script.rb +90 -0
  16. data/lib/reqless/middleware/requeue_exceptions.rb +94 -0
  17. data/lib/reqless/middleware/retry_exceptions.rb +72 -0
  18. data/lib/reqless/middleware/sentry.rb +66 -0
  19. data/lib/reqless/middleware/timeout.rb +63 -0
  20. data/lib/reqless/queue.rb +189 -0
  21. data/lib/reqless/queue_priority_pattern.rb +16 -0
  22. data/lib/reqless/server/static/css/bootstrap-responsive.css +686 -0
  23. data/lib/reqless/server/static/css/bootstrap-responsive.min.css +12 -0
  24. data/lib/reqless/server/static/css/bootstrap.css +3991 -0
  25. data/lib/reqless/server/static/css/bootstrap.min.css +689 -0
  26. data/lib/reqless/server/static/css/codemirror.css +112 -0
  27. data/lib/reqless/server/static/css/docs.css +839 -0
  28. data/lib/reqless/server/static/css/jquery.noty.css +105 -0
  29. data/lib/reqless/server/static/css/noty_theme_twitter.css +137 -0
  30. data/lib/reqless/server/static/css/style.css +200 -0
  31. data/lib/reqless/server/static/favicon.ico +0 -0
  32. data/lib/reqless/server/static/img/glyphicons-halflings-white.png +0 -0
  33. data/lib/reqless/server/static/img/glyphicons-halflings.png +0 -0
  34. data/lib/reqless/server/static/js/bootstrap-alert.js +94 -0
  35. data/lib/reqless/server/static/js/bootstrap-scrollspy.js +125 -0
  36. data/lib/reqless/server/static/js/bootstrap-tab.js +130 -0
  37. data/lib/reqless/server/static/js/bootstrap-tooltip.js +270 -0
  38. data/lib/reqless/server/static/js/bootstrap-typeahead.js +285 -0
  39. data/lib/reqless/server/static/js/bootstrap.js +1726 -0
  40. data/lib/reqless/server/static/js/bootstrap.min.js +6 -0
  41. data/lib/reqless/server/static/js/codemirror.js +2972 -0
  42. data/lib/reqless/server/static/js/jquery.noty.js +220 -0
  43. data/lib/reqless/server/static/js/mode/javascript.js +360 -0
  44. data/lib/reqless/server/static/js/theme/cobalt.css +18 -0
  45. data/lib/reqless/server/static/js/theme/eclipse.css +25 -0
  46. data/lib/reqless/server/static/js/theme/elegant.css +10 -0
  47. data/lib/reqless/server/static/js/theme/lesser-dark.css +45 -0
  48. data/lib/reqless/server/static/js/theme/monokai.css +28 -0
  49. data/lib/reqless/server/static/js/theme/neat.css +9 -0
  50. data/lib/reqless/server/static/js/theme/night.css +21 -0
  51. data/lib/reqless/server/static/js/theme/rubyblue.css +21 -0
  52. data/lib/reqless/server/static/js/theme/xq-dark.css +46 -0
  53. data/lib/reqless/server/views/_job.erb +259 -0
  54. data/lib/reqless/server/views/_job_list.erb +8 -0
  55. data/lib/reqless/server/views/_pagination.erb +7 -0
  56. data/lib/reqless/server/views/about.erb +130 -0
  57. data/lib/reqless/server/views/completed.erb +11 -0
  58. data/lib/reqless/server/views/config.erb +14 -0
  59. data/lib/reqless/server/views/failed.erb +48 -0
  60. data/lib/reqless/server/views/failed_type.erb +18 -0
  61. data/lib/reqless/server/views/job.erb +17 -0
  62. data/lib/reqless/server/views/layout.erb +451 -0
  63. data/lib/reqless/server/views/overview.erb +137 -0
  64. data/lib/reqless/server/views/queue.erb +125 -0
  65. data/lib/reqless/server/views/queues.erb +45 -0
  66. data/lib/reqless/server/views/tag.erb +6 -0
  67. data/lib/reqless/server/views/throttles.erb +38 -0
  68. data/lib/reqless/server/views/track.erb +75 -0
  69. data/lib/reqless/server/views/worker.erb +34 -0
  70. data/lib/reqless/server/views/workers.erb +14 -0
  71. data/lib/reqless/server.rb +549 -0
  72. data/lib/reqless/subscriber.rb +74 -0
  73. data/lib/reqless/test_helpers/worker_helpers.rb +55 -0
  74. data/lib/reqless/throttle.rb +57 -0
  75. data/lib/reqless/version.rb +5 -0
  76. data/lib/reqless/worker/base.rb +237 -0
  77. data/lib/reqless/worker/forking.rb +215 -0
  78. data/lib/reqless/worker/serial.rb +41 -0
  79. data/lib/reqless/worker.rb +5 -0
  80. data/lib/reqless.rb +309 -0
  81. metadata +399 -0
@@ -0,0 +1,496 @@
1
+ # Encoding: utf-8
2
+
3
+ require 'reqless'
4
+ require 'reqless/queue'
5
+ require 'reqless/lua_script'
6
+ require 'redis'
7
+ require 'json'
8
+
9
+ module Reqless
10
+ # The base for both Job and RecurringJob
11
+ class BaseJob
12
+ attr_reader :client
13
+
14
+ def initialize(client, jid)
15
+ @client = client
16
+ @jid = jid
17
+ end
18
+
19
+ def klass
20
+ @klass ||= @klass_name.split('::').reduce(Object) do |context, name|
21
+ context.const_get(name, false)
22
+ end
23
+ end
24
+
25
+ def queue
26
+ @queue ||= Queue.new(@queue_name, @client)
27
+ end
28
+
29
+ def ==(other)
30
+ self.class == other.class &&
31
+ jid == other.jid &&
32
+ client == other.client
33
+ end
34
+ alias eql? ==
35
+
36
+ def hash
37
+ self.class.hash ^ jid.hash ^ client.hash
38
+ end
39
+ end
40
+
41
+ # A Reqless job
42
+ class Job < BaseJob
43
+ attr_reader :jid, :expires_at, :state, :queue_name, :worker_name, :failure, :spawned_from_jid
44
+ attr_reader :klass_name, :tracked, :dependencies, :dependents
45
+ attr_reader :original_retries, :retries_left, :raw_queue_history
46
+ attr_reader :state_changed
47
+ attr_accessor :data, :priority, :tags, :throttles
48
+
49
+ alias_method(:state_changed?, :state_changed)
50
+
51
+ MiddlewareMisconfiguredError = Class.new(StandardError)
52
+
53
+ module SupportsMiddleware
54
+ def around_perform(job)
55
+ perform(job)
56
+ end
57
+ end
58
+
59
+ def perform
60
+ # If we can't find the class, we should fail the job, not try to process
61
+ begin
62
+ klass
63
+ rescue NameError
64
+ return fail("#{queue_name}-NameError", "Cannot find #{klass_name}")
65
+ end
66
+
67
+ # log a real process executing job -- before we start processing
68
+ log("started by pid:#{Process.pid}")
69
+
70
+ middlewares = Job.middlewares_on(klass)
71
+
72
+ if middlewares.last == SupportsMiddleware
73
+ klass.around_perform(self)
74
+ elsif middlewares.any?
75
+ raise MiddlewareMisconfiguredError, 'The middleware chain for ' +
76
+ "#{klass} (#{middlewares.inspect}) is misconfigured." +
77
+ 'Reqless::Job::SupportsMiddleware must be extended onto your job' +
78
+ 'class first if you want to use any middleware.'
79
+ elsif !klass.respond_to?(:perform)
80
+ # If the klass doesn't have a :perform method, we should raise an error
81
+ fail("#{queue_name}-method-missing",
82
+ "#{klass_name} has no perform method")
83
+ else
84
+ klass.perform(self)
85
+ end
86
+ end
87
+
88
+ def self.build(client, klass, attributes = {})
89
+ defaults = {
90
+ 'jid' => Reqless.generate_jid,
91
+ 'spawned_from_jid' => nil,
92
+ 'data' => {},
93
+ 'klass' => klass.to_s,
94
+ 'priority' => 0,
95
+ 'tags' => [],
96
+ 'worker' => 'mock_worker',
97
+ 'expires' => Time.now + (60 * 60), # an hour from now
98
+ 'state' => 'running',
99
+ 'tracked' => false,
100
+ 'queue' => 'mock_queue',
101
+ 'retries' => 5,
102
+ 'remaining' => 5,
103
+ 'failure' => {},
104
+ 'history' => [],
105
+ 'dependencies' => [],
106
+ 'dependents' => [],
107
+ 'throttles' => [],
108
+ }
109
+ attributes = defaults.merge(Reqless.stringify_hash_keys(attributes))
110
+ attributes['data'] = JSON.dump(attributes['data'])
111
+ new(client, attributes)
112
+ end
113
+
114
+ # Converts a hash of job options (as returned by job.to_hash) into the array
115
+ # format the reqless api expects.
116
+ def self.build_opts_array(opts)
117
+ result = []
118
+ result << JSON.generate(opts.fetch(:data, {}))
119
+ result.concat([opts.fetch(:delay, 0)])
120
+ result.concat(['priority', opts.fetch(:priority, 0)])
121
+ result.concat(['tags', JSON.generate(opts.fetch(:tags, []))])
122
+ result.concat(['retries', opts.fetch(:retries, 5)])
123
+ result.concat(['depends', JSON.generate(opts.fetch(:depends, []))])
124
+ result.concat(['throttles', JSON.generate(opts.fetch(:throttles, []))])
125
+ end
126
+
127
+ def self.middlewares_on(job_klass)
128
+ singleton_klass = job_klass.singleton_class
129
+ singleton_klass.ancestors.select do |ancestor|
130
+ ancestor != singleton_klass && ancestor.method_defined?(:around_perform)
131
+ end
132
+ end
133
+
134
+ def initialize(client, atts)
135
+ super(client, atts.fetch('jid'))
136
+ %w{
137
+ data failure dependencies dependents jid priority state tags throttles
138
+ tracked
139
+ }.each do |att|
140
+ instance_variable_set(:"@#{att}", atts.fetch(att))
141
+ # Redis doesn't handle nil values so well, sometimes instead returning false,
142
+ # so massage spawned_by_jid to consistent be nil or a jid
143
+ @spawned_from_jid = atts.fetch('spawned_from_jid', nil) || nil
144
+ end
145
+
146
+ # Parse the data string
147
+ @data = JSON.parse(@data)
148
+
149
+ @expires_at = atts.fetch('expires')
150
+ @klass_name = atts.fetch('klass')
151
+ @queue_name = atts.fetch('queue')
152
+ @worker_name = atts.fetch('worker')
153
+ @original_retries = atts.fetch('retries')
154
+ @retries_left = atts.fetch('remaining')
155
+ @raw_queue_history = atts.fetch('history')
156
+
157
+ # This is a silly side-effect of Lua doing JSON serialization
158
+ @tags = [] if @tags == {}
159
+ @dependents = [] if @dependents == {}
160
+ @dependencies = [] if @dependencies == {}
161
+ @state_changed = false
162
+ @before_callbacks = Hash.new { |h, k| h[k] = [] }
163
+ @after_callbacks = Hash.new { |h, k| h[k] = [] }
164
+ end
165
+
166
+ def priority=(priority)
167
+ @priority = priority if @client.call('job.setPriority', @jid, priority)
168
+ end
169
+
170
+ def [](key)
171
+ @data[key]
172
+ end
173
+
174
+ def []=(key, val)
175
+ @data[key] = val
176
+ end
177
+
178
+ def to_s
179
+ inspect
180
+ end
181
+
182
+ def description
183
+ "#{@klass_name} (#{@jid} / #{@queue_name} / #{@state})"
184
+ end
185
+
186
+ def inspect
187
+ "<Reqless::Job #{description}>"
188
+ end
189
+
190
+ def ttl
191
+ @expires_at - Time.now.to_f
192
+ end
193
+
194
+ def throttle_objects
195
+ throttles.map { |name| Throttle.new(name, client) }
196
+ end
197
+
198
+ def history
199
+ warn 'WARNING: Reqless::Job#history is deprecated; use' +
200
+ "Reqless::Job#raw_queue_history instead; from:\n#{caller.first}"
201
+ raw_queue_history
202
+ end
203
+
204
+ def queue_history
205
+ @queue_history ||= @raw_queue_history.map do |history_event|
206
+ history_event.each_with_object({}) do |(key, value), hash|
207
+ # The only Numeric (Integer or Float) values we get in the history
208
+ # are timestamps
209
+ if value.is_a?(Numeric)
210
+ hash[key] = Time.at(value).utc
211
+ else
212
+ hash[key] = value
213
+ end
214
+ end
215
+ end
216
+ end
217
+
218
+ def initially_put_at
219
+ @initially_put_at ||= history_timestamp('put', :min)
220
+ end
221
+
222
+ def spawned_from
223
+ return nil if @spawned_from_jid.nil?
224
+ @spawned_from ||= @client.jobs[@spawned_from_jid]
225
+ end
226
+
227
+ def to_hash
228
+ {
229
+ jid: jid,
230
+ spawned_from_jid: spawned_from_jid,
231
+ expires_at: expires_at,
232
+ state: state,
233
+ queue_name: queue_name,
234
+ history: raw_queue_history,
235
+ worker_name: worker_name,
236
+ failure: failure,
237
+ klass_name: klass_name,
238
+ tracked: tracked,
239
+ dependencies: dependencies,
240
+ dependents: dependents,
241
+ original_retries: original_retries,
242
+ retries_left: retries_left,
243
+ data: data,
244
+ priority: priority,
245
+ tags: tags,
246
+ throttles: throttles,
247
+ }
248
+ end
249
+
250
+ # Extract the enqueue options from the job
251
+ # @return [Hash] options
252
+ # @option options [Integer] :retries
253
+ # @option options [Integer] :priority
254
+ # @option options [Array<String>] :depends
255
+ # @option options [Array<String>] :tags
256
+ # @option options [Array<String>] throttles
257
+ # @option options [Hash] :data
258
+ def enqueue_opts
259
+ {
260
+ retries: original_retries,
261
+ priority: priority,
262
+ depends: dependents,
263
+ tags: tags,
264
+ throttles: throttles,
265
+ data: data,
266
+ }
267
+ end
268
+
269
+ # Move this from it's current queue into another
270
+ def requeue(queue, opts = {})
271
+ note_state_change :requeue do
272
+ @client.call('job.requeue', @client.worker_name, queue, @jid, @klass_name,
273
+ *self.class.build_opts_array(self.enqueue_opts.merge!(opts))
274
+ )
275
+ end
276
+ end
277
+ alias move requeue # for backwards compatibility
278
+
279
+ CantFailError = Class.new(Reqless::LuaScriptError)
280
+
281
+ # Fail a job
282
+ def fail(group, message)
283
+ note_state_change :fail do
284
+ @client.call(
285
+ 'job.fail',
286
+ @jid,
287
+ @worker_name,
288
+ group, message,
289
+ JSON.dump(@data)) || false
290
+ end
291
+ rescue Reqless::LuaScriptError => err
292
+ raise CantFailError.new(err.message)
293
+ end
294
+
295
+ # Heartbeat a job
296
+ def heartbeat
297
+ @expires_at = @client.call(
298
+ 'job.heartbeat',
299
+ @jid,
300
+ @worker_name,
301
+ JSON.dump(@data))
302
+ end
303
+
304
+ CantCompleteError = Class.new(Reqless::LuaScriptError)
305
+
306
+ # Complete a job
307
+ # Options include
308
+ # => next (String) the next queue
309
+ # => delay (int) how long to delay it in the next queue
310
+ def complete(nxt = nil, options = {})
311
+ note_state_change :complete do
312
+ if nxt.nil?
313
+ @client.call(
314
+ 'job.complete', @jid, @worker_name, @queue_name, JSON.dump(@data))
315
+ else
316
+ @client.call('job.completeAndRequeue', @jid, @worker_name, @queue_name,
317
+ JSON.dump(@data), 'next', nxt, 'delay',
318
+ options.fetch(:delay, 0), 'depends',
319
+ JSON.dump(options.fetch(:depends, [])))
320
+ end
321
+ end
322
+ rescue Reqless::LuaScriptError => err
323
+ raise CantCompleteError.new(err.message)
324
+ end
325
+
326
+ def cancel
327
+ note_state_change :cancel do
328
+ @client.call('job.cancel', @jid)
329
+ end
330
+ end
331
+
332
+ def track
333
+ @client.call('job.track', @jid)
334
+ end
335
+
336
+ def untrack
337
+ @client.call('job.untrack', @jid)
338
+ end
339
+
340
+ def tag(*tags)
341
+ JSON.parse(@client.call('job.addTag', @jid, *tags))
342
+ end
343
+
344
+ def untag(*tags)
345
+ JSON.parse(@client.call('job.removeTag', @jid, *tags))
346
+ end
347
+
348
+ def retry(delay = 0, group = nil, message = nil)
349
+ note_state_change :retry do
350
+ if group.nil?
351
+ results = @client.call(
352
+ 'job.retry', @jid, @queue_name, @worker_name, delay)
353
+ results.nil? ? false : results
354
+ else
355
+ results = @client.call(
356
+ 'job.retry', @jid, @queue_name, @worker_name, delay, group, message)
357
+ results.nil? ? false : results
358
+ end
359
+ end
360
+ end
361
+
362
+ def depend(*jids)
363
+ !!@client.call('job.addDependency', @jid, *jids)
364
+ end
365
+
366
+ def undepend(*jids)
367
+ !!@client.call('job.removeDependency', @jid, *jids)
368
+ end
369
+
370
+ def timeout
371
+ @client.call('job.timeout', @jid)
372
+ end
373
+
374
+ def log(message, data = nil)
375
+ if data
376
+ @client.call('job.log', @jid, message, JSON.dump(data))
377
+ else
378
+ @client.call('job.log', @jid, message)
379
+ end
380
+ end
381
+
382
+ [:fail, :complete, :cancel, :requeue, :retry].each do |event|
383
+ define_method :"before_#{event}" do |&block|
384
+ @before_callbacks[event] << block
385
+ end
386
+
387
+ define_method :"after_#{event}" do |&block|
388
+ @after_callbacks[event].unshift block
389
+ end
390
+ end
391
+ alias before_move before_requeue
392
+ alias after_move after_requeue
393
+
394
+ def note_state_change(event)
395
+ @before_callbacks[event].each { |blk| blk.call(self) }
396
+ result = yield
397
+ @state_changed = true
398
+ @after_callbacks[event].each { |blk| blk.call(self) }
399
+ result
400
+ end
401
+
402
+ private
403
+
404
+ def history_timestamp(name, selector)
405
+ items = queue_history.select do |q|
406
+ q['what'] == name
407
+ end
408
+ items.map do |q|
409
+ q['when']
410
+ end.public_send(selector)
411
+ end
412
+ end
413
+
414
+ # Wraps a recurring job
415
+ class RecurringJob < BaseJob
416
+ attr_reader :jid, :data, :priority, :tags, :retries, :interval, :count
417
+ attr_reader :queue_name, :klass_name, :backlog
418
+
419
+ def initialize(client, atts)
420
+ super(client, atts.fetch('jid'))
421
+ %w{jid data priority tags retries interval count backlog}.each do |att|
422
+ instance_variable_set("@#{att}".to_sym, atts.fetch(att))
423
+ end
424
+
425
+ # Parse the data string
426
+ @data = JSON.parse(@data)
427
+ @klass_name = atts.fetch('klass')
428
+ @queue_name = atts.fetch('queue')
429
+ @tags = [] if @tags == {}
430
+ end
431
+
432
+ def priority=(value)
433
+ @client.call('recurringJob.update', @jid, 'priority', value)
434
+ @priority = value
435
+ end
436
+
437
+ def retries=(value)
438
+ @client.call('recurringJob.update', @jid, 'retries', value)
439
+ @retries = value
440
+ end
441
+
442
+ def interval=(value)
443
+ @client.call('recurringJob.update', @jid, 'interval', value)
444
+ @interval = value
445
+ end
446
+
447
+ def data=(value)
448
+ @client.call('recurringJob.update', @jid, 'data', JSON.dump(value))
449
+ @data = value
450
+ end
451
+
452
+ def klass=(value)
453
+ @client.call('recurringJob.update', @jid, 'klass', value.to_s)
454
+ @klass_name = value.to_s
455
+ end
456
+
457
+ def backlog=(value)
458
+ @client.call('recurringJob.update', @jid, 'backlog', value.to_s)
459
+ @backlog = value
460
+ end
461
+
462
+ def move(queue)
463
+ @client.call('recurringJob.update', @jid, 'queue', queue)
464
+ @queue_name = queue
465
+ end
466
+ alias requeue move # for API parity with normal jobs
467
+
468
+ def cancel
469
+ @client.call('recurringJob.cancel', @jid)
470
+ end
471
+
472
+ def tag(*tags)
473
+ @client.call('recurringJob.addTag', @jid, *tags)
474
+ end
475
+
476
+ def untag(*tags)
477
+ @client.call('recurringJob.removeTag', @jid, *tags)
478
+ end
479
+
480
+ def last_spawned_jid
481
+ return nil if never_spawned?
482
+ "#{jid}-#{count}"
483
+ end
484
+
485
+ def last_spawned_job
486
+ return nil if never_spawned?
487
+ @client.jobs[last_spawned_jid]
488
+ end
489
+
490
+ private
491
+
492
+ def never_spawned?
493
+ count.zero?
494
+ end
495
+ end
496
+ end
@@ -0,0 +1,29 @@
1
+ # Encoding: utf-8
2
+
3
+ module Reqless
4
+ module JobReservers
5
+ class Ordered
6
+ attr_reader :queues
7
+
8
+ def initialize(queues)
9
+ @queues = queues
10
+ end
11
+
12
+ def reserve
13
+ @queues.each do |q|
14
+ job = q.pop
15
+ return job if job
16
+ end
17
+ nil
18
+ end
19
+
20
+ def prep_for_work!
21
+ # nothing here on purpose
22
+ end
23
+
24
+ def description
25
+ @description ||= @queues.map(&:name).join(', ') + ' (ordered)'
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,46 @@
1
+ # Encoding: utf-8
2
+
3
+ module Reqless
4
+ module JobReservers
5
+ # Round-robins through all the provided queues
6
+ class RoundRobin
7
+ attr_reader :queues
8
+
9
+ def initialize(queues)
10
+ @queues = queues
11
+ @num_queues = queues.size
12
+ @last_popped_queue_index = @num_queues - 1
13
+ end
14
+
15
+ def reserve
16
+ @num_queues.times do |i|
17
+ job = next_queue.pop
18
+ return job if job
19
+ end
20
+ nil
21
+ end
22
+
23
+ def prep_for_work!
24
+ # nothing here on purpose
25
+ end
26
+
27
+ def description
28
+ @description ||=
29
+ @queues.map(&:name).join(', ') + " (#{self.class::TYPE_DESCRIPTION})"
30
+ end
31
+
32
+ def reset_description!
33
+ @description = nil
34
+ end
35
+
36
+ private
37
+
38
+ TYPE_DESCRIPTION = 'round robin'
39
+
40
+ def next_queue
41
+ @last_popped_queue_index = (@last_popped_queue_index + 1) % @num_queues
42
+ @queues[@last_popped_queue_index]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,21 @@
1
+ # Encoding: utf-8
2
+
3
+ require 'reqless/job_reservers/round_robin'
4
+
5
+ module Reqless
6
+ module JobReservers
7
+ # Like round-robin but shuffles the order of the queues
8
+ class ShuffledRoundRobin < RoundRobin
9
+ def initialize(queues)
10
+ super(queues.shuffle)
11
+ end
12
+
13
+ def prep_for_work!
14
+ @queues = @queues.shuffle
15
+ reset_description!
16
+ end
17
+
18
+ TYPE_DESCRIPTION = 'shuffled round robin'
19
+ end
20
+ end
21
+ end