ealdent-resque-scheduler 2.0.0.e

Sign up to get free protection for your applications and to get access to all the features.
@@ -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