resque-scheduler 2.0.0.g → 2.0.0.h
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.
Potentially problematic release.
This version of resque-scheduler might be problematic. Click here for more details.
- data/HISTORY.md +2 -2
- data/README.markdown +29 -6
- data/lib/resque/scheduler.rb +18 -1
- data/lib/resque_scheduler.rb +30 -22
- data/lib/resque_scheduler/plugin.rb +25 -0
- data/lib/resque_scheduler/version.rb +1 -1
- data/test/scheduler_hooks_test.rb +13 -42
- data/test/scheduler_test.rb +39 -1
- metadata +13 -12
data/HISTORY.md
CHANGED
data/README.markdown
CHANGED
@@ -128,12 +128,6 @@ down when a particular job is supposed to be queue, they will simply "catch up"
|
|
128
128
|
once they are started again. Jobs are guaranteed to run (provided they make it
|
129
129
|
into the delayed queue) after their given queue_at time has passed.
|
130
130
|
|
131
|
-
Similar to `before_enqueue` and `after_enqueue` hooks provided in Resque
|
132
|
-
(>= 1.19.1), your jobs can specify one or more `before_schedule` and
|
133
|
-
`after_schedule` hooks, to be run before or after scheduling. If *any* of your
|
134
|
-
`before_schedule` hooks returns `false`, the job will *not* be scheduled and
|
135
|
-
your `after_schedule` hooks will *not* be run.
|
136
|
-
|
137
131
|
One other thing to note is that insertion into the delayed queue is O(log(n))
|
138
132
|
since the jobs are stored in a redis sorted set (zset). I can't imagine this
|
139
133
|
being an issue for someone since redis is stupidly fast even at log(n), but full
|
@@ -158,10 +152,15 @@ The schedule is a list of Resque worker classes with arguments and a
|
|
158
152
|
schedule frequency (in crontab syntax). The schedule is just a hash, but
|
159
153
|
is most likely stored in a YAML like so:
|
160
154
|
|
155
|
+
CancelAbandonedOrders:
|
156
|
+
cron: "*/5 * * * *"
|
157
|
+
|
161
158
|
queue_documents_for_indexing:
|
162
159
|
cron: "0 0 * * *"
|
163
160
|
# you can use rufus-scheduler "every" syntax in place of cron if you prefer
|
164
161
|
# every: 1hr
|
162
|
+
# By default the job name (hash key) will be taken as worker class name.
|
163
|
+
# If you want to have a different job name and class name, provide the 'class' option
|
165
164
|
class: QueueDocuments
|
166
165
|
queue: high
|
167
166
|
args:
|
@@ -180,6 +179,15 @@ defined. If you're getting "uninitialized constant" errors, you probably
|
|
180
179
|
need to either set the queue in the schedule or require your jobs in your
|
181
180
|
"resque:setup" rake task.
|
182
181
|
|
182
|
+
You can provide options to "every" or "cron" via Array:
|
183
|
+
|
184
|
+
clear_leaderboards_moderator:
|
185
|
+
every: ["30s", :first_in => '120s']
|
186
|
+
class: CheckDaemon
|
187
|
+
queue: daemons
|
188
|
+
description: "This job will check Daemon every 30 seconds after 120 seconds after start"
|
189
|
+
|
190
|
+
|
183
191
|
NOTE: Six parameter cron's are also supported (as they supported by
|
184
192
|
rufus-scheduler which powers the resque-scheduler process). This allows you
|
185
193
|
to schedule jobs per second (ie: "30 * * * * *" would fire a job every 30
|
@@ -206,6 +214,21 @@ from the `config.time_zone` value, make sure it's the right format, e.g. with:
|
|
206
214
|
|
207
215
|
A future version of resque-scheduler may do this for you.
|
208
216
|
|
217
|
+
#### Hooks
|
218
|
+
|
219
|
+
Similar to the `before_enqueue`- and `after_enqueue`-hooks provided in Resque
|
220
|
+
(>= 1.19.1), your jobs can specify one or more of the following hooks:
|
221
|
+
|
222
|
+
* `before_schedule`: Called with the job args before a job is placed on
|
223
|
+
the delayed queue. If the hook returns `false`, the job will not be placed on
|
224
|
+
the queue.
|
225
|
+
* `after_schedule`: Called with the job args after a job is placed on the
|
226
|
+
delayed queue. Any exception raised propagates up to the code with queued the
|
227
|
+
job.
|
228
|
+
* `before_delayed_enqueue`: Called with the job args after the job has been
|
229
|
+
removed from the delayed queue, but not yet put on a normal queue. It is
|
230
|
+
called before `before_enqueue`-hooks, and on the same job instance as the
|
231
|
+
`before_enqueue`-hooks will be invoked on. Return values are ignored.
|
209
232
|
|
210
233
|
#### Support for resque-status (and other custom jobs)
|
211
234
|
|
data/lib/resque/scheduler.rb
CHANGED
@@ -103,6 +103,21 @@ module Resque
|
|
103
103
|
Resque.redis.del(:schedules_changed)
|
104
104
|
procline "Schedules Loaded"
|
105
105
|
end
|
106
|
+
|
107
|
+
# modify interval type value to value with options if options available
|
108
|
+
def optionizate_interval_value(value)
|
109
|
+
args = value
|
110
|
+
if args.is_a?(::Array)
|
111
|
+
return args.first if args.size > 2 || !args.last.is_a?(::Hash)
|
112
|
+
# symbolize keys of hash for options
|
113
|
+
args[1] = args[1].inject({}) do |m, i|
|
114
|
+
key, value = i
|
115
|
+
m[(key.to_sym rescue key) || key] = value
|
116
|
+
m
|
117
|
+
end
|
118
|
+
end
|
119
|
+
args
|
120
|
+
end
|
106
121
|
|
107
122
|
# Loads a job schedule into the Rufus::Scheduler and stores it in @@scheduled_jobs
|
108
123
|
def load_schedule_job(name, config)
|
@@ -116,7 +131,8 @@ module Resque
|
|
116
131
|
interval_types = %w{cron every}
|
117
132
|
interval_types.each do |interval_type|
|
118
133
|
if !config[interval_type].nil? && config[interval_type].length > 0
|
119
|
-
|
134
|
+
args = optionizate_interval_value(config[interval_type])
|
135
|
+
@@scheduled_jobs[name] = rufus_scheduler.send(interval_type, *args) do
|
120
136
|
log! "queueing #{config['class']} (#{name})"
|
121
137
|
handle_errors { enqueue_from_config(config) }
|
122
138
|
end
|
@@ -202,6 +218,7 @@ module Resque
|
|
202
218
|
# for non-existent classes (for example: running scheduler in
|
203
219
|
# one app that schedules for another
|
204
220
|
if Class === klass
|
221
|
+
ResqueScheduler::Plugin.run_before_delayed_enqueue_hooks(klass, *params)
|
205
222
|
Resque.enqueue(klass, *params)
|
206
223
|
else
|
207
224
|
# This will not run the before_hooks in rescue, but will at least
|
data/lib/resque_scheduler.rb
CHANGED
@@ -4,27 +4,36 @@ require 'resque/server'
|
|
4
4
|
require 'resque_scheduler/version'
|
5
5
|
require 'resque/scheduler'
|
6
6
|
require 'resque_scheduler/server'
|
7
|
+
require 'resque_scheduler/plugin'
|
7
8
|
|
8
9
|
module ResqueScheduler
|
9
10
|
|
10
11
|
#
|
11
12
|
# Accepts a new schedule configuration of the form:
|
12
13
|
#
|
13
|
-
# {
|
14
|
-
#
|
15
|
-
#
|
16
|
-
#
|
17
|
-
#
|
14
|
+
# {
|
15
|
+
# "MakeTea" => {
|
16
|
+
# "every" => "1m" },
|
17
|
+
# "some_name" => {
|
18
|
+
# "cron" => "5/* * * *",
|
19
|
+
# "class" => "DoSomeWork",
|
20
|
+
# "args" => "work on this string",
|
21
|
+
# "description" => "this thing works it"s butter off" },
|
22
|
+
# ...
|
23
|
+
# }
|
18
24
|
#
|
19
|
-
#
|
20
|
-
# the scheduled job
|
25
|
+
# Hash keys can be anything and are used to describe and reference
|
26
|
+
# the scheduled job. If the "class" argument is missing, the key
|
27
|
+
# is used implicitly as "class" argument - in the "MakeTea" example,
|
28
|
+
# "MakeTea" is used both as job name and resque worker class.
|
21
29
|
#
|
22
|
-
# :cron can be any cron scheduling string
|
30
|
+
# :cron can be any cron scheduling string
|
23
31
|
#
|
24
32
|
# :every can be used in lieu of :cron. see rufus-scheduler's 'every' usage
|
25
33
|
# for valid syntax. If :cron is present it will take precedence over :every.
|
26
34
|
#
|
27
|
-
# :class must be a resque worker class
|
35
|
+
# :class must be a resque worker class. If it is missing, the job name (hash key)
|
36
|
+
# will be used as :class.
|
28
37
|
#
|
29
38
|
# :args can be any yaml which will be converted to a ruby literal and
|
30
39
|
# passed in a params. (optional)
|
@@ -36,6 +45,8 @@ module ResqueScheduler
|
|
36
45
|
# params is an array, each element in the array is passed as a separate
|
37
46
|
# param, otherwise params is passed in as the only parameter to perform.
|
38
47
|
def schedule=(schedule_hash)
|
48
|
+
schedule_hash = prepare_schedule(schedule_hash)
|
49
|
+
|
39
50
|
if Resque::Scheduler.dynamic
|
40
51
|
schedule_hash.each do |name, job_spec|
|
41
52
|
set_schedule(name, job_spec)
|
@@ -109,16 +120,11 @@ module ResqueScheduler
|
|
109
120
|
# a queue in which the job will be placed after the
|
110
121
|
# timestamp has passed.
|
111
122
|
def enqueue_at_with_queue(queue, timestamp, klass, *args)
|
112
|
-
|
113
|
-
klass.send(hook,*args)
|
114
|
-
end
|
115
|
-
return false if before_hooks.any? { |result| result == false }
|
123
|
+
return false unless Plugin.run_before_schedule_hooks(klass, *args)
|
116
124
|
|
117
125
|
delayed_push(timestamp, job_to_hash_with_queue(queue, klass, args))
|
118
126
|
|
119
|
-
|
120
|
-
klass.send(hook,*args)
|
121
|
-
end
|
127
|
+
Plugin.run_after_schedule_hooks(klass, *args)
|
122
128
|
end
|
123
129
|
|
124
130
|
# Identical to enqueue_at but takes number_of_seconds_from_now
|
@@ -264,12 +270,14 @@ module ResqueScheduler
|
|
264
270
|
end
|
265
271
|
end
|
266
272
|
|
267
|
-
def
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
+
def prepare_schedule(schedule_hash)
|
274
|
+
prepared_hash = {}
|
275
|
+
schedule_hash.each do |name, job_spec|
|
276
|
+
job_spec = job_spec.dup
|
277
|
+
job_spec['class'] = name unless job_spec.key?('class') || job_spec.key?(:class)
|
278
|
+
prepared_hash[name] = job_spec
|
279
|
+
end
|
280
|
+
prepared_hash
|
273
281
|
end
|
274
282
|
|
275
283
|
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module ResqueScheduler
|
2
|
+
module Plugin
|
3
|
+
extend self
|
4
|
+
def hooks(job, pattern)
|
5
|
+
job.methods.grep(/^#{pattern}/).sort
|
6
|
+
end
|
7
|
+
|
8
|
+
def run_hooks(job, pattern, *args)
|
9
|
+
results = hooks(job, pattern).collect do |hook|
|
10
|
+
job.send(hook, *args)
|
11
|
+
end
|
12
|
+
|
13
|
+
results.all? { |result| result != false }
|
14
|
+
end
|
15
|
+
|
16
|
+
def method_missing(method_name, *args, &block)
|
17
|
+
if method_name =~ /^run_(.*)_hooks$/
|
18
|
+
job = args.shift
|
19
|
+
run_hooks job, $1, *args
|
20
|
+
else
|
21
|
+
super
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -1,52 +1,23 @@
|
|
1
1
|
require File.dirname(__FILE__) + '/test_helper'
|
2
2
|
|
3
3
|
context "scheduling jobs with hooks" do
|
4
|
-
class JobThatCannotBeScheduledWithoutArguments < Resque::Job
|
5
|
-
@queue = :job_that_cannot_be_scheduled_without_arguments
|
6
|
-
def self.perform(*x);end
|
7
|
-
def self.before_schedule_return_nil_if_arguments_not_supplied(*args)
|
8
|
-
counters[:before_schedule] += 1
|
9
|
-
return false if args.empty?
|
10
|
-
end
|
11
|
-
|
12
|
-
def self.after_schedule_do_something(*args)
|
13
|
-
counters[:after_schedule] += 1
|
14
|
-
end
|
15
|
-
|
16
|
-
class << self
|
17
|
-
def counters
|
18
|
-
@counters ||= Hash.new{|h,k| h[k]=0}
|
19
|
-
end
|
20
|
-
def clean
|
21
|
-
counters.clear
|
22
|
-
self
|
23
|
-
end
|
24
|
-
end
|
25
|
-
end
|
26
|
-
|
27
4
|
setup do
|
28
|
-
Resque
|
29
|
-
Resque.redis.del(:schedules)
|
30
|
-
Resque.redis.del(:schedules_changed)
|
31
|
-
Resque::Scheduler.mute = true
|
32
|
-
Resque::Scheduler.clear_schedule!
|
33
|
-
Resque::Scheduler.send(:class_variable_set, :@@scheduled_jobs, {})
|
5
|
+
Resque.redis.flushall
|
34
6
|
end
|
35
7
|
|
36
|
-
test "before_schedule hook that does not return false should
|
37
|
-
enqueue_time = Time.now
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
assert_equal(1,
|
8
|
+
test "before_schedule hook that does not return false should be enqueued" do
|
9
|
+
enqueue_time = Time.now
|
10
|
+
SomeRealClass.expects(:before_schedule_example).with(:foo)
|
11
|
+
SomeRealClass.expects(:after_schedule_example).with(:foo)
|
12
|
+
Resque.enqueue_at(enqueue_time.to_i, SomeRealClass, :foo)
|
13
|
+
assert_equal(1, Resque.delayed_timestamp_size(enqueue_time.to_i), "job should be enqueued")
|
42
14
|
end
|
43
15
|
|
44
|
-
test "before_schedule hook that returns false should
|
45
|
-
enqueue_time = Time.now
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
assert_equal(
|
50
|
-
assert_equal(0, JobThatCannotBeScheduledWithoutArguments.counters[:after_schedule], 'after_schedule was run')
|
16
|
+
test "before_schedule hook that returns false should not be enqueued" do
|
17
|
+
enqueue_time = Time.now
|
18
|
+
SomeRealClass.expects(:before_schedule_example).with(:foo).returns(false)
|
19
|
+
SomeRealClass.expects(:after_schedule_example).never
|
20
|
+
Resque.enqueue_at(enqueue_time.to_i, SomeRealClass, :foo)
|
21
|
+
assert_equal(0, Resque.delayed_timestamp_size(enqueue_time.to_i), "job should not be enqueued")
|
51
22
|
end
|
52
23
|
end
|
data/test/scheduler_test.rb
CHANGED
@@ -25,7 +25,9 @@ context "Resque::Scheduler" do
|
|
25
25
|
config = {'cron' => "* * * * *", 'class' => 'SomeRealClass', 'args' => "/tmp"}
|
26
26
|
|
27
27
|
Resque::Job.expects(:create).with(SomeRealClass.queue, SomeRealClass, '/tmp')
|
28
|
-
SomeRealClass.expects(:
|
28
|
+
SomeRealClass.expects(:before_delayed_enqueue_example).with("/tmp")
|
29
|
+
SomeRealClass.expects(:before_enqueue_example).with("/tmp")
|
30
|
+
SomeRealClass.expects(:after_enqueue_example).with("/tmp")
|
29
31
|
|
30
32
|
Resque::Scheduler.enqueue_from_config(config)
|
31
33
|
end
|
@@ -69,6 +71,24 @@ context "Resque::Scheduler" do
|
|
69
71
|
assert_equal(1, Resque::Scheduler.scheduled_jobs.size)
|
70
72
|
assert Resque::Scheduler.scheduled_jobs.keys.include?("some_ivar_job")
|
71
73
|
end
|
74
|
+
|
75
|
+
test "load_schedule_job with every with options" do
|
76
|
+
Resque::Scheduler.load_schedule_job("some_ivar_job", {'every' => ['30s', {'first_in' => '60s'}], 'class' => 'SomeIvarJob', 'args' => "/tmp"})
|
77
|
+
|
78
|
+
assert_equal(1, Resque::Scheduler.rufus_scheduler.all_jobs.size)
|
79
|
+
assert_equal(1, Resque::Scheduler.scheduled_jobs.size)
|
80
|
+
assert Resque::Scheduler.scheduled_jobs.keys.include?("some_ivar_job")
|
81
|
+
assert Resque::Scheduler.scheduled_jobs["some_ivar_job"].params.keys.include?(:first_in)
|
82
|
+
end
|
83
|
+
|
84
|
+
test "load_schedule_job with cron with options" do
|
85
|
+
Resque::Scheduler.load_schedule_job("some_ivar_job", {'cron' => ['* * * * *', {'allow_overlapping' => 'true'}], 'class' => 'SomeIvarJob', 'args' => "/tmp"})
|
86
|
+
|
87
|
+
assert_equal(1, Resque::Scheduler.rufus_scheduler.all_jobs.size)
|
88
|
+
assert_equal(1, Resque::Scheduler.scheduled_jobs.size)
|
89
|
+
assert Resque::Scheduler.scheduled_jobs.keys.include?("some_ivar_job")
|
90
|
+
assert Resque::Scheduler.scheduled_jobs["some_ivar_job"].params.keys.include?(:allow_overlapping)
|
91
|
+
end
|
72
92
|
|
73
93
|
test "load_schedule_job without cron" do
|
74
94
|
Resque::Scheduler.load_schedule_job("some_ivar_job", {'class' => 'SomeIvarJob', 'args' => "/tmp"})
|
@@ -165,6 +185,24 @@ context "Resque::Scheduler" do
|
|
165
185
|
Resque.decode(Resque.redis.hget(:schedules, "my_ivar_job")))
|
166
186
|
end
|
167
187
|
|
188
|
+
test "schedule= uses job name as 'class' argument if it's missing" do
|
189
|
+
Resque::Scheduler.dynamic = true
|
190
|
+
Resque.schedule = {"SomeIvarJob" => {
|
191
|
+
'cron' => "* * * * *", 'args' => "/tmp/75"
|
192
|
+
}}
|
193
|
+
assert_equal({'cron' => "* * * * *", 'class' => 'SomeIvarJob', 'args' => "/tmp/75"},
|
194
|
+
Resque.decode(Resque.redis.hget(:schedules, "SomeIvarJob")))
|
195
|
+
assert_equal('SomeIvarJob', Resque.schedule['SomeIvarJob']['class'])
|
196
|
+
end
|
197
|
+
|
198
|
+
test "schedule= does not mutate argument" do
|
199
|
+
schedule = {"SomeIvarJob" => {
|
200
|
+
'cron' => "* * * * *", 'args' => "/tmp/75"
|
201
|
+
}}
|
202
|
+
Resque.schedule = schedule
|
203
|
+
assert !schedule['SomeIvarJob'].key?('class')
|
204
|
+
end
|
205
|
+
|
168
206
|
test "set_schedule can set an individual schedule" do
|
169
207
|
Resque.set_schedule("some_ivar_job", {
|
170
208
|
'cron' => "* * * * *", 'class' => 'SomeIvarJob', 'args' => "/tmp/22"
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: resque-scheduler
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.0.0.
|
4
|
+
version: 2.0.0.h
|
5
5
|
prerelease: 6
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,11 +9,11 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2012-
|
12
|
+
date: 2012-03-19 00:00:00.000000000Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: bundler
|
16
|
-
requirement: &
|
16
|
+
requirement: &2169473600 !ruby/object:Gem::Requirement
|
17
17
|
none: false
|
18
18
|
requirements:
|
19
19
|
- - ! '>='
|
@@ -21,10 +21,10 @@ dependencies:
|
|
21
21
|
version: 1.0.0
|
22
22
|
type: :development
|
23
23
|
prerelease: false
|
24
|
-
version_requirements: *
|
24
|
+
version_requirements: *2169473600
|
25
25
|
- !ruby/object:Gem::Dependency
|
26
26
|
name: redis
|
27
|
-
requirement: &
|
27
|
+
requirement: &2169465640 !ruby/object:Gem::Requirement
|
28
28
|
none: false
|
29
29
|
requirements:
|
30
30
|
- - ! '>='
|
@@ -32,10 +32,10 @@ dependencies:
|
|
32
32
|
version: 2.0.1
|
33
33
|
type: :runtime
|
34
34
|
prerelease: false
|
35
|
-
version_requirements: *
|
35
|
+
version_requirements: *2169465640
|
36
36
|
- !ruby/object:Gem::Dependency
|
37
37
|
name: resque
|
38
|
-
requirement: &
|
38
|
+
requirement: &2169464480 !ruby/object:Gem::Requirement
|
39
39
|
none: false
|
40
40
|
requirements:
|
41
41
|
- - ! '>='
|
@@ -43,10 +43,10 @@ dependencies:
|
|
43
43
|
version: 1.19.0
|
44
44
|
type: :runtime
|
45
45
|
prerelease: false
|
46
|
-
version_requirements: *
|
46
|
+
version_requirements: *2169464480
|
47
47
|
- !ruby/object:Gem::Dependency
|
48
48
|
name: rufus-scheduler
|
49
|
-
requirement: &
|
49
|
+
requirement: &2169462660 !ruby/object:Gem::Requirement
|
50
50
|
none: false
|
51
51
|
requirements:
|
52
52
|
- - ! '>='
|
@@ -54,7 +54,7 @@ dependencies:
|
|
54
54
|
version: '0'
|
55
55
|
type: :runtime
|
56
56
|
prerelease: false
|
57
|
-
version_requirements: *
|
57
|
+
version_requirements: *2169462660
|
58
58
|
description: ! "Light weight job scheduling on top of Resque.\n Adds methods enqueue_at/enqueue_in
|
59
59
|
to schedule jobs in the future.\n Also supports queueing jobs on a fixed, cron-like
|
60
60
|
schedule."
|
@@ -72,6 +72,7 @@ files:
|
|
72
72
|
- Rakefile
|
73
73
|
- lib/resque/scheduler.rb
|
74
74
|
- lib/resque_scheduler.rb
|
75
|
+
- lib/resque_scheduler/plugin.rb
|
75
76
|
- lib/resque_scheduler/server.rb
|
76
77
|
- lib/resque_scheduler/server/views/delayed.erb
|
77
78
|
- lib/resque_scheduler/server/views/delayed_timestamp.erb
|
@@ -101,7 +102,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
101
102
|
version: '0'
|
102
103
|
segments:
|
103
104
|
- 0
|
104
|
-
hash: -
|
105
|
+
hash: -2739279222393439195
|
105
106
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
106
107
|
none: false
|
107
108
|
requirements:
|
@@ -110,7 +111,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
110
111
|
version: 1.3.6
|
111
112
|
requirements: []
|
112
113
|
rubyforge_project:
|
113
|
-
rubygems_version: 1.8.
|
114
|
+
rubygems_version: 1.8.17
|
114
115
|
signing_key:
|
115
116
|
specification_version: 3
|
116
117
|
summary: Light weight job scheduling on top of Resque
|