balsamique 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/balsamique.rb +486 -0
  3. metadata +67 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: fa87508db8374e4941511cf4aea2322d1fdd7819
4
+ data.tar.gz: 5258fc17aa56b385a2643c27572f4ddad5e35e32
5
+ SHA512:
6
+ metadata.gz: c0f08994466deb01389d682aefa71ee7130f392fba1bab0a3020248aac69563cb4ececf832f9e45131fa397973b26b7a3e99fa13d7644741f390533f6adc858e
7
+ data.tar.gz: fb3bddda2ecb9449d9ae6f7d5ff6acaedd70ce28b3275af14f0ce748f5083c844a52672c3eb8fa1f0695e75a54a20c10db99a609349d92969a75d8f37e6f74e6
data/lib/balsamique.rb ADDED
@@ -0,0 +1,486 @@
1
+ require 'digest/sha1'
2
+ require 'json'
3
+ require 'redis'
4
+
5
+ class Balsamique
6
+ def initialize(redis, namespace = 'bQ')
7
+ @redis = redis
8
+
9
+ @que_prefix = namespace + ':que:'
10
+ @questats_prefix = namespace + ':questats:'
11
+ @env_prefix = namespace + ':env:'
12
+
13
+ @status = namespace + ':status'
14
+ @queues = namespace + ':queues'
15
+ @retries = namespace + ':retries'
16
+ @failures = namespace + ':failures'
17
+ @failz = namespace + ':failz'
18
+ @unique = namespace + ':unique'
19
+ @tasks = namespace + ':tasks'
20
+ @args = namespace + ':args'
21
+ @report_queue = @que_prefix + '_report'
22
+ end
23
+
24
+ REPORT_RETRY_DELAY = 60.0 # seconds
25
+ RETRY_DELAY = 600.0 # seconds
26
+
27
+ def redis
28
+ @redis
29
+ end
30
+
31
+ def redis_eval(cmd_sha, cmd, keys, argv)
32
+ redis.evalsha(cmd_sha, keys, argv)
33
+ rescue Redis::CommandError
34
+ puts "[INFO] Balsamique falling back to EVAL for #{cmd_sha}"
35
+ redis.eval(cmd, keys, argv)
36
+ end
37
+
38
+ def self.next_task(tasks)
39
+ item = tasks.find { |t| t.size == 1 }
40
+ item && item.first
41
+ end
42
+
43
+ def self.current_task(tasks)
44
+ item = tasks.reverse.find { |t| t.size > 1 }
45
+ item && item.first
46
+ end
47
+
48
+ def self.strip_prefix(str, prefix)
49
+ s = prefix.size
50
+ if str[0,s] == prefix
51
+ str[s, str.size - s]
52
+ end
53
+ end
54
+
55
+ STATS_SLICE = 10 # seconds
56
+ STATS_CHUNK = 90 # slices (= 900 seconds = 15 minutes)
57
+ def self.slice_timestamp(ts)
58
+ slice = ts.to_i / STATS_SLICE
59
+ return slice / STATS_CHUNK, slice % STATS_CHUNK
60
+ end
61
+ def self.enc36(i)
62
+ i.to_s(36)
63
+ end
64
+ def self.dec36(s)
65
+ s.to_i(36)
66
+ end
67
+ def self.enc36_slice_timestamp(ts)
68
+ self.slice_timestamp(ts).map { |i| self.enc36(i) }
69
+ end
70
+ def self.assemble_timestamp(chunk, slice)
71
+ (chunk * STATS_CHUNK + slice) * STATS_SLICE
72
+ end
73
+ def self.dec36_assemble_timestamp(echunk, eslice)
74
+ self.assemble_timestamp(self.dec36(echunk), self.dec36(eslice))
75
+ end
76
+
77
+ # Lua script ENQUEUE_JOB takes keys
78
+ # [tasks_h, args_h, jobstat_h, task1_z, queues_h, uniq_h]
79
+ # and args [tasks, args, run_at, uniq].
80
+ # uniq is optional. If it's present, the script first checks to see
81
+ # if the key uniq is already set in the hash uniq_h. If so, the
82
+ # negative of the integer value therein is returned and the script does
83
+ # nothing. Otherwise, an integer id is written as that value, the
84
+ # tasks_h hash gets the value of tasks (JSON-encoded task list)
85
+ # written under the key id, the args_h hash gets the value args
86
+ # written under the key id, task1_z gets id zadded with score
87
+ # run_at. Also, task1_z is written to jobstatus_h under the key id.
88
+ # The value returned from the operation is the id. A successful
89
+ # enqueueing is thus signaled by the return of the job id, while an
90
+ # enqueueing blocked by the uniq_in_flight constraint returns minus
91
+ # the blocking id.
92
+ ENQUEUE_JOB = <<EOF
93
+ local id = redis.call('hincrby', KEYS[6], '', 1)
94
+ if ARGV[4] then
95
+ local ukey = 'u:' .. ARGV[4]
96
+ local uniq = redis.call('hsetnx', KEYS[6], ukey, id)
97
+ if 0 == uniq then
98
+ return (- redis.call('hget', KEYS[6], ukey))
99
+ else
100
+ redis.call('hset', KEYS[6], id, ukey)
101
+ end
102
+ end
103
+ redis.call('hset', KEYS[1], id, ARGV[1])
104
+ redis.call('hset', KEYS[2], id, ARGV[2])
105
+ redis.call('hset', KEYS[3], id, KEYS[4] .. ',' .. ARGV[3])
106
+ redis.call('zadd', KEYS[4], ARGV[3], id)
107
+ redis.call('hset', KEYS[5], KEYS[4], id .. ',' .. ARGV[3])
108
+ return id
109
+ EOF
110
+ ENQUEUE_JOB_SHA = Digest::SHA1.hexdigest(ENQUEUE_JOB)
111
+
112
+ def enqueue(tasks, args, uniq_in_flight = nil, run_at = Time.now.to_f)
113
+ next_task = self.class.next_task(tasks)
114
+ return false, nil unless next_task
115
+ queue_key = @que_prefix + next_task.to_s
116
+ keys = [@tasks, @args, @status, queue_key, @queues, @unique]
117
+ argv = [tasks.to_json, args.to_json, run_at]
118
+ argv << uniq_in_flight if uniq_in_flight
119
+ result_id = redis_eval(ENQUEUE_JOB_SHA, ENQUEUE_JOB, keys, argv)
120
+ return result_id > 0, result_id.abs.to_s
121
+ end
122
+
123
+ # Lua script DEQUEUE_TASK takes keys
124
+ # [args_h, tasks_h, questats_h, retries_h, task1_z, ...],
125
+ # and args [timestamp_f, retry_delay, tmod].
126
+ # It performs a conditional ZPOP on task1_z, where the
127
+ # condition is that the score of the first item is <= timestamp_f.
128
+ # If nothing is available to ZPOP, it tries task2_z, etc. If an id
129
+ # is returned from any ZPOP, it increments the retry count in retries_h
130
+ # and reschedules the task accordingly. Then it writes stats info to
131
+ # questats_h, and returns the job information from args_h and tasks_h.
132
+
133
+ DEQUEUE_TASK = <<EOF
134
+ local ts = tonumber(ARGV[1])
135
+ local i = 5
136
+ while KEYS[i] do
137
+ local elem = redis.call('zrange', KEYS[i], 0, 0, 'withscores')
138
+ if elem[2] and tonumber(elem[2]) < ts then
139
+ local retries = redis.call('hincrby', KEYS[4], elem[1] .. ',' .. KEYS[i], 1)
140
+ local t_retry = ts + ARGV[2] * 2 ^ retries
141
+ redis.call('zadd', KEYS[i], t_retry, elem[1])
142
+ redis.call('hset', KEYS[3], KEYS[i] .. ',len,' .. ARGV[3],
143
+ redis.call('zcard', KEYS[i]))
144
+ redis.call('hincrby', KEYS[3], KEYS[i] .. ',dq,' .. ARGV[3], 1)
145
+ redis.call('expire', KEYS[3], 21600)
146
+ return({ elem[1],
147
+ redis.call('hget', KEYS[1], elem[1]),
148
+ redis.call('hget', KEYS[2], elem[1]), retries })
149
+ end
150
+ i = i + 1
151
+ end
152
+ EOF
153
+ DEQUEUE_TASK_SHA = Digest::SHA1.hexdigest(DEQUEUE_TASK)
154
+
155
+ def dequeue(tasks, retry_delay = RETRY_DELAY, timestamp = Time.now.to_f)
156
+ stats_chunk, stats_slice = self.class.enc36_slice_timestamp(timestamp)
157
+ questats_key = @questats_prefix + stats_chunk
158
+ keys = [@args, @tasks, questats_key, @retries]
159
+ tasks.each { |task| keys << @que_prefix + task.to_s }
160
+ result = redis_eval(
161
+ DEQUEUE_TASK_SHA, DEQUEUE_TASK, keys,
162
+ [timestamp, retry_delay, stats_slice])
163
+ if result
164
+ id, args, tasks, retries = result
165
+ { id: id, args: JSON.parse(args), tasks: JSON.parse(tasks),
166
+ retries: retries }
167
+ end
168
+ end
169
+
170
+ SUCCEED_TASK = <<EOF
171
+ local id = ARGV[1]
172
+ local ts = ARGV[2]
173
+ local tasks = cjson.decode(redis.call('hget', KEYS[1], id))
174
+ local cur_task = ''
175
+ for _, task in ipairs(tasks) do
176
+ if not task[2] then cur_task = task[1]; break end
177
+ end
178
+ if (not (string.sub(KEYS[7], - string.len(cur_task)) == cur_task)) then
179
+ return redis.error_reply(
180
+ string.format('task mis-match %s %s %s', id, cur_task, KEYS[7]))
181
+ end
182
+ if (redis.call('hdel', KEYS[3], id .. ',' .. KEYS[7]) > 0) then
183
+ redis.call('zrem', KEYS[7], id)
184
+ else
185
+ return redis.error_reply('missing retry count %s %s', id, KEYS[7])
186
+ end
187
+ local status = redis.call('hget', KEYS[2], id)
188
+ local i = 0
189
+ for r in string.gmatch(status, "[^,]+") do
190
+ i = i + 1
191
+ if (i > 2 and i % 2 == 1) then
192
+ local rkey = id .. ',' .. KEYS[7] .. ',' .. r
193
+ redis.call('zrem', KEYS[5], rkey)
194
+ redis.call('hdel', KEYS[6], rkey)
195
+ end
196
+ end
197
+ redis.call('hset', KEYS[1], id, ARGV[3])
198
+ redis.call('hdel', KEYS[3], id .. ',' .. KEYS[4])
199
+ redis.call('zadd', KEYS[4], ts, id)
200
+ if (KEYS[8]) then
201
+ redis.call('hset', KEYS[2], id, KEYS[8] .. ',' .. ts)
202
+ redis.call('hdel', KEYS[3], id .. ',' .. KEYS[8])
203
+ redis.call('zadd', KEYS[8], ts, id)
204
+ redis.call('hset', KEYS[9], KEYS[8], id .. ',' .. ts)
205
+ else
206
+ redis.call('hset', KEYS[2], id, '_' .. ',' .. ts)
207
+ end
208
+ return id
209
+ EOF
210
+ SUCCEED_TASK_SHA = Digest::SHA1.hexdigest(SUCCEED_TASK)
211
+
212
+ def succeed(id, tasks, timestamp = Time.now.to_f)
213
+ current_task = self.class.current_task(tasks)
214
+ next_task = self.class.next_task(tasks)
215
+ keys = [
216
+ @tasks, @status, @retries, @report_queue, @failz, @failures,
217
+ @que_prefix + current_task]
218
+ argv = [id, timestamp, tasks.to_json]
219
+ keys << (@que_prefix + next_task) << @queues if next_task
220
+ id == redis_eval(SUCCEED_TASK_SHA, SUCCEED_TASK, keys, argv)
221
+ end
222
+
223
+ FAIL_TASK = <<EOF
224
+ local id = ARGV[1]
225
+ local ts = ARGV[2]
226
+ local tasks = cjson.decode(redis.call('hget', KEYS[1], id))
227
+ local cur_task = ''
228
+ for _, task in ipairs(tasks) do
229
+ if not task[2] then cur_task = task[1]; break end
230
+ end
231
+ if (not (string.sub(ARGV[3], - string.len(cur_task)) == cur_task)) then
232
+ return redis.error_reply(
233
+ string.format('task mismatch %s %s %s', id, cur_task, ARGV[3]))
234
+ end
235
+ local rkey = id .. ',' .. ARGV[3]
236
+ local retries = tonumber(redis.call('hget', KEYS[3], rkey))
237
+ if (not retries) then
238
+ return redis.error_reply(
239
+ string.format('missing retry count %s %s', id, ARGV[3]))
240
+ end
241
+ rkey = rkey .. ',' .. retries
242
+ redis.call('zadd', KEYS[4], ts, rkey)
243
+ redis.call('hset', KEYS[5], rkey, ARGV[4])
244
+ local status = redis.call('hget', KEYS[2], id)
245
+ status = status .. ',' .. retries .. ',' .. ts
246
+ redis.call('hset', KEYS[2], id, status)
247
+ redis.call('hdel', KEYS[3], id .. ',' .. KEYS[6])
248
+ redis.call('zadd', KEYS[6], ts, id)
249
+ return id
250
+ EOF
251
+ FAIL_TASK_SHA = Digest::SHA1.hexdigest(FAIL_TASK)
252
+
253
+ def fail(id, task, details, timestamp = Time.now.to_f)
254
+ keys = [@tasks, @status, @retries, @failz, @failures, @report_queue]
255
+ argv = [id, timestamp, @que_prefix + task, JSON.generate(details)]
256
+ id == redis_eval(FAIL_TASK_SHA, FAIL_TASK, keys, argv)
257
+ end
258
+
259
+ def get_failures(failz)
260
+ result = Hash.new { Array.new }
261
+ fkeys = failz.keys
262
+ if fkeys.size > 0
263
+ failures = redis.hmget(@failures, fkeys)
264
+ fkeys.zip(failures).each do |key, details|
265
+ id, queue, r = key.split(',')
266
+ r = r.to_i
267
+ task = self.class.strip_prefix(queue, @que_prefix)
268
+ result[id] <<= {
269
+ task: task, retries: r, ts: failz[key],
270
+ details: JSON.parse(details) }
271
+ end
272
+ end
273
+ result
274
+ end
275
+
276
+ def get_failz(earliest = 0, latest = Time.now.to_f, limit = -100)
277
+ values =
278
+ if limit < 0
279
+ redis.zrevrangebyscore(
280
+ @failz, latest, earliest, limit: [0, -limit], with_scores: true)
281
+ else
282
+ redis.zrangebyscore(
283
+ @failz, earliest, latest, limit: [0, limit], with_scores: true)
284
+ end
285
+ result = {}
286
+ values.each { |v| result[v[0]] = v[1] }
287
+ result
288
+ end
289
+
290
+ def failures(*args)
291
+ get_failures(get_failz(*args))
292
+ end
293
+
294
+ def delete_queue(queue)
295
+ queue_key = @que_prefix + queue.to_s
296
+ redis.multi do |r|
297
+ r.del(queue_key)
298
+ r.hdel(@queues, queue_key)
299
+ end.last == 1
300
+ end
301
+
302
+ def queues
303
+ result = redis.hgetall(@queues)
304
+ result.keys.map { |k| self.class.strip_prefix(k, @que_prefix) }
305
+ end
306
+
307
+ def queue_length(queue)
308
+ redis.zcard(@que_prefix + queue) || 0
309
+ end
310
+
311
+ def decode_job_status(status)
312
+ queue, ts, *retries = status.split(',')
313
+ ts = ts.to_f
314
+ timestamps = [ts]
315
+ while retries.size > 0
316
+ i = retries.shift.to_i
317
+ timestamps[i] = retries.shift.to_f
318
+ end
319
+ return queue, timestamps
320
+ end
321
+
322
+ def remove_job(id)
323
+ status = redis.hget(@status, id)
324
+ queue, timestamps = decode_job_status(status)
325
+ redis.multi do |r|
326
+ if queue.start_with?(@que_prefix)
327
+ r.zrem(queue, id)
328
+ rkey = "#{id},#{queue}"
329
+ r.hdel(@retries, rkey)
330
+ rkeys = []
331
+ timestamps.drop(1).each_with_index do |ts, i|
332
+ rkeys << rkey + ",#{i + 1}"
333
+ end
334
+ if rkeys.size > 0
335
+ r.hdel(@failures, rkeys)
336
+ r.zrem(@failz, rkeys)
337
+ end
338
+ end
339
+ r.hdel(@args, id)
340
+ r.hdel(@tasks, id)
341
+ end
342
+ check_status = redis.hget(@status, id)
343
+ return if check_status.nil?
344
+ if check_status == status
345
+ redis.hdel(@status, id)
346
+ if (uid = redis.hget(@unique, id))
347
+ redis.hdel(@unique, [id, uid])
348
+ end
349
+ else
350
+ remove_job(id)
351
+ end
352
+ end
353
+
354
+ def job_status(*ids)
355
+ statuses = redis.hmget(@status, *ids)
356
+ result = {}
357
+ ids.zip(statuses).each do |(id, status)|
358
+ next unless status
359
+ queue, timestamps = decode_job_status(status)
360
+ result[id] = {
361
+ task: self.class.strip_prefix(queue, @que_prefix),
362
+ timestamps: timestamps }
363
+ end
364
+ result
365
+ end
366
+
367
+ def fill_job_failures(statuses)
368
+ failz = {}
369
+ statuses.each do |id, status|
370
+ next unless (task = status[:task])
371
+ timestamps = status[:timestamps]
372
+ next unless timestamps.size > 1
373
+ queue = @que_prefix + task
374
+ timestamps.drop(1).each_with_index do |ts, i|
375
+ failz["#{id},#{queue},#{i+1}"] = ts
376
+ end
377
+ end
378
+ get_failures(failz).each do |id, failures|
379
+ statuses[id][:failures] = failures
380
+ end
381
+ statuses
382
+ end
383
+
384
+ def fill_args_tasks(statuses)
385
+ ids = statuses.keys
386
+ args, tasks = redis.multi do |r|
387
+ r.hmget(@args, ids)
388
+ r.hmget(@tasks, ids)
389
+ end
390
+ ids.zip(args, tasks).each do |id, a, t|
391
+ statuses[id][:args] = a && JSON.parse(a)
392
+ statuses[id][:tasks] = t && JSON.parse(t)
393
+ end
394
+ end
395
+
396
+ def queue_stats(chunks = 3, latest = Time.now.to_f)
397
+ last_chunk, last_slice = self.class.slice_timestamp(latest)
398
+ stats = {}
399
+ (0..(chunks - 1)).each do |chunk_i|
400
+ chunk_ts = self.class.enc36(last_chunk - chunk_i)
401
+ questats_key = @questats_prefix + chunk_ts
402
+ stats_chunk = redis.hgetall(questats_key)
403
+ next unless stats_chunk
404
+ stats_chunk.each do |key, val|
405
+ queue, stat, slice = key.split(',')
406
+ queue = self.class.strip_prefix(queue, @que_prefix)
407
+ timestamp = self.class.dec36_assemble_timestamp(chunk_ts, slice)
408
+ stats[stat] = {} unless stats[stat]
409
+ stats[stat][timestamp] = {} unless stats[stat][timestamp]
410
+ stats[stat][timestamp][queue] = val.to_i
411
+ end
412
+ end
413
+ stats
414
+ end
415
+
416
+ def push_report(id, timestamp = Time.now.to_f)
417
+ redis.multi do |r|
418
+ r.hdel(@retries, "#{id},#{@report_queue}")
419
+ r.zadd(@report_queue, timestamp, id)
420
+ end
421
+ end
422
+
423
+ REPORT_POP = <<EOF
424
+ local t_pop = tonumber(ARGV[1])
425
+ local elem = redis.call('zrange', KEYS[1], 0, 0, 'withscores')
426
+ local t_elem = tonumber(elem[2])
427
+ if (t_elem and t_elem < t_pop) then
428
+ local retries = redis.call('hincrby', KEYS[2], elem[1] .. ',' .. KEYS[1], 1)
429
+ local t_retry = t_pop + tonumber(ARGV[2]) * 2 ^ retries
430
+ redis.call('zadd', KEYS[1], t_retry, elem[1])
431
+ elem[3] = retries
432
+ elem[2] = t_elem
433
+ return cjson.encode(elem)
434
+ end
435
+ EOF
436
+ REPORT_POP_SHA = Digest::SHA1.hexdigest(REPORT_POP)
437
+
438
+ def pop_report(timestamp = Time.now.to_f)
439
+ result = redis_eval(
440
+ REPORT_POP_SHA, REPORT_POP, [@report_queue, @retries],
441
+ [timestamp, REPORT_RETRY_DELAY])
442
+ result &&= JSON.parse(result)
443
+ end
444
+
445
+ REPORT_COMPLETE = <<EOF
446
+ if (redis.call('hdel', KEYS[2], ARGV[1] .. ',' .. KEYS[1]) > 0) then
447
+ redis.call('zrem', KEYS[1], ARGV[1])
448
+ end
449
+ EOF
450
+ REPORT_COMPLETE_SHA = Digest::SHA1.hexdigest(REPORT_COMPLETE)
451
+ def complete_report(id)
452
+ redis_eval(REPORT_COMPLETE_SHA, REPORT_COMPLETE,
453
+ [@report_queue, @retries], [id])
454
+ end
455
+
456
+ def put_env(topic, h)
457
+ return if h.empty?
458
+ kvs = []
459
+ h.each { |k, v| kvs << k << v }
460
+ hkey = @env_prefix + topic.to_s
461
+ 'OK' == redis.hmset(hkey, *kvs)
462
+ end
463
+
464
+ def rm_env(topic, keys = nil)
465
+ hkey = @env_prefix + topic.to_s
466
+ if keys.nil?
467
+ redis.del(hkey)
468
+ elsif !keys.empty?
469
+ redis.hdel(hkey, keys)
470
+ end
471
+ end
472
+
473
+ def get_env(topic, keys = nil)
474
+ hkey = @env_prefix + topic.to_s
475
+ if keys.nil?
476
+ redis.hgetall(hkey)
477
+ elsif keys.empty?
478
+ {}
479
+ else
480
+ result = {}
481
+ values = redis.hmget(hkey, keys)
482
+ keys.zip(values).each { |k, v| result[k] = v }
483
+ result
484
+ end
485
+ end
486
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: balsamique
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - DWNLD
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-12-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: redis
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: |
28
+ Balsamique (pronounced "Balsami-QUEUE") is a Redis-backed Ruby library
29
+ which implements a job queue system. Balsamique jobs consist of
30
+ JSON-encoded args hashes, along with lists of tasks and their
31
+ successful outputs. Jobs can be enqueued to run at some time in the
32
+ future, and workers can also delay the running of subsequent tasks.
33
+ Retries are automatically scheduled at the time a worker checks out a
34
+ job, and cancelled only when the worker reports success. In contrast
35
+ to Resque, Balsamique uses Lua scripting in Redis extensively to make
36
+ job state transitions as atomic as possible.
37
+ email: keith@dwnld.me
38
+ executables: []
39
+ extensions: []
40
+ extra_rdoc_files: []
41
+ files:
42
+ - lib/balsamique.rb
43
+ homepage: https://github.com/dwnld/balsamique
44
+ licenses:
45
+ - MIT
46
+ metadata: {}
47
+ post_install_message:
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubyforge_project:
63
+ rubygems_version: 2.4.3
64
+ signing_key:
65
+ specification_version: 4
66
+ summary: Redis-backed Job Queue System
67
+ test_files: []