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.
@@ -1,25 +1,63 @@
1
1
  module Workhorse
2
+ # Main worker class that manages job polling and execution.
3
+ # Workers poll the database for jobs, manage thread pools for parallel execution,
4
+ # and handle graceful shutdown and memory monitoring.
5
+ #
6
+ # @example Basic worker setup
7
+ # worker = Workhorse::Worker.new(
8
+ # queues: [:default, :urgent],
9
+ # pool_size: 4,
10
+ # polling_interval: 30
11
+ # )
12
+ # worker.start
13
+ # worker.wait
14
+ #
15
+ # @example Auto-terminating worker
16
+ # Workhorse::Worker.start_and_wait(
17
+ # queues: [:email, :reports],
18
+ # auto_terminate: true
19
+ # )
2
20
  class Worker
3
21
  LOG_LEVELS = %i[fatal error warn info debug].freeze
4
22
  SHUTDOWN_SIGNALS = %w[TERM INT].freeze
5
23
  LOG_REOPEN_SIGNAL = 'HUP'.freeze
6
24
 
25
+ # @return [Array<Symbol>] The queues this worker processes
7
26
  attr_reader :queues
27
+
28
+ # @return [Symbol] Current worker state (:initialized, :running, :shutdown)
8
29
  attr_reader :state
30
+
31
+ # @return [Integer] Number of threads in the worker pool
9
32
  attr_reader :pool_size
33
+
34
+ # @return [Integer] Polling interval in seconds
10
35
  attr_reader :polling_interval
36
+
37
+ # @return [Mutex] Synchronization mutex for thread safety
11
38
  attr_reader :mutex
39
+
40
+ # @return [Logger, nil] Optional logger instance
12
41
  attr_reader :logger
42
+
43
+ # @return [Workhorse::Poller] The poller instance
13
44
  attr_reader :poller
14
45
 
15
46
  # Instantiates and starts a new worker with the given arguments and then
16
47
  # waits for its completion (i.e. an interrupt).
48
+ #
49
+ # @param args [Hash] Arguments passed to {#initialize}
50
+ # @return [void]
17
51
  def self.start_and_wait(**args)
18
52
  worker = new(**args)
19
53
  worker.start
20
54
  worker.wait
21
55
  end
22
56
 
57
+ # Returns the path to the shutdown file for a given process ID.
58
+ #
59
+ # @param pid [Integer] Process ID
60
+ # @return [String, nil] Path to shutdown file or nil if not in Rails
23
61
  # @private
24
62
  def self.shutdown_file_for(pid)
25
63
  return nil unless defined?(Rails)
@@ -69,6 +107,12 @@ module Workhorse
69
107
  end
70
108
  end
71
109
 
110
+ # Logs a message with worker ID prefix.
111
+ #
112
+ # @param text [String] The message to log
113
+ # @param level [Symbol] The log level (must be in LOG_LEVELS)
114
+ # @return [void]
115
+ # @raise [RuntimeError] If log level is invalid
72
116
  def log(text, level = :info)
73
117
  text = "[Job worker #{id}] #{text}"
74
118
  puts text unless @quiet
@@ -77,20 +121,33 @@ module Workhorse
77
121
  logger.send(level, text.strip)
78
122
  end
79
123
 
124
+ # Returns the unique identifier for this worker.
125
+ # Format: hostname.pid.random_hex
126
+ #
127
+ # @return [String] Unique worker identifier
80
128
  def id
81
129
  @id ||= "#{hostname}.#{pid}.#{SecureRandom.hex(3)}"
82
130
  end
83
131
 
132
+ # Returns the process ID of this worker.
133
+ #
134
+ # @return [Integer] Process ID
84
135
  def pid
85
136
  @pid ||= Process.pid
86
137
  end
87
138
 
139
+ # Returns the hostname of the machine running this worker.
140
+ #
141
+ # @return [String] Hostname
88
142
  def hostname
89
143
  @hostname ||= Socket.gethostname
90
144
  end
91
145
 
92
- # Starts the worker. This call is not blocking - call {wait} for this
146
+ # Starts the worker. This call is not blocking - call {#wait} for this
93
147
  # purpose.
148
+ #
149
+ # @return [void]
150
+ # @raise [RuntimeError] If worker is not in initialized state
94
151
  def start
95
152
  mutex.synchronize do
96
153
  assert_state! :initialized
@@ -104,13 +161,20 @@ module Workhorse
104
161
  end
105
162
  end
106
163
 
164
+ # Asserts that the worker is in the expected state.
165
+ #
166
+ # @param state [Symbol] Expected state
167
+ # @return [void]
168
+ # @raise [RuntimeError] If worker is not in expected state
107
169
  def assert_state!(state)
108
170
  fail "Expected worker to be in state #{state} but current state is #{self.state}." unless self.state == state
109
171
  end
110
172
 
111
- # Shuts down worker and DB poller. Jobs currently beeing processed are
173
+ # Shuts down worker and DB poller. Jobs currently being processed are
112
174
  # properly finished before this method returns. Subsequent calls to this
113
175
  # method are ignored.
176
+ #
177
+ # @return [void]
114
178
  def shutdown
115
179
  # This is safe to be checked outside of the mutex as 'shutdown' is the
116
180
  # final state this worker can be in.
@@ -131,19 +195,28 @@ module Workhorse
131
195
  end
132
196
  end
133
197
 
134
- # Waits until the worker is shut down. This only happens if shutdown gets
198
+ # Waits until the worker is shut down. This only happens if {#shutdown} gets
135
199
  # called - either by another thread or by enabling `auto_terminate` and
136
200
  # receiving a respective signal. Use this method to let worker run
137
- # undefinitely.
201
+ # indefinitely.
202
+ #
203
+ # @return [void]
138
204
  def wait
139
205
  @poller.wait
140
206
  @pool.wait
141
207
  end
142
208
 
209
+ # Returns the number of idle threads in the pool.
210
+ #
211
+ # @return [Integer] Number of idle threads
143
212
  def idle
144
213
  @pool.idle
145
214
  end
146
215
 
216
+ # Schedules a job for execution in the thread pool.
217
+ #
218
+ # @param db_job_id [Integer] The ID of the {Workhorse::DbJob} to perform
219
+ # @return [void]
147
220
  def perform(db_job_id)
148
221
  begin # rubocop:disable Style/RedundantBegin
149
222
  mutex.synchronize do
@@ -166,6 +239,10 @@ module Workhorse
166
239
 
167
240
  private
168
241
 
242
+ # Checks current memory usage and initiates shutdown if limit exceeded.
243
+ #
244
+ # @return [Boolean] True if memory is within limits, false if exceeded
245
+ # @private
169
246
  def check_memory
170
247
  mem = current_memory_consumption
171
248
 
@@ -191,12 +268,20 @@ module Workhorse
191
268
  return false
192
269
  end
193
270
 
271
+ # Returns current memory consumption in MB.
272
+ #
273
+ # @return [Integer, nil] Memory usage in MB or nil if unable to determine
274
+ # @private
194
275
  def current_memory_consumption
195
276
  mem = `ps -p #{pid} -o rss=`&.strip
196
277
  return nil if mem.blank?
197
278
  return mem.to_i / 1024
198
279
  end
199
280
 
281
+ # Sets up signal handler for log file reopening (HUP signal).
282
+ #
283
+ # @return [void]
284
+ # @private
200
285
  def trap_log_reopen
201
286
  Signal.trap(LOG_REOPEN_SIGNAL) do
202
287
  Thread.new do
@@ -209,6 +294,10 @@ module Workhorse
209
294
  end
210
295
  end
211
296
 
297
+ # Sets up signal handlers for graceful termination (TERM/INT signals).
298
+ #
299
+ # @return [void]
300
+ # @private
212
301
  def trap_termination
213
302
  SHUTDOWN_SIGNALS.each do |signal|
214
303
  Signal.trap(signal) do
data/lib/workhorse.rb CHANGED
@@ -8,58 +8,83 @@ require 'workhorse/enqueuer'
8
8
  require 'workhorse/scoped_env'
9
9
  require 'workhorse/active_job_extension'
10
10
 
11
+ # Main Gem module.
11
12
  module Workhorse
12
13
  # Check if the available Arel version is greater or equal than 7.0.0
13
14
  AREL_GTE_7 = Gem::Version.new(Arel::VERSION) >= Gem::Version.new('7.0.0')
14
15
 
15
16
  extend Workhorse::Enqueuer
16
17
 
17
- # Returns the performer currently performing the active job. This can only be
18
- # called from within a job and the same thread.
18
+ # Returns the performer currently performing the active job.
19
+ # This can only be called from within a job and the same thread.
20
+ #
21
+ # @return [Workhorse::Performer] The current performer instance
22
+ # @raise [RuntimeError] If called outside of a job context
19
23
  def self.performer
20
24
  Thread.current[:workhorse_current_performer] \
21
25
  || fail('No performer is associated with the current thread. This method must always be called inside of a job.')
22
26
  end
23
27
 
24
- # A worker will log an error and, if defined, call the on_exception callback,
25
- # if it couldn't obtain the global lock for the specified number of times in a
26
- # row.
28
+ # Maximum number of consecutive global lock failures before triggering error handling.
29
+ # A {Workhorse::Worker} will log an error and call the {.on_exception} callback if it can't
30
+ # obtain the global lock for this many times in a row.
31
+ #
32
+ # @return [Integer] The maximum number of allowed consecutive lock failures
27
33
  mattr_accessor :max_global_lock_fails
28
34
  self.max_global_lock_fails = 10
29
35
 
36
+ # Transaction callback used for database operations.
37
+ # Defaults to ActiveRecord::Base.transaction.
38
+ #
39
+ # @return [Proc] The transaction callback
30
40
  mattr_accessor :tx_callback
31
41
  self.tx_callback = proc do |*args, &block|
32
42
  ActiveRecord::Base.transaction(*args, &block)
33
43
  end
34
44
 
45
+ # Exception callback called when an exception occurs during job processing.
46
+ # Override this to integrate with your error reporting system.
47
+ #
48
+ # @return [Proc] The exception callback
35
49
  mattr_accessor :on_exception
36
50
  self.on_exception = proc do |exception|
37
51
  # Do something with this exception, i.e.
38
52
  # ExceptionNotifier.notify_exception(exception)
39
53
  end
40
54
 
41
- # If set to `false`, shell handler (CLI) won't lock commands using a lockfile.
42
- # You should generally only disable this if you are performing the locking
43
- # yourself (e.g. in a wrapper script).
55
+ # Controls whether {Workhorse::Daemon::ShellHandler} commands use lockfiles.
56
+ # Set to false if you're handling locking yourself (e.g. in a wrapper script).
57
+ #
58
+ # @return [Boolean] Whether to lock shell commands
44
59
  mattr_accessor :lock_shell_commands
45
60
  self.lock_shell_commands = true
46
61
 
47
- # If set to `true`, the defined `on_exception` will not be called when the
48
- # poller encounters an exception and the worker has to be shut down. The
49
- # exception will still be logged.
62
+ # Controls whether to silence exception callbacks for {Workhorse::Poller} exceptions.
63
+ # When true, {.on_exception} won't be called for poller failures, but exceptions
64
+ # will still be logged.
65
+ #
66
+ # @return [Boolean] Whether to silence poller exception callbacks
50
67
  mattr_accessor :silence_poller_exceptions
51
68
  self.silence_poller_exceptions = false
52
69
 
53
- # If set to `true`, the `watch` command won't produce any output. This does
54
- # not include warnings such as the "development mode" warning.
70
+ # Controls output verbosity for the watch command.
71
+ # When true, the watch command won't produce output (warnings still shown).
72
+ #
73
+ # @return [Boolean] Whether to silence watcher output
55
74
  mattr_accessor :silence_watcher
56
75
  self.silence_watcher = false
57
76
 
77
+ # Controls whether jobs are performed within database transactions.
78
+ # Individual job classes can override this with skip_tx?.
79
+ #
80
+ # @return [Boolean] Whether to perform jobs in transactions
58
81
  mattr_accessor :perform_jobs_in_tx
59
82
  self.perform_jobs_in_tx = true
60
83
 
61
- # If enabled, each poller will attempt to clean jobs that are stuck in state
62
- # 'locked' or 'running' when it is starting up.
84
+ # Controls automatic cleanup of stuck jobs on {Workhorse::Poller} startup.
85
+ # When enabled, pollers will clean jobs stuck in 'locked' or 'running' states.
86
+ #
87
+ # @return [Boolean] Whether to clean stuck jobs on startup
63
88
  mattr_accessor :clean_stuck_jobs
64
89
  self.clean_stuck_jobs = false
65
90
 
@@ -75,12 +100,20 @@ module Workhorse
75
100
  mattr_accessor :stale_detection_run_time_threshold
76
101
  self.stale_detection_run_time_threshold = 12 * 60
77
102
 
78
- # Maximum memory for a worker in MB. If this memory limit (RSS / resident
79
- # size) is reached for a worker process, the 'watch' command will restart said
80
- # worker. Set this to 0 disable this feature.
103
+ # Maximum memory usage per {Workhorse::Worker} process in MB.
104
+ # When exceeded, the watch command will restart the worker. Set to 0 to disable.
105
+ #
106
+ # @return [Integer] Memory limit in megabytes
81
107
  mattr_accessor :max_worker_memory_mb
82
108
  self.max_worker_memory_mb = 0
83
109
 
110
+ # Configuration method for setting up Workhorse options.
111
+ #
112
+ # @yield [self] Configuration block
113
+ # @example
114
+ # Workhorse.setup do |config|
115
+ # config.max_global_lock_fails = 5
116
+ # end
84
117
  def self.setup
85
118
  yield self
86
119
  end
data/workhorse.gemspec CHANGED
@@ -1,14 +1,14 @@
1
1
  # -*- encoding: utf-8 -*-
2
- # stub: workhorse 1.3.0.rc2 ruby lib
2
+ # stub: workhorse 1.3.0.rc4 ruby lib
3
3
 
4
4
  Gem::Specification.new do |s|
5
5
  s.name = "workhorse".freeze
6
- s.version = "1.3.0.rc2"
6
+ s.version = "1.3.0.rc4"
7
7
 
8
8
  s.required_rubygems_version = Gem::Requirement.new("> 1.3.1".freeze) if s.respond_to? :required_rubygems_version=
9
9
  s.require_paths = ["lib".freeze]
10
10
  s.authors = ["Sitrox".freeze]
11
- s.date = "2025-06-10"
11
+ s.date = "2025-08-27"
12
12
  s.files = [".github/workflows/ruby.yml".freeze, ".gitignore".freeze, ".releaser_config".freeze, ".rubocop.yml".freeze, "CHANGELOG.md".freeze, "FAQ.md".freeze, "Gemfile".freeze, "LICENSE".freeze, "README.md".freeze, "RUBY_VERSION".freeze, "Rakefile".freeze, "VERSION".freeze, "bin/rubocop".freeze, "lib/active_job/queue_adapters/workhorse_adapter.rb".freeze, "lib/generators/workhorse/install_generator.rb".freeze, "lib/generators/workhorse/templates/bin/workhorse.rb".freeze, "lib/generators/workhorse/templates/config/initializers/workhorse.rb".freeze, "lib/generators/workhorse/templates/create_table_jobs.rb".freeze, "lib/workhorse.rb".freeze, "lib/workhorse/active_job_extension.rb".freeze, "lib/workhorse/daemon.rb".freeze, "lib/workhorse/daemon/shell_handler.rb".freeze, "lib/workhorse/db_job.rb".freeze, "lib/workhorse/enqueuer.rb".freeze, "lib/workhorse/jobs/cleanup_succeeded_jobs.rb".freeze, "lib/workhorse/jobs/detect_stale_jobs_job.rb".freeze, "lib/workhorse/jobs/run_active_job.rb".freeze, "lib/workhorse/jobs/run_rails_op.rb".freeze, "lib/workhorse/performer.rb".freeze, "lib/workhorse/poller.rb".freeze, "lib/workhorse/pool.rb".freeze, "lib/workhorse/scoped_env.rb".freeze, "lib/workhorse/worker.rb".freeze, "test/active_job/queue_adapters/workhorse_adapter_test.rb".freeze, "test/lib/db_schema.rb".freeze, "test/lib/jobs.rb".freeze, "test/lib/test_helper.rb".freeze, "test/workhorse/daemon_test.rb".freeze, "test/workhorse/db_job_test.rb".freeze, "test/workhorse/enqueuer_test.rb".freeze, "test/workhorse/performer_test.rb".freeze, "test/workhorse/poller_test.rb".freeze, "test/workhorse/pool_test.rb".freeze, "test/workhorse/worker_test.rb".freeze, "workhorse.gemspec".freeze]
13
13
  s.rubygems_version = "3.4.6".freeze
14
14
  s.summary = "Multi-threaded job backend with database queuing for ruby.".freeze
@@ -16,7 +16,7 @@ Gem::Specification.new do |s|
16
16
 
17
17
  s.specification_version = 4
18
18
 
19
- s.add_runtime_dependency(%q<activesupport>.freeze, [">= 0"])
20
- s.add_runtime_dependency(%q<activerecord>.freeze, [">= 0"])
19
+ s.add_runtime_dependency(%q<activesupport>.freeze, [">= 7.0.0"])
20
+ s.add_runtime_dependency(%q<activerecord>.freeze, [">= 7.0.0"])
21
21
  s.add_runtime_dependency(%q<concurrent-ruby>.freeze, [">= 0"])
22
22
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: workhorse
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0.rc2
4
+ version: 1.3.0.rc4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sitrox
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-06-10 00:00:00.000000000 Z
10
+ date: 2025-08-27 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '0'
18
+ version: 7.0.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '0'
25
+ version: 7.0.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: activerecord
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '0'
32
+ version: 7.0.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '0'
39
+ version: 7.0.0
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: concurrent-ruby
42
42
  requirement: !ruby/object:Gem::Requirement