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