ealdent-resque-scheduler 2.0.0.e

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.
@@ -0,0 +1,21 @@
1
+ require 'bundler'
2
+ require 'rake/rdoctask'
3
+ Bundler::GemHelper.install_tasks
4
+
5
+ $LOAD_PATH.unshift 'lib'
6
+
7
+ task :default => :test
8
+
9
+ desc "Run tests"
10
+ task :test do
11
+ Dir['test/*_test.rb'].each do |f|
12
+ require File.expand_path(f)
13
+ end
14
+ end
15
+
16
+ Rake::RDocTask.new do |rd|
17
+ rd.main = "README.markdown"
18
+ rd.rdoc_files.include("README.markdown", "lib/**/*.rb")
19
+ rd.rdoc_dir = 'doc'
20
+ end
21
+
@@ -0,0 +1,285 @@
1
+ require 'rufus/scheduler'
2
+ require 'thwait'
3
+
4
+ module Resque
5
+
6
+ class Scheduler
7
+
8
+ extend Resque::Helpers
9
+
10
+ class << self
11
+
12
+ # If true, logs more stuff...
13
+ attr_accessor :verbose
14
+
15
+ # If set, produces no output
16
+ attr_accessor :mute
17
+
18
+ # If set, will try to update the schulde in the loop
19
+ attr_accessor :dynamic
20
+
21
+ # Amount of time in seconds to sleep between polls of the delayed
22
+ # queue. Defaults to 5
23
+ attr_writer :poll_sleep_amount
24
+
25
+ # the Rufus::Scheduler jobs that are scheduled
26
+ def scheduled_jobs
27
+ @@scheduled_jobs
28
+ end
29
+
30
+ def poll_sleep_amount
31
+ @poll_sleep_amount ||= 5 # seconds
32
+ end
33
+
34
+ # Schedule all jobs and continually look for delayed jobs (never returns)
35
+ def run
36
+ $0 = "resque-scheduler: Starting"
37
+ # trap signals
38
+ register_signal_handlers
39
+
40
+ # Load the schedule into rufus
41
+ # If dynamic is set, load that schedule otherwise use normal load
42
+ if dynamic
43
+ reload_schedule!
44
+ else
45
+ load_schedule!
46
+ end
47
+
48
+ # Now start the scheduling part of the loop.
49
+ loop do
50
+ begin
51
+ handle_delayed_items
52
+ update_schedule if dynamic
53
+ rescue Errno::EAGAIN, Errno::ECONNRESET => e
54
+ warn e.message
55
+ end
56
+ poll_sleep
57
+ end
58
+
59
+ # never gets here.
60
+ end
61
+
62
+ # For all signals, set the shutdown flag and wait for current
63
+ # poll/enqueing to finish (should be almost istant). In the
64
+ # case of sleeping, exit immediately.
65
+ def register_signal_handlers
66
+ trap("TERM") { shutdown }
67
+ trap("INT") { shutdown }
68
+
69
+ begin
70
+ trap('QUIT') { shutdown }
71
+ trap('USR1') { print_schedule }
72
+ trap('USR2') { reload_schedule! }
73
+ rescue ArgumentError
74
+ warn "Signals QUIT and USR1 and USR2 not supported."
75
+ end
76
+ end
77
+
78
+ def print_schedule
79
+ if rufus_scheduler
80
+ log! "Scheduling Info\tLast Run"
81
+ scheduler_jobs = rufus_scheduler.all_jobs
82
+ scheduler_jobs.each do |k, v|
83
+ log! "#{v.t}\t#{v.last}\t"
84
+ end
85
+ end
86
+ end
87
+
88
+ # Pulls the schedule from Resque.schedule and loads it into the
89
+ # rufus scheduler instance
90
+ def load_schedule!
91
+ procline "Loading Schedule"
92
+
93
+ # Need to load the schedule from redis for the first time if dynamic
94
+ Resque.reload_schedule! if dynamic
95
+
96
+ log! "Schedule empty! Set Resque.schedule" if Resque.schedule.empty?
97
+
98
+ @@scheduled_jobs = {}
99
+
100
+ Resque.schedule.each do |name, config|
101
+ load_schedule_job(name, config)
102
+ end
103
+ Resque.redis.del(:schedules_changed)
104
+ procline "Schedules Loaded"
105
+ end
106
+
107
+ # Loads a job schedule into the Rufus::Scheduler and stores it in @@scheduled_jobs
108
+ def load_schedule_job(name, config)
109
+ # If rails_env is set in the config, enforce ENV['RAILS_ENV'] as
110
+ # required for the jobs to be scheduled. If rails_env is missing, the
111
+ # job should be scheduled regardless of what ENV['RAILS_ENV'] is set
112
+ # to.
113
+ if config['rails_env'].nil? || rails_env_matches?(config)
114
+ log! "Scheduling #{name} "
115
+ interval_defined = false
116
+ interval_types = %w{cron every}
117
+ interval_types.each do |interval_type|
118
+ if !config[interval_type].nil? && config[interval_type].length > 0
119
+ begin
120
+ @@scheduled_jobs[name] = rufus_scheduler.send(interval_type, config[interval_type]) do
121
+ log! "queueing #{config['class']} (#{name})"
122
+ enqueue_from_config(config)
123
+ end
124
+ rescue Exception => e
125
+ log! "#{e.class.name}: #{e.message}"
126
+ end
127
+ interval_defined = true
128
+ break
129
+ end
130
+ end
131
+ unless interval_defined
132
+ log! "no #{interval_types.join(' / ')} found for #{config['class']} (#{name}) - skipping"
133
+ end
134
+ end
135
+ end
136
+
137
+ # Returns true if the given schedule config hash matches the current
138
+ # ENV['RAILS_ENV']
139
+ def rails_env_matches?(config)
140
+ config['rails_env'] && ENV['RAILS_ENV'] && config['rails_env'].gsub(/\s/,'').split(',').include?(ENV['RAILS_ENV'])
141
+ end
142
+
143
+ # Handles queueing delayed items
144
+ # at_time - Time to start scheduling items (default: now).
145
+ def handle_delayed_items(at_time=nil)
146
+ item = nil
147
+ if timestamp = Resque.next_delayed_timestamp(at_time)
148
+ procline "Processing Delayed Items"
149
+ while !timestamp.nil?
150
+ enqueue_delayed_items_for_timestamp(timestamp)
151
+ timestamp = Resque.next_delayed_timestamp(at_time)
152
+ end
153
+ end
154
+ end
155
+
156
+ # Enqueues all delayed jobs for a timestamp
157
+ def enqueue_delayed_items_for_timestamp(timestamp)
158
+ item = nil
159
+ begin
160
+ handle_shutdown do
161
+ if item = Resque.next_item_for_timestamp(timestamp)
162
+ log "queuing #{item['class']} [delayed]"
163
+ enqueue_from_config(item)
164
+ end
165
+ end
166
+ # continue processing until there are no more ready items in this timestamp
167
+ end while !item.nil?
168
+ end
169
+
170
+ def handle_shutdown
171
+ exit if @shutdown
172
+ yield
173
+ exit if @shutdown
174
+ end
175
+
176
+ # Enqueues a job based on a config hash
177
+ def enqueue_from_config(job_config)
178
+ args = job_config['args'] || job_config[:args]
179
+
180
+ klass_name = job_config['class'] || job_config[:class]
181
+ klass = constantize(klass_name) rescue klass_name
182
+
183
+ params = args.is_a?(Hash) ? [args] : Array(args)
184
+ queue = job_config['queue'] || job_config[:queue] || Resque.queue_from_class(klass)
185
+ # Support custom job classes like those that inherit from Resque::JobWithStatus (resque-status)
186
+ if (job_klass = job_config['custom_job_class']) && (job_klass != 'Resque::Job')
187
+ # The custom job class API must offer a static "scheduled" method. If the custom
188
+ # job class can not be constantized (via a requeue call from the web perhaps), fall
189
+ # back to enqueing normally via Resque::Job.create.
190
+ begin
191
+ constantize(job_klass).scheduled(queue, klass_name, *params)
192
+ rescue NameError
193
+ # Note that the custom job class (job_config['custom_job_class']) is the one enqueued
194
+ Resque::Job.create(queue, job_klass, *params)
195
+ end
196
+ else
197
+ # hack to avoid havoc for people shoving stuff into queues
198
+ # for non-existent classes (for example: running scheduler in
199
+ # one app that schedules for another
200
+ if Class === klass
201
+ Resque.enqueue(klass, *params)
202
+ else
203
+ Resque::Job.create(queue, klass, *params)
204
+ end
205
+ end
206
+ rescue
207
+ log! "Failed to enqueue #{klass_name}:\n #{$!}"
208
+ end
209
+
210
+ def rufus_scheduler
211
+ @rufus_scheduler ||= Rufus::Scheduler.start_new
212
+ end
213
+
214
+ # Stops old rufus scheduler and creates a new one. Returns the new
215
+ # rufus scheduler
216
+ def clear_schedule!
217
+ rufus_scheduler.stop
218
+ @rufus_scheduler = nil
219
+ @@scheduled_jobs = {}
220
+ rufus_scheduler
221
+ end
222
+
223
+ def reload_schedule!
224
+ procline "Reloading Schedule"
225
+ clear_schedule!
226
+ load_schedule!
227
+ end
228
+
229
+ def update_schedule
230
+ if Resque.redis.scard(:schedules_changed) > 0
231
+ procline "Updating schedule"
232
+ Resque.reload_schedule!
233
+ while schedule_name = Resque.redis.spop(:schedules_changed)
234
+ if Resque.schedule.keys.include?(schedule_name)
235
+ unschedule_job(schedule_name)
236
+ load_schedule_job(schedule_name, Resque.schedule[schedule_name])
237
+ else
238
+ unschedule_job(schedule_name)
239
+ end
240
+ end
241
+ procline "Schedules Loaded"
242
+ end
243
+ end
244
+
245
+ def unschedule_job(name)
246
+ if scheduled_jobs[name]
247
+ log "Removing schedule #{name}"
248
+ scheduled_jobs[name].unschedule
249
+ @@scheduled_jobs.delete(name)
250
+ end
251
+ end
252
+
253
+ # Sleeps and returns true
254
+ def poll_sleep
255
+ @sleeping = true
256
+ handle_shutdown { sleep poll_sleep_amount }
257
+ @sleeping = false
258
+ true
259
+ end
260
+
261
+ # Sets the shutdown flag, exits if sleeping
262
+ def shutdown
263
+ @shutdown = true
264
+ exit if @sleeping
265
+ end
266
+
267
+ def log!(msg)
268
+ puts "#{Time.now.strftime("%Y-%m-%d %H:%M:%S")} #{msg}" unless mute
269
+ end
270
+
271
+ def log(msg)
272
+ # add "verbose" logic later
273
+ log!(msg) if verbose
274
+ end
275
+
276
+ def procline(string)
277
+ log! string
278
+ $0 = "resque-scheduler-#{ResqueScheduler::Version}: #{string}"
279
+ end
280
+
281
+ end
282
+
283
+ end
284
+
285
+ end
@@ -0,0 +1,248 @@
1
+ require 'rubygems'
2
+ require 'resque'
3
+ require 'resque/server'
4
+ require 'resque_scheduler/version'
5
+ require 'resque/scheduler'
6
+ require 'resque_scheduler/server'
7
+
8
+ module ResqueScheduler
9
+
10
+ #
11
+ # Accepts a new schedule configuration of the form:
12
+ #
13
+ # {'some_name' => {"cron" => "5/* * * *",
14
+ # "class" => "DoSomeWork",
15
+ # "args" => "work on this string",
16
+ # "description" => "this thing works it"s butter off"},
17
+ # ...}
18
+ #
19
+ # 'some_name' can be anything and is used only to describe and reference
20
+ # the scheduled job
21
+ #
22
+ # :cron can be any cron scheduling string :job can be any resque job class
23
+ #
24
+ # :every can be used in lieu of :cron. see rufus-scheduler's 'every' usage
25
+ # for valid syntax. If :cron is present it will take precedence over :every.
26
+ #
27
+ # :class must be a resque worker class
28
+ #
29
+ # :args can be any yaml which will be converted to a ruby literal and
30
+ # passed in a params. (optional)
31
+ #
32
+ # :rails_envs is the list of envs where the job gets loaded. Envs are
33
+ # comma separated (optional)
34
+ #
35
+ # :description is just that, a description of the job (optional). If
36
+ # params is an array, each element in the array is passed as a separate
37
+ # param, otherwise params is passed in as the only parameter to perform.
38
+ def schedule=(schedule_hash)
39
+ if Resque::Scheduler.dynamic
40
+ schedule_hash.each do |name, job_spec|
41
+ set_schedule(name, job_spec)
42
+ end
43
+ end
44
+ @schedule = schedule_hash
45
+ end
46
+
47
+ # Returns the schedule hash
48
+ def schedule
49
+ @schedule ||= {}
50
+ end
51
+
52
+ # reloads the schedule from redis
53
+ def reload_schedule!
54
+ @schedule = get_schedules
55
+ end
56
+
57
+ # gets the schedule as it exists in redis
58
+ def get_schedules
59
+ if redis.exists(:schedules)
60
+ redis.hgetall(:schedules).tap do |h|
61
+ h.each do |name, config|
62
+ h[name] = decode(config)
63
+ end
64
+ end
65
+ else
66
+ nil
67
+ end
68
+ end
69
+
70
+ # Create or update a schedule with the provided name and configuration.
71
+ #
72
+ # Note: values for class and custom_job_class need to be strings,
73
+ # not constants.
74
+ #
75
+ # Resque.set_schedule('some_job', {:class => 'SomeJob',
76
+ # :every => '15mins',
77
+ # :queue => 'high',
78
+ # :args => '/tmp/poop'})
79
+ def set_schedule(name, config)
80
+ existing_config = get_schedule(name)
81
+ unless existing_config && existing_config == config
82
+ redis.hset(:schedules, name, encode(config))
83
+ redis.sadd(:schedules_changed, name)
84
+ end
85
+ config
86
+ end
87
+
88
+ # retrive the schedule configuration for the given name
89
+ def get_schedule(name)
90
+ decode(redis.hget(:schedules, name))
91
+ end
92
+
93
+ # remove a given schedule by name
94
+ def remove_schedule(name)
95
+ redis.hdel(:schedules, name)
96
+ redis.sadd(:schedules_changed, name)
97
+ end
98
+
99
+ # This method is nearly identical to +enqueue+ only it also
100
+ # takes a timestamp which will be used to schedule the job
101
+ # for queueing. Until timestamp is in the past, the job will
102
+ # sit in the schedule list.
103
+ def enqueue_at(timestamp, klass, *args)
104
+ validate_job!(klass)
105
+ delayed_push(timestamp, job_to_hash(klass, args))
106
+ end
107
+
108
+ # Identical to +enqueue_at+, except you can also specify
109
+ # a queue in which the job will be placed after the
110
+ # timestamp has passed.
111
+ def enqueue_at_with_queue(queue, timestamp, klass, *args)
112
+ delayed_push(timestamp, job_to_hash_with_queue(queue, klass, args))
113
+ end
114
+
115
+ # Identical to enqueue_at but takes number_of_seconds_from_now
116
+ # instead of a timestamp.
117
+ def enqueue_in(number_of_seconds_from_now, klass, *args)
118
+ enqueue_at(Time.now + number_of_seconds_from_now, klass, *args)
119
+ end
120
+
121
+ # Identical to +enqueue_in+, except you can also specify
122
+ # a queue in which the job will be placed after the
123
+ # number of seconds has passed.
124
+ def enqueue_in_with_queue(queue, number_of_seconds_from_now, klass, *args)
125
+ enqueue_at_with_queue(queue, Time.now + number_of_seconds_from_now, klass, *args)
126
+ end
127
+
128
+ # Used internally to stuff the item into the schedule sorted list.
129
+ # +timestamp+ can be either in seconds or a datetime object
130
+ # Insertion if O(log(n)).
131
+ # Returns true if it's the first job to be scheduled at that time, else false
132
+ def delayed_push(timestamp, item)
133
+ # First add this item to the list for this timestamp
134
+ redis.rpush("delayed:#{timestamp.to_i}", encode(item))
135
+
136
+ # Now, add this timestamp to the zsets. The score and the value are
137
+ # the same since we'll be querying by timestamp, and we don't have
138
+ # anything else to store.
139
+ redis.zadd :delayed_queue_schedule, timestamp.to_i, timestamp.to_i
140
+ end
141
+
142
+ # Returns an array of timestamps based on start and count
143
+ def delayed_queue_peek(start, count)
144
+ Array(redis.zrange(:delayed_queue_schedule, start, start+count)).collect{|x| x.to_i}
145
+ end
146
+
147
+ # Returns the size of the delayed queue schedule
148
+ def delayed_queue_schedule_size
149
+ redis.zcard :delayed_queue_schedule
150
+ end
151
+
152
+ # Returns the number of jobs for a given timestamp in the delayed queue schedule
153
+ def delayed_timestamp_size(timestamp)
154
+ redis.llen("delayed:#{timestamp.to_i}").to_i
155
+ end
156
+
157
+ # Returns an array of delayed items for the given timestamp
158
+ def delayed_timestamp_peek(timestamp, start, count)
159
+ if 1 == count
160
+ r = list_range "delayed:#{timestamp.to_i}", start, count
161
+ r.nil? ? [] : [r]
162
+ else
163
+ list_range "delayed:#{timestamp.to_i}", start, count
164
+ end
165
+ end
166
+
167
+ # Returns the next delayed queue timestamp
168
+ # (don't call directly)
169
+ def next_delayed_timestamp(at_time=nil)
170
+ items = redis.zrangebyscore :delayed_queue_schedule, '-inf', (at_time || Time.now).to_i, :limit => [0, 1]
171
+ timestamp = items.nil? ? nil : Array(items).first
172
+ timestamp.to_i unless timestamp.nil?
173
+ end
174
+
175
+ # Returns the next item to be processed for a given timestamp, nil if
176
+ # done. (don't call directly)
177
+ # +timestamp+ can either be in seconds or a datetime
178
+ def next_item_for_timestamp(timestamp)
179
+ key = "delayed:#{timestamp.to_i}"
180
+
181
+ item = decode redis.lpop(key)
182
+
183
+ # If the list is empty, remove it.
184
+ clean_up_timestamp(key, timestamp)
185
+ item
186
+ end
187
+
188
+ # Clears all jobs created with enqueue_at or enqueue_in
189
+ def reset_delayed_queue
190
+ Array(redis.zrange(:delayed_queue_schedule, 0, -1)).each do |item|
191
+ redis.del "delayed:#{item}"
192
+ end
193
+
194
+ redis.del :delayed_queue_schedule
195
+ end
196
+
197
+ # given an encoded item, remove it from the delayed_queue
198
+ def remove_delayed(klass, *args)
199
+ destroyed = 0
200
+ search = encode(job_to_hash(klass, args))
201
+ Array(redis.keys("delayed:*")).each do |key|
202
+ destroyed += redis.lrem key, 0, search
203
+ end
204
+ destroyed
205
+ end
206
+
207
+ def count_all_scheduled_jobs
208
+ total_jobs = 0
209
+ Array(redis.zrange(:delayed_queue_schedule, 0, -1)).each do |timestamp|
210
+ total_jobs += redis.llen("delayed:#{timestamp}").to_i
211
+ end
212
+ total_jobs
213
+ end
214
+
215
+ private
216
+
217
+ def job_to_hash(klass, args)
218
+ {:class => klass.to_s, :args => args, :queue => queue_from_class(klass)}
219
+ end
220
+
221
+ def job_to_hash_with_queue(queue, klass, args)
222
+ {:class => klass.to_s, :args => args, :queue => queue}
223
+ end
224
+
225
+ def clean_up_timestamp(key, timestamp)
226
+ # If the list is empty, remove it.
227
+ if 0 == redis.llen(key).to_i
228
+ redis.del key
229
+ redis.zrem :delayed_queue_schedule, timestamp.to_i
230
+ end
231
+ end
232
+
233
+ def validate_job!(klass)
234
+ if klass.to_s.empty?
235
+ raise Resque::NoClassError.new("Jobs must be given a class.")
236
+ end
237
+
238
+ unless queue_from_class(klass)
239
+ raise Resque::NoQueueError.new("Jobs must be placed onto a queue.")
240
+ end
241
+ end
242
+
243
+ end
244
+
245
+ Resque.extend ResqueScheduler
246
+ Resque::Server.class_eval do
247
+ include ResqueScheduler::Server
248
+ end