rescheduler 0.2.0

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.
Files changed (2) hide show
  1. data/lib/rescheduler.rb +302 -0
  2. metadata +83 -0
@@ -0,0 +1,302 @@
1
+ require 'json'
2
+ require 'redis'
3
+
4
+ module Rescheduler
5
+ extend self
6
+
7
+ # Setup configuration
8
+ attr_accessor :config
9
+ self.config = {}
10
+ #==========================
11
+ # Management routines
12
+ def prefix
13
+ return @config[:prefix] || ''
14
+ end
15
+
16
+ def reinitialize # Very slow reinitialize
17
+ keys = %w{TMCOUNTER TMMAINT TMDEFERRED}
18
+ %w{TMTUBE:* TMARGS:* TMRUNNING:*}.each do |p|
19
+ keys += redis.keys(prefix + p)
20
+ end
21
+ redis.del(keys)
22
+ end
23
+
24
+ # Return a hash of statistics
25
+ def stats
26
+ qnids = redis.keys(rk_args('*'))
27
+ stats = {}
28
+ qnids.each do |k|
29
+ qnid = k.split('TMARGS:')[1]
30
+ queue = qnid_to_queue(qnid)
31
+ stats[queue] ||= 0
32
+ stats[queue] += 1
33
+ end
34
+ return {:jobs=>stats}
35
+ end
36
+
37
+ #==========================
38
+ # Task producer routines
39
+ # Add an immediate task to the queue
40
+ def enqueue(options=nil)
41
+ options ||= {}
42
+ now = Time.now
43
+
44
+ # Error check
45
+ validate_queue_name(options[:queue]) if options.include?(:queue)
46
+
47
+ # Convert due_in to due_at
48
+ if options.include?(:due_in)
49
+ raise ArgumentError, ':due_it and :due_at can not be both specified' if options.include?(:due_at)
50
+ options[:due_at] = now + options[:due_in]
51
+ end
52
+
53
+ # Get an ID if not already have one
54
+ user_id = options.include?(:id)
55
+ unless user_id
56
+ options[:id] = redis.incr(rk_counter)
57
+ end
58
+
59
+ ts = options[:due_at].to_i || 0
60
+ qnid = get_qnid(options)
61
+
62
+ # Encode and save args
63
+ redis.multi do # Transaction to enqueue the job and save args together
64
+ if user_id # Delete possible existing job if user set id
65
+ redis.zrem(rk_deferred, qnid)
66
+ redis.lrem(rk_queue(options[:queue]), 0, qnid)
67
+ end
68
+
69
+ # Save options
70
+ redis.set(rk_args(qnid), options.to_json)
71
+
72
+ # Determine the due time
73
+ if ts > now.to_i # Future job
74
+ redis.zadd(rk_deferred, ts, qnid)
75
+ else
76
+ redis.lpush(rk_queue(options[:queue]), qnid)
77
+ end
78
+ end
79
+
80
+ # Now decide if we need to wait up the workers (outside of the transaction)
81
+ if (ts > now.to_i)
82
+ dt = redis.zrange(rk_deferred, 0, 0)[0]
83
+ # Wake up workers if our job is the first one in deferred queue, so they can reset timeout
84
+ if dt && dt == qnid
85
+ redis.lpush(rk_maintenace, '-') # Kick start the maintenance token ring
86
+ end
87
+ end
88
+ nil
89
+ end
90
+
91
+ def exists?(options)
92
+ raise ArgumentError, 'Can not test existence without :id' unless options.include?(:id)
93
+ qnid = get_qnid(options)
94
+ return redis.exists(rk_args(qnid))
95
+ end
96
+
97
+ def enqueue_unless_exists(options)
98
+ enqueue(options) unless exists?(options)
99
+ end
100
+
101
+ def delete(options)
102
+ qnid = get_qnid(options)
103
+
104
+ redis.multi do
105
+ redis.del(rk_args(qnid))
106
+ redis.zrem(rk_deferred, qnid)
107
+ redis.lrem(rk_queue(options[:queue]), 0, qnid)
108
+ end
109
+ end
110
+
111
+ #=================
112
+ # Job definition
113
+ # Task consumer routines
114
+ def job(tube, &block)
115
+ @runners ||= {}
116
+ @runners[tube] = block
117
+ return nil
118
+ end
119
+
120
+ #=================
121
+ # Runner/Maintenance routines
122
+ def start(*tubes)
123
+ # Check arguments
124
+ if !@runners || @runners.size == 0
125
+ raise Exception, 'Can not start worker without defining job handlers.'
126
+ end
127
+
128
+ tubes.each do |t|
129
+ next if @runners.include?(t)
130
+ raise Exception, "Handler for queue #{t} is undefined."
131
+ end
132
+
133
+ tubes = @runners.keys if !tubes || tubes.size == 0
134
+
135
+ log_debug "[[ Starting: #{tubes.join(',')} ]]"
136
+
137
+ # Generate a random clientkey for maintenance token ring maintenance.
138
+ client_key = [[rand(0xFFFFFFFF)].pack('L')].pack('m0')[0...6]
139
+
140
+ keys = tubes.map {|t| rk_queue(t)}
141
+ keys << rk_maintenace
142
+
143
+ dopush = nil
144
+
145
+ loop do
146
+ # Run maintenance and determine timeout
147
+ next_job_time = determine_next_deferred_job_time.to_i
148
+
149
+ if dopush # Only pass-on the token after we are done with maintenance. Avoid contention
150
+ redis.lpush(rk_maintenace, dopush)
151
+ dopush = nil
152
+ end
153
+
154
+ # Blocking wait
155
+ timeout = next_job_time - Time.now.to_i
156
+ timeout = 1 if timeout < 1
157
+ result = redis.brpop(keys, :timeout=>timeout)
158
+
159
+ # Handle task
160
+ if result # Got a task
161
+ tube = result[0]
162
+ qnid = result[1]
163
+ if tube == rk_maintenace
164
+ # Circulate maintenance task until it comes a full circle. This depends on redis
165
+ # first come first serve policy in brpop.
166
+ dopush = qnid + client_key unless qnid.include?(client_key) # Push if we have not pushed yet.
167
+ else
168
+ run_job(qnid)
169
+ end
170
+ else
171
+ # Do nothing when got timeout, the run_maintenance will take care of deferred jobs
172
+ end
173
+ end
174
+ end
175
+
176
+ private
177
+
178
+ # Runner routines
179
+ def run_job(qnid)
180
+ # First load job parameters for running
181
+ rk = rk_args(qnid)
182
+ rkr = rk_running(qnid)
183
+ optstr = nil
184
+ begin
185
+ res = nil
186
+ redis.watch(rk) do # Transaction to ensure read/delete is atomic
187
+ optstr = redis.get(rk)
188
+ if optstr.nil?
189
+ redis.unwatch
190
+ log_debug("Job is deleted mysteriously")
191
+ return # Job is deleted somewhere
192
+ end
193
+ res = redis.multi do
194
+ redis.del(rk)
195
+ redis.set(rkr, optstr)
196
+ end
197
+ if !res
198
+ # Contention, try read again
199
+ log_debug("Job read contention")
200
+ end
201
+ end
202
+ end until res
203
+
204
+ # Parse and run
205
+ opt = JSON.parse(optstr)
206
+ #opt.symbolize_keys # Be mindful of non-rails people, explicitly here
207
+ sopt = {}
208
+ opt.each { |key, val| sopt[key.to_sym] = val }
209
+
210
+ # 2. Find runner and invoke it
211
+ begin
212
+ log_debug(">>---- Starting #{qnid}")
213
+ runner = @runners[qnid_to_queue(qnid)]
214
+ runner.call(sopt)
215
+ log_debug("----<< Finished #{qnid}")
216
+ rescue
217
+ log_debug("----<< Failed #{qnid}: #{$!}")
218
+ end
219
+
220
+ # 3. Remove job from running list (Done)
221
+ redis.del(rkr)
222
+ end
223
+
224
+ # Helper routines
225
+
226
+ # Find all the "due" deferred jobs and move them into respective queues
227
+ def service_deferred_jobs
228
+ dtn = rk_deferred
229
+ ntry = 0
230
+ loop do
231
+ curtime = Time.now.to_i
232
+ redis.watch(dtn) do
233
+ tasks = redis.zrangebyscore(dtn, 0, curtime)
234
+ if tasks.empty?
235
+ redis.unwatch
236
+ return # Nothing to transfer, moving on.
237
+ end
238
+
239
+ redis.multi
240
+ redis.zremrangebyscore(dtn, 0, curtime)
241
+ tasks.each do |qnid|
242
+ q = qnid_to_queue(qnid)
243
+ redis.lpush(rk_queue(q), qnid)
244
+ end
245
+ if !redis.exec
246
+ # Contention happens, retrying
247
+ # Sleep a random amount of time after first try
248
+ log_debug("service_deferred_jobs contention")
249
+ Kernel.sleep (rand(ntry * 1000) / 1000.0) if ntry > 0
250
+ else
251
+ return # Done transfering
252
+ end
253
+ end
254
+ ntry += 1
255
+ end
256
+ end
257
+
258
+ def determine_next_deferred_job_time(skip_service = nil)
259
+ tsnow = Time.now.to_f
260
+ maxtime = tsnow + 3600
261
+
262
+ dt = redis.zrange(rk_deferred, 0, 0, :with_scores=>true)[0]
263
+ nt = (dt && dt[1] && dt[1] < maxtime) ? dt[1] : maxtime
264
+ if !skip_service && nt <= tsnow
265
+ service_deferred_jobs
266
+ # Get the deferred jobs again.
267
+ dt = redis.zrange(rk_deferred, 0, 0, :with_scores=>true)[0]
268
+ nt = (dt && dt[1] && dt[1] < maxtime) ? dt[1] : maxtime
269
+ end
270
+ return nt
271
+ end
272
+
273
+ def rk_queue(tube)
274
+ return "#{prefix}TMTUBE:#{tube}"
275
+ end
276
+
277
+ def rk_deferred; prefix + 'TMDEFERRED'; end
278
+ def rk_maintenace; prefix + 'TMMAINT'; end
279
+ def rk_args(qnid); "#{prefix}TMARGS:#{qnid}"; end
280
+ def rk_running(qnid); "#{prefix}TMRUNNING:#{qnid}"; end
281
+ def rk_counter; prefix + 'TMCOUNTER'; end
282
+
283
+ def get_qnid(options)
284
+ return "#{options[:queue]}:#{options[:id]}"
285
+ end
286
+
287
+ def qnid_to_queue(qnid); qnid[0...qnid.index(':')]; end
288
+
289
+ def redis
290
+ @redis ||= Redis.new(@config[:redis_connection] || {})
291
+ end
292
+
293
+ def validate_queue_name(queue)
294
+ raise ArgumentError, 'Queue name can not contain special characters' if queue.include?(':')
295
+ end
296
+
297
+ # Logging facility
298
+ def log_debug(msg)
299
+ print("#{Time.now.to_i} #{msg}\n")
300
+ end
301
+
302
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rescheduler
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Dongyi Liao
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-10-01 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: redis
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: json
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ description: ! " Rescheduler is a library that uses Redis to maintain a task queue
47
+ of immediate and delayed jobs without any polling.\n\n Goals:\n 1. Light weight.
48
+ Leave as much control to user as possible.\n 2. Fast response: no polling, pipe
49
+ architecture for immediate job response time.\n 3. No Setup: Can not require extra
50
+ \"maintenance thread\" in background. Auto maintained by each and every worker thread.\n\n"
51
+ email: liaody@gmail.com
52
+ executables: []
53
+ extensions: []
54
+ extra_rdoc_files: []
55
+ files:
56
+ - lib/rescheduler.rb
57
+ homepage: https://github.com/liaody/rescheduler
58
+ licenses:
59
+ - BSD
60
+ post_install_message:
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ none: false
72
+ requirements:
73
+ - - ! '>='
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubyforge_project:
78
+ rubygems_version: 1.8.24
79
+ signing_key:
80
+ specification_version: 3
81
+ summary: A job queue for immediate and delayed jobs using Redis
82
+ test_files: []
83
+ has_rdoc: