rescheduler 0.4.1 → 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 5e0575537baa956f4e8628a7892d9bcc0c3c6a80
4
+ data.tar.gz: 1481a1e1b3f2f4e491787793466db8479235ff61
5
+ SHA512:
6
+ metadata.gz: 2107fe553f4b8a9570b8623d5d46b46552d8ead112201c7d80dd62df309ad280b3fb9d3c3876995ac02674a035e02f6d5f2653e959b03b346fdf589e2600c26a
7
+ data.tar.gz: f897e041abbab34c119c70aad94c9333c4f4c94b52d72940b72483844d2be4f38eea31de2d38bc91401c6e2f7c9e3083db0b2b1b014926f1d383a7f5c715a844
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env ruby
2
+ =begin
3
+ rescheduler_launch <options> [<worker_name> <jobs.rb>]
4
+ Options:
5
+ --rails=<folder> Use rails environment from the givne folder
6
+ --log=<file> Specify logfile (in Rails mode default to <rails_foler>/log/<worker_name>.log)
7
+ --env=<env> Set these ENV into the new process
8
+ --debug Do not daemonize or redirect output, work with current STDOUT
9
+ --respawn_jobs=N Respawn worker after N jobs
10
+ --respawn_time=N Respawn every N seconds (NOTE: Only will respawn after finish a job)
11
+ Internal purpose only:
12
+ --respawn Set to indicate this is a respawn of the named worker and will take over the
13
+ name without deleting stats
14
+ =end
15
+
16
+ # First parse options
17
+ opts = {}
18
+ while ARGV.size > 0 && ARGV[0][0..1] == '--'
19
+ opt,val = ARGV.shift[2..-1].split('=',2)
20
+ val ||= true
21
+ opts[opt] = val
22
+ end
23
+
24
+ opts['worker_name'] = ARGV.shift
25
+ opts['worker_file'] = ARGV.shift
26
+
27
+ abort "rescheduler_launch <options> <worker_name> <jobs.rb>" unless opts['worker_file'] && File.exists?(opts['worker_file'])
28
+
29
+ # Daemonize first before we load rails
30
+ begin
31
+ Process.daemon unless opts['debug'] # Do not daemonize if debugging
32
+ rescue NotImplementedError
33
+ # This happens in windows, it is OK
34
+ end
35
+
36
+ # Load rails environment if --rails is specified
37
+ require File.join(opts['rails'], 'config/environment.rb') if opts['rails']
38
+
39
+ # Load rescheduler
40
+ require File.expand_path('../../lib/rescheduler', __FILE__)
41
+
42
+ Rescheduler::Worker.register(opts)
43
+ Rescheduler::Worker.redirect_logging(opts) unless opts['debug']
44
+
45
+ # Running the job file
46
+ begin
47
+ Kernel.load opts['worker_file']
48
+ ensure
49
+ Rescheduler::Worker.unregister unless Rescheduler::Worker.respawn_if_requested
50
+ end
@@ -0,0 +1,44 @@
1
+ module Rescheduler
2
+ module Sync
3
+ extend self
4
+
5
+ def lock(name, opts={})
6
+ raise "Requires a block to be supplied" unless block_given? # Maybe later offer naked lock/unlocks?
7
+ raise "Need a valid string name" unless name.is_a?(String)
8
+
9
+ res = do_lock(name, opts) # This would block
10
+ return nil unless res # Timeout or failed somehow
11
+
12
+ begin
13
+ yield
14
+ ensure
15
+ do_unlock(name)
16
+ end
17
+ return true # Lock was successful
18
+ end
19
+
20
+ # Forcefully unlock and delete existing locks
21
+ def clear!(name)
22
+ redis.del(rk_exists_name(name), rk_lock_name(name))
23
+ end
24
+
25
+ private def do_lock(name, opts)
26
+ # Make sure semaphore for a given name is only created once
27
+ if redis.getset(rk_exists_name(name), 1)
28
+ # Already created, block wait for the release (possibility for unlock)
29
+ return redis.brpop(rk_lock_name(name), timeout: (opts[:timeout] || 0))
30
+ else
31
+ # First time, get the lock automatically
32
+ return true
33
+ end
34
+ end
35
+
36
+ private def do_unlock(name)
37
+ redis.lpush(rk_lock_name(name), 1)
38
+ end
39
+
40
+ private def redis; Rescheduler.send(:redis); end
41
+ private def rk_exists_name(name); return "#{Rescheduler.prefix}Sync:#{name}"; end
42
+ private def rk_lock_name(name); return "#{Rescheduler.prefix}SyncQ:#{name}"; end
43
+ end
44
+ end
@@ -0,0 +1,293 @@
1
+ require 'socket'
2
+
3
+ module Rescheduler
4
+ module Worker
5
+ extend self
6
+ # Each worker is a process of it own, handled by this singleton module
7
+
8
+ NONCMD_OPTS = %w[pid machine worker_name worker_file]
9
+ CMD_OPTS = %w[rails log env respawn] # respanw is for internal only
10
+ attr_accessor :launch_options
11
+ def worker_name; @launch_options && @launch_options['worker_name']; end
12
+ def worker_index; @launch_options && @launch_options['worker_index'] || -1; end
13
+ # ====================================================================
14
+ # Controller
15
+ # ====================================================================
16
+ WORKERCMD_SUSPEND = 'suspend'
17
+ WORKERCMD_RESUME = 'resume'
18
+ WORKERCMD_STOP = 'stop'
19
+ WORKERCMD_RESTART = 'restart'
20
+ [WORKERCMD_SUSPEND, WORKERCMD_RESUME, WORKERCMD_STOP, WORKERCMD_RESTART].each do |cmd|
21
+ define_method(cmd) {|name| redis.lpush(rk_queue(name), cmd) }
22
+ define_method(cmd + '_all') {|pattern| cmd_to_pattern(pattern, cmd) }
23
+ end
24
+
25
+ def workers_from_pattern(pattern)
26
+ pattern = pattern.gsub('%', '*')
27
+ workers = redis.keys(rk_worker(pattern))
28
+ kl = rk_worker('').length
29
+ workers.map {|w| w[kl..-1]}
30
+ end
31
+
32
+ def cmd_to_pattern(pattern, cmd)
33
+ ws = []
34
+ workers_from_pattern(pattern).each do |wname|
35
+ redis.lpush(rk_queue(wname), cmd)
36
+ ws << wname
37
+ end
38
+ return ws
39
+ end
40
+
41
+ def kill_all(pattern, force=false)
42
+ workers_from_pattern(pattern).each do |wname|
43
+ kill(wname, force)
44
+ end
45
+ end
46
+
47
+ # Do not run this unless everything else fails, it kills unconditionally
48
+ def kill(worker_name, force=false)
49
+ key = rk_worker(worker_name)
50
+ opts = redis.hgetall(key)
51
+ if force || opts['machine'] == Socket.gethostname
52
+ stop(worker_name)
53
+ sleep 1 # Give the process a second to quick peacefully
54
+ redis.del(key)
55
+ # Actually kill the process
56
+ begin
57
+ pid = opts['pid']
58
+ # "HUP" does not get recognized in windows
59
+ Process.kill(9, pid.to_i) if pid && opts['machine'] == Socket.gethostname
60
+ rescue Errno::ESRCH, Errno::EPERM # No such process, Not permitted, these will be ginored
61
+ end
62
+ end
63
+ end
64
+
65
+ def clean_dead_workers
66
+ hostname = Socket.gethostname # Only can cleanup PIDs on own machine
67
+ workers = redis.keys(rk_worker('*'))
68
+ workers.each do |w|
69
+ opts = redis.hgetall(w)
70
+ next unless opts['machine'] == hostname
71
+ next if pid_exists?(opts['pid'])
72
+ print "Cleaning dead worker: #{w.split(':',3).last}\n"
73
+ redis.del(w) # Remove the record (Main part of the cleanup)
74
+ end
75
+ nil
76
+ end
77
+
78
+ def restart_self
79
+ Rescheduler.log_debug "[#{worker_name}] Restarting"
80
+ @respawn = true
81
+ Rescheduler.end_job_loop
82
+ @in_suspend = false
83
+ end
84
+
85
+ def handle_command(command)
86
+ case command
87
+ when WORKERCMD_SUSPEND
88
+ Rescheduler.log_debug "[#{worker_name}] Suspending"
89
+ @in_suspend = true
90
+ suspend_loop
91
+ when WORKERCMD_RESUME
92
+ if @in_suspend
93
+ Rescheduler.log_debug "[#{worker_name}] Resuming"
94
+ @in_suspend = false # This will resume the normal job loop
95
+ else
96
+ Rescheduler.log_debug "[#{worker_name}] Resume command received when not suspended"
97
+ end
98
+ when WORKERCMD_RESTART
99
+ restart_self
100
+ when WORKERCMD_STOP
101
+ Rescheduler.log_debug "[#{worker_name}] Stopping"
102
+ Rescheduler.end_job_loop
103
+ @in_suspend = false
104
+ else
105
+ Rescheduler.log_debug "[#{worker_name}] Unknown command: #{command}"
106
+ end
107
+ end
108
+
109
+ def suspend_loop
110
+ while @in_suspend do
111
+ result = redis.brpop(rk_queue)
112
+ if result
113
+ command = result[1]
114
+ handle_command(command)
115
+ end
116
+ end
117
+ end
118
+
119
+ # ====================================================================
120
+ # Runner
121
+ # ====================================================================
122
+
123
+ DEVNULL = '/dev/null'
124
+ def redirect_logging(opts)
125
+ return if windows_env? # Do not redirect in windows environment, let it run with cmd console
126
+
127
+ logfile = opts['log']
128
+ logfile ||= File.join(opts['rails'], "log/#{worker_name}.log") if opts['rails']
129
+ logfile ||= DEVNULL
130
+
131
+ # Redirect io
132
+ unless logfile == DEVNULL
133
+ # Code inspired by Daemonize::redirect_io
134
+ begin
135
+ STDOUT.reopen logfile, "a"
136
+ File.chmod(0644, logfile)
137
+ STDOUT.sync = true
138
+ rescue ::Exception
139
+ begin; STDOUT.reopen DEVNULL; rescue ::Exception; end
140
+ end
141
+ begin; STDERR.reopen STDOUT; rescue ::Exception; end
142
+ STDERR.sync = true
143
+ end
144
+ end
145
+
146
+ def is_respawning?; @launch_options && @launch_options['respawn']; end
147
+
148
+ def register(opt)
149
+ @launch_options = opt # Save for respawning
150
+ name_pattern = opt['worker_name']
151
+
152
+ last_wname = nil
153
+ widx = 0 # We do not preserve worker_index upon respawn
154
+ wname = nil
155
+ if opt['respawn']
156
+ wname = name_pattern
157
+ # Reset created for respawn purposes.
158
+ redis.hset(rk_worker(wname), 'created', Time.now.to_i)
159
+ else
160
+ loop do
161
+ wname = name_pattern.gsub('%', widx.to_s)
162
+ if wname == last_wname # For the name without %, we add one to the end if clashes
163
+ wname += widx.to_s
164
+ name_pattern += '%'
165
+ end
166
+
167
+ break if redis.hsetnx(rk_worker(wname), 'created', Time.now.to_i)
168
+ widx += 1
169
+ last_wname = wname
170
+ end
171
+ end
172
+ @launch_options['worker_name'] = wname # Save this in launch options
173
+ @launch_options['worker_index'] = widx # Sequence of the same worker
174
+ @launch_options['pid'] = Process.pid
175
+ @launch_options['machine'] = Socket.gethostname
176
+
177
+ # Setup the worker stats
178
+ redis.multi do
179
+ redis.hincrby(rk_worker, 'spawn_count', 1)
180
+ redis.hset(rk_worker, 'job_count', 0) # Reset the job count since respawn
181
+ redis.mapped_hmset(rk_worker, @launch_options) # Save launch options
182
+ redis.del(rk_queue) # Clear old control commands
183
+ end
184
+ nil
185
+ end
186
+
187
+ def unregister
188
+ redis.del(rk_worker)
189
+ end
190
+
191
+ def exists?(name)
192
+ redis.hexists(rk_workers, name)
193
+ end
194
+
195
+ def rk_worker(name = nil)
196
+ name ||= worker_name
197
+ Rescheduler.prefix + 'TMWORKER:' + name
198
+ end
199
+
200
+ def rk_queue(name = nil)
201
+ name ||= worker_name
202
+ Rescheduler.prefix + 'TMWORKERQUEUE:' + name
203
+ end
204
+
205
+ def stats
206
+ stats = {}
207
+ workers = redis.keys(rk_worker('*'))
208
+ kl = rk_worker('').length
209
+ workers.each do |w|
210
+ stats[w[kl..-1]] = redis.hgetall(w)
211
+ end
212
+ return stats
213
+ end
214
+
215
+ def named?
216
+ @launch_options && @launch_options.include?('worker_name')
217
+ end
218
+
219
+ def inc_job_count
220
+ # Only do this if we are launched as a worker
221
+ if named?
222
+ jc = redis.hincrby(rk_worker, 'job_count', 1)
223
+ # Check for respawn_jobs
224
+ respawn_jobs = @launch_options['respawn_jobs'].to_i
225
+ restart_self if respawn_jobs > 0 && jc >= respawn_jobs
226
+ # Check for respawn_time
227
+ respawn_time = @launch_options['respawn_time'].to_i
228
+ if respawn_time > 0
229
+ created = redis.hget(rk_worker, 'created')
230
+ restart_self if created && created.to_i + respawn_time < Time.now.to_i
231
+ end
232
+ end
233
+ end
234
+
235
+ def respawn_if_requested
236
+ return false unless @respawn
237
+ @launch_options['respawn'] = true
238
+ spawn(@launch_options)
239
+ return true
240
+ end
241
+
242
+ def spawn(opts)
243
+ system_options = {}
244
+ env = load_env(opts['env']) if opts.include?('env')
245
+ env ||= {}
246
+ #system_options['chdir'] = opts['chdir'] if opts.include?('chdir')
247
+
248
+ cmd = "rescheduler_launch #{opt_to_str(opts)}"
249
+ print "EXEC: #{cmd}\n"
250
+ pid = Kernel.spawn(env, cmd, system_options)
251
+ Process.detach(pid)
252
+ end
253
+
254
+ def load_env(env)
255
+ env.is_a?(String) ? Hash[env.split(';').map{|e| a,b=e.split('=', 2); b ||= true; [a,b]}] : env
256
+ end
257
+
258
+ def pack_env(env)
259
+ return env if env.is_a?(String)
260
+ env.map {|k,v| "#{k}=#{v}"}.join(';')
261
+ end
262
+
263
+ def opt_to_str(opts)
264
+ name = opts['worker_name']
265
+ file = opts['worker_file']
266
+ args = opts.map do |k,v|
267
+ next if NONCMD_OPTS.include?(k)
268
+ if k == 'env'
269
+ "--env=#{pack_env(v)}"
270
+ elsif v==true || v == 'true'
271
+ "--#{k}"
272
+ else
273
+ "--#{k}=#{v}"
274
+ end
275
+ end
276
+ args << name
277
+ args << file
278
+ return args.join(' ')
279
+ end
280
+
281
+ def redis
282
+ Rescheduler.send :redis # Call private method in Rescheduler module
283
+ end
284
+
285
+ def windows_env?; RUBY_PLATFORM.end_with?('mingw32'); end
286
+ def pid_exists?(pid)
287
+ Process.kill(0, pid.to_i)
288
+ return true
289
+ rescue Errno::ESRCH
290
+ return false
291
+ end
292
+ end
293
+ end
data/lib/rescheduler.rb CHANGED
@@ -1,142 +1,207 @@
1
+ require 'date'
1
2
  require 'time' # Needed for Time.parse
2
3
  require 'multi_json'
3
4
  require 'redis'
4
5
 
6
+ require File.expand_path('../rescheduler/worker', __FILE__)
7
+ require File.expand_path('../rescheduler/sync', __FILE__)
8
+
9
+ =begin
10
+
11
+ Immediate Queue: "TMTUBE:queue" - List of qnids
12
+ Deferred Tasks: "TMDEFERRED" - Sorted set of all tasks based on their due date
13
+ Task Args: "TMARGS:qnid" - JSON args of the task
14
+ Running Tasks: "TMRUNNING" - ??
15
+
16
+ Maintenane: "TMMAINT" - ?? An internal queue for maintenance jobs
17
+
18
+ Worker Semaphore: "TMWORKERLOCK" - Exclusion semaphore for worker maintenance
19
+ Auto-increment Id:"TMCOUNTER" - Global unique id generator
20
+
21
+ Worker Registry: "TMWORKERS" - Map of workerid=>worker info
22
+ =end
23
+
24
+ # NOTE: We use class variables instead of class instance variables so that
25
+ # "include Rescheduler" would work as intended for DSL definition
26
+
27
+
5
28
  module Rescheduler
6
29
  extend self
7
30
 
8
31
  # Setup configuration
9
- attr_accessor :config
10
- self.config = {}
32
+ def config
33
+ @@config ||= { prefix:'' }
34
+ @@config
35
+ end
36
+
37
+ def config=(c); @@config = c; end
11
38
 
12
- #==========================
13
- # Management routines
39
+ #====================================================================
40
+ # Global management / Query
41
+ #====================================================================
14
42
  def prefix
15
- return @config[:prefix] || ''
43
+ return @@config[:prefix]
16
44
  end
17
45
 
18
- def reinitialize # Very slow reinitialize
46
+ def reinitialize
19
47
  keys = %w{TMCOUNTER TMMAINT TMDEFERRED TMARGS TMRUNNING}.map {|p| prefix + p }
20
- %w{TMTUBE:*}.each do |p|
48
+ %w{TMTUBE:* TMARGS:*}.each do |p|
21
49
  keys += redis.keys(prefix + p)
22
50
  end
23
51
  redis.del(keys)
24
52
  end
25
53
 
26
- # Return a hash of statistics, in this format
27
- #
54
+ # Warning: Linear time operation (see #show_queue)
55
+ def delete_queue(queue)
56
+ entries = show_queue(queue)
57
+ return 0 if entries.blank?
58
+
59
+ entries.map do |entry|
60
+ idelete(get_qnid(queue, entry))
61
+ end.length
62
+ end
63
+
64
+ def fast_delete_immediate_queue(queue) # NOTE: only use this when there is no inserters around
65
+ argkeys = redis.keys(rk_args(get_qnid(queue, '*')))
66
+ redis.multi do
67
+ redis.del(argkeys)
68
+ redis.del(rk_queue(queue))
69
+ end
70
+ nil
71
+ end
72
+
73
+ # Return a hash of statistics
28
74
  def stats
29
- loop do
30
- redis.watch(rk_args) do
31
- stats = {}
32
- qnids = redis.hkeys(rk_args)
33
- # Get all the "pending jobs"
34
- qnids.each do |qnid|
35
- queue = qnid_to_queue(qnid)
36
- stats[queue] ||= {}
37
- stats[queue][:pending] ||= 0
38
- stats[queue][:pending] += 1
39
- end
75
+ stats = {}
76
+ @@runners ||= {}
77
+ @@runners.keys.each {|queue| stats[queue] = {} } unless @@runners.blank?
78
+
79
+ # Discover all immediate queues
80
+ ql = rk_queue('').length
81
+ redis.keys(rk_queue('*')).each do |rkqueue|
82
+ queue = rkqueue[ql..-1]
83
+ stats[queue] ||= {}
84
+ stats[queue][:immediate] = queue_length(queue)
85
+ end
40
86
 
41
- # Get all running
42
- qnids = redis.hkeys(rk_running)
43
- # Get all the "pending jobs"
44
- qnids.each do |qnid|
45
- queue = qnid_to_queue(qnid)
46
- stats[queue] ||= {}
47
- stats[queue][:running] ||= 0
48
- stats[queue][:running] += 1
49
- end
87
+ # Get all the deferred
88
+ deferred = redis.zrange(rk_deferred, 0, -1, :with_scores=>true)
89
+ deferred.each do |qnid, ts|
90
+ queue = qnid_to_queue(qnid)
91
+ stats[queue] ||= {}
92
+ stats[queue][:deferred] ||= 0
93
+ stats[queue][:deferred] += 1
94
+ stats[queue][:first] ||= ts # First is first
95
+ end
50
96
 
51
- # Get all the deferred
52
- deferred = redis.zrange(rk_deferred, 0, -1, :with_scores=>true)
53
- deferred.each do |qnid, ts|
54
- queue = qnid_to_queue(qnid)
55
- stats[queue] ||= {}
56
- stats[queue][:deferred] ||= 0
57
- stats[queue][:deferred] += 1
58
- stats[queue][:first] ||= ts # First is first
59
- end
60
-
61
- # Get all the immediate
62
- qus = stats.keys
63
- quls = redis.multi do
64
- qus.each { |queue| redis.llen(rk_queue(queue)) }
65
- end
97
+ # Get all the immediate
98
+ return {:jobs=>stats, :workers=>Worker.stats}
99
+ end
66
100
 
67
- unless quls # Retry
68
- log_debug('Contention during stats')
69
- return {:jobs=>{'Job contention'=>{}}}
70
- end
101
+ #----------------------------------------------
102
+ # Queue management
103
+ #----------------------------------------------
104
+ # Returns number of jobs waiting to be handled in a queue (all immediate jobs)
105
+ def queue_length(queue)
106
+ return redis.llen(rk_queue(queue))
107
+ end
71
108
 
72
- qus.each_with_index do |k, idx|
73
- stats[k][:immediate] = quls[idx]
74
- end
109
+ # Reads a background job and returns its properties; returns nil if the job does not exist
110
+ # Takes :queue and :id as arguments
111
+ def peek(options)
112
+ qnid = get_qnid(options[:queue], options[:id])
113
+ optstr = redis.get(rk_args(qnid))
114
+ return nil unless optstr
115
+ sopt = MultiJson.load(optstr, :symbolize_keys => true)
116
+ sopt[:queue] = options[:queue]
117
+ sopt[:id] = options[:id]
118
+ return sopt
119
+ end
75
120
 
76
- return {:jobs=>stats}
77
- end
78
- end
121
+ # Warning: Linear time operation, where n is the number if items in all the queues
122
+ def show_queue(queue)
123
+ qstr = ":#{queue}:"
124
+ # TODO: Use SCAN after upgrade to Redis 2.8
125
+ redis.keys(rk_args(get_qnid(queue, '*'))).map {|k| k.split(qstr, 2).last }
79
126
  end
80
127
 
81
- # NOTE: Use this with care. Some lost jobs can be moved to immediate queue instead of deleted
82
- # Pass '*' to delete everything.
83
- def purge_bad_jobs(queue = '*')
84
- pending, running, deferred = redis.multi do
85
- redis.hkeys(rk_args)
86
- redis.hkeys(rk_running)
87
- redis.zrange(rk_deferred, 0, -1)
88
- end
128
+ #----------------------------------------------
129
+ # Task management
130
+ #----------------------------------------------
89
131
 
90
- bad = pending - running - deferred
91
- bad.each do |qnid|
92
- next if queue != '*' && !qnid.start_with?(queue + ':')
93
- idelete(qnid)
94
- end
132
+ # Check existence of one task
133
+ def exists?(options)
134
+ raise ArgumentError, 'Can not test existence without :id' unless options.include?(:id)
135
+ qnid = get_qnid(options[:queue], options[:id])
136
+ return redis.exists(rk_args(qnid))
95
137
  end
96
138
 
97
- #==========================
139
+ # Delete one task
140
+ def delete(options)
141
+ qnid = get_qnid(options[:queue], options[:id])
142
+ idelete(qnid)
143
+ end
144
+
145
+ #====================================================================
98
146
  # Task producer routines
147
+ #====================================================================
99
148
  # Add an immediate task to the queue
100
149
  def enqueue(options=nil)
101
- options ||= {}
150
+ internal_enqueue(options, false)
151
+ end
152
+
153
+ def enqueue_to_top(options = nil)
154
+ internal_enqueue(options, true)
155
+ end
156
+
157
+ def internal_enqueue(options, push_to_top)
158
+ sopt = options ? options.dup : {}
159
+ queue = sopt[:queue] || '' # Default queue name is ''
160
+ has_id = sopt.include?(:id)
161
+ job_id = sopt[:id] || redis.incr(rk_counter) # Default random unique id
162
+
102
163
  now = Time.now.to_i
103
164
 
104
- # Error check
105
- validate_queue_name(options[:queue]) if options.include?(:queue)
106
- validate_recurrance(options)
165
+ # Error check
166
+ validate_queue_name(queue)
167
+ validate_recurrance(sopt)
107
168
 
108
169
  # Convert due_in to due_at
109
- if options.include?(:due_in)
110
- raise ArgumentError, ':due_in and :due_at can not be both specified' if options.include?(:due_at)
111
- options[:due_at] = now + options[:due_in]
170
+ if sopt.include?(:due_in)
171
+ # log_debug 'Both due_in and due_at specified, favoring due_in' if sopt.include?(:due_at)
172
+ sopt[:due_at] = now + sopt[:due_in]
112
173
  end
113
174
 
114
- # Get an ID if not already have one
115
- user_id = options.include?(:id)
116
- unless user_id
117
- options[:id] = redis.incr(rk_counter)
118
- end
175
+ qnid = get_qnid(queue, job_id)
119
176
 
120
- ts = options[:due_at].to_i
121
- ts = now if ts == 0 # 0 means immediate
122
- options[:due_at] = ts # Convert :due_at to integer timestamp to be reused in recurrance
123
- qnid = get_qnid(options)
177
+ ts = sopt[:due_at].to_i
178
+ if ts == 0 || ts < now # immediate
179
+ ts = now
180
+ sopt.delete(:due_at)
181
+ else
182
+ raise ArgumentError, 'Can not enqueue_to_top deferred jobs' if push_to_top
183
+ sopt[:due_at] = ts # Convert :due_at to integer timestamp to be reused in recurrance
184
+ end
124
185
 
125
186
  # Encode and save args
126
187
  redis.multi do # Transaction to enqueue the job and save args together
127
- if user_id # Delete possible existing job if user set id
188
+ if has_id # Delete possible existing job if user set id
128
189
  redis.zrem(rk_deferred, qnid)
129
- redis.lrem(rk_queue(options[:queue]), 0, qnid)
190
+ redis.lrem(rk_queue(queue), 0, qnid) # This is going to be slow for long queues
130
191
  end
131
192
 
132
- # Save options
133
- redis.hset(rk_args, qnid, MultiJson.dump(options))
193
+ # Save args even if it is empty (for existence checks)
194
+ redis.set(rk_args(qnid), MultiJson.dump(sopt))
134
195
 
135
196
  # Determine the due time
136
197
  if ts > now # Future job
137
198
  redis.zadd(rk_deferred, ts, qnid)
138
199
  else
139
- redis.lpush(rk_queue(options[:queue]), qnid)
200
+ if push_to_top
201
+ redis.rpush(rk_queue(queue), qnid)
202
+ else
203
+ redis.lpush(rk_queue(queue), qnid)
204
+ end
140
205
  end
141
206
  end
142
207
 
@@ -151,103 +216,72 @@ module Rescheduler
151
216
  nil
152
217
  end
153
218
 
154
- def exists?(options)
155
- raise ArgumentError, 'Can not test existence without :id' unless options.include?(:id)
156
- qnid = get_qnid(options)
157
- return redis.hexists(rk_args, qnid)
158
- end
159
-
160
- def enqueue_unless_exists(options)
161
- enqueue(options) unless exists?(options)
162
- end
163
-
164
- def delete(options)
165
- qnid = get_qnid(options)
166
- idelete(qnid)
167
- end
168
-
169
- # Make a job immediate if it is not already. Erase the wait
170
- def make_immediate(options)
171
- dtn = rk_deferred # Make a copy in case prefix changes
172
- qnid = get_qnid(options)
173
- ntry = 0
174
- loop do
175
- redis.watch(dtn) do
176
- if redis.zcard(dtn, qnid) == 0
177
- redis.unwatch(dtn)
178
- return # Not a deferred job
179
- else
180
- redis.multi
181
- redis.zrem(dtn, qnid)
182
- q = qnid_to_queue(qnid)
183
- redis.lpush(rk_queue(q), qnid)
184
- if !redis.exec
185
- # Contention happens, retrying
186
- log_debug("make_immediate contention for #{qnid}")
187
- Kernel.sleep (rand(ntry * 1000) / 1000.0) if ntry > 0
188
- else
189
- return # Done
190
- end
191
- end
219
+ # Temp function for special purpose. Completely by-pass concurrency check to increase speed
220
+ def quick_enqueue_batch(queue, ids, reset = false)
221
+ argsmap = {}
222
+ vals = []
223
+ ids.each do |id|
224
+ qnid = get_qnid(queue, id)
225
+ vals << qnid
226
+ argsmap[rk_args(qnid)] = '{}' # Empty args
227
+ end unless ids.blank?
228
+ redis.pipelined do # Should do redis.multi if concurrency is a problem
229
+ redis.del(rk_queue(queue)) if reset # Empty the list fast
230
+ unless ids.blank?
231
+ redis.lpush(rk_queue(queue), vals)
232
+ argsmap.each { |k,v| redis.set(k,v) }
192
233
  end
193
- ntry += 1
194
234
  end
235
+ nil
195
236
  end
196
237
 
197
- #=================
198
- # Serialization (in case it is needed to transfer all Rescheduler across to another redis instance)
199
-
200
- # Atomically save the state to file and stop all workers (state in redis is not destroyed)
201
- # This function can take a while as it will wait for running jobs to finish first.
202
- def serialize_and_stop(filename)
203
- # TODO
204
- end
205
-
206
- # Load state from a file. Will merge into existing jobs if there are any (make sure it is done only once)
207
- # This can be done before any worker starts, or after.
208
- # Workers still need to be manually started
209
- def deserialize(filename)
210
- # TODO
211
- end
212
-
213
- # Clear redis states and delete all jobs (useful before deserialize)
214
- def erase_all
215
- # TODO
238
+ # Returns true if enqueued a new job, otherwise returns false
239
+ def enqueue_unless_exists(options)
240
+ # NOTE: There is no point synchronizing exists and enqueue
241
+ return false if exists?(options)
242
+ enqueue(options)
243
+ return true
216
244
  end
217
245
 
218
- #=================
246
+ #====================================================================
219
247
  # Job definition
248
+ #====================================================================
249
+
220
250
  # Task consumer routines
221
251
  def job(tube, &block)
222
- @runners ||= {}
223
- @runners[tube] = block
252
+ @@runners ||= {}
253
+ @@runners[tube] = block
224
254
  return nil
225
255
  end
226
256
 
227
- #=================
257
+ #====================================================================
228
258
  # Error handling
259
+ #====================================================================
229
260
  def on_error(tube=nil, &block)
230
261
  if tube != nil
231
- @error_handlers ||= {}
232
- @error_handlers[tube] = block
262
+ @@error_handlers ||= {}
263
+ @@error_handlers[tube] = block
233
264
  else
234
- @global_error_handler = block;
265
+ @@global_error_handler = block;
235
266
  end
236
267
  end
237
- #=================
268
+
269
+ #====================================================================
238
270
  # Runner/Maintenance routines
271
+ #====================================================================
239
272
  def start(*tubes)
273
+ @@runners ||= {}
240
274
  # Check arguments
241
- if !@runners || @runners.size == 0
275
+ if @@runners.size == 0
242
276
  raise Exception, 'Can not start worker without defining job handlers.'
243
277
  end
244
278
 
245
279
  tubes.each do |t|
246
- next if @runners.include?(t)
280
+ next if @@runners.include?(t)
247
281
  raise Exception, "Handler for queue #{t} is undefined."
248
282
  end
249
283
 
250
- tubes = @runners.keys if !tubes || tubes.size == 0
284
+ tubes = @@runners.keys if !tubes || tubes.size == 0
251
285
 
252
286
  log_debug "[[ Starting: #{tubes.join(',')} ]]"
253
287
 
@@ -257,20 +291,33 @@ module Rescheduler
257
291
  keys = tubes.map {|t| rk_queue(t)}
258
292
  keys << rk_maintenace
259
293
 
294
+ # Queue to control a named worker
295
+ worker_queue = Worker.rk_queue if Worker.named?
296
+ keys.unshift(worker_queue) if worker_queue # worker control queue is the first we respond to
297
+
260
298
  dopush = nil
261
299
 
262
- loop do
300
+ @@end_job_loop = false
301
+ while !@@end_job_loop
263
302
  # Run maintenance and determine timeout
264
303
  next_job_time = determine_next_deferred_job_time.to_i
265
304
 
266
305
  if dopush # Only pass-on the token after we are done with maintenance. Avoid contention
267
- redis.lpush(rk_maintenace, dopush)
306
+ redis.lpush(rk_maintenace, dopush)
268
307
  dopush = nil
269
308
  end
270
309
 
271
310
  # Blocking wait
272
311
  timeout = next_job_time - Time.now.to_i
273
312
  timeout = 1 if timeout < 1
313
+
314
+ # A producer may insert another job after BRPOP and before WATCH
315
+ # Due to limitations of BRPOP we can not prevent this from happening.
316
+ # When it happens we will consume the args of the later job, causing
317
+ # the newly inserted job to be "promoted" to the front of the queue
318
+ # This may not be desirable...
319
+ # (too bad BRPOPLPUSH does not support multiple queues...)
320
+ # TODO: Maybe LUA script is the way out of this.
274
321
  result = redis.brpop(keys, :timeout=>timeout)
275
322
 
276
323
  # Handle task
@@ -278,25 +325,39 @@ module Rescheduler
278
325
  tube = result[0]
279
326
  qnid = result[1]
280
327
  if tube == rk_maintenace
281
- # Circulate maintenance task until it comes a full circle. This depends on redis
282
- # first come first serve policy in brpop.
328
+ # Circulate maintenance task until it comes a full circle. This depends on redis
329
+ # first come first serve policy in brpop.
283
330
  dopush = qnid + client_key unless qnid.include?(client_key) # Push if we have not pushed yet.
331
+ elsif tube == worker_queue
332
+ Worker.handle_command(qnid)
284
333
  else
285
334
  run_job(qnid)
286
335
  end
287
- else
336
+ else
288
337
  # Do nothing when got timeout, the run_maintenance will take care of deferred jobs
289
338
  end
290
339
  end
291
340
  end
292
341
 
293
- private
342
+ def end_job_loop; @@end_job_loop = true; end
343
+
344
+
345
+ # Logging facility
346
+ def log_debug(msg)
347
+ return if config[:silent]
348
+ print("#{Time.now.iso8601} #{msg}\n")
349
+ STDOUT.flush
350
+ end
351
+
352
+ #====================================================================
353
+ private
354
+ #====================================================================
294
355
 
295
356
  # Internal routines operating out of qnid
296
357
  def idelete(qnid)
297
358
  queue = qnid.split(':').first
298
359
  redis.multi do
299
- redis.hdel(rk_args, qnid)
360
+ redis.del(rk_args(qnid))
300
361
  redis.zrem(rk_deferred, qnid)
301
362
  redis.lrem(rk_queue(queue), 0, qnid)
302
363
  end
@@ -306,74 +367,70 @@ module Rescheduler
306
367
  def run_job(qnid)
307
368
  # 1. load job parameters for running
308
369
  optstr = nil
309
- begin
310
- res = nil
311
- # Note: We use a single key to watch, can be improved by having a per-job key,
312
- redis.watch(rk_args) do # Transaction to ensure read/delete is atomic
313
- optstr = redis.hget(rk_args, qnid)
314
- if optstr.nil?
315
- redis.unwatch
316
- log_debug("Job is deleted mysteriously")
317
- return # Job is deleted somewhere
318
- end
319
- res = redis.multi do
320
- redis.hdel(rk_args, qnid)
321
- redis.hset(rk_running, qnid, optstr)
322
- end
323
- if !res
324
- # Contention, try read again
325
- log_debug("Job read contention: (#{qnid})")
326
- end
327
- end
328
- end until res
370
+ key = rk_args(qnid)
371
+ # Atomic get and delete the arg
372
+ redis.multi do
373
+ optstr = redis.get(key)
374
+ redis.del(key)
375
+ end
376
+
377
+ optstr = optstr.value # get the value from the multi block future
378
+ if optstr.nil?
379
+ log_debug("Job is deleted mysteriously: (#{qnid})")
380
+ return # Job is deleted somewhere
381
+ end
329
382
 
330
383
  # Parse and run
331
384
  sopt = MultiJson.load(optstr, :symbolize_keys => true)
385
+ queue,id = qnid.split(':', 2)
386
+ sopt[:queue] ||= queue
387
+ sopt[:id] ||= id
332
388
 
333
389
  # Handle parameters
334
390
  if (sopt.include?(:recur_every))
335
391
  newopt = sopt.dup
336
392
  newopt[:due_at] = (sopt[:due_at] || Time.now).to_i + sopt[:recur_every].to_i
337
393
  newopt.delete(:due_in) # In case the first job was specified by :due_in
338
- log_debug("---Enqueue #{qnid}: due_every #{sopt[:due_every]}")
394
+ log_debug("---Enqueue #{qnid}: recur_every #{sopt[:recur_every]}")
339
395
  enqueue(newopt)
340
396
  end
341
397
 
342
- if (sopt.include?(:recur_daily))
343
- newopt = sopt.dup
344
- newopt[:due_at] = time_from_recur_daily(sopt[:recur_daily])
345
- newopt.delete(:due_in) # In case the first job was specified by :due_in
346
- log_debug("---Enqueue #{qnid}: due_daily #{sopt[:recur_daily]}")
398
+ if (sopt.include?(:recur_daily) || sopt.include?(:recur_weekly))
399
+ newopt = sopt.dup
400
+ newopt.delete(:due_at)
401
+ newopt.delete(:due_in) # No more due info, just the recurrance
402
+ log_debug("---Enqueue #{qnid}: recur_daily #{sopt[:recur_daily]}") if sopt.include?(:recur_daily)
403
+ log_debug("---Enqueue #{qnid}: recur_weekly #{sopt[:recur_weekly]}") if sopt.include?(:recur_weekly)
347
404
  enqueue(newopt)
348
405
  end
349
406
 
350
407
  # 2. Find runner and invoke it
351
408
  begin
352
409
  log_debug(">>---- Starting #{qnid}")
353
- runner = @runners[qnid_to_queue(qnid)]
410
+ runner = @@runners[qnid_to_queue(qnid)]
354
411
  if runner.is_a?(Proc)
355
412
  runner.call(sopt)
356
413
  log_debug("----<< Finished #{qnid}")
414
+ Worker.inc_job_count # Stats for the worker
357
415
  else
358
416
  log_debug("----<< Failed #{qnid}: Unknown queue name, handler not defined")
359
417
  end
360
418
  rescue Exception => e
361
419
  log_debug("----<< Failed #{qnid}: -------------\n #{$!}")
362
420
  log_debug(e.backtrace[0..4].join("\n"))
363
- handle_error(e, qnid, sopt)
421
+ handle_error(e, queue, sopt)
364
422
  log_debug("------------------------------------\n")
365
423
  end
366
-
367
- # 3. Remove job from running list (Done)
368
- redis.hdel(rk_running, qnid)
369
424
  end
370
425
 
371
- def handle_error(e, qnid, sopt)
372
- error_handler = @error_handlers && @error_handlers[qnid]
426
+ def handle_error(e, queue, sopt)
427
+ @@error_handlers ||= {}
428
+ @@global_error_handler ||= nil
429
+ error_handler = @@error_handlers[queue]
373
430
  if error_handler
374
- error_handler.call(e, sopt)
375
- elsif @global_error_handler
376
- @global_error_handler.call(e, sopt)
431
+ error_handler.call(e, sopt)
432
+ elsif @@global_error_handler
433
+ @@global_error_handler.call(e, sopt)
377
434
  end
378
435
  end
379
436
 
@@ -383,17 +440,18 @@ module Rescheduler
383
440
  def service_deferred_jobs
384
441
  dtn = rk_deferred # Make a copy in case prefix changes
385
442
  ntry = 0
386
- loop do
443
+ while ntry < 6 do
387
444
  curtime = Time.now.to_i
445
+ return if redis.zcount(dtn, 0, curtime) == 0
446
+
447
+ limit = ntry < 3 ? 100 : 1 # After first 3 tries, do just 1
388
448
  redis.watch(dtn) do
389
- tasks = redis.zrangebyscore(dtn, 0, curtime)
449
+ tasks = redis.zrangebyscore(dtn, 0, curtime, :limit=>[0,limit]) # Serve at most 100
390
450
  if tasks.empty?
391
451
  redis.unwatch
392
452
  return # Nothing to transfer, moving on.
393
453
  end
394
454
 
395
- redis.multi
396
- redis.zremrangebyscore(dtn, 0, curtime)
397
455
  to_push = {}
398
456
  tasks.each do |qnid|
399
457
  q = rk_queue(qnid_to_queue(qnid))
@@ -401,6 +459,9 @@ module Rescheduler
401
459
  to_push[q] << qnid
402
460
  end
403
461
 
462
+ redis.multi
463
+ redis.zrem(dtn, tasks)
464
+
404
465
  to_push.each do |q, qnids|
405
466
  redis.lpush(q, qnids) # Batch command
406
467
  end
@@ -409,63 +470,30 @@ module Rescheduler
409
470
  # Contention happens, retrying
410
471
  # Sleep a random amount of time after first try
411
472
  ntry += 1
412
- log_debug("service_deferred_jobs contention")
473
+ log_debug("service_deferred_jobs(#{limit}) contention")
413
474
  Kernel.sleep (rand(ntry * 1000) / 1000.0)
414
475
  else
415
476
  return # Done transfering
416
477
  end
417
478
  end
418
-
419
- if ntry > 3 # Max number of tries
420
- # Fall back to
421
- service_one_deferred_job
422
- return
423
- end
424
479
  end
425
- end
426
-
427
- def service_one_deferred_jobs
428
- dtn = rk_deferred # Make a copy in case prefix changes
429
- ntry = 0
430
- curtime = Time.now.to_i
431
- loop do
432
- redis.watch(dtn) do
433
- tasks = redis.zrangebyscore(dtn, 0, curtime, :limit=>[0,1])
434
- if tasks.empty?
435
- redis.unwatch
436
- return # Nothing to transfer, moving on.
437
- end
438
480
 
439
- qnid = tasks[0]
440
- q = qnid_to_queue(qnid)
441
-
442
- redis.multi
443
- redis.zrem(dtn, qnid)
444
- redis.lpush(rk_queue(q), qnid)
445
- if !redis.exec
446
- # Contention happens, retrying
447
- # Sleep a random amount of time after first try
448
- log_debug("service_one_deferred_job contention")
449
- ntry += 1
450
- Kernel.sleep (rand(ntry * 1000) / 1000.0)
451
- else
452
- break # Done transfering one job
453
- end
454
- end
455
- end
481
+ log_debug("service_deferred_jobs failed, will try next time")
456
482
  end
457
483
 
458
484
  def determine_next_deferred_job_time(skip_service = nil)
459
485
  tsnow = Time.now.to_f
460
- maxtime = tsnow + 3600
486
+ maxtime = tsnow + 3600 + rand(100) # Randomize wake time to avoid multi worker service contention
461
487
 
462
488
  dt = redis.zrange(rk_deferred, 0, 0, :with_scores=>true)[0]
463
489
  nt = (dt && dt[1] && dt[1] < maxtime) ? dt[1] : maxtime
464
490
  if !skip_service && nt <= tsnow
465
- service_deferred_jobs
466
- # Get the deferred jobs again.
467
- dt = redis.zrange(rk_deferred, 0, 0, :with_scores=>true)[0]
468
- nt = (dt && dt[1] && dt[1] < maxtime) ? dt[1] : maxtime
491
+ do_if_can_acquire_semaphore do
492
+ service_deferred_jobs
493
+ # Get the deferred jobs again.
494
+ dt = redis.zrange(rk_deferred, 0, 0, :with_scores=>true)[0]
495
+ nt = (dt && dt[1] && dt[1] < maxtime) ? dt[1] : maxtime
496
+ end
469
497
  end
470
498
  return nt
471
499
  end
@@ -474,50 +502,99 @@ module Rescheduler
474
502
 
475
503
  def rk_deferred; prefix + 'TMDEFERRED'; end
476
504
  def rk_maintenace; prefix + 'TMMAINT'; end
477
- def rk_args; prefix + "TMARGS"; end
478
- def rk_running; prefix + "TMRUNNING"; end
505
+ def rk_args(qnid); "#{prefix}TMARGS:#{qnid}"; end
479
506
  def rk_counter; prefix + 'TMCOUNTER'; end
507
+ def rk_worker_semaphore; prefix + 'TMWORKERLOCK'; end # This is a boolean with a timeout for workers to exclude each other
508
+
509
+ # None blocking, returns true if semaphore is acquired (for a given timeout), this is cooperative to avoid guaranteed contentions
510
+ def try_acquire_semaphore(timeout=300) # Default for 5 minutes, there must be a timeout
511
+ semkey = rk_worker_semaphore
512
+ if redis.setnx(semkey, 1) # Any value would be fine
513
+ redis.expire(semkey, timeout)
514
+ return true
515
+ else
516
+ # Already created, someone has it
517
+ return false
518
+ end
519
+ end
520
+
521
+ # This releases semaphore unconditionally
522
+ def release_semaphore
523
+ # NOTE: There is a chance we remove the lock created by another worker after our own expired.
524
+ # This is OK since the lock is cooperative and not necessary (real locking is done through contention checks)
525
+ redis.del(rk_worker_semaphore)
526
+ end
480
527
 
481
- def get_qnid(options)
482
- return "#{options[:queue]}:#{options[:id]}"
528
+ # Run block only if the semaphore can be acquired, otherwise do nothing
529
+ def do_if_can_acquire_semaphore
530
+ if try_acquire_semaphore
531
+ yield
532
+ release_semaphore
533
+ end
483
534
  end
484
535
 
536
+ def get_qnid(queue, id); return "#{queue}:#{id}"; end
537
+
485
538
  def qnid_to_queue(qnid)
486
539
  idx = qnid.index(':')
487
540
  unless idx
488
541
  log_debug("Invalid qnid: #{qnid}")
489
- return nil
542
+ return nil
490
543
  end
491
544
  qnid[0...idx]
492
545
  end
493
546
 
494
547
  def redis
495
- @redis ||= @config[:redis] || Redis.new(@config[:redis_connection] || {})
548
+ @@redis ||= config[:redis] || Redis.new(config[:redis_connection] || {})
496
549
  end
497
550
 
498
551
  def validate_queue_name(queue)
499
552
  raise ArgumentError, 'Queue name can not contain special characters' if queue.include?(':')
500
553
  end
501
554
 
502
- def parse_seconds_of_day(recur_daily)
503
- return recur_daily if recur_daily.is_a?(Fixnum)
504
- time = Time.parse(recur_daily)
505
- return time.to_i - Time.new(time.year, time.month, time.day).to_i
506
- end
507
-
508
555
  # Find the next recur time
509
556
  def time_from_recur_daily(recur_daily, now = Time.now)
510
- recur = parse_seconds_of_day(recur_daily)
511
- t = Time.new(now.year, now.month, now.day).to_i + recur
512
- t += 86400 if t < now.to_i
513
- return Time.at(t)
557
+ parsed = Date._parse(recur_daily)
558
+ if !parsed[:hour] || (parsed.keys - [:zone, :hour, :min, :sec, :offset, :sec_fraction]).present?
559
+ raise ArgumentError, 'Unexpected recur_daily value: ' + recur_daily
560
+ end
561
+
562
+ if !parsed[:offset]
563
+ raise ArgumentError, 'A timezone is required for recur_daily: ' + recur_daily
564
+ end
565
+
566
+ # Never offset over one day (e.g. 23:59 PDT)
567
+ offset = (parsed[:hour] * 3600 + (parsed[:min]||0) * 60 + (parsed[:sec] || 0) - parsed[:offset]) % 86400
568
+
569
+ t = Time.utc(now.year, now.month, now.day) + offset
570
+ t += 86400 if t <= now + 1
571
+ return t
572
+ end
573
+
574
+ def time_from_recur_weekly(recur_weekly, now = Time.now)
575
+ parsed = Date._parse(recur_weekly)
576
+ if !parsed[:hour] || !parsed[:wday] || (parsed.keys - [:wday, :zone, :hour, :min, :sec, :offset, :sec_fraction]).present?
577
+ raise ArgumentError, 'Unexpected recur_weekly value: ' + recur_weekly
578
+ end
579
+
580
+ if !parsed[:offset]
581
+ raise ArgumentError, 'A timezone is required for recur_weekly: ' + recur_weekly
582
+ end
583
+
584
+ # Never offset over one week
585
+ offset = parsed[:hour] * 3600 + (parsed[:min]||0) * 60 + (parsed[:sec] || 0) - parsed[:offset]
586
+ offset = (offset + parsed[:wday] * 86400) % (86400 * 7)
587
+
588
+ t = Time.utc(now.year, now.month, now.day) - now.wday * 86400 + offset
589
+ t += 86400 * 7 if t <= now + 1
590
+ return t
514
591
  end
515
592
 
516
593
  def validate_recurrance(options)
517
594
  rcnt = 0
518
595
  if (options.include?(:recur_every))
519
596
  rcnt += 1
520
- raise 'Expect integer for :recur_every parameter' unless options[:recur_every].is_a?(Fixnum)
597
+ raise 'Expect integer for :recur_every parameter' unless options[:recur_every].is_a?(Integer)
521
598
  end
522
599
 
523
600
  if (options.include?(:recur_daily))
@@ -527,14 +604,16 @@ module Rescheduler
527
604
  options[:due_at] = time # Setup the first run
528
605
  end
529
606
  end
530
- raise 'Can only specify one recurrance parameter' if rcnt > 1
531
- end
532
607
 
533
- # Logging facility
534
- def log_debug(msg)
535
- return if @config[:silent]
536
- print("#{Time.now.iso8601} #{msg}\n")
537
- STDOUT.flush
608
+ if (options.include?(:recur_weekly))
609
+ rcnt += 1
610
+ time = time_from_recur_weekly(options[:recur_weekly]) # Try parse and make sure we can
611
+ unless options.include?(:due_at) || options.include?(:due_in)
612
+ options[:due_at] = time # Setup the first run
613
+ end
614
+ end
615
+
616
+ raise 'Can only specify one recurrance parameter' if rcnt > 1
538
617
  end
539
618
 
540
619
  end
metadata CHANGED
@@ -1,80 +1,77 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rescheduler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
5
- prerelease:
4
+ version: 0.5.1
6
5
  platform: ruby
7
6
  authors:
8
7
  - Dongyi Liao
9
8
  autorequire:
10
9
  bindir: bin
11
10
  cert_chain: []
12
- date: 2014-04-22 00:00:00.000000000 Z
11
+ date: 2016-06-04 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
14
  name: redis
16
15
  requirement: !ruby/object:Gem::Requirement
17
- none: false
18
16
  requirements:
19
- - - ! '>='
17
+ - - ">="
20
18
  - !ruby/object:Gem::Version
21
19
  version: '0'
22
20
  type: :runtime
23
21
  prerelease: false
24
22
  version_requirements: !ruby/object:Gem::Requirement
25
- none: false
26
23
  requirements:
27
- - - ! '>='
24
+ - - ">="
28
25
  - !ruby/object:Gem::Version
29
26
  version: '0'
30
27
  - !ruby/object:Gem::Dependency
31
28
  name: multi_json
32
29
  requirement: !ruby/object:Gem::Requirement
33
- none: false
34
30
  requirements:
35
- - - ! '>='
31
+ - - ">="
36
32
  - !ruby/object:Gem::Version
37
33
  version: '0'
38
34
  type: :runtime
39
35
  prerelease: false
40
36
  version_requirements: !ruby/object:Gem::Requirement
41
- none: false
42
37
  requirements:
43
- - - ! '>='
38
+ - - ">="
44
39
  - !ruby/object:Gem::Version
45
40
  version: '0'
46
41
  description: Rescheduler is a library that uses Redis to maintain a task queue of
47
42
  immediate and delayed jobs without polling.
48
43
  email: liaody@gmail.com
49
- executables: []
44
+ executables:
45
+ - rescheduler_launch
50
46
  extensions: []
51
47
  extra_rdoc_files: []
52
48
  files:
49
+ - bin/rescheduler_launch
53
50
  - lib/rescheduler.rb
51
+ - lib/rescheduler/sync.rb
52
+ - lib/rescheduler/worker.rb
54
53
  homepage: https://github.com/liaody/rescheduler
55
54
  licenses:
56
55
  - BSD
56
+ metadata: {}
57
57
  post_install_message:
58
58
  rdoc_options: []
59
59
  require_paths:
60
60
  - lib
61
61
  required_ruby_version: !ruby/object:Gem::Requirement
62
- none: false
63
62
  requirements:
64
- - - ! '>='
63
+ - - ">="
65
64
  - !ruby/object:Gem::Version
66
65
  version: '0'
67
66
  required_rubygems_version: !ruby/object:Gem::Requirement
68
- none: false
69
67
  requirements:
70
- - - ! '>='
68
+ - - ">="
71
69
  - !ruby/object:Gem::Version
72
70
  version: '0'
73
71
  requirements: []
74
72
  rubyforge_project:
75
- rubygems_version: 1.8.25
73
+ rubygems_version: 2.5.1
76
74
  signing_key:
77
- specification_version: 3
75
+ specification_version: 4
78
76
  summary: A job queue for immediate and delayed jobs using Redis
79
77
  test_files: []
80
- has_rdoc: