workhorse 1.2.17.rc0 → 1.2.17.rc1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25908f294cd9623ebde44ec8bba5181ee54965703f4fb93df18876b216718eb8
4
- data.tar.gz: 568e620571a9ded165fee1fc4420efbb6810f073edd4365c6c8efbe7c3676052
3
+ metadata.gz: 2bcfdbfa710579ee64f436236ed5c4214404a82211ae7471513ba141e7e03eaf
4
+ data.tar.gz: 680b91c6bbdbc8473fdc454175bdf6a2f4b14a0831162ab02e92208914f69f7a
5
5
  SHA512:
6
- metadata.gz: 83b6ac441e3762f251e35f04dd213044d6fb904a005155235546391cdecd4f9c0c1db24be7c30371da4704b27e6f18aa6ce0b448b6c6c1dbc8c77eeb81faa9e3
7
- data.tar.gz: 9c2940a6bfb7cd0be49170d81af2378f27395dbf597f6d8fa2c777bdc812b26d06cc849454ba5925fcf5f26b7724779b3d6e941bc6c9237d6dc251b7d071188f
6
+ metadata.gz: 29fe50d7eb989a0d51cdcbd23adb7d42ce56fccf553448c7baf112bae2dd678a81613d593637fe6f168ff3620d9b31048abcdd0f8674b4db2244a59a82865779
7
+ data.tar.gz: '094cb3bb85f2be4bd2575f6441e2abc6ef17d522796c586dca69f864031fde2b0095bc8ce2c3d8bee6507ab799297fd8e79c86fbbadd28467495a39cc7031694'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Workhorse Changelog
2
2
 
3
+ ## 1.2.17.rc1 - 2024-02-05
4
+
5
+ * Revamp memory handling:
6
+
7
+ * Change memory handling for workers to automatically shut down themselves
8
+ upon exceeding `config.max_worker_memory_mb` (if configured and > 0),
9
+ triggering the creation of a shutdown file
10
+ (`tmp/pids/workhorse.<pid>.shutdown`).
11
+ * Have the `watch` command, if scheduled, silently restart the shutdown worker
12
+ and remove the shutdown file.
13
+ * The presence of the shutdown file informs the watcher to produce output or
14
+ remain silent.
15
+ * Implement this adjustment to limit `watch` command output to error cases,
16
+ facilitating seamless cron integration for notification purposes.
17
+
18
+ Sitrox reference: #121312.
19
+
3
20
  ## 1.2.17.rc0 - 2024-02-05
4
21
 
5
22
  * Add option `config.max_worker_memory_mb` for automatic restart of workers
data/README.md CHANGED
@@ -466,17 +466,18 @@ succeeded jobs. You can run this using your scheduler in a specific interval.
466
466
 
467
467
  ## Memory handling
468
468
 
469
- When dealing with jobs that may exhibit a large memory footprint, it's important
470
- to note that Ruby might not release consumed memory back to the operating
471
- system. Consequently, your job workers could accumulate a significant amount of
472
- memory over time. To address this, Workhorse provides the
473
- `config.max_worker_memory_mb` option.
474
-
475
- If `config.max_worker_memory_mb` is set to a value above `0`, the `watch`
476
- command will check the memory footprint (RSS / resident size) of all worker
477
- processes. If any worker exceeds the specified footprint, Workhorse will
478
- silently restart it to ensure proper memory release. This process does not
479
- produce any output in the `watch` command.
469
+ When a worker exceeds the memory limit specified by
470
+ `config.max_worker_memory_mb` (assuming it is configured and > 0), it initiates
471
+ a graceful shutdown process by creating a shutdown file named
472
+ `tmp/pids/workhorse.<pid>.shutdown`.
473
+
474
+ Simultaneously, the `watch` command, if scheduled, monitors the presence of this
475
+ shutdown file. Upon detecting its existence, it silently triggers the restart of
476
+ the shutdown worker and removes the shutdown file to signify that the restart
477
+ process has begun.
478
+
479
+ This mechanism ensures that workers are automatically restarted without manual
480
+ intervention when memory limits are exceeded.
480
481
 
481
482
  Example configuration:
482
483
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.2.17.rc0
1
+ 1.2.17.rc1
@@ -46,15 +46,20 @@ module Workhorse
46
46
  code = 0
47
47
 
48
48
  for_each_worker do |worker|
49
- pid_file, pid = read_pid(worker)
49
+ pid_file, pid, active = read_pid(worker)
50
50
 
51
- if pid_file && pid
51
+ if pid_file && pid && active
52
52
  warn "Worker ##{worker.id} (#{worker.name}): Already started (PID #{pid})" unless quiet
53
53
  code = 2
54
54
  elsif pid_file
55
55
  File.delete pid_file
56
- puts "Worker ##{worker.id} (#{worker.name}): Starting (stale pid file)" unless quiet
56
+
57
+ shutdown_file = pid ? Workhorse::Worker.shutdown_file_for(pid) : nil
58
+ shutdown_file = nil if shutdown_file && !File.exist?(shutdown_file)
59
+
60
+ puts "Worker ##{worker.id} (#{worker.name}): Starting (stale pid file)" unless quiet || shutdown_file
57
61
  start_worker worker
62
+ FileUtils.rm(shutdown_file) if shutdown_file
58
63
  else
59
64
  warn "Worker ##{worker.id} (#{worker.name}): Starting" unless quiet
60
65
  start_worker worker
@@ -68,9 +73,9 @@ module Workhorse
68
73
  code = 0
69
74
 
70
75
  for_each_worker do |worker|
71
- pid_file, pid = read_pid(worker)
76
+ pid_file, pid, active = read_pid(worker)
72
77
 
73
- if pid_file && pid
78
+ if pid_file && pid && active
74
79
  puts "Worker (#{worker.name}) ##{worker.id}: Stopping"
75
80
  stop_worker pid_file, pid, kill: kill
76
81
  elsif pid_file
@@ -89,9 +94,9 @@ module Workhorse
89
94
  code = 0
90
95
 
91
96
  for_each_worker do |worker|
92
- pid_file, pid = read_pid(worker)
97
+ pid_file, pid, active = read_pid(worker)
93
98
 
94
- if pid_file && pid
99
+ if pid_file && pid && active
95
100
  puts "Worker ##{worker.id} (#{worker.name}): Running" unless quiet
96
101
  elsif pid_file
97
102
  warn "Worker ##{worker.id} (#{worker.name}): Not running (stale PID file)" unless quiet
@@ -113,14 +118,10 @@ module Workhorse
113
118
  end
114
119
 
115
120
  if should_be_running && status(quiet: true) != 0
116
- code = start(quiet: Workhorse.silence_watcher)
121
+ return start(quiet: Workhorse.silence_watcher)
117
122
  else
118
- code = 0
123
+ return 0
119
124
  end
120
-
121
- watch_memory! if should_be_running
122
-
123
- return code
124
125
  end
125
126
 
126
127
  def restart
@@ -132,7 +133,9 @@ module Workhorse
132
133
  code = 0
133
134
 
134
135
  for_each_worker do |worker|
135
- _pid_file, pid = read_pid(worker)
136
+ _pid_file, pid, active = read_pid(worker)
137
+
138
+ next unless pid && active
136
139
 
137
140
  begin
138
141
  Process.kill 'HUP', pid
@@ -148,30 +151,6 @@ module Workhorse
148
151
 
149
152
  private
150
153
 
151
- def watch_memory!
152
- return if Workhorse.max_worker_memory_mb == 0
153
-
154
- for_each_worker do |worker|
155
- pid_file, pid = read_pid(worker)
156
- next unless pid_file && pid
157
-
158
- memory = memory_for(pid)
159
- next unless memory
160
-
161
- if memory > Workhorse.max_worker_memory_mb
162
- stop_worker pid_file, pid
163
- start_worker worker
164
- end
165
- end
166
- end
167
-
168
- # Returns the memory (RSS) in MB for the given process.
169
- def memory_for(pid)
170
- mem = `ps -p #{pid} -o rss=`&.strip
171
- return nil if mem.blank?
172
- return mem.to_i / 1024
173
- end
174
-
175
154
  def for_each_worker(&block)
176
155
  @workers.each(&block)
177
156
  end
@@ -237,16 +216,21 @@ module Workhorse
237
216
 
238
217
  def read_pid(worker)
239
218
  file = pid_file_for(worker)
219
+ pid = nil
220
+ active = false
240
221
 
241
222
  if File.exist?(file)
242
223
  raw_pid = File.read(file)
243
- return nil, nil if raw_pid.blank?
244
224
 
245
- pid = Integer(raw_pid)
246
- return file, process?(pid) ? pid : nil
225
+ unless raw_pid.blank?
226
+ pid = Integer(raw_pid)
227
+ active = process?(pid)
228
+ end
247
229
  else
248
230
  return nil, nil
249
231
  end
232
+
233
+ return file, pid, active
250
234
  end
251
235
  end
252
236
  end
@@ -22,7 +22,7 @@ module Workhorse
22
22
  Thread.current[:workhorse_current_performer] = self
23
23
 
24
24
  ActiveRecord::Base.connection_pool.with_connection do
25
- if defined?(Rails) && Rails.application && Rails.application.respond_to?(:executor)
25
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application && Rails.application.respond_to?(:executor)
26
26
  Rails.application.executor.wrap do
27
27
  perform_wrapped
28
28
  end
@@ -63,7 +63,6 @@ module Workhorse
63
63
 
64
64
  inner_job_class = deserialized_job.try(:job_class) || deserialized_job.class
65
65
  skip_tx = inner_job_class.try(:skip_tx?)
66
- log "SKIP TX: #{skip_tx.inspect}".red, :error
67
66
 
68
67
  if Workhorse.perform_jobs_in_tx && !skip_tx
69
68
  Workhorse.tx_callback.call do
@@ -9,7 +9,7 @@ module Workhorse
9
9
  attr_reader :worker
10
10
  attr_reader :table
11
11
 
12
- def initialize(worker)
12
+ def initialize(worker, before_poll = proc { true })
13
13
  @worker = worker
14
14
  @running = false
15
15
  @table = Workhorse::DbJob.arel_table
@@ -17,6 +17,7 @@ module Workhorse
17
17
  @instant_repoll = Concurrent::AtomicBoolean.new(false)
18
18
  @global_lock_fails = 0
19
19
  @max_global_lock_fails_reached = false
20
+ @before_poll = before_poll
20
21
  end
21
22
 
22
23
  def running?
@@ -34,6 +35,12 @@ module Workhorse
34
35
  break unless running?
35
36
 
36
37
  begin
38
+ unless @before_poll.call
39
+ Thread.new { worker.shutdown }
40
+ sleep
41
+ next
42
+ end
43
+
37
44
  poll
38
45
  sleep
39
46
  rescue Exception => e
@@ -2,6 +2,7 @@ module Workhorse
2
2
  # Abstraction layer of a simple thread pool implementation used by the worker.
3
3
  class Pool
4
4
  attr_reader :mutex
5
+ attr_reader :active_threads
5
6
 
6
7
  def initialize(size)
7
8
  @size = size
@@ -20,6 +20,12 @@ module Workhorse
20
20
  worker.wait
21
21
  end
22
22
 
23
+ # @private
24
+ def self.shutdown_file_for(pid)
25
+ return nil unless defined?(Rails)
26
+ Rails.root.join('tmp', 'pids', "workhorse.#{pid}.shutdown")
27
+ end
28
+
23
29
  # Instantiates a new worker. The worker is not automatically started.
24
30
  #
25
31
  # @param queues [Array] The queues you want this worker to process. If an
@@ -51,7 +57,7 @@ module Workhorse
51
57
 
52
58
  @mutex = Mutex.new
53
59
  @pool = Pool.new(@pool_size)
54
- @poller = Workhorse::Poller.new(self)
60
+ @poller = Workhorse::Poller.new(self, proc { check_memory })
55
61
  @logger = logger
56
62
 
57
63
  unless (@polling_interval / 0.1).round(2).modulo(1).zero?
@@ -155,6 +161,37 @@ module Workhorse
155
161
 
156
162
  private
157
163
 
164
+ def check_memory
165
+ mem = current_memory_consumption
166
+
167
+ unless mem
168
+ log "Could not determine memory consumption of worker with pid #{pid}"
169
+ return false
170
+ end
171
+
172
+ max = Workhorse.max_worker_memory_mb
173
+ exceeded = max > 0 && current_memory_consumption > max
174
+
175
+ return true unless exceeded
176
+
177
+ if defined?(Rails)
178
+ FileUtils.touch self.class.shutdown_file_for(pid)
179
+ end
180
+
181
+ log "Worker process #{id.inspect} memory consumption (RSS) of #{mem}MB exceeds "\
182
+ "configured per-worker limit of #{max}MB and is now being shut down. Make sure "\
183
+ 'that your worker processes are watched (e.g. using the "watch"-command) for ' \
184
+ 'this worker to be restarted automatically.'
185
+
186
+ return false
187
+ end
188
+
189
+ def current_memory_consumption
190
+ mem = `ps -p #{pid} -o rss=`&.strip
191
+ return nil if mem.blank?
192
+ return mem.to_i / 1024
193
+ end
194
+
158
195
  def check_rails_env
159
196
  unless Rails.env.production?
160
197
  warn 'WARNING: Always run workhorse workers in production environment. Other environments can lead to unexpected behavior.'
data/test/lib/jobs.rb CHANGED
@@ -36,6 +36,15 @@ class SyntaxErrorJob
36
36
  end
37
37
  end
38
38
 
39
+ class MemHungryJob
40
+ class_attribute :data
41
+
42
+ # Should consume roughly 1GB of memory.
43
+ def perform
44
+ self.class.data = 'x' * 250.megabytes
45
+ end
46
+ end
47
+
39
48
  class DummyRailsOpsOp
40
49
  class_attribute :results
41
50
  self.results = Concurrent::Array.new
@@ -5,8 +5,33 @@ require 'pry'
5
5
  require 'colorize'
6
6
  require 'mysql2'
7
7
  require 'benchmark'
8
+ require 'concurrent'
8
9
  require 'jobs'
9
10
 
11
+ class MockRailsEnv < String
12
+ def production?
13
+ self == 'production'
14
+ end
15
+
16
+ def test?
17
+ self == 'test'
18
+ end
19
+
20
+ def development?
21
+ self == 'development'
22
+ end
23
+ end
24
+
25
+ class Rails
26
+ def self.root
27
+ Pathname.new(File.expand_path(File.join(File.dirname(__FILE__), '../../')))
28
+ end
29
+
30
+ def self.env
31
+ MockRailsEnv.new('production')
32
+ end
33
+ end
34
+
10
35
  class WorkhorseTest < ActiveSupport::TestCase
11
36
  def setup
12
37
  Workhorse::DbJob.delete_all
@@ -31,6 +56,14 @@ class WorkhorseTest < ActiveSupport::TestCase
31
56
  end
32
57
  end
33
58
 
59
+ def work_until(max: 50, interval: 0.1, **options, &block)
60
+ w = Workhorse::Worker.new(**options)
61
+ w.start
62
+ return with_retries(max, interval: interval, &block)
63
+ ensure
64
+ w.shutdown
65
+ end
66
+
34
67
  def with_worker(options = {})
35
68
  w = Workhorse::Worker.new(**options)
36
69
  w.start
@@ -40,6 +73,18 @@ class WorkhorseTest < ActiveSupport::TestCase
40
73
  w.shutdown
41
74
  end
42
75
  end
76
+
77
+ def with_retries(max = 50, interval: 0.1, &_block)
78
+ runs = 0
79
+
80
+ loop do
81
+ return yield
82
+ rescue Minitest::Assertion => e
83
+ fail if runs > max
84
+ sleep interval
85
+ runs += 1
86
+ end
87
+ end
43
88
  end
44
89
 
45
90
  ActiveRecord::Base.establish_connection(
@@ -180,8 +180,86 @@ class Workhorse::WorkerTest < WorkhorseTest
180
180
  assert_equal 'waiting', jobs[1].state
181
181
  end
182
182
 
183
+ def test_controlled_shutdown
184
+ remove_pids!
185
+
186
+ Workhorse.max_worker_memory_mb = 50
187
+
188
+ daemon = start_daemon
189
+
190
+ pid = with_retries do
191
+ pid = daemon.workers.first.pid
192
+ assert_process(pid)
193
+ pid
194
+ end
195
+
196
+ 10.times do
197
+ Workhorse.enqueue BasicJob.new(sleep_time: 0.1)
198
+
199
+ with_retries do
200
+ assert_equal 'succeeded', Workhorse::DbJob.first.state
201
+ Workhorse::DbJob.delete_all
202
+ end
203
+ end
204
+
205
+ Workhorse.enqueue MemHungryJob.new
206
+
207
+ with_retries do
208
+ assert_equal 'succeeded', Workhorse::DbJob.first.state
209
+
210
+ assert File.exist?("tmp/pids/workhorse.#{pid}.shutdown")
211
+ assert_not_process pid
212
+ end
213
+
214
+ daemon.watch
215
+
216
+ with_retries do
217
+ assert_not File.exist?("tmp/pids/workhorse.#{pid}.shutdown")
218
+ end
219
+ ensure
220
+ daemon.stop
221
+ Workhorse.max_worker_memory_mb = 0
222
+ end
223
+
183
224
  private
184
225
 
226
+ def remove_pids!
227
+ Dir[Rails.root.join('tmp', 'pids', '*')].each do |file|
228
+ FileUtils.rm file
229
+ end
230
+ end
231
+
232
+ def start_daemon
233
+ daemon = Workhorse::Daemon.new(pidfile: 'tmp/pids/test%s.pid') do |d|
234
+ d.worker 'Test Worker' do
235
+ begin
236
+ Workhorse::Worker.start_and_wait(
237
+ pool_size: 1,
238
+ polling_interval: 0.1,
239
+ logger: ActiveSupport::Logger.new('tmp/log.log')
240
+ )
241
+ end
242
+ end
243
+ end
244
+ daemon.start
245
+ return daemon
246
+ end
247
+
248
+ def assert_process(pid)
249
+ assert process?(pid), "Process #{pid} expected to be running"
250
+ end
251
+
252
+ def assert_not_process(pid)
253
+ assert_not process?(pid), "Process #{pid} expected to be stopped"
254
+ end
255
+
256
+ def process?(pid)
257
+ Process.kill(0, pid)
258
+ true
259
+ rescue Errno::EPERM, Errno::ESRCH
260
+ false
261
+ end
262
+
185
263
  def enqueue_in_multiple_queues
186
264
  Workhorse.enqueue BasicJob.new(some_param: nil)
187
265
  Workhorse.enqueue BasicJob.new(some_param: :q1), queue: :q1
data/workhorse.gemspec CHANGED
@@ -1,14 +1,14 @@
1
1
  # -*- encoding: utf-8 -*-
2
- # stub: workhorse 1.2.17.rc0 ruby lib
2
+ # stub: workhorse 1.2.17.rc1 ruby lib
3
3
 
4
4
  Gem::Specification.new do |s|
5
5
  s.name = "workhorse".freeze
6
- s.version = "1.2.17.rc0"
6
+ s.version = "1.2.17.rc1"
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 = "2024-02-05"
11
+ s.date = "2024-02-08"
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/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
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: workhorse
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.17.rc0
4
+ version: 1.2.17.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sitrox
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-02-05 00:00:00.000000000 Z
11
+ date: 2024-02-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler