rescheduler 0.4.1 → 0.5.1

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.
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: