workhorse 1.3.0.rc2 → 1.3.0.rc4
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/.github/workflows/ruby.yml +1 -1
- data/CHANGELOG.md +13 -2
- data/FAQ.md +10 -10
- data/Gemfile +5 -5
- data/LICENSE +1 -1
- data/README.md +23 -23
- data/Rakefile +2 -2
- data/VERSION +1 -1
- data/lib/active_job/queue_adapters/workhorse_adapter.rb +22 -6
- data/lib/workhorse/active_job_extension.rb +9 -0
- data/lib/workhorse/daemon.rb +99 -0
- data/lib/workhorse/db_job.rb +53 -0
- data/lib/workhorse/enqueuer.rb +24 -3
- data/lib/workhorse/jobs/cleanup_succeeded_jobs.rb +18 -0
- data/lib/workhorse/jobs/detect_stale_jobs_job.rb +22 -8
- data/lib/workhorse/jobs/run_active_job.rb +17 -0
- data/lib/workhorse/jobs/run_rails_op.rb +19 -0
- data/lib/workhorse/performer.rb +42 -0
- data/lib/workhorse/poller.rb +68 -7
- data/lib/workhorse/pool.rb +32 -4
- data/lib/workhorse/scoped_env.rb +20 -0
- data/lib/workhorse/worker.rb +93 -4
- data/lib/workhorse.rb +51 -18
- data/workhorse.gemspec +5 -5
- metadata +6 -6
data/lib/workhorse/db_job.rb
CHANGED
@@ -1,4 +1,17 @@
|
|
1
1
|
module Workhorse
|
2
|
+
# ActiveRecord model representing a job in the database.
|
3
|
+
# This class manages the job lifecycle and state transitions within the Workhorse system.
|
4
|
+
#
|
5
|
+
# @example Creating a job
|
6
|
+
# job = DbJob.create!(
|
7
|
+
# queue: 'default',
|
8
|
+
# handler: Marshal.dump(job_instance),
|
9
|
+
# priority: 0
|
10
|
+
# )
|
11
|
+
#
|
12
|
+
# @example Querying jobs by state
|
13
|
+
# waiting_jobs = DbJob.waiting
|
14
|
+
# failed_jobs = DbJob.failed
|
2
15
|
class DbJob < ActiveRecord::Base
|
3
16
|
STATE_WAITING = :waiting
|
4
17
|
STATE_LOCKED = :locked
|
@@ -14,26 +27,45 @@ module Workhorse
|
|
14
27
|
|
15
28
|
self.table_name = 'jobs'
|
16
29
|
|
30
|
+
# Returns jobs in waiting state.
|
31
|
+
#
|
32
|
+
# @return [ActiveRecord::Relation] Jobs waiting to be processed
|
17
33
|
def self.waiting
|
18
34
|
where(state: STATE_WAITING)
|
19
35
|
end
|
20
36
|
|
37
|
+
# Returns jobs in locked state.
|
38
|
+
#
|
39
|
+
# @return [ActiveRecord::Relation] Jobs currently locked by workers
|
21
40
|
def self.locked
|
22
41
|
where(state: STATE_LOCKED)
|
23
42
|
end
|
24
43
|
|
44
|
+
# Returns jobs in started state.
|
45
|
+
#
|
46
|
+
# @return [ActiveRecord::Relation] Jobs currently being executed
|
25
47
|
def self.started
|
26
48
|
where(state: STATE_STARTED)
|
27
49
|
end
|
28
50
|
|
51
|
+
# Returns jobs in succeeded state.
|
52
|
+
#
|
53
|
+
# @return [ActiveRecord::Relation] Jobs that completed successfully
|
29
54
|
def self.succeeded
|
30
55
|
where(state: STATE_SUCCEEDED)
|
31
56
|
end
|
32
57
|
|
58
|
+
# Returns jobs in failed state.
|
59
|
+
#
|
60
|
+
# @return [ActiveRecord::Relation] Jobs that failed during execution
|
33
61
|
def self.failed
|
34
62
|
where(state: STATE_FAILED)
|
35
63
|
end
|
36
64
|
|
65
|
+
# Returns a relation with split locked_by field for easier querying.
|
66
|
+
# Extracts host, PID, and random string components from locked_by.
|
67
|
+
#
|
68
|
+
# @return [ActiveRecord::Relation] Relation with additional computed columns
|
37
69
|
# @private
|
38
70
|
def self.with_split_locked_by
|
39
71
|
select(<<~SQL)
|
@@ -74,6 +106,9 @@ module Workhorse
|
|
74
106
|
# ("failed"), make sure the actions performed in the job are repeatable or
|
75
107
|
# have been rolled back. E.g. if the job already wrote something to an
|
76
108
|
# external API, it may cause inconsistencies if the job is performed again.
|
109
|
+
#
|
110
|
+
# @param force [Boolean] Whether to force reset without state validation
|
111
|
+
# @raise [RuntimeError] If job is not in a final state and force is false
|
77
112
|
def reset!(force = false)
|
78
113
|
unless force
|
79
114
|
assert_state! STATE_SUCCEEDED, STATE_FAILED
|
@@ -90,6 +125,10 @@ module Workhorse
|
|
90
125
|
save!
|
91
126
|
end
|
92
127
|
|
128
|
+
# Marks the job as locked by a specific worker.
|
129
|
+
#
|
130
|
+
# @param worker_id [String] The ID of the worker locking this job
|
131
|
+
# @raise [RuntimeError] If the job is dirty or already locked
|
93
132
|
# @private Only to be used by workhorse
|
94
133
|
def mark_locked!(worker_id)
|
95
134
|
if changed?
|
@@ -108,6 +147,9 @@ module Workhorse
|
|
108
147
|
save!
|
109
148
|
end
|
110
149
|
|
150
|
+
# Marks the job as started.
|
151
|
+
#
|
152
|
+
# @raise [RuntimeError] If the job is not in locked state
|
111
153
|
# @private Only to be used by workhorse
|
112
154
|
def mark_started!
|
113
155
|
assert_state! STATE_LOCKED
|
@@ -117,6 +159,10 @@ module Workhorse
|
|
117
159
|
save!
|
118
160
|
end
|
119
161
|
|
162
|
+
# Marks the job as failed with the given exception.
|
163
|
+
#
|
164
|
+
# @param exception [Exception] The exception that caused the failure
|
165
|
+
# @raise [RuntimeError] If the job is not in locked or started state
|
120
166
|
# @private Only to be used by workhorse
|
121
167
|
def mark_failed!(exception)
|
122
168
|
assert_state! STATE_LOCKED, STATE_STARTED
|
@@ -127,6 +173,9 @@ module Workhorse
|
|
127
173
|
save!
|
128
174
|
end
|
129
175
|
|
176
|
+
# Marks the job as succeeded.
|
177
|
+
#
|
178
|
+
# @raise [RuntimeError] If the job is not in started state
|
130
179
|
# @private Only to be used by workhorse
|
131
180
|
def mark_succeeded!
|
132
181
|
assert_state! STATE_STARTED
|
@@ -136,6 +185,10 @@ module Workhorse
|
|
136
185
|
save!
|
137
186
|
end
|
138
187
|
|
188
|
+
# Asserts that the job is in one of the specified states.
|
189
|
+
#
|
190
|
+
# @param states [Array<Symbol>] Valid states for the job
|
191
|
+
# @raise [RuntimeError] If the job is not in any of the specified states
|
139
192
|
def assert_state!(*states)
|
140
193
|
unless states.include?(state.to_sym)
|
141
194
|
fail "Job #{id} is not in state #{states.inspect} but in state #{state.inspect}."
|
data/lib/workhorse/enqueuer.rb
CHANGED
@@ -1,6 +1,16 @@
|
|
1
1
|
module Workhorse
|
2
|
+
# Module providing job enqueuing functionality.
|
3
|
+
# Extended by the main Workhorse module to provide enqueuing capabilities.
|
4
|
+
# Supports plain Ruby objects, ActiveJob instances, and Rails operations.
|
2
5
|
module Enqueuer
|
3
|
-
#
|
6
|
+
# Enqueues any object that is serializable and has a `perform` method.
|
7
|
+
#
|
8
|
+
# @param job [Object] The job object to enqueue (must respond to #perform)
|
9
|
+
# @param queue [String, Symbol, nil] The queue name
|
10
|
+
# @param priority [Integer] Job priority (lower numbers = higher priority)
|
11
|
+
# @param perform_at [Time] When to perform the job
|
12
|
+
# @param description [String, nil] Optional job description
|
13
|
+
# @return [Workhorse::DbJob] The created database job record
|
4
14
|
def enqueue(job, queue: nil, priority: 0, perform_at: Time.now, description: nil)
|
5
15
|
return DbJob.create!(
|
6
16
|
queue: queue,
|
@@ -11,7 +21,13 @@ module Workhorse
|
|
11
21
|
)
|
12
22
|
end
|
13
23
|
|
14
|
-
#
|
24
|
+
# Enqueues an ActiveJob job instance.
|
25
|
+
#
|
26
|
+
# @param job [ActiveJob::Base] The ActiveJob instance to enqueue
|
27
|
+
# @param perform_at [Time] When to perform the job
|
28
|
+
# @param queue [String, Symbol, nil] Optional queue override
|
29
|
+
# @param description [String, nil] Optional job description
|
30
|
+
# @return [Workhorse::DbJob] The created database job record
|
15
31
|
def enqueue_active_job(job, perform_at: Time.now, queue: nil, description: nil)
|
16
32
|
wrapper_job = Jobs::RunActiveJob.new(job.serialize)
|
17
33
|
queue ||= job.queue_name if job.queue_name.present?
|
@@ -26,7 +42,12 @@ module Workhorse
|
|
26
42
|
return db_job
|
27
43
|
end
|
28
44
|
|
29
|
-
#
|
45
|
+
# Enqueues the execution of a Rails operation by its class and parameters.
|
46
|
+
#
|
47
|
+
# @param cls [Class] The operation class to execute
|
48
|
+
# @param args [Array] Variable arguments (workhorse_args, op_args)
|
49
|
+
# @return [Workhorse::DbJob] The created database job record
|
50
|
+
# @raise [ArgumentError] If wrong number of arguments provided
|
30
51
|
def enqueue_op(cls, *args)
|
31
52
|
case args.size
|
32
53
|
when 0
|
@@ -1,4 +1,14 @@
|
|
1
1
|
module Workhorse::Jobs
|
2
|
+
# Job for cleaning up old succeeded jobs from the database.
|
3
|
+
# This maintenance job helps keep the jobs table from growing indefinitely
|
4
|
+
# by removing successfully completed jobs older than a specified age.
|
5
|
+
#
|
6
|
+
# @example Schedule cleanup job
|
7
|
+
# Workhorse.enqueue(CleanupSucceededJobs.new(max_age: 30))
|
8
|
+
#
|
9
|
+
# @example Daily cleanup with cron
|
10
|
+
# # Clean up jobs older than 14 days every day at 2 AM
|
11
|
+
# Workhorse.enqueue(CleanupSucceededJobs.new, perform_at: 1.day.from_now.beginning_of_day + 2.hours)
|
2
12
|
class CleanupSucceededJobs
|
3
13
|
# Instantiates a new job.
|
4
14
|
#
|
@@ -8,6 +18,9 @@ module Workhorse::Jobs
|
|
8
18
|
@max_age = max_age
|
9
19
|
end
|
10
20
|
|
21
|
+
# Executes the cleanup by deleting old succeeded jobs.
|
22
|
+
#
|
23
|
+
# @return [void]
|
11
24
|
def perform
|
12
25
|
age_limit = seconds_ago(@max_age)
|
13
26
|
Workhorse::DbJob.where(
|
@@ -17,6 +30,11 @@ module Workhorse::Jobs
|
|
17
30
|
|
18
31
|
private
|
19
32
|
|
33
|
+
# Calculates a timestamp for the given number of days ago.
|
34
|
+
#
|
35
|
+
# @param days [Integer] Number of days in the past
|
36
|
+
# @return [Time] Timestamp for the specified days ago
|
37
|
+
# @private
|
20
38
|
def seconds_ago(days)
|
21
39
|
Time.now - (days * 24 * 60 * 60)
|
22
40
|
end
|
@@ -1,22 +1,36 @@
|
|
1
1
|
module Workhorse::Jobs
|
2
|
-
#
|
2
|
+
# Job that detects and reports stale jobs in the system.
|
3
|
+
# This monitoring job picks up jobs that remained `locked` or `started` (running) for
|
3
4
|
# more than a certain amount of time. If any of these jobs are found, an
|
4
5
|
# exception is thrown (which may cause a notification if you configured
|
5
|
-
#
|
6
|
+
# {Workhorse.on_exception} accordingly).
|
6
7
|
#
|
7
8
|
# The thresholds are obtained from the configuration options
|
8
|
-
# {Workhorse.stale_detection_locked_to_started_threshold
|
9
|
-
#
|
10
|
-
#
|
11
|
-
#
|
9
|
+
# {Workhorse.stale_detection_locked_to_started_threshold} and
|
10
|
+
# {Workhorse.stale_detection_run_time_threshold}.
|
11
|
+
#
|
12
|
+
# @example Schedule stale job detection
|
13
|
+
# Workhorse.enqueue(DetectStaleJobsJob.new)
|
14
|
+
#
|
15
|
+
# @example Configure thresholds
|
16
|
+
# Workhorse.setup do |config|
|
17
|
+
# config.stale_detection_locked_to_started_threshold = 300 # 5 minutes
|
18
|
+
# config.stale_detection_run_time_threshold = 3600 # 1 hour
|
19
|
+
# end
|
12
20
|
class DetectStaleJobsJob
|
13
|
-
#
|
21
|
+
# Creates a new stale job detection job.
|
22
|
+
# Reads configuration thresholds at initialization time.
|
14
23
|
def initialize
|
15
24
|
@locked_to_started_threshold = Workhorse.stale_detection_locked_to_started_threshold
|
16
25
|
@run_time_threshold = Workhorse.stale_detection_run_time_threshold
|
17
26
|
end
|
18
27
|
|
19
|
-
#
|
28
|
+
# Executes the stale job detection.
|
29
|
+
# Checks for jobs that have been locked or running too long and raises
|
30
|
+
# an exception if any are found.
|
31
|
+
#
|
32
|
+
# @return [void]
|
33
|
+
# @raise [RuntimeError] If stale jobs are detected
|
20
34
|
def perform
|
21
35
|
messages = []
|
22
36
|
|
@@ -1,15 +1,32 @@
|
|
1
1
|
module Workhorse::Jobs
|
2
|
+
# Wrapper job for executing ActiveJob instances within Workhorse.
|
3
|
+
# This job handles the deserialization and execution of ActiveJob jobs
|
4
|
+
# that have been enqueued through the Workhorse adapter.
|
5
|
+
#
|
6
|
+
# @example Internal usage
|
7
|
+
# wrapper = RunActiveJob.new(job.serialize)
|
8
|
+
# wrapper.perform
|
2
9
|
class RunActiveJob
|
10
|
+
# @return [Hash] Serialized ActiveJob data
|
3
11
|
attr_reader :job_data
|
4
12
|
|
13
|
+
# Creates a new ActiveJob wrapper.
|
14
|
+
#
|
15
|
+
# @param job_data [Hash] Serialized ActiveJob data from job.serialize
|
5
16
|
def initialize(job_data)
|
6
17
|
@job_data = job_data
|
7
18
|
end
|
8
19
|
|
20
|
+
# Returns the ActiveJob class for this job.
|
21
|
+
#
|
22
|
+
# @return [Class, nil] The job class or nil if not found
|
9
23
|
def job_class
|
10
24
|
@job_data['job_class'].safe_constantize
|
11
25
|
end
|
12
26
|
|
27
|
+
# Executes the wrapped ActiveJob.
|
28
|
+
#
|
29
|
+
# @return [void]
|
13
30
|
def perform
|
14
31
|
ActiveJob::Base.execute(@job_data)
|
15
32
|
end
|
@@ -1,14 +1,33 @@
|
|
1
1
|
module Workhorse::Jobs
|
2
|
+
# Job wrapper for executing Rails operations (trailblazer-operation or similar).
|
3
|
+
# This job allows enqueuing of operation classes with parameters for later execution.
|
4
|
+
#
|
5
|
+
# @example Enqueue an operation
|
6
|
+
# Workhorse.enqueue_op(MyOperation, { user_id: 123 })
|
7
|
+
#
|
8
|
+
# @example Manual instantiation
|
9
|
+
# job = RunRailsOp.new(MyOperation, { user_id: 123 })
|
10
|
+
# Workhorse.enqueue(job)
|
2
11
|
class RunRailsOp
|
12
|
+
# Creates a new Rails operation job.
|
13
|
+
#
|
14
|
+
# @param cls [Class] The operation class to execute
|
15
|
+
# @param params [Hash] Parameters to pass to the operation
|
3
16
|
def initialize(cls, params = {})
|
4
17
|
@cls = cls
|
5
18
|
@params = params
|
6
19
|
end
|
7
20
|
|
21
|
+
# Returns the operation class for this job.
|
22
|
+
#
|
23
|
+
# @return [Class] The operation class
|
8
24
|
def job_class
|
9
25
|
@cls
|
10
26
|
end
|
11
27
|
|
28
|
+
# Executes the Rails operation with the provided parameters.
|
29
|
+
#
|
30
|
+
# @return [void]
|
12
31
|
def perform
|
13
32
|
@cls.run!(@params)
|
14
33
|
end
|
data/lib/workhorse/performer.rb
CHANGED
@@ -1,13 +1,30 @@
|
|
1
1
|
module Workhorse
|
2
|
+
# Executes individual jobs within worker processes.
|
3
|
+
# The Performer handles job lifecycle management, error handling,
|
4
|
+
# and integration with Rails application executors.
|
5
|
+
#
|
6
|
+
# @example Basic usage (typically called internally)
|
7
|
+
# performer = Workhorse::Performer.new(job_id, worker)
|
8
|
+
# performer.perform
|
2
9
|
class Performer
|
10
|
+
# @return [Workhorse::Worker] The worker that owns this performer
|
3
11
|
attr_reader :worker
|
4
12
|
|
13
|
+
# Creates a new performer for a specific job.
|
14
|
+
#
|
15
|
+
# @param db_job_id [Integer] The ID of the {Workhorse::DbJob} to perform
|
16
|
+
# @param worker [Workhorse::Worker] The worker instance managing this performer
|
5
17
|
def initialize(db_job_id, worker)
|
6
18
|
@db_job = Workhorse::DbJob.find(db_job_id)
|
7
19
|
@worker = worker
|
8
20
|
@started = false
|
9
21
|
end
|
10
22
|
|
23
|
+
# Executes the job with full error handling and state management.
|
24
|
+
# This method can only be called once per performer instance.
|
25
|
+
#
|
26
|
+
# @return [void]
|
27
|
+
# @raise [RuntimeError] If called more than once
|
11
28
|
def perform
|
12
29
|
begin # rubocop:disable Style/RedundantBegin
|
13
30
|
fail 'Performer can only run once.' if @started
|
@@ -20,6 +37,12 @@ module Workhorse
|
|
20
37
|
|
21
38
|
private
|
22
39
|
|
40
|
+
# Internal job execution with thread-local performer tracking.
|
41
|
+
# Wraps the job execution with Rails application executor if available.
|
42
|
+
#
|
43
|
+
# @return [void]
|
44
|
+
# @raise [Exception] Any exception raised during job execution
|
45
|
+
# @private
|
23
46
|
def perform!
|
24
47
|
begin # rubocop:disable Style/RedundantBegin
|
25
48
|
Thread.current[:workhorse_current_performer] = self
|
@@ -50,6 +73,13 @@ module Workhorse
|
|
50
73
|
end
|
51
74
|
end
|
52
75
|
|
76
|
+
# Core job execution logic with state transitions.
|
77
|
+
# Handles marking job as started, deserializing and executing the job,
|
78
|
+
# and marking as succeeded.
|
79
|
+
#
|
80
|
+
# @return [void]
|
81
|
+
# @raise [Exception] Any exception raised during job execution
|
82
|
+
# @private
|
53
83
|
def perform_wrapped
|
54
84
|
# ---------------------------------------------------------------
|
55
85
|
# Mark job as started
|
@@ -87,11 +117,23 @@ module Workhorse
|
|
87
117
|
end
|
88
118
|
end
|
89
119
|
|
120
|
+
# Logs a message with job ID prefix.
|
121
|
+
#
|
122
|
+
# @param text [String] The message to log
|
123
|
+
# @param level [Symbol] The log level
|
124
|
+
# @return [void]
|
125
|
+
# @private
|
90
126
|
def log(text, level = :info)
|
91
127
|
text = "[#{@db_job.id}] #{text}"
|
92
128
|
worker.log text, level
|
93
129
|
end
|
94
130
|
|
131
|
+
# Deserializes the job from the database handler field.
|
132
|
+
# Uses Marshal.load which is safe as long as jobs are enqueued through
|
133
|
+
# {Workhorse::Enqueuer}.
|
134
|
+
#
|
135
|
+
# @return [Object] The deserialized job instance
|
136
|
+
# @private
|
95
137
|
def deserialized_job
|
96
138
|
# The source is safe as long as jobs are always enqueued using
|
97
139
|
# Workhorse::Enqueuer so it is ok to use Marshal.load.
|
data/lib/workhorse/poller.rb
CHANGED
@@ -1,4 +1,11 @@
|
|
1
1
|
module Workhorse
|
2
|
+
# Database poller that discovers and locks jobs for execution.
|
3
|
+
# Handles job querying, global locking, and job distribution to workers.
|
4
|
+
# Supports both MySQL and Oracle databases with database-specific optimizations.
|
5
|
+
#
|
6
|
+
# @example Basic usage (typically used internally)
|
7
|
+
# poller = Workhorse::Poller.new(worker, proc { true })
|
8
|
+
# poller.start
|
2
9
|
class Poller
|
3
10
|
MIN_LOCK_TIMEOUT = 0.1 # In seconds
|
4
11
|
MAX_LOCK_TIMEOUT = 1.0 # In seconds
|
@@ -6,9 +13,16 @@ module Workhorse
|
|
6
13
|
ORACLE_LOCK_MODE = 6 # X_MODE (exclusive)
|
7
14
|
ORACLE_LOCK_HANDLE = 478_564_848 # Randomly chosen number
|
8
15
|
|
16
|
+
# @return [Workhorse::Worker] The worker this poller serves
|
9
17
|
attr_reader :worker
|
18
|
+
|
19
|
+
# @return [Arel::Table] The jobs table for query building
|
10
20
|
attr_reader :table
|
11
21
|
|
22
|
+
# Creates a new poller for the given worker.
|
23
|
+
#
|
24
|
+
# @param worker [Workhorse::Worker] The worker to serve
|
25
|
+
# @param before_poll [Proc] Callback executed before each poll (should return boolean)
|
12
26
|
def initialize(worker, before_poll = proc { true })
|
13
27
|
@worker = worker
|
14
28
|
@running = false
|
@@ -20,10 +34,17 @@ module Workhorse
|
|
20
34
|
@before_poll = before_poll
|
21
35
|
end
|
22
36
|
|
37
|
+
# Checks if the poller is currently running.
|
38
|
+
#
|
39
|
+
# @return [Boolean] True if poller is running
|
23
40
|
def running?
|
24
41
|
@running
|
25
42
|
end
|
26
43
|
|
44
|
+
# Starts the poller in a background thread.
|
45
|
+
#
|
46
|
+
# @return [void]
|
47
|
+
# @raise [RuntimeError] If poller is already running
|
27
48
|
def start
|
28
49
|
fail 'Poller is already running.' if running?
|
29
50
|
@running = true
|
@@ -55,18 +76,27 @@ module Workhorse
|
|
55
76
|
end
|
56
77
|
end
|
57
78
|
|
79
|
+
# Shuts down the poller and waits for completion.
|
80
|
+
#
|
81
|
+
# @return [void]
|
82
|
+
# @raise [RuntimeError] If poller is not running
|
58
83
|
def shutdown
|
59
84
|
fail 'Poller is not running.' unless running?
|
60
85
|
@running = false
|
61
86
|
wait
|
62
87
|
end
|
63
88
|
|
89
|
+
# Waits for the poller thread to complete.
|
90
|
+
#
|
91
|
+
# @return [void]
|
64
92
|
def wait
|
65
93
|
@thread.join
|
66
94
|
end
|
67
95
|
|
68
|
-
#
|
69
|
-
#
|
96
|
+
# Interrupts current sleep and performs the next poll immediately.
|
97
|
+
# After the poll, resumes normal polling interval.
|
98
|
+
#
|
99
|
+
# @return [void]
|
70
100
|
def instant_repoll!
|
71
101
|
worker.log 'Aborting next sleep to perform instant repoll', :debug
|
72
102
|
@instant_repoll.make_true
|
@@ -74,6 +104,11 @@ module Workhorse
|
|
74
104
|
|
75
105
|
private
|
76
106
|
|
107
|
+
# Cleans up jobs stuck in locked or started states from dead processes.
|
108
|
+
# Only cleans jobs from the current hostname.
|
109
|
+
#
|
110
|
+
# @return [void]
|
111
|
+
# @private
|
77
112
|
def clean_stuck_jobs!
|
78
113
|
with_global_lock timeout: MAX_LOCK_TIMEOUT do
|
79
114
|
Workhorse.tx_callback.call do
|
@@ -131,6 +166,10 @@ module Workhorse
|
|
131
166
|
end
|
132
167
|
end
|
133
168
|
|
169
|
+
# Sleeps for the configured polling interval with instant repoll support.
|
170
|
+
#
|
171
|
+
# @return [void]
|
172
|
+
# @private
|
134
173
|
def sleep
|
135
174
|
remaining = worker.polling_interval
|
136
175
|
|
@@ -140,6 +179,14 @@ module Workhorse
|
|
140
179
|
end
|
141
180
|
end
|
142
181
|
|
182
|
+
# Executes a block with a global database lock.
|
183
|
+
# Supports both MySQL GET_LOCK and Oracle DBMS_LOCK.
|
184
|
+
#
|
185
|
+
# @param name [Symbol] Lock name identifier
|
186
|
+
# @param timeout [Integer] Lock timeout in seconds
|
187
|
+
# @yield Block to execute while holding the lock
|
188
|
+
# @return [void]
|
189
|
+
# @private
|
143
190
|
def with_global_lock(name: :workhorse, timeout: 2, &_block)
|
144
191
|
begin # rubocop:disable Style/RedundantBegin
|
145
192
|
if @is_oracle
|
@@ -201,14 +248,18 @@ module Workhorse
|
|
201
248
|
end
|
202
249
|
end
|
203
250
|
|
251
|
+
# Performs a single poll cycle to discover and lock jobs.
|
252
|
+
#
|
253
|
+
# @return [void]
|
254
|
+
# @private
|
204
255
|
def poll
|
205
256
|
@instant_repoll.make_false
|
206
257
|
|
207
258
|
timeout = [MIN_LOCK_TIMEOUT, [MAX_LOCK_TIMEOUT, worker.polling_interval].min].max
|
208
259
|
with_global_lock timeout: timeout do
|
209
|
-
|
210
|
-
job_ids = []
|
260
|
+
job_ids = []
|
211
261
|
|
262
|
+
Workhorse.tx_callback.call do
|
212
263
|
# As we are the only thread posting into the worker pool, it is safe to
|
213
264
|
# get the number of idle threads without mutex synchronization. The
|
214
265
|
# actual number of idle workers at time of posting can only be larger
|
@@ -230,13 +281,23 @@ module Workhorse
|
|
230
281
|
worker.log 'Rolling back transaction to unlock jobs, as worker has been shut down in the meantime'
|
231
282
|
fail ActiveRecord::Rollback
|
232
283
|
end
|
233
|
-
|
234
|
-
job_ids.each { |job_id| worker.perform(job_id) }
|
235
284
|
end
|
285
|
+
|
286
|
+
# This needs to be outside the above transaction because it runs the job
|
287
|
+
# in a new thread which opens a new connection. Even though it would be
|
288
|
+
# non-blocking and thus directly conclude the block and the transaction,
|
289
|
+
# there would still be a risk that the transaction is not committed yet
|
290
|
+
# when the job starts.
|
291
|
+
job_ids.each { |job_id| worker.perform(job_id) } if running?
|
236
292
|
end
|
237
293
|
end
|
238
294
|
|
239
|
-
# Returns an
|
295
|
+
# Returns an array of {Workhorse::DbJob}s that can be started.
|
296
|
+
# Uses complex SQL with UNIONs to respect queue ordering and limits.
|
297
|
+
#
|
298
|
+
# @param limit [Integer] Maximum number of jobs to return
|
299
|
+
# @return [Array<Workhorse::DbJob>] Jobs ready for execution
|
300
|
+
# @private
|
240
301
|
def queued_db_jobs(limit)
|
241
302
|
# ---------------------------------------------------------------
|
242
303
|
# Select jobs to execute
|
data/lib/workhorse/pool.rb
CHANGED
@@ -1,9 +1,22 @@
|
|
1
1
|
module Workhorse
|
2
|
-
#
|
2
|
+
# Thread pool abstraction used by workers for concurrent job execution.
|
3
|
+
# Wraps Concurrent::ThreadPoolExecutor to provide a simpler interface
|
4
|
+
# and custom behavior for job processing.
|
5
|
+
#
|
6
|
+
# @example Basic usage
|
7
|
+
# pool = Workhorse::Pool.new(4)
|
8
|
+
# pool.post { puts "Working..." }
|
9
|
+
# pool.shutdown
|
3
10
|
class Pool
|
11
|
+
# @return [Mutex] Synchronization mutex for thread safety
|
4
12
|
attr_reader :mutex
|
13
|
+
|
14
|
+
# @return [Concurrent::AtomicFixnum] Thread-safe counter of active threads
|
5
15
|
attr_reader :active_threads
|
6
16
|
|
17
|
+
# Creates a new thread pool with the specified size.
|
18
|
+
#
|
19
|
+
# @param size [Integer] Maximum number of threads in the pool
|
7
20
|
def initialize(size)
|
8
21
|
@size = size
|
9
22
|
@executor = Concurrent::ThreadPoolExecutor.new(
|
@@ -18,11 +31,19 @@ module Workhorse
|
|
18
31
|
@on_idle = nil
|
19
32
|
end
|
20
33
|
|
34
|
+
# Sets a callback to be executed when the pool becomes idle.
|
35
|
+
#
|
36
|
+
# @yield Block to execute when all threads become idle
|
37
|
+
# @return [void]
|
21
38
|
def on_idle(&block)
|
22
39
|
@on_idle = block
|
23
40
|
end
|
24
41
|
|
25
|
-
# Posts a new work unit to the pool.
|
42
|
+
# Posts a new work unit to the pool for execution.
|
43
|
+
#
|
44
|
+
# @yield The work block to execute
|
45
|
+
# @return [void]
|
46
|
+
# @raise [RuntimeError] If all threads are busy
|
26
47
|
def post
|
27
48
|
mutex.synchronize do
|
28
49
|
if idle.zero?
|
@@ -44,14 +65,18 @@ module Workhorse
|
|
44
65
|
end
|
45
66
|
end
|
46
67
|
|
47
|
-
# Returns the number of idle threads.
|
68
|
+
# Returns the number of idle threads in the pool.
|
69
|
+
#
|
70
|
+
# @return [Integer] Number of idle threads
|
48
71
|
def idle
|
49
72
|
@size - @active_threads.value
|
50
73
|
end
|
51
74
|
|
52
75
|
# Waits until the pool is shut down. This will wait forever unless you
|
53
|
-
# eventually call shutdown (either before calling `wait` or after it in
|
76
|
+
# eventually call {#shutdown} (either before calling `wait` or after it in
|
54
77
|
# another thread).
|
78
|
+
#
|
79
|
+
# @return [void]
|
55
80
|
def wait
|
56
81
|
# Here we use a loop-sleep combination instead of using
|
57
82
|
# ThreadPoolExecutor's `wait_for_termination`. See issue #21 for more
|
@@ -63,6 +88,9 @@ module Workhorse
|
|
63
88
|
end
|
64
89
|
|
65
90
|
# Shuts down the pool and waits for termination.
|
91
|
+
# All currently executing jobs will complete before shutdown.
|
92
|
+
#
|
93
|
+
# @return [void]
|
66
94
|
def shutdown
|
67
95
|
@executor.shutdown
|
68
96
|
wait
|
data/lib/workhorse/scoped_env.rb
CHANGED
@@ -1,11 +1,26 @@
|
|
1
1
|
module Workhorse
|
2
|
+
# Scoped environment for method delegation.
|
3
|
+
# Used internally to provide scoped access to daemon configuration methods.
|
4
|
+
#
|
5
|
+
# @private
|
2
6
|
class ScopedEnv
|
7
|
+
# Creates a new scoped environment.
|
8
|
+
#
|
9
|
+
# @param delegation_object [Object] Object to delegate method calls to
|
10
|
+
# @param methods [Array<Symbol>] Methods that should be delegated
|
11
|
+
# @param backup_binding [Object, nil] Fallback object for method resolution
|
3
12
|
def initialize(delegation_object, methods, backup_binding = nil)
|
4
13
|
@delegation_object = delegation_object
|
5
14
|
@methods = methods
|
6
15
|
@backup_binding = backup_binding
|
7
16
|
end
|
8
17
|
|
18
|
+
# Handles method delegation to the configured objects.
|
19
|
+
#
|
20
|
+
# @param symbol [Symbol] Method name
|
21
|
+
# @param args [Array] Method arguments
|
22
|
+
# @param block [Proc, nil] Block to pass to the method
|
23
|
+
# @return [Object] Result of the delegated method call
|
9
24
|
def method_missing(symbol, *args, &block)
|
10
25
|
if @methods.include?(symbol)
|
11
26
|
@delegation_object.send(symbol, *args, &block)
|
@@ -16,6 +31,11 @@ module Workhorse
|
|
16
31
|
end
|
17
32
|
end
|
18
33
|
|
34
|
+
# Checks if this object can respond to the given method.
|
35
|
+
#
|
36
|
+
# @param symbol [Symbol] Method name to check
|
37
|
+
# @param include_private [Boolean] Whether to include private methods
|
38
|
+
# @return [Boolean] True if method can be handled
|
19
39
|
def respond_to_missing?(symbol, include_private = false)
|
20
40
|
@methods.include?(symbol) || super
|
21
41
|
end
|