istox-resque-scheduler 1.0.0.pre

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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/AUTHORS.md +87 -0
  3. data/CHANGELOG.md +478 -0
  4. data/CODE_OF_CONDUCT.md +74 -0
  5. data/CONTRIBUTING.md +6 -0
  6. data/Gemfile +4 -0
  7. data/LICENSE +23 -0
  8. data/README.md +698 -0
  9. data/Rakefile +21 -0
  10. data/exe/resque-scheduler +5 -0
  11. data/istox-resque-scheduler.gemspec +61 -0
  12. data/lib/resque-scheduler.rb +4 -0
  13. data/lib/resque/scheduler.rb +460 -0
  14. data/lib/resque/scheduler/cli.rb +147 -0
  15. data/lib/resque/scheduler/configuration.rb +73 -0
  16. data/lib/resque/scheduler/delaying_extensions.rb +356 -0
  17. data/lib/resque/scheduler/env.rb +89 -0
  18. data/lib/resque/scheduler/extension.rb +13 -0
  19. data/lib/resque/scheduler/failure_handler.rb +11 -0
  20. data/lib/resque/scheduler/lock.rb +4 -0
  21. data/lib/resque/scheduler/lock/base.rb +61 -0
  22. data/lib/resque/scheduler/lock/basic.rb +27 -0
  23. data/lib/resque/scheduler/lock/resilient.rb +78 -0
  24. data/lib/resque/scheduler/locking.rb +104 -0
  25. data/lib/resque/scheduler/logger_builder.rb +72 -0
  26. data/lib/resque/scheduler/plugin.rb +31 -0
  27. data/lib/resque/scheduler/scheduling_extensions.rb +142 -0
  28. data/lib/resque/scheduler/server.rb +268 -0
  29. data/lib/resque/scheduler/server/views/delayed.erb +63 -0
  30. data/lib/resque/scheduler/server/views/delayed_schedules.erb +20 -0
  31. data/lib/resque/scheduler/server/views/delayed_timestamp.erb +26 -0
  32. data/lib/resque/scheduler/server/views/requeue-params.erb +23 -0
  33. data/lib/resque/scheduler/server/views/scheduler.erb +58 -0
  34. data/lib/resque/scheduler/server/views/search.erb +72 -0
  35. data/lib/resque/scheduler/server/views/search_form.erb +8 -0
  36. data/lib/resque/scheduler/signal_handling.rb +40 -0
  37. data/lib/resque/scheduler/tasks.rb +25 -0
  38. data/lib/resque/scheduler/util.rb +39 -0
  39. data/lib/resque/scheduler/version.rb +7 -0
  40. metadata +343 -0
@@ -0,0 +1,147 @@
1
+ # vim:fileencoding=utf-8
2
+
3
+ require 'optparse'
4
+
5
+ module Resque
6
+ module Scheduler
7
+ CLI_OPTIONS_ENV_MAPPING = {
8
+ app_name: 'APP_NAME',
9
+ background: 'BACKGROUND',
10
+ dynamic: 'DYNAMIC_SCHEDULE',
11
+ env: 'RAILS_ENV',
12
+ initializer_path: 'INITIALIZER_PATH',
13
+ logfile: 'LOGFILE',
14
+ logformat: 'LOGFORMAT',
15
+ quiet: 'QUIET',
16
+ pidfile: 'PIDFILE',
17
+ poll_sleep_amount: 'RESQUE_SCHEDULER_INTERVAL',
18
+ verbose: 'VERBOSE'
19
+ }.freeze
20
+
21
+ class Cli
22
+ BANNER = <<-EOF.gsub(/ {6}/, '')
23
+ Usage: resque-scheduler [options]
24
+
25
+ Runs a resque scheduler process directly (rather than via rake).
26
+
27
+ EOF
28
+ OPTIONS = [
29
+ {
30
+ args: ['-n', '--app-name [APP_NAME]',
31
+ 'Application name for procline'],
32
+ callback: ->(options) { ->(n) { options[:app_name] = n } }
33
+ },
34
+ {
35
+ args: ['-B', '--background', 'Run in the background [BACKGROUND]'],
36
+ callback: ->(options) { ->(b) { options[:background] = b } }
37
+ },
38
+ {
39
+ args: ['-D', '--dynamic-schedule',
40
+ 'Enable dynamic scheduling [DYNAMIC_SCHEDULE]'],
41
+ callback: ->(options) { ->(d) { options[:dynamic] = d } }
42
+ },
43
+ {
44
+ args: ['-E', '--environment [RAILS_ENV]', 'Environment name'],
45
+ callback: ->(options) { ->(e) { options[:env] = e } }
46
+ },
47
+ {
48
+ args: ['-I', '--initializer-path [INITIALIZER_PATH]',
49
+ 'Path to optional initializer ruby file'],
50
+ callback: ->(options) { ->(i) { options[:initializer_path] = i } }
51
+ },
52
+ {
53
+ args: ['-i', '--interval [RESQUE_SCHEDULER_INTERVAL]',
54
+ 'Interval for checking if a scheduled job must run'],
55
+ callback: ->(options) { ->(i) { options[:poll_sleep_amount] = i } }
56
+ },
57
+ {
58
+ args: ['-l', '--logfile [LOGFILE]', 'Log file name'],
59
+ callback: ->(options) { ->(l) { options[:logfile] = l } }
60
+ },
61
+ {
62
+ args: ['-F', '--logformat [LOGFORMAT]', 'Log output format'],
63
+ callback: ->(options) { ->(f) { options[:logformat] = f } }
64
+ },
65
+ {
66
+ args: ['-P', '--pidfile [PIDFILE]', 'PID file name'],
67
+ callback: ->(options) { ->(p) { options[:pidfile] = p } }
68
+ },
69
+ {
70
+ args: ['-q', '--quiet', 'Run with minimal output [QUIET]'],
71
+ callback: ->(options) { ->(q) { options[:quiet] = q } }
72
+ },
73
+ {
74
+ args: ['-v', '--verbose', 'Run with verbose output [VERBOSE]'],
75
+ callback: ->(options) { ->(v) { options[:verbose] = v } }
76
+ }
77
+ ].freeze
78
+
79
+ def self.run!(argv = ARGV, env = ENV)
80
+ new(argv, env).run!
81
+ end
82
+
83
+ def initialize(argv = ARGV, env = ENV)
84
+ @argv = argv
85
+ @env = env
86
+ end
87
+
88
+ def run!
89
+ pre_run
90
+ run_forever
91
+ end
92
+
93
+ def pre_run
94
+ parse_options
95
+ pre_setup
96
+ setup_env
97
+ end
98
+
99
+ def parse_options
100
+ option_parser.parse!(argv.dup)
101
+ end
102
+
103
+ def pre_setup
104
+ if options[:initializer_path]
105
+ load options[:initializer_path].to_s.strip
106
+ else
107
+ false
108
+ end
109
+ end
110
+
111
+ def setup_env
112
+ require_relative 'env'
113
+ runtime_env.setup
114
+ end
115
+
116
+ def run_forever
117
+ Resque::Scheduler.run
118
+ end
119
+
120
+ private
121
+
122
+ attr_reader :argv, :env
123
+
124
+ def runtime_env
125
+ @runtime_env ||= Resque::Scheduler::Env.new(options)
126
+ end
127
+
128
+ def option_parser
129
+ OptionParser.new do |opts|
130
+ opts.banner = BANNER
131
+ opts.version = Resque::Scheduler::VERSION
132
+ OPTIONS.each do |opt|
133
+ opts.on(*opt[:args], &opt[:callback].call(options))
134
+ end
135
+ end
136
+ end
137
+
138
+ def options
139
+ @options ||= {}.tap do |o|
140
+ CLI_OPTIONS_ENV_MAPPING.each do |key, envvar|
141
+ o[key] = env[envvar] if env.include?(envvar)
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,73 @@
1
+ # vim:fileencoding=utf-8
2
+
3
+ module Resque
4
+ module Scheduler
5
+ module Configuration
6
+ # Allows for block-style configuration
7
+ def configure
8
+ yield self
9
+ end
10
+
11
+ # Used in `#load_schedule_job`
12
+ attr_writer :env
13
+
14
+ def env
15
+ return @env if @env
16
+ @env ||= Rails.env if defined?(Rails) && Rails.respond_to?(:env)
17
+ @env ||= ENV['RAILS_ENV']
18
+ @env
19
+ end
20
+
21
+ # If true, logs more stuff...
22
+ attr_writer :verbose
23
+
24
+ def verbose
25
+ @verbose ||= !!ENV['VERBOSE']
26
+ end
27
+
28
+ # If set, produces no output
29
+ attr_writer :quiet
30
+
31
+ def quiet
32
+ @quiet ||= !!ENV['QUIET']
33
+ end
34
+
35
+ # If set, will write messages to the file
36
+ attr_writer :logfile
37
+
38
+ def logfile
39
+ @logfile ||= ENV['LOGFILE']
40
+ end
41
+
42
+ # Sets whether to log in 'text' or 'json'
43
+ attr_writer :logformat
44
+
45
+ def logformat
46
+ @logformat ||= ENV['LOGFORMAT']
47
+ end
48
+
49
+ # If set, will try to update the schedule in the loop
50
+ attr_writer :dynamic
51
+
52
+ def dynamic
53
+ @dynamic ||= !!ENV['DYNAMIC_SCHEDULE']
54
+ end
55
+
56
+ # If set, will append the app name to procline
57
+ attr_writer :app_name
58
+
59
+ def app_name
60
+ @app_name ||= ENV['APP_NAME']
61
+ end
62
+
63
+ # Amount of time in seconds to sleep between polls of the delayed
64
+ # queue. Defaults to 5
65
+ attr_writer :poll_sleep_amount
66
+
67
+ def poll_sleep_amount
68
+ @poll_sleep_amount ||=
69
+ Float(ENV.fetch('RESQUE_SCHEDULER_INTERVAL', '5'))
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,356 @@
1
+ # vim:fileencoding=utf-8
2
+ require 'resque'
3
+ require_relative 'plugin'
4
+ require_relative '../scheduler'
5
+
6
+ module Resque
7
+ module Scheduler
8
+ module DelayingExtensions
9
+ # This method is nearly identical to +enqueue+ only it also
10
+ # takes a timestamp which will be used to schedule the job
11
+ # for queueing. Until timestamp is in the past, the job will
12
+ # sit in the schedule list.
13
+ def enqueue_at(timestamp, klass, *args)
14
+ validate(klass)
15
+ enqueue_at_with_queue(
16
+ queue_from_class(klass), timestamp, klass, *args
17
+ )
18
+ end
19
+
20
+ # Identical to +enqueue_at+, except you can also specify
21
+ # a queue in which the job will be placed after the
22
+ # timestamp has passed. It respects Resque.inline option, by
23
+ # creating the job right away instead of adding to the queue.
24
+ def enqueue_at_with_queue(queue, timestamp, klass, *args)
25
+ return false unless plugin.run_before_schedule_hooks(klass, *args)
26
+
27
+ if Resque.inline? || timestamp.to_i <= Time.now.to_i
28
+ # Just create the job and let resque perform it right away with
29
+ # inline. If the class is a custom job class, call self#scheduled
30
+ # on it. This allows you to do things like
31
+ # Resque.enqueue_at(timestamp, CustomJobClass, :opt1 => val1).
32
+ # Otherwise, pass off to Resque.
33
+ if klass.respond_to?(:scheduled)
34
+ klass.scheduled(queue, klass.to_s, *args)
35
+ else
36
+ Resque.enqueue_to(queue, klass, *args)
37
+ end
38
+ else
39
+ delayed_push(timestamp, job_to_hash_with_queue(queue, klass, args))
40
+ end
41
+
42
+ plugin.run_after_schedule_hooks(klass, *args)
43
+ end
44
+
45
+ # Identical to enqueue_at but takes number_of_seconds_from_now
46
+ # instead of a timestamp.
47
+ def enqueue_in(number_of_seconds_from_now, klass, *args)
48
+ unless number_of_seconds_from_now.is_a?(Numeric)
49
+ raise ArgumentError, 'Please supply a numeric number of seconds'
50
+ end
51
+ enqueue_at(Time.now + number_of_seconds_from_now, klass, *args)
52
+ end
53
+
54
+ # Identical to +enqueue_in+, except you can also specify
55
+ # a queue in which the job will be placed after the
56
+ # number of seconds has passed.
57
+ def enqueue_in_with_queue(queue, number_of_seconds_from_now,
58
+ klass, *args)
59
+ unless number_of_seconds_from_now.is_a?(Numeric)
60
+ raise ArgumentError, 'Please supply a numeric number of seconds'
61
+ end
62
+ enqueue_at_with_queue(queue, Time.now + number_of_seconds_from_now,
63
+ klass, *args)
64
+ end
65
+
66
+ # Used internally to stuff the item into the schedule sorted list.
67
+ # +timestamp+ can be either in seconds or a datetime object. The
68
+ # insertion time complexity is O(log(n)). Returns true if it's
69
+ # the first job to be scheduled at that time, else false.
70
+ def delayed_push(timestamp, item)
71
+ # First add this item to the list for this timestamp
72
+ redis.rpush("delayed:#{timestamp.to_i}", encode(item))
73
+
74
+ # Store the timestamps at with this item occurs
75
+ redis.sadd("timestamps:#{encode(item)}", "delayed:#{timestamp.to_i}")
76
+
77
+ # Now, add this timestamp to the zsets. The score and the value are
78
+ # the same since we'll be querying by timestamp, and we don't have
79
+ # anything else to store.
80
+ redis.zadd :delayed_queue_schedule, timestamp.to_i, timestamp.to_i
81
+ end
82
+
83
+ # Returns an array of timestamps based on start and count
84
+ def delayed_queue_peek(start, count)
85
+ result = redis.zrange(:delayed_queue_schedule, start,
86
+ start + count - 1)
87
+ Array(result).map(&:to_i)
88
+ end
89
+
90
+ # Returns the size of the delayed queue schedule
91
+ # this does not represent the number of items in the queue to be scheduled
92
+ def delayed_queue_schedule_size
93
+ redis.zcard :delayed_queue_schedule
94
+ end
95
+
96
+ # Returns the number of jobs for a given timestamp in the delayed queue
97
+ # schedule
98
+ def delayed_timestamp_size(timestamp)
99
+ redis.llen("delayed:#{timestamp.to_i}").to_i
100
+ end
101
+
102
+ # Returns an array of delayed items for the given timestamp
103
+ def delayed_timestamp_peek(timestamp, start, count)
104
+ if 1 == count
105
+ r = list_range "delayed:#{timestamp.to_i}", start, count
106
+ r.nil? ? [] : [r]
107
+ else
108
+ list_range "delayed:#{timestamp.to_i}", start, count
109
+ end
110
+ end
111
+
112
+ # Returns the next delayed queue timestamp
113
+ # (don't call directly)
114
+ def next_delayed_timestamp(at_time = nil)
115
+ search_first_delayed_timestamp_in_range(nil, at_time || Time.now)
116
+ end
117
+
118
+ # Returns the next item to be processed for a given timestamp, nil if
119
+ # done. (don't call directly)
120
+ # +timestamp+ can either be in seconds or a datetime
121
+ def next_item_for_timestamp(timestamp)
122
+ key = "delayed:#{timestamp.to_i}"
123
+
124
+ encoded_item = redis.lpop(key)
125
+ redis.srem("timestamps:#{encoded_item}", key)
126
+ item = decode(encoded_item)
127
+
128
+ # If the list is empty, remove it.
129
+ clean_up_timestamp(key, timestamp)
130
+ item
131
+ end
132
+
133
+ # Clears all jobs created with enqueue_at or enqueue_in
134
+ def reset_delayed_queue
135
+ Array(redis.zrange(:delayed_queue_schedule, 0, -1)).each do |item|
136
+ key = "delayed:#{item}"
137
+ items = redis.lrange(key, 0, -1)
138
+ redis.pipelined do
139
+ items.each { |ts_item| redis.del("timestamps:#{ts_item}") }
140
+ end
141
+ redis.del key
142
+ end
143
+
144
+ redis.del :delayed_queue_schedule
145
+ end
146
+
147
+ # Given an encoded item, remove it from the delayed_queue
148
+ def remove_delayed(klass, *args)
149
+ search = encode(job_to_hash(klass, args))
150
+ remove_delayed_job(search)
151
+ end
152
+
153
+ def remove_delayed_in_queue(klass, queue, *args)
154
+ search = encode(job_to_hash_with_queue(queue, klass, args))
155
+ remove_delayed_job(search)
156
+ end
157
+
158
+ # Given an encoded item, enqueue it now
159
+ def enqueue_delayed(klass, *args)
160
+ hash = job_to_hash(klass, args)
161
+ remove_delayed(klass, *args).times do
162
+ Resque::Scheduler.enqueue_from_config(hash)
163
+ end
164
+ end
165
+
166
+ def enqueue_delayed_with_queue(klass, queue, *args)
167
+ hash = job_to_hash_with_queue(queue, klass, args)
168
+ remove_delayed_in_queue(klass, queue, *args).times do
169
+ Resque::Scheduler.enqueue_from_config(hash)
170
+ end
171
+ end
172
+
173
+ # Given a block, remove jobs that return true from a block
174
+ #
175
+ # This allows for removal of delayed jobs that have arguments matching
176
+ # certain criteria
177
+ def remove_delayed_selection(klass = nil)
178
+ raise ArgumentError, 'Please supply a block' unless block_given?
179
+
180
+ found_jobs = find_delayed_selection(klass) { |args| yield(args) }
181
+ found_jobs.reduce(0) do |sum, encoded_job|
182
+ sum + remove_delayed_job(encoded_job)
183
+ end
184
+ end
185
+
186
+ # Given a block, enqueue jobs now that return true from a block
187
+ #
188
+ # This allows for enqueuing of delayed jobs that have arguments matching
189
+ # certain criteria
190
+ def enqueue_delayed_selection(klass = nil)
191
+ raise ArgumentError, 'Please supply a block' unless block_given?
192
+
193
+ found_jobs = find_delayed_selection(klass) { |args| yield(args) }
194
+ found_jobs.reduce(0) do |sum, encoded_job|
195
+ decoded_job = decode(encoded_job)
196
+ klass = Util.constantize(decoded_job['class'])
197
+ queue = decoded_job['queue']
198
+
199
+ if queue
200
+ jobs_queued = enqueue_delayed_with_queue(klass, queue, *decoded_job['args'])
201
+ else
202
+ jobs_queued = enqueue_delayed(klass, *decoded_job['args'])
203
+ end
204
+
205
+ jobs_queued + sum
206
+ end
207
+ end
208
+
209
+ # Given a block, find jobs that return true from a block
210
+ #
211
+ # This allows for finding of delayed jobs that have arguments matching
212
+ # certain criteria
213
+ def find_delayed_selection(klass = nil, &block)
214
+ raise ArgumentError, 'Please supply a block' unless block_given?
215
+
216
+ timestamps = redis.zrange(:delayed_queue_schedule, 0, -1)
217
+
218
+ # Beyond 100 there's almost no improvement in speed
219
+ found = timestamps.each_slice(100).map do |ts_group|
220
+ jobs = redis.pipelined do |r|
221
+ ts_group.each do |ts|
222
+ r.lrange("delayed:#{ts}", 0, -1)
223
+ end
224
+ end
225
+
226
+ jobs.flatten.select do |payload|
227
+ payload_matches_selection?(decode(payload), klass, &block)
228
+ end
229
+ end
230
+
231
+ found.flatten
232
+ end
233
+
234
+ # Given a timestamp and job (klass + args) it removes all instances and
235
+ # returns the count of jobs removed.
236
+ #
237
+ # O(N) where N is the number of jobs scheduled to fire at the given
238
+ # timestamp
239
+ def remove_delayed_job_from_timestamp(timestamp, klass, *args)
240
+ return 0 if Resque.inline?
241
+
242
+ key = "delayed:#{timestamp.to_i}"
243
+ encoded_job = encode(job_to_hash(klass, args))
244
+
245
+ redis.srem("timestamps:#{encoded_job}", key)
246
+ count = redis.lrem(key, 0, encoded_job)
247
+ clean_up_timestamp(key, timestamp)
248
+
249
+ count
250
+ end
251
+
252
+ def count_all_scheduled_jobs
253
+ total_jobs = 0
254
+ Array(redis.zrange(:delayed_queue_schedule, 0, -1)).each do |ts|
255
+ total_jobs += redis.llen("delayed:#{ts}").to_i
256
+ end
257
+ total_jobs
258
+ end
259
+
260
+ # Discover if a job has been delayed.
261
+ # Examples
262
+ # Resque.delayed?(MyJob)
263
+ # Resque.delayed?(MyJob, id: 1)
264
+ # Returns true if the job has been delayed
265
+ def delayed?(klass, *args)
266
+ !scheduled_at(klass, *args).empty?
267
+ end
268
+
269
+ # Returns delayed jobs schedule timestamp for +klass+, +args+.
270
+ def scheduled_at(klass, *args)
271
+ search = encode(job_to_hash(klass, args))
272
+ redis.smembers("timestamps:#{search}").map do |key|
273
+ key.tr('delayed:', '').to_i
274
+ end
275
+ end
276
+
277
+ def last_enqueued_at(job_name, date)
278
+ redis.hset('delayed:last_enqueued_at', job_name, date)
279
+ end
280
+
281
+ def get_last_enqueued_at(job_name)
282
+ redis.hget('delayed:last_enqueued_at', job_name)
283
+ end
284
+
285
+ private
286
+
287
+ def job_to_hash(klass, args)
288
+ { class: klass.to_s, args: args, queue: queue_from_class(klass) }
289
+ end
290
+
291
+ def job_to_hash_with_queue(queue, klass, args)
292
+ { class: klass.to_s, args: args, queue: queue }
293
+ end
294
+
295
+ # Removes a job from the queue, but not modify the timestamp schedule. This method
296
+ # will not effect the output of `delayed_queue_schedule_size`
297
+ def remove_delayed_job(encoded_job)
298
+ return 0 if Resque.inline?
299
+
300
+ timestamps = redis.smembers("timestamps:#{encoded_job}")
301
+
302
+ replies = redis.pipelined do
303
+ timestamps.each do |key|
304
+ redis.lrem(key, 0, encoded_job)
305
+ redis.srem("timestamps:#{encoded_job}", key)
306
+ end
307
+ end
308
+
309
+ # timestamp key is not removed from the schedule, this is done later
310
+ # by the scheduler loop
311
+
312
+ return 0 if replies.nil? || replies.empty?
313
+ replies.each_slice(2).map(&:first).inject(:+)
314
+ end
315
+
316
+ def clean_up_timestamp(key, timestamp)
317
+ # Use a watch here to ensure nobody adds jobs to this delayed
318
+ # queue while we're removing it.
319
+ redis.watch(key) do
320
+ if redis.llen(key).to_i == 0
321
+ # If the list is empty, remove it.
322
+ redis.multi do
323
+ redis.del(key)
324
+ redis.zrem(:delayed_queue_schedule, timestamp.to_i)
325
+ end
326
+ else
327
+ redis.redis.unwatch
328
+ end
329
+ end
330
+ end
331
+
332
+ def search_first_delayed_timestamp_in_range(start_at, stop_at)
333
+ start_at = start_at.nil? ? '-inf' : start_at.to_i
334
+ stop_at = stop_at.nil? ? '+inf' : stop_at.to_i
335
+
336
+ items = redis.zrangebyscore(
337
+ :delayed_queue_schedule, start_at, stop_at,
338
+ limit: [0, 1]
339
+ )
340
+ timestamp = items.nil? ? nil : Array(items).first
341
+ timestamp.to_i unless timestamp.nil?
342
+ end
343
+
344
+ def payload_matches_selection?(decoded_payload, klass)
345
+ return false if decoded_payload.nil?
346
+ job_class = decoded_payload['class']
347
+ relevant_class = (klass.nil? || klass.to_s == job_class)
348
+ relevant_class && yield(decoded_payload['args'])
349
+ end
350
+
351
+ def plugin
352
+ Resque::Scheduler::Plugin
353
+ end
354
+ end
355
+ end
356
+ end