workhorse 0.0.1 → 0.0.2
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 +4 -4
- data/.gitignore +2 -1
- data/.releaser_config +3 -0
- data/.rubocop.yml +84 -0
- data/.travis.yml +11 -0
- data/README.md +156 -4
- data/Rakefile +35 -0
- data/VERSION +1 -1
- data/bin/rubocop +1 -0
- data/lib/generators/workhorse/install_generator.rb +24 -0
- data/lib/generators/workhorse/templates/bin/workhorse.rb +7 -0
- data/lib/generators/workhorse/templates/config/initializers/workhorse.rb +11 -0
- data/lib/generators/workhorse/templates/create_table_jobs.rb +23 -0
- data/lib/workhorse.rb +42 -0
- data/lib/workhorse/daemon.rb +164 -0
- data/lib/workhorse/daemon/shell_handler.rb +54 -0
- data/lib/workhorse/db_job.rb +67 -0
- data/lib/workhorse/enqueuer.rb +22 -0
- data/lib/workhorse/jobs/run_rails_op.rb +12 -0
- data/lib/workhorse/performer.rb +91 -0
- data/lib/workhorse/poller.rb +119 -0
- data/lib/workhorse/pool.rb +51 -0
- data/lib/workhorse/worker.rb +144 -0
- data/test/lib/db_schema.rb +20 -0
- data/test/lib/jobs.rb +46 -0
- data/test/lib/test_helper.rb +29 -0
- data/test/workhorse/enqueuer_test.rb +42 -0
- data/test/workhorse/performer_test.rb +18 -0
- data/test/workhorse/poller_test.rb +13 -0
- data/test/workhorse/pool_test.rb +72 -0
- data/test/workhorse/worker_test.rb +117 -0
- data/workhorse.gemspec +55 -0
- metadata +97 -5
@@ -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,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
|