taf2-rjqueue 0.1.4

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.
data/lib/jobs/base.rb ADDED
@@ -0,0 +1,58 @@
1
+ module Jobs
2
+ class Base
3
+ attr_reader :record, :logger, :lock
4
+
5
+ def initialize(job_record, logger, lock)
6
+ @record = job_record
7
+ @logger = logger
8
+ @lock = lock
9
+ end
10
+
11
+ def execute
12
+ end
13
+
14
+ # really run the execute method
15
+ def real_execute
16
+ timer = Time.now
17
+
18
+ res = execute
19
+
20
+ @record.duration = Time.now - timer
21
+
22
+ @record.status = res ? "complete" : "error"
23
+
24
+ rescue Object => e
25
+ # add error details to the record
26
+ @record.details ||= ""
27
+ @record.details << "#{e.message}\n#{e.backtrace.join("\n")}"
28
+
29
+ # log the error
30
+ @logger.error "#{e.message}\n#{e.backtrace.join("\n")}"
31
+ @record.status = "error"
32
+ ensure
33
+ # it's no longer processing, so we must have landed here by error
34
+ @record.status = 'error' if @record.status == 'processing'
35
+ @record.locked = false
36
+ @record.save
37
+ end
38
+
39
+ protected
40
+
41
+ #
42
+ # call a process: capture the command output and status code
43
+ # returns [status, output]
44
+ def call(cmd)
45
+ rd, wr = IO.pipe
46
+ pid = fork do
47
+ $stdout.reopen wr
48
+ $stderr.reopen wr
49
+ $stdin.reopen rd
50
+ exec cmd
51
+ end
52
+ wr.close
53
+ pid, status = Process.wait2(pid)
54
+ [status, rd.read]
55
+ end
56
+
57
+ end
58
+ end
@@ -0,0 +1,4 @@
1
+ require 'jobs/initializer'
2
+ require 'jobs/scheduler'
3
+ require 'jobs/job'
4
+ require 'jobs/base'
@@ -0,0 +1,13 @@
1
+ module Jobs
2
+ class Config
3
+
4
+ def self.host_for_job(name)
5
+ if defined?(::Jobs::HostMap) and ::Jobs::HostMap.key?(name)
6
+ ::Jobs::HostMap[name]
7
+ else
8
+ {:host => ::Jobs::Keys[:host], :port => ::Jobs::Keys[:port]}
9
+ end
10
+ end
11
+
12
+ end
13
+ end
@@ -0,0 +1,39 @@
1
+ require 'yaml'
2
+ require 'active_record'
3
+
4
+ module Jobs
5
+ class Initializer
6
+ def self.run!(job_root_path,job_config_path,env)
7
+ ::ActiveRecord::Base.send(:include,Jobs::Scheduler)
8
+ # define the Jobs::Keys constant, e.g. RAILS_ROOT/config/jobs.yml
9
+ eval %{::Jobs::Keys=HashWithIndifferentAccess.new(YAML.load_file(job_config_path)[env])}
10
+ # define the jobs root e.g. RAILS_ROOT/apps/jobs
11
+ eval %{::Jobs::Root=job_root_path}
12
+ end
13
+
14
+ def self.ready?
15
+ defined?(::Jobs::Root) and defined?(::Jobs::Keys)
16
+ end
17
+
18
+ def self.rails!
19
+ run! "#{RAILS_ROOT}/app/jobs", "#{RAILS_ROOT}/config/jobs.yml", RAILS_ENV
20
+ end
21
+
22
+ # Configure client to be selective about which job servers to send work
23
+ #
24
+ # Jobs::Initializer.config do|cfg|
25
+ #
26
+ # cfg[:sphinx] = [ {:host => '192.168.1.102', :port => 4321} ]
27
+ # cfg[:video_encoder] = [ {:host => '192.168.1.102', :port => 4321} ]
28
+ #
29
+ def self.config
30
+ config = {}
31
+ yield config
32
+ eval %{::Jobs::HostMap=config}
33
+ end
34
+
35
+ def self.test!
36
+ run! File.join(File.dirname(__FILE__),'..','..','tests','jobs'), File.join(File.dirname(__FILE__),'..','..','config','jobs.yml'), 'test'
37
+ end
38
+ end
39
+ end
data/lib/jobs/job.rb ADDED
@@ -0,0 +1,40 @@
1
+ module Jobs
2
+ class Job < ActiveRecord::Base
3
+ serialize :data
4
+ belongs_to :taskable, :polymorphic => true
5
+
6
+ #
7
+ # check if the job is currently being processed
8
+ #
9
+ # e.g. status == 'processing' or status == 'pending'
10
+ #
11
+ def busy?
12
+ self.status != 'processing' and self.status != 'pending'
13
+ end
14
+
15
+ def instance(logger_instance, lock)
16
+ load("#{Jobs::Root}/#{name}_job.rb")
17
+ klass = "#{name}_job".camelize.constantize
18
+ klass.new(self,logger_instance, lock)
19
+ rescue Object => e
20
+ logger.error "#{e.message}\n#{e.backtrace.join("\n")}"
21
+ return nil
22
+ end
23
+
24
+ def retry
25
+ return false if locked
26
+ self.status = 'pending'
27
+ signal
28
+ save
29
+ end
30
+
31
+ LockedError = Class.new(::StandardError)
32
+
33
+ def retry!
34
+ raise LockedError if locked
35
+ self.status = 'pending'
36
+ signal
37
+ save!
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,19 @@
1
+ class CreateJobs < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :jobs, :force => true do |t|
4
+ t.string :name, :null => false
5
+ t.text :data
6
+ t.string :status
7
+ t.datetime :created_at
8
+ t.datetime :updated_at
9
+ t.integer :duration
10
+ t.integer :taskable_id
11
+ t.string :taskable_type
12
+ t.text :details
13
+ t.boolean :locked, :default => false, :null => false
14
+ t.integer :attempts, :default => 0, :null => false
15
+ end
16
+
17
+ add_index :jobs, [:status, :locked]
18
+ end
19
+ end
@@ -0,0 +1,26 @@
1
+ module Jobs
2
+ module Runnable
3
+ #
4
+ # returns a sql query condition given the included and excluded
5
+ # jobs for the given process. e.g. jobs.name IN ('job1','job2') and jobs.name NOT IN ('jobs3','jobs5')
6
+ # would allow only job1 and job2 to run on the server, but also deny jobs3 and jobs5
7
+ # in this way the typical use would be to either use the deny or use the allow, but never both together...
8
+ # however the following will handle both...
9
+ #
10
+ def sql_runnable_conditions(allowed,denied)
11
+ conditions = "status='pending' and locked=0 "
12
+ if allowed and !allowed.empty? and denied and !denied.empty? # both have something
13
+ includes = allowed.map{|a| "'#{a}'" }.join(',')
14
+ conditions << %Q( and name IN (#{includes}) )
15
+ excludes = denied.map{|d| "'#{d}'" }.join(',')
16
+ conditions << %Q( and name not IN (#{excludes}) )
17
+ elsif allowed and !allowed.empty? and denied.nil? # only allowed
18
+ includes = allowed.map{|a| "'#{a}'" }.join(',')
19
+ conditions << %Q( and name IN (#{includes}) )
20
+ elsif denied and !denied.empty? and allowed.nil? # only denied
21
+ excludes = denied.map{|d| "'#{d}'" }.join(',')
22
+ conditions << %Q( and name not IN (#{excludes}) )
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,41 @@
1
+ require 'socket'
2
+ require 'yaml'
3
+ require 'active_record'
4
+ require 'jobs/job'
5
+ require 'jobs/config'
6
+
7
+ module Jobs
8
+
9
+ module Scheduler
10
+ def schedule(job_name, options={})
11
+ threadable = options.delete(:threadable)
12
+
13
+ job = Jobs::Job.new(:name => job_name.to_s.strip,
14
+ :data => options,
15
+ :taskable_id => self.id,
16
+ :taskable_type => self.class.to_s,
17
+ :status => "pending")
18
+ job.save!
19
+
20
+ signal(job_name,threadable)
21
+
22
+ job.id
23
+ end
24
+
25
+ #
26
+ # send a signal to wake job queue
27
+ #
28
+ # threadable: provides a hint that a threaded worker may be used to run the job
29
+ #
30
+ def signal(job_name=nil,threadable=nil)
31
+ socket = UDPSocket.open
32
+ #socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true)
33
+ hosts = ::Jobs::Config.host_for_job(job_name)
34
+ if hosts.is_a?(Array)
35
+ else
36
+ socket.send(threadable ? 't' : 's', 0, hosts[:host], hosts[:port] )
37
+ end
38
+ end
39
+ end
40
+
41
+ end
@@ -0,0 +1,382 @@
1
+ #
2
+ # Listen for UDP packets
3
+ #
4
+ # Start up X worker processes, communicates with worker processes by sending USR1 signal
5
+ # and USR2 to kill the process
6
+ #
7
+ require 'yaml'
8
+ require 'socket'
9
+ require 'jobs/runnable'
10
+
11
+ module Jobs
12
+
13
+ class Server
14
+ include Jobs::Runnable
15
+
16
+ def initialize(runpath, config_path, env)
17
+ @runpath = runpath
18
+ @config_path = File.expand_path(config_path)
19
+ @env = env
20
+ @workers = []
21
+ @next_worker = 0
22
+ load_config
23
+ end
24
+
25
+ def migrate
26
+ enable_logger
27
+ if @config['preload']
28
+ preload = @config['preload']
29
+ if preload.is_a?(Array)
30
+ preload.each { |f| require f }
31
+ else
32
+ require preload
33
+ end
34
+ end
35
+
36
+ require 'rubygems'
37
+ require 'active_record'
38
+
39
+ @logger.info("[job worker #{@pid}]: establish connection environment with #{@config_path.inspect} and env: #{@env.inspect}")
40
+ @db = YAML.load_file(File.join(File.dirname(@config_path),'database.yml'))[@env]
41
+ ActiveRecord::Base.establish_connection @db
42
+ ActiveRecord::Base.logger = @logger
43
+ #ActiveRecord::Base.logger = Logger.new( "#{@logfile}-db.log" )
44
+ #ActiveRecord::Base.logger = Logger.new( "/dev/null" )
45
+
46
+ # load the jobs/job model
47
+ require 'jobs/job'
48
+ require 'jobs/migrate'
49
+ CreateJobs.up
50
+ end
51
+
52
+ def kill
53
+ load_config
54
+ if File.exist?(@pid_file)
55
+ system("kill #{File.read(@pid_file)}")
56
+ else
57
+ puts "Job Queue Pid File not found! Try ps -ef | grep rjqueue"
58
+ end
59
+ end
60
+
61
+ def run(daemonize=true)
62
+
63
+ @daemonize = daemonize
64
+ if @daemonize
65
+ @child_up = false
66
+
67
+ # use this to determine when all workers are started and the child process is ready to listen for events
68
+ trap("USR1"){ @child_up = true }
69
+
70
+ if File.exist?(@pid_file)
71
+ STDERR.puts "Pid file for job already exists: #{@pid_file}"
72
+ exit 1
73
+ end
74
+ # daemonize, create a pipe to send status to the parent process, after the child has successfully started or failed
75
+ rd, wr = IO.pipe
76
+
77
+ @parent_pid = Process.pid
78
+
79
+ # wait for child process to start running before exiting parent process
80
+ fork do
81
+ rd.close
82
+ Process.setsid
83
+ fork do
84
+ begin
85
+ Process.setsid
86
+ File.open(@pid_file, 'wb') {|f| f << Process.pid}
87
+ Dir.chdir('/')
88
+ File.umask 0000
89
+ STDIN.reopen "/dev/null"
90
+ STDOUT.reopen "/dev/null", "a"
91
+ STDERR.reopen STDOUT
92
+
93
+ # XXX: change back to the runpath... this means if the runpath is removed
94
+ # say by a cap deploy... the daemon will likely die.
95
+ Dir.chdir(@runpath)
96
+
97
+ startup
98
+
99
+ @logger.info "Job process active"
100
+
101
+ wr.write "Listening on udp://#{@host}:#{@port}\n"
102
+ wr.flush
103
+ wr.close # signal to our parent we're up and running, this lets the parent exit
104
+ run_loop
105
+ rescue => e
106
+ if wr.closed?
107
+ @logger.error "#{e.message} #{e.backtrace.join("\n")}"
108
+ else
109
+ wr.write e.message
110
+ wr.write e.backtrace.join("\n")
111
+ wr.write "\n"
112
+ wr.write "ERROR!"
113
+ wr.flush
114
+ wr.close
115
+ end
116
+ ensure
117
+ cleanup
118
+ end
119
+ end
120
+ wr.close
121
+ end
122
+ wr.close
123
+ output = rd.read
124
+ puts output
125
+ rd.close
126
+
127
+ w = 0
128
+ puts "Waiting for child workers to start..."
129
+ while( !@child_up and w < 20 ) do
130
+ sleep 5
131
+ w+= 1
132
+ end
133
+ if @child_up
134
+ puts "Workers alive"
135
+ else
136
+ puts "Check error log, workers may not be started, or you may be starting so many workers it has taken longer then 10 seconds to start them all. In either event checking the log for details would be the first place to look."
137
+ @parent_pid = nil
138
+ end
139
+
140
+ exit(1) if output.match(/ERROR/i)
141
+ else
142
+ startup
143
+ run_loop
144
+ end
145
+ end
146
+
147
+ private
148
+
149
+ def check_count
150
+ count = 0
151
+
152
+ query = "select count(id) from jobs where #{sql_runnable_conditions(@config['jobs_included'], @config['jobs_excluded'])}"
153
+ @logger.debug("timeout check: #{query.inspect}")
154
+
155
+ res = @conn.query(query)
156
+
157
+ count = res.fetch_row[0].to_i
158
+ count
159
+ rescue Mysql::Error => e
160
+ @logger.error("[jobqueue]: #{e.message}\n#{e.backtrace.join("\n")}")
161
+ count = 1
162
+ db_connect!
163
+ count
164
+ ensure
165
+ res.free if res
166
+ end
167
+
168
+ def db_connect!
169
+ # connect to the MySQL server
170
+ dbconf = YAML.load_file(File.join(File.dirname(@config_path),'database.yml'))[@env]
171
+ @conn = Mysql.real_connect(dbconf['host'], dbconf['username'], dbconf['password'], dbconf['database'], (dbconf['port'] or 3306) )
172
+ end
173
+
174
+ def run_loop
175
+
176
+ @sleep_time = @wait_time
177
+
178
+ @config['workers'].times {|i| start_worker(i) }
179
+
180
+ unless defined?(Jobs::Initializer) and Jobs::Initializer.ready?
181
+ require 'rubygems'
182
+ require 'jobs/client'
183
+ Jobs::Initializer.run! File.join(@runpath,@config['jobpath']), @config_path, @env
184
+ end
185
+
186
+ # job queue master process needs to be aware of the number of jobs pending on timeout
187
+ require 'rubygems'
188
+ require 'mysql'
189
+
190
+ db_connect!
191
+
192
+ begin
193
+ count = check_count
194
+ signal_work(count)
195
+
196
+ begin
197
+ Process.kill("USR1", @parent_pid) if !@parent_pid.nil?
198
+ rescue => e
199
+ @logger.error "#{e.message}\n#{e.backtrace.join("\n")}"
200
+ end
201
+
202
+ while(@running) do
203
+ count = 0
204
+ begin
205
+ if IO.select([@sock],[],[],@sleep_time)
206
+ while( (msg = @sock.read_nonblock(1)) ) do # each message is 1 byte ['s','t']
207
+ count += 1 # a new message, increment the request count
208
+ end
209
+ else
210
+ count = check_count
211
+ end
212
+ rescue Errno::EAGAIN => e
213
+ # there is more information pending, but we don't have it hear yet... not sure if this condition happens with UDP
214
+ end
215
+
216
+ if count > 0
217
+ @logger.info("[jobqueue]: received #{count} events")
218
+ # signal workers, distribute work load evenly to each worker
219
+ signal_work(count)
220
+ end
221
+
222
+ end
223
+ rescue Object => e
224
+ if @running
225
+ @logger.error "[jobqueue] #{e.message}\n#{e.backtrace.join("\n")}"
226
+ sleep 0.1 # throttle incase things get crazy
227
+ retry
228
+ end
229
+ end
230
+
231
+ @logger.info "Stopping workers"
232
+ @workers.each do|worker|
233
+ stop_worker(worker)
234
+ end
235
+
236
+ end
237
+
238
+ def stop_worker(pid)
239
+ @logger.info "Stopping worker: #{pid}"
240
+ Process.kill('USR2', pid)
241
+ Process.waitpid2(pid,0)
242
+ end
243
+
244
+ def start_worker(worker_id)
245
+ config = @config
246
+ config_path = @config_path
247
+ logger = @logger
248
+ env = @env
249
+
250
+ rd, wr = IO.pipe
251
+ # startup workers
252
+ @logger.info "starting worker"
253
+ @workers << fork do
254
+ begin
255
+ rd.close
256
+ require 'jobs/worker'
257
+ worker = Jobs::Worker.new(config,@runpath, config_path,logger,env)
258
+ @logger.info "created worker: #{Process.pid}"
259
+ # drop a worker pid
260
+ File.open(@pid_file.gsub(/\.pid$/,"-#{worker_id}.pid"),'wb') {|f| f << Process.pid }
261
+ # signal parent
262
+ wr.write "up"
263
+ wr.close
264
+ worker.listen
265
+ # cleanup pid
266
+ File.unlink @pid_file.gsub(/\.pid$/,"-#{worker_id}.pid")
267
+ rescue Object => e
268
+ msg = "#{e.message}\n#{e.backtrace.join("\n")}"
269
+ if wr.closed?
270
+ @logger.error msg
271
+ else
272
+ wr.write msg
273
+ wr.close
274
+ end
275
+ end
276
+ end
277
+ wr.close
278
+ @logger.info "waiting for worker to reply"
279
+ msg = rd.read
280
+ rd.close
281
+ @logger.info "worker: #{msg.inspect}"
282
+ raise "Failed to start worker: #{msg.inspect}" if msg != 'up'
283
+ end
284
+
285
+ # tell a non-busy worker there is new work
286
+ def signal_work(count) # count is unused
287
+ return if count.nil? or count == 0
288
+ count.times do
289
+ Process.kill( 'USR1', @workers[@next_worker] )
290
+ @next_worker += 1 # simple round robin...
291
+ @next_worker = 0 if @next_worker >= @workers.size
292
+ end
293
+ end
294
+
295
+ def startup
296
+ enable_logger
297
+
298
+ # open sock connection
299
+ @sock = bind_socket
300
+
301
+ @logger.info "Job process active"
302
+ @logger.info "Listening on udp://#{@host}:#{@port}\n"
303
+
304
+ @running = true
305
+
306
+ trap('TERM') { shutdown('TERM') }
307
+ trap('INT') { shutdown('INT') }
308
+ trap('HUP') { @logger.info 'ignore HUP' }
309
+ end
310
+
311
+ def shutdown(sig)
312
+ @logger.info "trap #{sig}"
313
+ @running = false # toggle the run state
314
+ trap(sig,"SIG_DFL") # turn the signal handler off
315
+ Process.kill("USR2", Process.pid) # resend the signal to trigger and exception in IO.select
316
+ end
317
+
318
+ def cleanup
319
+ @logger.info "[jobqueue] Stopping: #{@pid_file.inspect}"
320
+ if File.exist?(@pid_file)
321
+ File.unlink(@pid_file)
322
+ end
323
+ @conn.close if @conn
324
+ end
325
+
326
+ def bind_socket
327
+ # listen to udp packets
328
+ sock = UDPSocket.open
329
+ sock.bind(@host, @port)
330
+ sock
331
+ end
332
+
333
+ # load the server configuration, and intialize configuration instance variables
334
+ def load_config
335
+ @config = YAML.load_file(@config_path)
336
+ @config = @config[@env]
337
+ if @config['runpath']
338
+ runpath = @config['runpath']
339
+ if !runpath.match(/^\//)
340
+ @runpath = File.expand_path(runpath)
341
+ else
342
+ @runpath = runpath
343
+ end
344
+ end
345
+
346
+ @pid_file = @config['pidfile'] or File.join(@runpath,'log','jobs.pid')
347
+
348
+ if @pid_file and !@pid_file.match(/^\//)
349
+ # make pidfile path absolute
350
+ @pid_file = File.join(@runpath,File.dirname(@pid_file), File.basename(@pid_file))
351
+ end
352
+
353
+ # store some common config keys
354
+ @wait_time = @config['wait_time'] || 10
355
+ @port = @config['port'] || 4321
356
+ @host = @config['host'] || '127.0.0.1'
357
+ @threads = @config['threads'] || 10
358
+ end
359
+
360
+ # setup logging
361
+ def enable_logger
362
+ require 'logger'
363
+ if @config['logfile']
364
+ @logfile = @config['logfile']
365
+ if @daemonize
366
+ if !@logfile.match(/^\//)
367
+ @logfile = File.join(@runpath, @logfile)
368
+ end
369
+ @logger = Logger.new( @logfile )
370
+ else
371
+ @logger = Logger.new(STDOUT)
372
+ end
373
+ else
374
+ @logger = Logger.new( '/dev/null' )
375
+ end
376
+
377
+ @logger.level = Logger::ERROR if @env == 'production'
378
+ end
379
+
380
+ end
381
+
382
+ end