resque-scheduler 2.3.1 → 2.4.0

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.

Files changed (49) hide show
  1. data/.gitignore +3 -0
  2. data/.rubocop.yml +11 -11
  3. data/.simplecov +1 -0
  4. data/.travis.yml +5 -2
  5. data/AUTHORS.md +3 -0
  6. data/HISTORY.md +26 -2
  7. data/LICENSE +1 -1
  8. data/README.md +120 -31
  9. data/ROADMAP.md +10 -0
  10. data/Rakefile +7 -19
  11. data/bin/resque-scheduler +5 -0
  12. data/examples/Rakefile +2 -0
  13. data/examples/config/initializers/resque-web.rb +37 -0
  14. data/examples/dynamic-scheduling/README.md +28 -0
  15. data/examples/dynamic-scheduling/app/jobs/fix_schedules_job.rb +54 -0
  16. data/examples/dynamic-scheduling/app/jobs/send_email_job.rb +9 -0
  17. data/examples/dynamic-scheduling/app/models/user.rb +16 -0
  18. data/examples/dynamic-scheduling/config/resque.yml +4 -0
  19. data/examples/dynamic-scheduling/config/static_schedule.yml +7 -0
  20. data/examples/dynamic-scheduling/lib/tasks/resque.rake +48 -0
  21. data/examples/run-resque-web +3 -0
  22. data/lib/resque-scheduler.rb +2 -0
  23. data/lib/resque/scheduler.rb +130 -41
  24. data/lib/resque/scheduler/lock/resilient.rb +1 -1
  25. data/lib/resque/scheduler_locking.rb +3 -1
  26. data/lib/resque_scheduler.rb +73 -31
  27. data/lib/resque_scheduler/cli.rb +160 -0
  28. data/lib/resque_scheduler/logger_builder.rb +27 -8
  29. data/lib/resque_scheduler/plugin.rb +10 -7
  30. data/lib/resque_scheduler/server.rb +52 -11
  31. data/lib/resque_scheduler/server/views/delayed.erb +2 -0
  32. data/lib/resque_scheduler/server/views/delayed_schedules.erb +20 -0
  33. data/lib/resque_scheduler/server/views/scheduler.erb +4 -12
  34. data/lib/resque_scheduler/tasks.rb +15 -27
  35. data/lib/resque_scheduler/version.rb +1 -1
  36. data/resque-scheduler.gemspec +2 -0
  37. data/test/cli_test.rb +286 -0
  38. data/test/delayed_queue_test.rb +70 -1
  39. data/test/resque-web_test.rb +36 -1
  40. data/test/scheduler_args_test.rb +51 -17
  41. data/test/scheduler_hooks_test.rb +1 -1
  42. data/test/scheduler_locking_test.rb +63 -1
  43. data/test/scheduler_setup_test.rb +54 -18
  44. data/test/scheduler_task_test.rb +35 -0
  45. data/test/scheduler_test.rb +130 -42
  46. data/test/support/redis_instance.rb +8 -3
  47. data/test/test_helper.rb +47 -20
  48. metadata +77 -6
  49. checksums.yaml +0 -15
@@ -3,7 +3,7 @@ require 'resque/scheduler/lock/base'
3
3
  module Resque
4
4
  class Scheduler
5
5
  module Lock
6
- class Resilient < Base
6
+ class Resilient < Base # rubocop:disable TrailingComma
7
7
  def acquire!
8
8
  Resque.redis.evalsha(
9
9
  acquire_sha,
@@ -79,7 +79,9 @@ module Resque
79
79
  end
80
80
 
81
81
  def master_lock_key
82
- "#{ENV['RESQUE_SCHEDULER_MASTER_LOCK_PREFIX'] || ''}resque_scheduler_master_lock".to_sym
82
+ lock_prefix = ENV['RESQUE_SCHEDULER_MASTER_LOCK_PREFIX'] || ''
83
+ lock_prefix += ':' if lock_prefix != ''
84
+ "#{Resque.redis.namespace}:#{lock_prefix}resque_scheduler_master_lock"
83
85
  end
84
86
 
85
87
  def redis_master_version
@@ -6,6 +6,8 @@ require 'resque/scheduler'
6
6
  require 'resque_scheduler/plugin'
7
7
 
8
8
  module ResqueScheduler
9
+ autoload :Cli, 'resque_scheduler/cli'
10
+
9
11
  #
10
12
  # Accepts a new schedule configuration of the form:
11
13
  #
@@ -91,7 +93,7 @@ module ResqueScheduler
91
93
  def clean_schedules
92
94
  if redis.exists(:schedules)
93
95
  redis.hkeys(:schedules).each do |key|
94
- remove_schedule(key)
96
+ remove_schedule(key) if !schedule_persisted?(key)
95
97
  end
96
98
  end
97
99
  @schedule = nil
@@ -109,9 +111,15 @@ module ResqueScheduler
109
111
  # :args => '/tmp/poop'})
110
112
  def set_schedule(name, config)
111
113
  existing_config = get_schedule(name)
114
+ persist = config.delete(:persist) || config.delete('persist')
112
115
  unless existing_config && existing_config == config
113
- redis.hset(:schedules, name, encode(config))
114
- redis.sadd(:schedules_changed, name)
116
+ redis.pipelined do
117
+ redis.hset(:schedules, name, encode(config))
118
+ redis.sadd(:schedules_changed, name)
119
+ if persist
120
+ redis.sadd(:persisted_schedules, name)
121
+ end
122
+ end
115
123
  end
116
124
  config
117
125
  end
@@ -121,10 +129,17 @@ module ResqueScheduler
121
129
  decode(redis.hget(:schedules, name))
122
130
  end
123
131
 
132
+ def schedule_persisted?(name)
133
+ redis.sismember(:persisted_schedules, name)
134
+ end
135
+
124
136
  # remove a given schedule by name
125
137
  def remove_schedule(name)
126
- redis.hdel(:schedules, name)
127
- redis.sadd(:schedules_changed, name)
138
+ redis.pipelined do
139
+ redis.hdel(:schedules, name)
140
+ redis.srem(:persisted_schedules, name)
141
+ redis.sadd(:schedules_changed, name)
142
+ end
128
143
  end
129
144
 
130
145
  # This method is nearly identical to +enqueue+ only it also
@@ -272,6 +287,33 @@ module ResqueScheduler
272
287
  remove_delayed(klass, *args).times { Resque::Scheduler.enqueue_from_config(hash) }
273
288
  end
274
289
 
290
+ # Given a block, remove jobs that return true from a block
291
+ #
292
+ # This allows for removal of delayed jobs that have arguments matching certain criteria
293
+ def remove_delayed_selection
294
+ fail ArgumentError, "Please supply a block" unless block_given?
295
+
296
+ destroyed = 0
297
+ # There is no way to search Redis list entries for a partial match, so we query for all
298
+ # delayed job tasks and do our matching after decoding the payload data
299
+ jobs = Resque.redis.keys("delayed:*")
300
+ jobs.each do |job|
301
+ index = Resque.redis.llen(job) - 1
302
+ while index >= 0
303
+ payload = Resque.redis.lindex(job, index)
304
+ decoded_payload = decode(payload)
305
+ if yield(decoded_payload['args'])
306
+ removed = redis.lrem job, 0, payload
307
+ destroyed += removed
308
+ index -= removed
309
+ else
310
+ index -= 1
311
+ end
312
+ end
313
+ end
314
+ destroyed
315
+ end
316
+
275
317
  # Given a timestamp and job (klass + args) it removes all instances and
276
318
  # returns the count of jobs removed.
277
319
  #
@@ -306,39 +348,39 @@ module ResqueScheduler
306
348
 
307
349
  private
308
350
 
309
- def job_to_hash(klass, args)
310
- {:class => klass.to_s, :args => args, :queue => queue_from_class(klass)}
311
- end
351
+ def job_to_hash(klass, args)
352
+ {:class => klass.to_s, :args => args, :queue => queue_from_class(klass)}
353
+ end
312
354
 
313
- def job_to_hash_with_queue(queue, klass, args)
314
- {:class => klass.to_s, :args => args, :queue => queue}
315
- end
355
+ def job_to_hash_with_queue(queue, klass, args)
356
+ {:class => klass.to_s, :args => args, :queue => queue}
357
+ end
316
358
 
317
- def clean_up_timestamp(key, timestamp)
318
- # If the list is empty, remove it.
359
+ def clean_up_timestamp(key, timestamp)
360
+ # If the list is empty, remove it.
319
361
 
320
- # Use a watch here to ensure nobody adds jobs to this delayed
321
- # queue while we're removing it.
322
- redis.watch key
323
- if 0 == redis.llen(key).to_i
324
- redis.multi do
325
- redis.del key
326
- redis.zrem :delayed_queue_schedule, timestamp.to_i
327
- end
328
- else
329
- redis.unwatch
362
+ # Use a watch here to ensure nobody adds jobs to this delayed
363
+ # queue while we're removing it.
364
+ redis.watch key
365
+ if 0 == redis.llen(key).to_i
366
+ redis.multi do
367
+ redis.del key
368
+ redis.zrem :delayed_queue_schedule, timestamp.to_i
330
369
  end
370
+ else
371
+ redis.unwatch
331
372
  end
373
+ end
332
374
 
333
- def prepare_schedule(schedule_hash)
334
- prepared_hash = {}
335
- schedule_hash.each do |name, job_spec|
336
- job_spec = job_spec.dup
337
- job_spec['class'] = name unless job_spec.key?('class') || job_spec.key?(:class)
338
- prepared_hash[name] = job_spec
339
- end
340
- prepared_hash
375
+ def prepare_schedule(schedule_hash)
376
+ prepared_hash = {}
377
+ schedule_hash.each do |name, job_spec|
378
+ job_spec = job_spec.dup
379
+ job_spec['class'] = name unless job_spec.key?('class') || job_spec.key?(:class)
380
+ prepared_hash[name] = job_spec
341
381
  end
382
+ prepared_hash
383
+ end
342
384
  end
343
385
 
344
386
  Resque.extend ResqueScheduler
@@ -0,0 +1,160 @@
1
+ # vim:fileencoding=utf-8
2
+
3
+ require 'optparse'
4
+
5
+ module ResqueScheduler
6
+ class Cli
7
+ BANNER = <<-EOF.gsub(/ {6}/, '')
8
+ Usage: resque-scheduler [options]
9
+
10
+ Runs a resque scheduler process directly (rather than via rake).
11
+
12
+ EOF
13
+ OPTIONS = [
14
+ {
15
+ args: ['-n', '--app-name [APP_NAME]', 'Application name for procline'],
16
+ callback: ->(options) { ->(n) { options[:app_name] = n } }
17
+ },
18
+ {
19
+ args: ['-B', '--background', 'Run in the background [BACKGROUND]'],
20
+ callback: ->(options) { ->(b) { options[:background] = b } }
21
+ },
22
+ {
23
+ args: ['-D', '--dynamic-schedule',
24
+ 'Enable dynamic scheduling [DYNAMIC_SCHEDULE]'],
25
+ callback: ->(options) { ->(d) { options[:dynamic] = d } }
26
+ },
27
+ {
28
+ args: ['-E', '--environment [RAILS_ENV]', 'Environment name'],
29
+ callback: ->(options) { ->(e) { options[:env] = e } }
30
+ },
31
+ {
32
+ args: ['-I', '--initializer-path [INITIALIZER_PATH]',
33
+ 'Path to optional initializer ruby file'],
34
+ callback: ->(options) { ->(i) { options[:initializer_path] = i } }
35
+ },
36
+ {
37
+ args: ['-i', '--interval [RESQUE_SCHEDULER_INTERVAL]',
38
+ 'Interval for checking if a scheduled job must run'],
39
+ callback: ->(options) { ->(i) { options[:poll_sleep_amount] = i } }
40
+ },
41
+ {
42
+ args: ['-l', '--logfile [LOGFILE]', 'Log file name'],
43
+ callback: ->(options) { ->(l) { options[:logfile] = l } }
44
+ },
45
+ {
46
+ args: ['-F', '--logformat [LOGFORMAT]', 'Log output format'],
47
+ callback: ->(options) { ->(f) { options[:logformat] = f } }
48
+ },
49
+ {
50
+ args: ['-P', '--pidfile [PIDFILE]', 'PID file name'],
51
+ callback: ->(options) { ->(p) { options[:pidfile] = p } }
52
+ },
53
+ {
54
+ args: ['-q', '--quiet', 'Run with minimal output [QUIET] (or [MUTE])'],
55
+ callback: ->(options) { ->(q) { options[:mute] = q } }
56
+ },
57
+ {
58
+ args: ['-v', '--verbose', 'Run with verbose output [VERBOSE]'],
59
+ callback: ->(options) { ->(v) { options[:verbose] = v } }
60
+ }
61
+ ].freeze
62
+
63
+ def self.run!(argv = ARGV, env = ENV)
64
+ new(argv, env).run!
65
+ end
66
+
67
+ def initialize(argv = ARGV, env = ENV)
68
+ @argv = argv
69
+ @env = env
70
+ end
71
+
72
+ def run!
73
+ pre_run
74
+ run_forever
75
+ end
76
+
77
+ def pre_run
78
+ parse_options
79
+ pre_setup
80
+ setup_env
81
+ end
82
+
83
+ def parse_options
84
+ OptionParser.new do |opts|
85
+ opts.banner = BANNER
86
+ OPTIONS.each do |opt|
87
+ opts.on(*opt[:args], &(opt[:callback].call(options)))
88
+ end
89
+ end.parse!(argv.dup)
90
+ end
91
+
92
+ def pre_setup
93
+ if options[:initializer_path]
94
+ load options[:initializer_path].to_s.strip
95
+ else
96
+ false
97
+ end
98
+ end
99
+
100
+ def setup_env
101
+ require 'resque'
102
+ require 'resque/scheduler'
103
+
104
+ # Need to set this here for conditional Process.daemon redirect of
105
+ # stderr/stdout to /dev/null
106
+ Resque::Scheduler.mute = !!options[:mute]
107
+
108
+ if options[:background]
109
+ unless Process.respond_to?('daemon')
110
+ abort 'background option is set, which requires ruby >= 1.9'
111
+ end
112
+
113
+ Process.daemon(true, !Resque::Scheduler.mute)
114
+ Resque.redis.client.reconnect
115
+ end
116
+
117
+ File.open(options[:pidfile], 'w') do |f|
118
+ f.puts $PROCESS_ID
119
+ end if options[:pidfile]
120
+
121
+ Resque::Scheduler.configure do |c|
122
+ # These settings are somewhat redundant given the defaults present
123
+ # in the attr reader methods. They are left here for clarity and
124
+ # to serve as an example of how to use `.configure`.
125
+
126
+ c.app_name = options[:app_name]
127
+ c.dynamic = !!options[:dynamic]
128
+ c.env = options[:env]
129
+ c.logfile = options[:logfile]
130
+ c.logformat = options[:logformat]
131
+ c.poll_sleep_amount = Float(options[:poll_sleep_amount] || '5')
132
+ c.verbose = !!options[:verbose]
133
+ end
134
+ end
135
+
136
+ def run_forever
137
+ Resque::Scheduler.run
138
+ end
139
+
140
+ private
141
+
142
+ attr_reader :argv, :env
143
+
144
+ def options
145
+ @options ||= {
146
+ app_name: env['APP_NAME'],
147
+ background: env['BACKGROUND'],
148
+ dynamic: env['DYNAMIC_SCHEDULE'],
149
+ env: env['RAILS_ENV'],
150
+ initializer_path: env['INITIALIZER_PATH'],
151
+ logfile: env['LOGFILE'],
152
+ logformat: env['LOGFORMAT'],
153
+ mute: env['MUTE'] || env['QUIET'],
154
+ pidfile: env['PIDFILE'],
155
+ poll_sleep_amount: env['RESQUE_SCHEDULER_INTERVAL'],
156
+ verbose: env['VERBOSE']
157
+ }
158
+ end
159
+ end
160
+ end
@@ -1,3 +1,5 @@
1
+ # vim:fileencoding=utf-8
2
+
1
3
  module ResqueScheduler
2
4
  # Just builds a logger, with specified verbosity and destination.
3
5
  # The simplest example:
@@ -10,23 +12,25 @@ module ResqueScheduler
10
12
  # - :mute if logger needs to be silent for all levels. Default - false
11
13
  # - :verbose if there is a need in debug messages. Default - false
12
14
  # - :log_dev to output logs into a desired file. Default - STDOUT
15
+ # - :format log format, either 'text' or 'json'. Default - 'text'
13
16
  #
14
17
  # Example:
15
18
  #
16
- # LoggerBuilder.new(:mute => false, :verbose => true, :log_dev => 'log/sheduler.log')
19
+ # LoggerBuilder.new(
20
+ # :mute => false, :verbose => true, :log_dev => 'log/scheduler.log'
21
+ # )
17
22
  def initialize(opts={})
18
- @muted = !!opts[:mute]
23
+ @muted = !!opts[:mute]
19
24
  @verbose = !!opts[:verbose]
20
- @log_dev = opts[:log_dev] || STDOUT
25
+ @log_dev = opts[:log_dev] || $stdout
26
+ @format = opts[:format] || 'text'
21
27
  end
22
28
 
23
29
  # Returns an instance of Logger
24
30
  def build
25
31
  logger = Logger.new(@log_dev)
26
32
  logger.level = level
27
- logger.datetime_format = "%Y-%m-%d %H:%M:%S"
28
- logger.formatter = formatter
29
-
33
+ logger.formatter = send(:"#{@format}_formatter")
30
34
  logger
31
35
  end
32
36
 
@@ -42,9 +46,24 @@ module ResqueScheduler
42
46
  end
43
47
  end
44
48
 
45
- def formatter
49
+ def text_formatter
50
+ proc do |severity, datetime, progname, msg|
51
+ "resque-scheduler: [#{severity}] #{datetime.iso8601}: #{msg}\n"
52
+ end
53
+ end
54
+
55
+ def json_formatter
46
56
  proc do |severity, datetime, progname, msg|
47
- "[#{severity}] #{datetime}: #{msg}\n"
57
+ require 'json'
58
+ JSON.dump(
59
+ {
60
+ :name => 'resque-scheduler',
61
+ :progname => progname,
62
+ :level => severity,
63
+ :timestamp => datetime.iso8601,
64
+ :msg => msg
65
+ }
66
+ ) + "\n"
48
67
  end
49
68
  end
50
69
  end
@@ -13,13 +13,16 @@ module ResqueScheduler
13
13
  results.all? { |result| result != false }
14
14
  end
15
15
 
16
- def method_missing(method_name, *args, &block)
17
- if method_name.to_s =~ /^run_(.*)_hooks$/
18
- job = args.shift
19
- run_hooks job, $1, *args
20
- else
21
- super
22
- end
16
+ def run_before_delayed_enqueue_hooks(klass, *args)
17
+ run_hooks(klass, 'before_delayed_enqueue', *args)
18
+ end
19
+
20
+ def run_before_schedule_hooks(klass, *args)
21
+ run_hooks(klass, 'before_schedule', *args)
22
+ end
23
+
24
+ def run_after_schedule_hooks(klass, *args)
25
+ run_hooks(klass, 'after_schedule', *args)
23
26
  end
24
27
  end
25
28
  end
@@ -1,14 +1,12 @@
1
1
  require 'resque_scheduler'
2
2
  require 'resque/server'
3
+ require 'json'
4
+
3
5
  # Extend Resque::Server to add tabs
4
6
  module ResqueScheduler
5
-
6
7
  module Server
7
-
8
8
  def self.included(base)
9
-
10
9
  base.class_eval do
11
-
12
10
  helpers do
13
11
  def format_time(t)
14
12
  t.strftime("%Y-%m-%d %H:%M:%S %z")
@@ -17,6 +15,41 @@ module ResqueScheduler
17
15
  def queue_from_class_name(class_name)
18
16
  Resque.queue_from_class(ResqueScheduler::Util.constantize(class_name))
19
17
  end
18
+
19
+ def schedule_interval(config)
20
+ if config['every']
21
+ schedule_interval_every(config['every'])
22
+ elsif config['cron']
23
+ 'cron: ' + config['cron'].to_s
24
+ else
25
+ 'Not currently scheduled'
26
+ end
27
+ end
28
+
29
+ def schedule_interval_every(every)
30
+ s = 'every: '
31
+ if every.respond_to?(:first)
32
+ s << every.first
33
+ else
34
+ s << every
35
+ end
36
+
37
+ return s unless every.respond_to?(:last) && every.length > 1
38
+
39
+ s << ' ('
40
+ meta = every.last.map do |key, value|
41
+ "#{key.to_s.gsub(/_/, ' ')} #{value}"
42
+ end
43
+ s << meta.join(', ') << ')'
44
+ end
45
+
46
+ def schedule_class(config)
47
+ if config['class'].nil? && !config['custom_job_class'].nil?
48
+ config['custom_job_class']
49
+ else
50
+ config['class']
51
+ end
52
+ end
20
53
  end
21
54
 
22
55
  get "/schedule" do
@@ -59,6 +92,18 @@ module ResqueScheduler
59
92
  erb File.read(File.join(File.dirname(__FILE__), 'server/views/delayed.erb'))
60
93
  end
61
94
 
95
+ get "/delayed/jobs/:klass" do
96
+ begin
97
+ klass = ResqueScheduler::Util::constantize(params[:klass])
98
+ @args = JSON.load(URI.decode(params[:args]))
99
+ @timestamps = Resque.scheduled_at(klass, *@args)
100
+ rescue => err
101
+ @timestamps = []
102
+ end
103
+
104
+ erb File.read(File.join(File.dirname(__FILE__), 'server/views/delayed_schedules.erb'))
105
+ end
106
+
62
107
  get "/delayed/:timestamp" do
63
108
  # Is there a better way to specify alternate template locations with sinatra?
64
109
  erb File.read(File.join(File.dirname(__FILE__), 'server/views/delayed_timestamp.erb'))
@@ -74,18 +119,14 @@ module ResqueScheduler
74
119
  Resque.reset_delayed_queue
75
120
  redirect u('delayed')
76
121
  end
77
-
78
122
  end
79
-
80
123
  end
81
-
82
- Resque::Server.tabs << 'Schedule'
83
- Resque::Server.tabs << 'Delayed'
84
-
85
124
  end
86
-
87
125
  end
88
126
 
127
+ Resque::Server.tabs << 'Schedule'
128
+ Resque::Server.tabs << 'Delayed'
129
+
89
130
  Resque::Server.class_eval do
90
131
  include ResqueScheduler::Server
91
132
  end