workhorse 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,164 @@
1
+ module Workhorse
2
+ class Daemon
3
+ def initialize(count: 1, pidfile: nil, quiet: false, &block)
4
+ @count = count
5
+ @pidfile = pidfile
6
+ @quiet = quiet
7
+ @block = block
8
+
9
+ fail 'Count must be an integer > 0.' unless count.is_a?(Integer) && count > 0
10
+
11
+ if @pidfile.nil?
12
+ @pidfile = count > 1 ? 'tmp/pids/workhorse.%i.pid' : 'tmp/pids/workhorse.pid'
13
+ elsif @count > 1 && !@pidfile.include?('%s')
14
+ fail 'Pidfile must include placeholder "%s" for worker id when specifying a count > 1.'
15
+ elsif @count == 0 && @pidfile.include?('%s')
16
+ fail 'Pidfile must not include placeholder "%s" for worker id when specifying a count of 1.'
17
+ end
18
+ end
19
+
20
+ def start
21
+ code = 0
22
+
23
+ for_each_worker do |worker_id|
24
+ pid_file, pid = read_pid(worker_id)
25
+
26
+ if pid_file && pid
27
+ warn "Worker ##{worker_id}: Already started (PID #{pid})"
28
+ code = 1
29
+ elsif pid_file
30
+ File.delete pid_file
31
+ puts "Worker ##{worker_id}: Starting (stale pid file)"
32
+ start_worker worker_id
33
+ else
34
+ warn "Worker ##{worker_id}: Starting"
35
+ start_worker worker_id
36
+ end
37
+ end
38
+
39
+ return code
40
+ end
41
+
42
+ def stop
43
+ code = 0
44
+
45
+ for_each_worker do |worker_id|
46
+ pid_file, pid = read_pid(worker_id)
47
+
48
+ if pid_file && pid
49
+ puts "Worker ##{worker_id}: Stopping"
50
+ stop_worker pid_file, pid
51
+ elsif pid_file
52
+ File.delete pid_file
53
+ puts "Worker ##{worker_id}: Already stopped (stale PID file)"
54
+ else
55
+ warn "Worker ##{worker_id}: Already stopped"
56
+ code = 1
57
+ end
58
+ end
59
+
60
+ return code
61
+ end
62
+
63
+ def status(quiet: false)
64
+ code = 0
65
+
66
+ for_each_worker do |worker_id|
67
+ pid_file, pid = read_pid(worker_id)
68
+
69
+ if pid_file && pid
70
+ puts "Worker ##{worker_id}: Running" unless quiet
71
+ elsif pid_file
72
+ warn "Worker ##{worker_id}: Not running (stale PID file)" unless quiet
73
+ code = 1
74
+ else
75
+ warn "Worker ##{worker_id}: Not running" unless quiet
76
+ code = 1
77
+ end
78
+ end
79
+
80
+ return code
81
+ end
82
+
83
+ def watch
84
+ if defined?(Rails)
85
+ should_be_running = !File.exist?(Rails.root.join('tmp/stop.txt'))
86
+ else
87
+ should_be_running = true
88
+ end
89
+
90
+ if should_be_running && status(quiet: true) != 0
91
+ return start
92
+ else
93
+ return 0
94
+ end
95
+ end
96
+
97
+ def restart
98
+ stop
99
+ return start
100
+ end
101
+
102
+ private
103
+
104
+ def for_each_worker(&block)
105
+ 1.upto(@count, &block)
106
+ end
107
+
108
+ def start_worker(worker_id)
109
+ pid = fork do
110
+ $0 = process_name(worker_id)
111
+ @block.call
112
+ end
113
+ IO.write(pid_file_for(worker_id), pid)
114
+ end
115
+
116
+ def stop_worker(pid_file, pid)
117
+ loop do
118
+ begin
119
+ Process.kill('TERM', pid)
120
+ rescue Errno::ESRCH
121
+ break
122
+ end
123
+
124
+ sleep 1
125
+ end
126
+
127
+ File.delete(pid_file)
128
+ end
129
+
130
+ def process_name(worker_id)
131
+ if defined?(Rails)
132
+ path = Rails.root
133
+ else
134
+ path = $PROGRAM_NAME
135
+ end
136
+
137
+ return "Workhorse Worker ##{worker_id}: #{path}"
138
+ end
139
+
140
+ def process?(pid)
141
+ return begin
142
+ Process.getpgid(pid)
143
+ true
144
+ rescue Errno::ESRCH
145
+ false
146
+ end
147
+ end
148
+
149
+ def pid_file_for(worker_id)
150
+ @pidfile % worker_id
151
+ end
152
+
153
+ def read_pid(worker_id)
154
+ file = pid_file_for(worker_id)
155
+
156
+ if File.exist?(file)
157
+ pid = IO.read(file).to_i
158
+ return file, process?(pid) ? pid : nil
159
+ else
160
+ return nil, nil
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,54 @@
1
+ module Workhorse
2
+ class Daemon::ShellHandler
3
+ def self.run(options = {}, &block)
4
+ unless ARGV.count == 1
5
+ usage
6
+ exit 99
7
+ end
8
+
9
+ lockfile_path = options.delete(:lockfile) || 'workhorse.lock'
10
+ lockfile = File.open(lockfile_path, 'a')
11
+ lockfile.flock(File::LOCK_EX || File::LOCK_NB)
12
+
13
+ daemon = Workhorse::Daemon.new(options, &block)
14
+
15
+ begin
16
+ case ARGV.first
17
+ when 'start'
18
+ exit daemon.start
19
+ when 'stop'
20
+ exit daemon.stop
21
+ when 'status'
22
+ exit daemon.status
23
+ when 'watch'
24
+ exit daemon.watch
25
+ when 'restart'
26
+ exit daemon.restart
27
+ when 'usage'
28
+ usage
29
+ exit 99
30
+ else
31
+ usage
32
+ end
33
+
34
+ exit 0
35
+ rescue => e
36
+ warn "#{e.message}\n#{e.backtrace.join("\n")}"
37
+ exit 99
38
+ ensure
39
+ lockfile.flock(File::LOCK_UN)
40
+ end
41
+ end
42
+
43
+ def self.usage
44
+ warn <<~USAGE
45
+ Usage: #{$PROGRAM_NAME} start|stop|status|watch|restart|usage
46
+
47
+ Exit status:
48
+ 0 if OK,
49
+ 1 if at least one worker has an unexpected status,
50
+ 99 on all other errors.
51
+ USAGE
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,67 @@
1
+ module Workhorse
2
+ class DbJob < ActiveRecord::Base
3
+ STATE_WAITING = :waiting
4
+ STATE_LOCKED = :locked
5
+ STATE_STARTED = :started
6
+ STATE_SUCCEEDED = :succeeded
7
+ STATE_FAILED = :failed
8
+
9
+ self.table_name = 'jobs'
10
+
11
+ def mark_locked!(worker_id)
12
+ if changed?
13
+ fail "Dirty jobs can't be locked."
14
+ end
15
+
16
+ if locked_at
17
+ fail "Job #{id} is already locked by #{locked_by.inspect}."
18
+ end
19
+
20
+ self.locked_at = Time.now
21
+ self.locked_by = worker_id
22
+ self.state = STATE_LOCKED
23
+ save!
24
+ end
25
+
26
+ def mark_started!
27
+ assert_state! STATE_LOCKED
28
+
29
+ self.started_at = Time.now
30
+ self.state = STATE_STARTED
31
+ save!
32
+ end
33
+
34
+ def mark_failed!(exception)
35
+ assert_state! STATE_LOCKED, STATE_STARTED
36
+
37
+ self.failed_at = Time.now
38
+ self.last_error = %(#{exception.message}\n#{exception.backtrace.join("\n")})
39
+ self.state = STATE_FAILED
40
+ save!
41
+ end
42
+
43
+ def mark_succeeded!
44
+ assert_state! STATE_STARTED
45
+
46
+ self.succeeded_at = Time.now
47
+ self.state = STATE_SUCCEEDED
48
+ save!
49
+ end
50
+
51
+ def assert_state!(*states)
52
+ unless states.include?(state.to_sym)
53
+ fail "Job #{id} is not in state #{states.inspect} but in state #{state.inspect}."
54
+ end
55
+ end
56
+
57
+ def assert_locked_by!(worker_id)
58
+ assert_state! STATE_WAITING
59
+
60
+ if locked_by.nil?
61
+ fail "Job #{id} is not locked by any worker."
62
+ elsif locked_by != worker_id
63
+ fail "Job #{id} is locked by another worker (#{locked_by})."
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,22 @@
1
+ module Workhorse
2
+ class Enqueuer
3
+ # Enqueue any object that is serializable and has a `perform` method
4
+ def self.enqueue(job, queue: nil)
5
+ return DbJob.create!(
6
+ queue: queue,
7
+ handler: Marshal.dump(job)
8
+ )
9
+ end
10
+
11
+ # Enqueue an ActiveJob job
12
+ def self.enqueue_active_job(job)
13
+ enqueue job, queue: job.queue_name
14
+ end
15
+
16
+ # Enqueue the execution of an operation by its class and params
17
+ def self.enqueue_op(cls, params, queue: nil)
18
+ job = Workhorse::Jobs::RunRailsOp.new(cls, params)
19
+ enqueue job, queue: queue
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,12 @@
1
+ module Workhorse::Jobs
2
+ class RunRailsOp
3
+ def initialize(cls, params = {})
4
+ @cls = cls
5
+ @params = params
6
+ end
7
+
8
+ def perform
9
+ @cls.run!(@params)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,91 @@
1
+ module Workhorse
2
+ class Performer
3
+ attr_reader :worker
4
+
5
+ def initialize(db_job, worker)
6
+ @db_job = db_job
7
+ @worker = worker
8
+ @started = false
9
+ end
10
+
11
+ def perform
12
+ fail 'Performer can only run once.' if @started
13
+ @started = true
14
+ perform!
15
+ end
16
+
17
+ private
18
+
19
+ def perform!
20
+ Thread.current[:workhorse_current_performer] = self
21
+
22
+ ActiveRecord::Base.connection_pool.with_connection do
23
+ if defined?(Rails) && Rails.application && Rails.application.respond_to?(:executor)
24
+ Rails.application.executor.wrap do
25
+ perform_wrapped
26
+ end
27
+ else
28
+ perform_wrapped
29
+ end
30
+ end
31
+ rescue => e
32
+ # ---------------------------------------------------------------
33
+ # Mark job as failed
34
+ # ---------------------------------------------------------------
35
+ log %(#{e.message}\n#{e.backtrace.join("\n")}), :error
36
+
37
+ Workhorse.tx_callback.call do
38
+ log 'Mark failed', :debug
39
+ @db_job.mark_failed!(e)
40
+ end
41
+ ensure
42
+ Thread.current[:workhorse_current_performer] = nil
43
+ end
44
+
45
+ def perform_wrapped
46
+ # ---------------------------------------------------------------
47
+ # Mark job as started
48
+ # ---------------------------------------------------------------
49
+ Workhorse.tx_callback.call do
50
+ log 'Marking as started', :debug
51
+ @db_job.mark_started!
52
+ end
53
+
54
+ # ---------------------------------------------------------------
55
+ # Deserialize and perform job
56
+ # ---------------------------------------------------------------
57
+ log 'Performing', :info
58
+
59
+ if Workhorse.perform_jobs_in_tx
60
+ Workhorse.tx_callback.call do
61
+ deserialized_job.perform
62
+ end
63
+ else
64
+ deserialized_job.perform
65
+ end
66
+
67
+ log 'Successfully performed', :info
68
+
69
+ # ---------------------------------------------------------------
70
+ # Mark job as succeeded
71
+ # ---------------------------------------------------------------
72
+ Workhorse.tx_callback.call do
73
+ log 'Mark succeeded', :debug
74
+ @db_job.mark_succeeded!
75
+ end
76
+ end
77
+
78
+ def log(text, level = :info)
79
+ text = "[#{@db_job.id}] #{text}"
80
+ worker.log text, level
81
+ end
82
+
83
+ def deserialized_job
84
+ # The source is safe as long as jobs are always enqueued using
85
+ # Workhorse::Enqueuer so it is ok to use Marshal.load.
86
+ # rubocop: disable Security/MarshalLoad
87
+ Marshal.load(@db_job.handler)
88
+ # rubocop: enable Security/MarshalLoad
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,119 @@
1
+ module Workhorse
2
+ class Poller
3
+ attr_reader :worker
4
+
5
+ def initialize(worker)
6
+ @worker = worker
7
+ @running = false
8
+ end
9
+
10
+ def running?
11
+ @running
12
+ end
13
+
14
+ def start
15
+ fail 'Poller is already running.' if running?
16
+ @running = true
17
+
18
+ @thread = Thread.new do
19
+ begin
20
+ loop do
21
+ poll
22
+ break unless running?
23
+ sleep
24
+ end
25
+ rescue => e
26
+ worker.log %(Poller stopped with exception:\n#{e.message}\n#{e.backtrace.join("\n")})
27
+ end
28
+ end
29
+ end
30
+
31
+ def shutdown
32
+ fail 'Poller is not running.' unless running?
33
+ @running = false
34
+ wait
35
+ end
36
+
37
+ def wait
38
+ @thread.join
39
+ end
40
+
41
+ private
42
+
43
+ def sleep
44
+ remaining = worker.polling_interval
45
+
46
+ while running? && remaining > 0
47
+ Kernel.sleep 1
48
+ remaining -= 1
49
+ end
50
+ end
51
+
52
+ def poll
53
+ Workhorse.tx_callback.call do
54
+ idle = worker.idle
55
+ worker.log "Polling DB for jobs (#{idle} available threads)...", :debug
56
+
57
+ unless idle.zero?
58
+ jobs = queued_db_jobs(idle)
59
+ jobs.each do |job|
60
+ worker.log "Marking job #{job.id} as locked", :debug
61
+ job.mark_locked!(worker.id)
62
+ worker.perform job
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ def queued_db_jobs(limit)
69
+ table = Workhorse::DbJob.arel_table
70
+ is_oracle = ActiveRecord::Base.connection.adapter_name == 'OracleEnhanced'
71
+
72
+ # ---------------------------------------------------------------
73
+ # Lock all queued jobs that are not complete
74
+ # ---------------------------------------------------------------
75
+ Workhorse::DbJob.connection.execute(
76
+ Workhorse::DbJob.select('null').where(
77
+ table[:queue].not_eq(nil)
78
+ .and(table[:state].eq(:waiting))
79
+ ).lock.to_sql
80
+ )
81
+
82
+ # ---------------------------------------------------------------
83
+ # Select jobs to execute
84
+ # ---------------------------------------------------------------
85
+
86
+ # Fetch all waiting jobs of the correct queues
87
+ select = table.project(Arel.sql('*')).where(table[:state].eq(:waiting))
88
+
89
+ # Restrict queues that are currently in progress
90
+ bad_queries_select = table.project(table[:queue])
91
+ .where(table[:state].in(%i[locked running]))
92
+ .distinct
93
+ select = select.where(table[:queue].not_in(bad_queries_select))
94
+
95
+ # Restrict queues to "open" ones
96
+ if worker.queues.any?
97
+ where = table[:queue].in(worker.queues.reject(&:nil?))
98
+ if worker.queues.include?(nil)
99
+ where = where.or(table[:queue].eq(nil))
100
+ end
101
+ select = select.where(where)
102
+ end
103
+
104
+ # Order by creation date
105
+ select = select.order(table[:created_at].asc)
106
+
107
+ # Limit number of records
108
+ if is_oracle
109
+ select = select.where(Arel.sql('ROWNUM').lteq(limit))
110
+ else
111
+ select = select.take(limit)
112
+ end
113
+
114
+ select = select.lock
115
+
116
+ return Workhorse::DbJob.find_by_sql(select.to_sql)
117
+ end
118
+ end
119
+ end