rescheduler 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (2) hide show
  1. data/lib/rescheduler.rb +177 -27
  2. metadata +4 -4
@@ -1,4 +1,5 @@
1
- require 'json'
1
+ require 'time' # Needed for Time.parse
2
+ require 'multi_json'
2
3
  require 'redis'
3
4
 
4
5
  module Rescheduler
@@ -8,6 +9,10 @@ module Rescheduler
8
9
  attr_accessor :config
9
10
  self.config = {}
10
11
 
12
+ # Debugging only (since initializers won't reload)
13
+ self.config[:prefix] = REDIS_PREFIX # This ensures cross DB deployment persistence
14
+ self.config[:redis] = RedisHelper.redis
15
+
11
16
  #==========================
12
17
  # Management routines
13
18
  def prefix
@@ -22,7 +27,8 @@ module Rescheduler
22
27
  redis.del(keys)
23
28
  end
24
29
 
25
- # Return a hash of statistics
30
+ # Return a hash of statistics, in this format
31
+ #
26
32
  def stats
27
33
  loop do
28
34
  redis.watch(rk_args) do
@@ -76,19 +82,36 @@ module Rescheduler
76
82
  end
77
83
  end
78
84
 
85
+ # NOTE: Use this with care. Some lost jobs can be moved to immediate queue instead of deleted
86
+ # Pass '*' to delete everything.
87
+ def purge_bad_jobs(queue = '*')
88
+ pending, running, deferred = redis.multi do
89
+ redis.hkeys(rk_args)
90
+ redis.hkeys(rk_running)
91
+ redis.zrange(rk_deferred, 0, -1)
92
+ end
93
+
94
+ bad = pending - running - deferred
95
+ bad.each do |qnid|
96
+ next if queue != '*' && !qnid.start_with?(queue + ':')
97
+ idelete(qnid)
98
+ end
99
+ end
100
+
79
101
  #==========================
80
102
  # Task producer routines
81
103
  # Add an immediate task to the queue
82
104
  def enqueue(options=nil)
83
105
  options ||= {}
84
- now = Time.now
106
+ now = Time.now.to_i
85
107
 
86
108
  # Error check
87
109
  validate_queue_name(options[:queue]) if options.include?(:queue)
110
+ validate_recurrance(options)
88
111
 
89
112
  # Convert due_in to due_at
90
113
  if options.include?(:due_in)
91
- raise ArgumentError, ':due_it and :due_at can not be both specified' if options.include?(:due_at)
114
+ raise ArgumentError, ':due_in and :due_at can not be both specified' if options.include?(:due_at)
92
115
  options[:due_at] = now + options[:due_in]
93
116
  end
94
117
 
@@ -98,7 +121,9 @@ module Rescheduler
98
121
  options[:id] = redis.incr(rk_counter)
99
122
  end
100
123
 
101
- ts = options[:due_at].to_i || 0
124
+ ts = options[:due_at].to_i
125
+ ts = now if ts == 0 # 0 means immediate
126
+ options[:due_at] = ts # Convert :due_at to integer timestamp to be reused in recurrance
102
127
  qnid = get_qnid(options)
103
128
 
104
129
  # Encode and save args
@@ -109,18 +134,18 @@ module Rescheduler
109
134
  end
110
135
 
111
136
  # Save options
112
- redis.hset(rk_args, qnid, options.to_json)
137
+ redis.hset(rk_args, qnid, MultiJson.dump(options))
113
138
 
114
139
  # Determine the due time
115
- if ts > now.to_i # Future job
140
+ if ts > now # Future job
116
141
  redis.zadd(rk_deferred, ts, qnid)
117
142
  else
118
143
  redis.lpush(rk_queue(options[:queue]), qnid)
119
144
  end
120
145
  end
121
146
 
122
- # Now decide if we need to wait up the workers (outside of the transaction)
123
- if (ts > now.to_i)
147
+ # Now decide if we need to wake up the workers (outside of the transaction)
148
+ if (ts > now)
124
149
  dt = redis.zrange(rk_deferred, 0, 0)[0]
125
150
  # Wake up workers if our job is the first one in deferred queue, so they can reset timeout
126
151
  if dt && dt == qnid
@@ -142,14 +167,58 @@ module Rescheduler
142
167
 
143
168
  def delete(options)
144
169
  qnid = get_qnid(options)
145
-
146
- redis.multi do
147
- redis.hdel(rk_args, qnid)
148
- redis.zrem(rk_deferred, qnid)
149
- redis.lrem(rk_queue(options[:queue]), 0, qnid)
170
+ idelete(qnid)
171
+ end
172
+
173
+ # Make a job immediate if it is not already. Erase the wait
174
+ def make_immediate(options)
175
+ dtn = rk_deferred # Make a copy in case prefix changes
176
+ qnid = get_qnid(options)
177
+ ntry = 0
178
+ loop do
179
+ redis.watch(dtn) do
180
+ if redis.zcard(dtn, qnid) == 0
181
+ redis.unwatch(dtn)
182
+ return # Not a deferred job
183
+ else
184
+ redis.multi
185
+ redis.zrem(dtn, qnid)
186
+ q = qnid_to_queue(qnid)
187
+ redis.lpush(rk_queue(q), qnid)
188
+ if !redis.exec
189
+ # Contention happens, retrying
190
+ log_debug("make_immediate contention for #{qnid}")
191
+ Kernel.sleep (rand(ntry * 1000) / 1000.0) if ntry > 0
192
+ else
193
+ return # Done
194
+ end
195
+ end
196
+ end
197
+ ntry += 1
150
198
  end
151
199
  end
152
200
 
201
+ #=================
202
+ # Serialization (in case it is needed to transfer all Rescheduler across to another redis instance)
203
+
204
+ # Atomically save the state to file and stop all workers (state in redis is not destroyed)
205
+ # This function can take a while as it will wait for running jobs to finish first.
206
+ def serialize_and_stop(filename)
207
+ # TODO
208
+ end
209
+
210
+ # Load state from a file. Will merge into existing jobs if there are any (make sure it is done only once)
211
+ # This can be done before any worker starts, or after.
212
+ # Workers still need to be manually started
213
+ def deserialize(filename)
214
+ # TODO
215
+ end
216
+
217
+ # Clear redis states and delete all jobs (useful before deserialize)
218
+ def erase_all
219
+ # TODO
220
+ end
221
+
153
222
  #=================
154
223
  # Job definition
155
224
  # Task consumer routines
@@ -159,6 +228,16 @@ module Rescheduler
159
228
  return nil
160
229
  end
161
230
 
231
+ #=================
232
+ # Error handling
233
+ def on_error(tube=nil, &block)
234
+ if tube != nil
235
+ @error_handlers ||= {}
236
+ @error_handlers[tube] = block
237
+ else
238
+ @global_error_handler = block;
239
+ end
240
+ end
162
241
  #=================
163
242
  # Runner/Maintenance routines
164
243
  def start(*tubes)
@@ -217,9 +296,19 @@ module Rescheduler
217
296
 
218
297
  private
219
298
 
299
+ # Internal routines operating out of qnid
300
+ def idelete(qnid)
301
+ queue = qnid.split(':').first
302
+ redis.multi do
303
+ redis.hdel(rk_args, qnid)
304
+ redis.zrem(rk_deferred, qnid)
305
+ redis.lrem(rk_queue(queue), 0, qnid)
306
+ end
307
+ end
308
+
220
309
  # Runner routines
221
310
  def run_job(qnid)
222
- # First load job parameters for running
311
+ # 1. load job parameters for running
223
312
  optstr = nil
224
313
  begin
225
314
  res = nil
@@ -237,36 +326,64 @@ module Rescheduler
237
326
  end
238
327
  if !res
239
328
  # Contention, try read again
240
- log_debug("Job read contention")
329
+ log_debug("Job read contention: (#{qnid})")
241
330
  end
242
331
  end
243
332
  end until res
244
333
 
245
334
  # Parse and run
246
- opt = JSON.parse(optstr)
247
- #opt.symbolize_keys # Be mindful of non-rails people, explicitly here
248
- sopt = {}
249
- opt.each { |key, val| sopt[key.to_sym] = val }
335
+ sopt = MultiJson.load(optstr, :symbolize_keys => true)
336
+
337
+ # Handle parameters
338
+ if (sopt.include?(:recur_every))
339
+ newopt = sopt.dup
340
+ newopt[:due_at] = (sopt[:due_at] || Time.now).to_i + sopt[:recur_every].to_i
341
+ newopt.delete(:due_in) # In case the first job was specified by :due_in
342
+ enqueue(newopt)
343
+ end
344
+
345
+ if (sopt.include?(:recur_daily))
346
+ newopt = sopt.dup
347
+ newopt[:due_at] = time_from_recur_daily(sopt[:recur_daily])
348
+ newopt.delete(:due_in) # In case the first job was specified by :due_in
349
+ enqueue(newopt)
350
+ end
250
351
 
251
352
  # 2. Find runner and invoke it
252
353
  begin
253
354
  log_debug(">>---- Starting #{qnid}")
254
355
  runner = @runners[qnid_to_queue(qnid)]
255
- runner.call(sopt)
256
- log_debug("----<< Finished #{qnid}")
257
- rescue
258
- log_debug("----<< Failed #{qnid}: #{$!}")
356
+ if runner.is_a?(Proc)
357
+ runner.call(sopt)
358
+ log_debug("----<< Finished #{qnid}")
359
+ else
360
+ log_debug("----<< Failed #{qnid}: Unknown queue name, handler not defined")
361
+ end
362
+ rescue Exception => e
363
+ log_debug("----<< Failed #{qnid}: -------------\n #{$!}")
364
+ log_debug(e.backtrace[0..4].join("\n"))
365
+ handle_error(e, qnid, sopt)
366
+ log_debug("------------------------------------\n")
259
367
  end
260
368
 
261
369
  # 3. Remove job from running list (Done)
262
370
  redis.hdel(rk_running, qnid)
263
371
  end
264
372
 
373
+ def handle_error(e, qnid, sopt)
374
+ error_handler = @error_handlers && @error_handlers[qnid]
375
+ if error_handler
376
+ error_handler.call(e, sopt)
377
+ elsif @global_error_handler
378
+ @global_error_handler.call(e, sopt)
379
+ end
380
+ end
381
+
265
382
  # Helper routines
266
383
 
267
384
  # Find all the "due" deferred jobs and move them into respective queues
268
385
  def service_deferred_jobs
269
- dtn = rk_deferred
386
+ dtn = rk_deferred # Make a copy in case prefix changes
270
387
  ntry = 0
271
388
  loop do
272
389
  curtime = Time.now.to_i
@@ -333,16 +450,49 @@ module Rescheduler
333
450
  end
334
451
 
335
452
  def redis
336
- @redis ||= Redis.new(@config[:redis_connection] || {})
453
+ @redis ||= @config[:redis] || Redis.new(@config[:redis_connection] || {})
337
454
  end
338
455
 
339
456
  def validate_queue_name(queue)
340
457
  raise ArgumentError, 'Queue name can not contain special characters' if queue.include?(':')
341
458
  end
342
459
 
460
+ def parse_seconds_of_day(recur_daily)
461
+ return recur_daily if recur_daily.is_a?(Fixnum)
462
+ time = Time.parse(recur_daily)
463
+ return time.to_i - Time.new(time.year, time.month, time.day).to_i
464
+ end
465
+
466
+ # Find the next recur time
467
+ def time_from_recur_daily(recur_daily, now = Time.now)
468
+ recur = parse_seconds_of_day(recur_daily)
469
+ t = Time.new(now.year, now.month, now.day).to_i + recur
470
+ t += 86400 if t < now.to_i
471
+ return Time.at(t)
472
+ end
473
+
474
+ def validate_recurrance(options)
475
+ rcnt = 0
476
+ if (options.include?(:recur_every))
477
+ rcnt += 1
478
+ raise 'Expect integer for :recur_every parameter' unless options[:recur_every].is_a?(Fixnum)
479
+ end
480
+
481
+ if (options.include?(:recur_daily))
482
+ rcnt += 1
483
+ time = time_from_recur_daily(options[:recur_daily]) # Try parse and make sure we can
484
+ unless options.include?(:due_at) || options.include?(:due_in)
485
+ options[:due_at] = time # Setup the first run
486
+ end
487
+ end
488
+ raise 'Can only specify one recurrance parameter' if rcnt > 1
489
+ end
490
+
343
491
  # Logging facility
344
492
  def log_debug(msg)
345
- print("#{Time.now.to_i} #{msg}\n")
493
+ return if @config[:silent]
494
+ print("#{Time.now.iso8601} #{msg}\n")
495
+ STDOUT.flush
346
496
  end
347
497
 
348
498
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rescheduler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-10-01 00:00:00.000000000 Z
12
+ date: 2014-04-22 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: redis
@@ -28,7 +28,7 @@ dependencies:
28
28
  - !ruby/object:Gem::Version
29
29
  version: '0'
30
30
  - !ruby/object:Gem::Dependency
31
- name: json
31
+ name: multi_json
32
32
  requirement: !ruby/object:Gem::Requirement
33
33
  none: false
34
34
  requirements:
@@ -72,7 +72,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
72
72
  version: '0'
73
73
  requirements: []
74
74
  rubyforge_project:
75
- rubygems_version: 1.8.24
75
+ rubygems_version: 1.8.25
76
76
  signing_key:
77
77
  specification_version: 3
78
78
  summary: A job queue for immediate and delayed jobs using Redis