workhorse 1.2.15 → 1.2.16

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: 772b634a73940718f57f2363859977dbc7f20ff4b7c12edada5c4cc499ff5f6c
4
- data.tar.gz: 8c44df6872578300e6577155626f851ae1cb9202653f835b4dabbcc70fd78c70
3
+ metadata.gz: 7c50e38fd05785d30e0dfa783d732f46df2be9b980ac0a54dca1a2d91264ff9d
4
+ data.tar.gz: a4b282a25d39429da15c5380a41a1786b54b24b2b8afb4961786881380eecacf
5
5
  SHA512:
6
- metadata.gz: 751c63f5b1d86238d3e403332972ea0d177ee6c92acab793f3477d9c68f8223d755c633140e2622c2979b404f369327b6e507303f0d2532e1bcb408edf4a02f6
7
- data.tar.gz: e5209a6b1472239ce48734eb99d0a1c7cfcd9c20d800c6bb5bb77dff001f84ce7b362b444d0d2d3b0ae2b92b035f75d59484df5dc9d4111c7816ad10aa1b17ef
6
+ metadata.gz: f8fefeb28bb6651e93163e5d9b8744994f681064bf55168b73d8ca5e9c72aee9e3f7642d03a86eb496ebd81bf15e724757cb4f0a6790dcb87e3ae5f3ef09344d
7
+ data.tar.gz: '090f112b83a2a69015e0244345d30616af8701bf99f2a6814238006efa2cca49850dfe024b26d1359b2863d01bf2c84d0bbf84a7122419f540969ba7f8d66dea'
@@ -31,6 +31,11 @@ jobs:
31
31
  sudo /etc/init.d/mysql start
32
32
  mysql -u${{ env.DB_USER }} -p${{ env.DB_PASSWORD }} -e 'CREATE DATABASE ${{ env.DB_DATABASE }};'
33
33
  - name: Run rake tests
34
- run: bundle exec rake test TESTOPTS='--verbose'
34
+ uses: nick-fields/retry@v2
35
+ with:
36
+ timeout_seconds: 120
37
+ retry_on: error
38
+ max_attempts: 3
39
+ command: bundle exec rake test TESTOPTS='--verbose'
35
40
  - name: Run rubocop
36
41
  run: bundle exec rubocop
data/.rubocop.yml CHANGED
@@ -1,81 +1,158 @@
1
1
  AllCops:
2
- TargetRubyVersion: 2.3
3
-
2
+ DisplayCopNames: true
3
+ NewCops: enable
4
+ SuggestExtensions: false
5
+ TargetRubyVersion: 2.5
4
6
  Exclude:
7
+ - 'local/**/*'
5
8
  - 'vendor/**/*'
6
9
  - 'tmp/**/*'
10
+ - 'target/**/*'
7
11
  - 'log/**/*'
12
+ - 'db/schema.rb'
13
+ - 'locale/translations.rb'
14
+ - 'config/initializers/assets.rb'
15
+ - 'config/puma.rb'
16
+ - 'config_scripts/release_notes'
17
+ - 'config/spring.rb'
18
+ - 'bin/yarn'
8
19
  - '*.gemspec'
9
20
 
10
- DisplayCopNames: true
21
+ # Make sure accessors are on separate lines for diff readability.
22
+ Style/AccessorGrouping:
23
+ EnforcedStyle: separated
24
+
25
+ # Cop would break a lot of existing code.
26
+ Style/OptionalBooleanParameter:
27
+ Enabled: false
28
+
29
+ # Multiline hashes should be aligned cleanly as a table to improve readability.
30
+ Layout/HashAlignment:
31
+ EnforcedHashRocketStyle: table
32
+ EnforcedColonStyle: table
11
33
 
34
+ # Template style is easier on the eyes.
35
+ Style/FormatStringToken:
36
+ EnforcedStyle: template
37
+
38
+ # file. This will be addressed when approaching the first ruby 3 application.
12
39
  Style/FrozenStringLiteralComment:
13
40
  Enabled: false
14
41
 
42
+ # Double negation is very useful to make sure you have a boolean in hand. Use it
43
+ # wisely though and know what you're doing.
15
44
  Style/DoubleNegation:
16
45
  Enabled: false
17
46
 
47
+ # Depending on the case, [].include? can be a lot harder to read and less
48
+ # expressive than multiple comparisons.
49
+ Style/MultipleComparison:
50
+ Enabled: false
51
+
52
+ # Over time, the ruby guide changed from raise to fail back to raise. Both fail
53
+ # and raise are programatically exactly the same and our decision fell to "fail"
54
+ # for all kinds of exceptions.
18
55
  Style/SignalException:
19
56
  EnforcedStyle: only_fail
20
57
 
21
- Lint/RescueWithoutErrorClass:
22
- Enabled: False
23
-
24
- Lint/RescueException:
25
- Enabled: False
26
-
58
+ # Enforced styles can sometimes be hard to read.
27
59
  Style/ConditionalAssignment:
28
60
  Enabled: false
29
61
 
30
- Layout/IndentArray:
62
+ # Enforce consistent array indentation.
63
+ Layout/FirstArrayElementIndentation:
31
64
  EnforcedStyle: consistent
32
65
 
66
+ # Disable layout cop because methods just consisting of a number of returns
67
+ # would look very odd with an extra empty line between each return.
68
+ Layout/EmptyLineAfterGuardClause:
69
+ Enabled: false
70
+
71
+ # While you should try to keep your code as expressive and short as possible,
72
+ # limitting lengths hardly is over the top.
33
73
  Metrics/MethodLength:
34
74
  Enabled: false
35
75
 
76
+ # While you should try to keep your code as expressive and short as possible,
77
+ # limitting lengths hardly is over the top.
36
78
  Metrics/ClassLength:
37
79
  Enabled: false
38
80
 
81
+ # While you should try to keep your code as expressive and short as possible,
82
+ # limitting lengths hardly is over the top.
39
83
  Metrics/ModuleLength:
40
84
  Enabled: false
41
85
 
86
+ # While you should try to keep your code as expressive and short as possible,
87
+ # limitting lengths hardly is over the top.
88
+ Metrics/BlockLength:
89
+ Enabled: false
90
+
91
+ # While not always desirable, it can be useful to have a lot of keyword
92
+ # arguments on certain methods. Try to avoid it though.
42
93
  Metrics/ParameterLists:
43
94
  Max: 5
44
95
  CountKeywordArgs: false
45
96
 
97
+ # The results of this cop sometimes seemed arbitrary and can signifficantly
98
+ # restrict certain styles of coding.
46
99
  Metrics/AbcSize:
47
100
  Enabled: False
48
101
 
102
+ # The results of this cop sometimes seemed arbitrary and can signifficantly
103
+ # restrict certain styles of coding.
49
104
  Metrics/CyclomaticComplexity:
50
105
  Enabled: False
51
106
 
107
+ # The results of this cop sometimes seemed arbitrary and can signifficantly
108
+ # restrict certain styles of coding.
52
109
  Metrics/PerceivedComplexity:
53
110
  Enabled: False
54
111
 
55
- Metrics/LineLength:
56
- Max: 160
57
-
112
+ # In certain cases, "excessive" block nesting might just be useful. Try to keep
113
+ # this down as much as possible though.
58
114
  Metrics/BlockNesting:
59
115
  Enabled: false
60
116
 
61
- Metrics/BlockLength:
62
- Enabled: false
117
+ # A line length of 80 is not considered to be temporary anymore. That's why line
118
+ # length is doubled to 160. If absolutely necessary, create a temporary rubocop
119
+ # exclusion for the lines in question.
120
+ Layout/LineLength:
121
+ Max: 160
63
122
 
123
+ # Prefer variable_1 over variable1 for aesthetic reasons. Do not check symbols,
124
+ # as they often need to be another case for use in external palces (e.g. :md5).
125
+ Naming/VariableNumber:
126
+ EnforcedStyle: snake_case
127
+ CheckSymbols: false
128
+
129
+ # Depending on the surrounding code, even simple if/unless clauses may be more
130
+ # descriptive when on multiple lines.
64
131
  Style/IfUnlessModifier:
65
132
  Enabled: false
66
133
 
134
+ # In most cases, timing does not allow documenting each and every bit of source
135
+ # code. Do not hesitate to enable this cop otherwise.
67
136
  Style/Documentation:
68
137
  Enabled: false
69
138
 
139
+ # Return should be used whenever there is more than one statement or line in a
140
+ # method. This helps avoiding programming mistakes. This is not enforced yet as
141
+ # this would require a custom cop. However, to allow this style of programming,
142
+ # the RedundantReturn cop needs to be disabled.
70
143
  Style/RedundantReturn:
71
144
  Enabled: false
72
145
 
146
+ # Non-ascii comments can be useful sometimes.
73
147
  Style/AsciiComments:
74
148
  Enabled: false
75
149
 
150
+ # Depending on the case, if/unless can be more descriptive than guard clauses.
76
151
  Style/GuardClause:
77
152
  Enabled: false
78
153
 
154
+ # For technical reasons, nested and compact styles must be mixed in certain
155
+ # applications.
79
156
  Style/ClassAndModuleChildren:
80
157
  Enabled: false
81
158
  EnforcedStyle: compact
@@ -83,8 +160,30 @@ Style/ClassAndModuleChildren:
83
160
  - nested
84
161
  - compact
85
162
 
163
+ # Depending on the case, it may be more descriptive to use i.e. == 0 instead of
164
+ # .zero?, especially when testing against multiple numbers.
86
165
  Style/NumericPredicate:
87
166
  Enabled: false
88
167
 
89
- Layout/IndentHeredoc:
168
+ # Detection is not implemented in a reliable manner for all cases which can lead
169
+ # to false positives and negatives.
170
+ Style/FormatString:
171
+ Enabled: false
172
+
173
+ # Do not require MFA, as gems checked with sitrox_standards are only pushed to the
174
+ # internal repo
175
+ Gemspec/RequireMFA:
176
+ Enabled: false
177
+
178
+ # Use explicit style
179
+ Naming/BlockForwarding:
180
+ Enabled: true
181
+ EnforcedStyle: explicit
182
+
183
+ Style/HashSyntax:
184
+ # Use `either` style for `EnforcedShorthandyntax` (see #106550)
185
+ EnforcedShorthandSyntax: either
186
+
187
+ # Allow rescue 'Exception', necessary for Workhorse
188
+ Lint/RescueException:
90
189
  Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Workhorse Changelog
2
2
 
3
+ ## 1.2.16 - 2023-09-18
4
+
5
+ * Add support for `--skip-initializer` flag to install generator.
6
+
7
+ Sitrox reference: #114673.
8
+
9
+ * Add option `config.clean_stuck_jobs` that enabled automatic cleaning of stuck
10
+ jobs whenever a worker starts up.
11
+
12
+ Sitrox reference: #113708
13
+
14
+ * Add `retry-step` to actions such that failed unit tests are executed again
15
+
16
+ Sitrox reference: #115888
17
+
3
18
  ## 1.2.15 - 2023-08-28
4
19
 
5
20
  * Add capability to skip transactions for enqueued RailsOps operations.
data/README.md CHANGED
@@ -62,6 +62,7 @@ What it does not do:
62
62
 
63
63
  * A database migration for creating a table named `jobs`
64
64
  * The initializer `config/initializers/workhorse.rb` for global configuration
65
+ * This can be skipped using the `--skip-initializer` flag
65
66
  * The daemon worker script `bin/workhorse.rb`
66
67
 
67
68
  Please customize the initializer and worker script to your liking.
@@ -527,6 +528,12 @@ exception is thrown (which may cause a notification if you configured
527
528
  `on_exception` accordingly). See the job's API documentation for more
528
529
  information.
529
530
 
531
+ Starting with Workhorse 1.2.16, there is also a feature that automatically
532
+ checks for stuck jobs (jobs in state `locked` or `started` running on the same
533
+ host where the corresponding PID does not have a process anymore) when starting
534
+ up the worker / poller. This feature can be turned on using the setting
535
+ `config.clean_stuck_jobs`. This is turned off by default.
536
+
530
537
  ## Frequently asked questions
531
538
 
532
539
  Please consult the [FAQ](FAQ.md).
data/RUBY_VERSION CHANGED
@@ -1 +1 @@
1
- ruby-2.6.2-p47
1
+ ruby-2.7.1-p83
data/Rakefile CHANGED
@@ -1,7 +1,7 @@
1
1
  task :gemspec do
2
2
  gemspec = Gem::Specification.new do |spec|
3
3
  spec.name = 'workhorse'
4
- spec.version = IO.read('VERSION').chomp
4
+ spec.version = File.read('VERSION').chomp
5
5
  spec.authors = ['Sitrox']
6
6
  spec.summary = %(
7
7
  Multi-threaded job backend with database queuing for ruby.
@@ -13,7 +13,7 @@ task :gemspec do
13
13
 
14
14
  spec.add_development_dependency 'bundler'
15
15
  spec.add_development_dependency 'rake'
16
- spec.add_development_dependency 'rubocop', '0.51.0'
16
+ spec.add_development_dependency 'rubocop', '~> 1.28.0' # Latest version supported with Ruby 2.5
17
17
  spec.add_development_dependency 'minitest'
18
18
  spec.add_development_dependency 'mysql2'
19
19
  spec.add_development_dependency 'colorize'
@@ -25,7 +25,7 @@ task :gemspec do
25
25
  spec.add_dependency 'concurrent-ruby'
26
26
  end
27
27
 
28
- File.open('workhorse.gemspec', 'w') { |f| f.write(gemspec.to_ruby.strip) }
28
+ File.write('workhorse.gemspec', gemspec.to_ruby.strip)
29
29
  end
30
30
 
31
31
  require 'rake/testtask'
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.2.15
1
+ 1.2.16
@@ -10,11 +10,11 @@ module ActiveJob
10
10
  #
11
11
  # Rails.application.config.active_job.queue_adapter = :workhorse
12
12
  class WorkhorseAdapter
13
- def enqueue(job) #:nodoc:
13
+ def enqueue(job) # :nodoc:
14
14
  Workhorse.enqueue_active_job(job)
15
15
  end
16
16
 
17
- def enqueue_at(job, timestamp = Time.now) #:nodoc:
17
+ def enqueue_at(job, timestamp = Time.now) # :nodoc:
18
18
  Workhorse.enqueue_active_job(job, perform_at: timestamp)
19
19
  end
20
20
  end
@@ -2,7 +2,10 @@ module Workhorse
2
2
  class InstallGenerator < Rails::Generators::Base
3
3
  include Rails::Generators::Migration
4
4
 
5
- source_root File.expand_path('../templates', __FILE__)
5
+ class_option :skip_initializer, type: :boolean, default: false,
6
+ desc: 'Skip generating the initializer file'
7
+
8
+ source_root File.expand_path('templates', __dir__)
6
9
 
7
10
  def self.next_migration_number(_dir)
8
11
  Time.now.utc.strftime('%Y%m%d%H%M%S')
@@ -18,7 +21,7 @@ module Workhorse
18
21
  end
19
22
 
20
23
  def install_initializer
21
- template 'config/initializers/workhorse.rb'
24
+ template 'config/initializers/workhorse.rb' unless options[:skip_initializer]
22
25
  end
23
26
  end
24
27
  end
@@ -40,7 +40,7 @@ module Workhorse
40
40
  end
41
41
 
42
42
  exit 0
43
- rescue => e
43
+ rescue StandardError => e
44
44
  warn "#{e.message}\n#{e.backtrace.join("\n")}"
45
45
  exit 99
46
46
  ensure
@@ -49,44 +49,44 @@ module Workhorse
49
49
  end
50
50
 
51
51
  def self.usage
52
- warn <<USAGE
53
- Usage: #{$PROGRAM_NAME} start|stop|status|watch|restart|usage
52
+ warn <<~USAGE
53
+ Usage: #{$PROGRAM_NAME} start|stop|status|watch|restart|usage
54
54
 
55
- Options:
55
+ Options:
56
56
 
57
- start
58
- Start the daemon
57
+ start
58
+ Start the daemon
59
59
 
60
- stop
61
- Stop the daemon
60
+ stop
61
+ Stop the daemon
62
62
 
63
- kill
64
- Kill the daemon
63
+ kill
64
+ Kill the daemon
65
65
 
66
- status
67
- Query the status of the daemon. Exit with status 1 if any worker is
68
- not running.
66
+ status
67
+ Query the status of the daemon. Exit with status 1 if any worker is
68
+ not running.
69
69
 
70
- watch
71
- Checks the status (running or stopped) and whether it is as
72
- expected. Starts the daemon if it is expected to run but is not.
70
+ watch
71
+ Checks the status (running or stopped) and whether it is as
72
+ expected. Starts the daemon if it is expected to run but is not.
73
73
 
74
- restart
75
- Shortcut for consecutive 'stop' and 'start'.
74
+ restart
75
+ Shortcut for consecutive 'stop' and 'start'.
76
76
 
77
- restart-logging
78
- Re-opens log files, useful e.g. after the log files have been moved or
79
- removed by log rotation.
77
+ restart-logging
78
+ Re-opens log files, useful e.g. after the log files have been moved or
79
+ removed by log rotation.
80
80
 
81
- usage
82
- Show this message
81
+ usage
82
+ Show this message
83
83
 
84
- Exit status:
85
- 0 if OK,
86
- 1 on fatal errors outside of workhorse,
87
- 2 if at least one worker has an unexpected status,
88
- 99 on all other errors.
89
- USAGE
84
+ Exit status:
85
+ 0 if OK,
86
+ 1 on fatal errors outside of workhorse,
87
+ 2 if at least one worker has an unexpected status,
88
+ 99 on all other errors.
89
+ USAGE
90
90
  end
91
91
  end
92
92
  end
@@ -4,6 +4,7 @@ module Workhorse
4
4
  attr_reader :id
5
5
  attr_reader :name
6
6
  attr_reader :block
7
+ attr_accessor :pid
7
8
 
8
9
  def initialize(id, name, &block)
9
10
  @id = id
@@ -12,6 +13,9 @@ module Workhorse
12
13
  end
13
14
  end
14
15
 
16
+ # @private
17
+ attr_reader :workers
18
+
15
19
  def initialize(pidfile: nil, quiet: false, &_block)
16
20
  @pidfile = pidfile
17
21
  @quiet = quiet
@@ -68,7 +72,7 @@ module Workhorse
68
72
 
69
73
  if pid_file && pid
70
74
  puts "Worker (#{worker.name}) ##{worker.id}: Stopping"
71
- stop_worker pid_file, pid, kill
75
+ stop_worker pid_file, pid, kill: kill
72
76
  elsif pid_file
73
77
  File.delete pid_file
74
78
  puts "Worker (#{worker.name}) ##{worker.id}: Already stopped (stale PID file)"
@@ -147,20 +151,20 @@ module Workhorse
147
151
  def start_worker(worker)
148
152
  pid = fork do
149
153
  $0 = process_name(worker)
150
-
151
154
  # Reopen pipes to prevent #107576
152
- STDIN.reopen File.open('/dev/null', 'r')
155
+ $stdin.reopen File.open('/dev/null', 'r')
153
156
  null_out = File.open '/dev/null', 'w'
154
- STDOUT.reopen null_out
155
- STDERR.reopen null_out
157
+ $stdout.reopen null_out
158
+ $stderr.reopen null_out
156
159
 
157
160
  worker.block.call
158
161
  end
159
- IO.write(pid_file_for(worker), pid)
162
+ worker.pid = pid
163
+ File.write(pid_file_for(worker), pid)
160
164
  Process.detach(pid)
161
165
  end
162
166
 
163
- def stop_worker(pid_file, pid, kill = false)
167
+ def stop_worker(pid_file, pid, kill: false)
164
168
  signals = kill ? %w[KILL] : %w[TERM INT]
165
169
 
166
170
  loop do
@@ -207,8 +211,9 @@ module Workhorse
207
211
  file = pid_file_for(worker)
208
212
 
209
213
  if File.exist?(file)
210
- raw_pid = IO.read(file)
214
+ raw_pid = File.read(file)
211
215
  return nil, nil if raw_pid.blank?
216
+
212
217
  pid = Integer(raw_pid)
213
218
  return file, process?(pid) ? pid : nil
214
219
  else
@@ -6,6 +6,8 @@ module Workhorse
6
6
  STATE_SUCCEEDED = :succeeded
7
7
  STATE_FAILED = :failed
8
8
 
9
+ EXP_LOCKED_BY = /^(.*?)\.(\d+?)\.([^.]+)$/.freeze
10
+
9
11
  if respond_to?(:attr_accessible)
10
12
  attr_accessible :queue, :priority, :perform_at, :handler, :description
11
13
  end
@@ -32,6 +34,31 @@ module Workhorse
32
34
  where(state: STATE_FAILED)
33
35
  end
34
36
 
37
+ # @private
38
+ def self.with_split_locked_by
39
+ select(<<~SQL)
40
+ #{table_name}.*,
41
+
42
+ -- random string
43
+ substring_index(locked_by, '.', -1) as locked_by_rnd,
44
+
45
+ -- pid
46
+ substring_index(
47
+ substring_index(locked_by, '.', -2),
48
+ '.',
49
+ 1
50
+ ) as locked_by_pid,
51
+
52
+ -- get host
53
+ substring(
54
+ locked_by,
55
+ 1,
56
+ length(locked_by) -
57
+ length(substring_index(locked_by, '.', -2)) - 1
58
+ ) as locked_by_host
59
+ SQL
60
+ end
61
+
35
62
  # Resets job to state "waiting" and clears all meta fields
36
63
  # set by workhorse in course of processing this job.
37
64
  #
@@ -69,11 +96,6 @@ module Workhorse
69
96
  fail "Dirty jobs can't be locked."
70
97
  end
71
98
 
72
- # TODO: Remove this debug output
73
- # if Workhorse::DbJob.lock.find(id).locked_at
74
- # puts "Already locked (with FOR UPDATE)"
75
- # end
76
-
77
99
  if locked_at
78
100
  # TODO: Remove this debug output
79
101
  # puts "Already locked. Job: #{self.id} Worker: #{worker_id}"
@@ -3,11 +3,11 @@ module Workhorse
3
3
  # Enqueue any object that is serializable and has a `perform` method
4
4
  def enqueue(job, queue: nil, priority: 0, perform_at: Time.now, description: nil)
5
5
  return DbJob.create!(
6
- queue: queue,
7
- priority: priority,
8
- perform_at: perform_at,
6
+ queue: queue,
7
+ priority: priority,
8
+ perform_at: perform_at,
9
9
  description: description,
10
- handler: Marshal.dump(job)
10
+ handler: Marshal.dump(job)
11
11
  )
12
12
  end
13
13
 
@@ -18,7 +18,7 @@ module Workhorse::Jobs
18
18
  private
19
19
 
20
20
  def seconds_ago(days)
21
- Time.now - days * 24 * 60 * 60
21
+ Time.now - (days * 24 * 60 * 60)
22
22
  end
23
23
  end
24
24
  end
@@ -27,7 +27,7 @@ module Workhorse::Jobs
27
27
  ids = rel.pluck(:id)
28
28
 
29
29
  unless ids.empty?
30
- messages << "Detected #{ids.size} jobs that were locked more than "\
30
+ messages << "Detected #{ids.size} jobs that were locked more than " \
31
31
  "#{@locked_to_started_threshold}s ago and might be stale: #{ids.inspect}."
32
32
  end
33
33
  end
@@ -39,7 +39,7 @@ module Workhorse::Jobs
39
39
  ids = rel.pluck(:id)
40
40
 
41
41
  unless ids.empty?
42
- messages << "Detected #{ids.size} jobs that are running for longer than "\
42
+ messages << "Detected #{ids.size} jobs that are running for longer than " \
43
43
  "#{@run_time_threshold}s ago and might be stale: #{ids.inspect}."
44
44
  end
45
45
  end
@@ -27,6 +27,8 @@ module Workhorse
27
27
  fail 'Poller is already running.' if running?
28
28
  @running = true
29
29
 
30
+ clean_stuck_jobs! if Workhorse.clean_stuck_jobs
31
+
30
32
  @thread = Thread.new do
31
33
  loop do
32
34
  break unless running?
@@ -65,6 +67,61 @@ module Workhorse
65
67
 
66
68
  private
67
69
 
70
+ def clean_stuck_jobs!
71
+ with_global_lock timeout: MAX_LOCK_TIMEOUT do
72
+ Workhorse.tx_callback.call do
73
+ # Basic relation: Fetch jobs locked by current host in state 'locked' or
74
+ # 'started'
75
+ rel = Workhorse::DbJob.select('*').from(<<~SQL)
76
+ (#{Workhorse::DbJob.with_split_locked_by.to_sql}) #{Workhorse::DbJob.table_name}
77
+ SQL
78
+ rel.where!(
79
+ locked_by_host: worker.hostname,
80
+ state: [Workhorse::DbJob::STATE_LOCKED, Workhorse::DbJob::STATE_STARTED]
81
+ )
82
+
83
+ # Select all pids
84
+ job_pids = rel.distinct.pluck(:locked_by_pid).to_set(&:to_i)
85
+
86
+ # Get pids without active process
87
+ orphaned_pids = job_pids.select do |pid|
88
+ Process.getpgid(pid)
89
+ false
90
+ rescue Errno::ESRCH
91
+ true
92
+ end
93
+
94
+ # Reset jobs in state 'locked'
95
+ rel.where(locked_by_pid: orphaned_pids.to_a, state: Workhorse::DbJob::STATE_LOCKED).each do |job|
96
+ worker.log(
97
+ "Job ##{job.id} has been locked but not yet startet by PID #{job.locked_by_pid} on host " \
98
+ "#{job.locked_by_host}, but the process is not running anymore. This job has therefore been " \
99
+ "reset (set to 'waiting') by the Workhorse cleanup logic.",
100
+ :warn
101
+ )
102
+ job.reset!(true)
103
+ end
104
+
105
+ # Mark jobs in state 'started' as failed
106
+ rel.where(locked_by_pid: orphaned_pids.to_a, state: Workhorse::DbJob::STATE_STARTED).each do |job|
107
+ worker.log(
108
+ "Job ##{job.id} has been started by PID #{job.locked_by_pid} on host #{job.locked_by_host} " \
109
+ 'but the process is not running anymore. This job has therefore been marked as ' \
110
+ 'failed by the Workhorse cleanup logic.',
111
+ :warn
112
+ )
113
+ exception = Exception.new(
114
+ "Job has been started by PID #{job.locked_by_pid} on host #{job.locked_by_host} " \
115
+ 'but the process is not running anymore. This job has therefore been marked as ' \
116
+ 'failed by the Workhorse cleanup logic.'
117
+ )
118
+ exception.set_backtrace []
119
+ job.mark_failed!(exception)
120
+ end
121
+ end
122
+ end
123
+ end
124
+
68
125
  def sleep
69
126
  remaining = worker.polling_interval
70
127
 
@@ -101,15 +158,15 @@ module Workhorse
101
158
  if @global_lock_fails > Workhorse.max_global_lock_fails && !@max_global_lock_fails_reached
102
159
  @max_global_lock_fails_reached = true
103
160
 
104
- worker.log 'Could not obtain global lock, retrying with next poll. '\
105
- 'This will be the last such message for this worker until '\
161
+ worker.log 'Could not obtain global lock, retrying with next poll. ' \
162
+ 'This will be the last such message for this worker until ' \
106
163
  'the issue is resolved.', :warn
107
164
 
108
165
  message = "Worker reached maximum number of consecutive times (#{Workhorse.max_global_lock_fails}) " \
109
166
  "where the global lock could no be acquired within the specified timeout (#{timeout}). " \
110
167
  'A worker that obtained this lock may have crashed without ending the database ' \
111
168
  'connection properly. On MySQL, use "show processlist;" to see which connection(s) ' \
112
- 'is / are holding the lock for a long period of time and consider killing them using '\
169
+ 'is / are holding the lock for a long period of time and consider killing them using ' \
113
170
  "MySQL's \"kill <Id>\" command. This message will be issued only once per worker " \
114
171
  'and may only be re-triggered if the error happens again *after* the lock has ' \
115
172
  'been solved in the meantime.'
@@ -137,7 +194,6 @@ module Workhorse
137
194
  @instant_repoll.make_false
138
195
 
139
196
  timeout = [MIN_LOCK_TIMEOUT, [MAX_LOCK_TIMEOUT, worker.polling_interval].min].max
140
-
141
197
  with_global_lock timeout: timeout do
142
198
  job_ids = []
143
199
 
@@ -199,9 +255,9 @@ module Workhorse
199
255
  # uses the keyword 'AS' in SQL generated for Oracle, which is invalid for
200
256
  # table aliases.
201
257
  union_query_sql = '('
202
- union_query_sql += 'SELECT * FROM (' + union_parts.shift.to_sql + ') union_0'
258
+ union_query_sql += "SELECT * FROM (#{union_parts.shift.to_sql}) union_0"
203
259
  union_parts.each_with_index do |part, idx|
204
- union_query_sql += ' UNION SELECT * FROM (' + part.to_sql + ") union_#{idx + 1}"
260
+ union_query_sql += " UNION SELECT * FROM (#{part.to_sql}) union_#{idx + 1}"
205
261
  end
206
262
  union_query_sql += ') subselect'
207
263
 
@@ -277,7 +333,7 @@ module Workhorse
277
333
  unless worker.queues.empty?
278
334
  if worker.queues.include?(nil)
279
335
  where = table[:queue].eq(nil)
280
- remaining_queues = worker.queues.reject(&:nil?)
336
+ remaining_queues = worker.queues.compact
281
337
  unless remaining_queues.empty?
282
338
  where = where.or(table[:queue].in(remaining_queues))
283
339
  end
@@ -6,11 +6,11 @@ module Workhorse
6
6
  def initialize(size)
7
7
  @size = size
8
8
  @executor = Concurrent::ThreadPoolExecutor.new(
9
- min_threads: 0,
10
- max_threads: @size,
11
- max_queue: 0,
9
+ min_threads: 0,
10
+ max_threads: @size,
11
+ max_queue: 0,
12
12
  fallback_policy: :abort,
13
- auto_terminate: false
13
+ auto_terminate: false
14
14
  )
15
15
  @mutex = Mutex.new
16
16
  @active_threads = Concurrent::AtomicFixnum.new(0)
@@ -33,12 +33,10 @@ module Workhorse
33
33
  active_threads.increment
34
34
 
35
35
  @executor.post do
36
- begin
37
- yield
38
- ensure
39
- active_threads.decrement
40
- @on_idle.try(:call)
41
- end
36
+ yield
37
+ ensure
38
+ active_threads.decrement
39
+ @on_idle.try(:call)
42
40
  end
43
41
  end
44
42
  end
@@ -43,7 +43,7 @@ module Workhorse
43
43
  # `Rails.logger`.
44
44
  def initialize(queues: [], pool_size: nil, polling_interval: 300, auto_terminate: true, quiet: true, instant_repolling: false, logger: nil)
45
45
  @queues = queues
46
- @pool_size = pool_size || queues.size + 1
46
+ @pool_size = pool_size || (queues.size + 1)
47
47
  @polling_interval = polling_interval
48
48
  @auto_terminate = auto_terminate
49
49
  @state = :initialized
@@ -54,7 +54,7 @@ module Workhorse
54
54
  @poller = Workhorse::Poller.new(self)
55
55
  @logger = logger
56
56
 
57
- unless (@polling_interval / 0.1).round(2).modulo(1) == 0.0
57
+ unless (@polling_interval / 0.1).round(2).modulo(1).zero?
58
58
  fail 'Polling interval must be a multiple of 0.1.'
59
59
  end
60
60
 
@@ -74,7 +74,15 @@ module Workhorse
74
74
  end
75
75
 
76
76
  def id
77
- @id ||= "#{Socket.gethostname}.#{Process.pid}.#{SecureRandom.hex(3)}"
77
+ @id ||= "#{hostname}.#{pid}.#{SecureRandom.hex(3)}"
78
+ end
79
+
80
+ def pid
81
+ @pid ||= Process.pid
82
+ end
83
+
84
+ def hostname
85
+ @hostname ||= Socket.gethostname
78
86
  end
79
87
 
80
88
  # Starts the worker. This call is not blocking - call {wait} for this
@@ -135,12 +143,10 @@ module Workhorse
135
143
  log "Posting job #{db_job_id} to thread pool"
136
144
 
137
145
  @pool.post do
138
- begin
139
- Workhorse::Performer.new(db_job_id, self).perform
140
- rescue Exception => e
141
- log %(#{e.message}\n#{e.backtrace.join("\n")}), :error
142
- Workhorse.on_exception.call(e)
143
- end
146
+ Workhorse::Performer.new(db_job_id, self).perform
147
+ rescue Exception => e
148
+ log %(#{e.message}\n#{e.backtrace.join("\n")}), :error
149
+ Workhorse.on_exception.call(e)
144
150
  end
145
151
  end
146
152
  rescue Exception => e
data/lib/workhorse.rb CHANGED
@@ -17,7 +17,7 @@ module Workhorse
17
17
  # Returns the performer currently performing the active job. This can only be
18
18
  # called from within a job and the same thread.
19
19
  def self.performer
20
- Thread.current[:workhorse_current_performer]\
20
+ Thread.current[:workhorse_current_performer] \
21
21
  || fail('No performer is associated with the current thread. This method must always be called inside of a job.')
22
22
  end
23
23
 
@@ -58,6 +58,11 @@ module Workhorse
58
58
  mattr_accessor :perform_jobs_in_tx
59
59
  self.perform_jobs_in_tx = true
60
60
 
61
+ # If enabled, each poller will attempt to clean jobs that are stuck in state
62
+ # 'locked' or 'running' when it is starting up.
63
+ mattr_accessor :clean_stuck_jobs
64
+ self.clean_stuck_jobs = false
65
+
61
66
  # This setting is for {Workhorse::Jobs::DetectStaleJobsJob} and specifies the
62
67
  # maximum number of seconds a job is allowed to stay 'locked' before this job
63
68
  # throws an exception. Set this to 0 to skip this check.
@@ -92,5 +97,5 @@ if RUBY_PLATFORM != 'java'
92
97
  end
93
98
 
94
99
  if defined?(ActiveJob)
95
- require 'active_job/queue_adapters/workhorse_adapter.rb'
100
+ require 'active_job/queue_adapters/workhorse_adapter'
96
101
  end
@@ -44,10 +44,10 @@ end
44
44
 
45
45
  ActiveRecord::Base.establish_connection(
46
46
  adapter: 'mysql2',
47
- database: ENV['DB_NAME'] || 'workhorse',
48
- username: ENV['DB_USERNAME'] || 'root',
49
- password: ENV['DB_PASSWORD'] || '',
50
- host: ENV['DB_HOST'] || '127.0.0.1',
47
+ database: ENV.fetch('DB_NAME', nil) || 'workhorse',
48
+ username: ENV.fetch('DB_USERNAME', nil) || 'root',
49
+ password: ENV.fetch('DB_PASSWORD', nil) || '',
50
+ host: ENV.fetch('DB_HOST', nil) || '127.0.0.1',
51
51
  pool: 10
52
52
  )
53
53
 
@@ -41,7 +41,7 @@ class Workhorse::PollerTest < WorkhorseTest
41
41
 
42
42
  begin
43
43
  fail 'Some exception'
44
- rescue => e
44
+ rescue StandardError => e
45
45
  last_job.mark_failed!(e)
46
46
  end
47
47
 
@@ -148,9 +148,10 @@ class Workhorse::PollerTest < WorkhorseTest
148
148
  assert_equal 25, used_workers
149
149
  end
150
150
 
151
- # rubocop: disable Style/GlobalVars
152
151
  def test_connection_loss
152
+ # rubocop: disable Style/GlobalVars
153
153
  $thread_conn = nil
154
+ # rubocop: enable Style/GlobalVars
154
155
 
155
156
  Workhorse.enqueue BasicJob.new(sleep_time: 3)
156
157
 
@@ -175,10 +176,95 @@ class Workhorse::PollerTest < WorkhorseTest
175
176
 
176
177
  assert_equal 1, Workhorse::DbJob.succeeded.count
177
178
  end
178
- # rubocop: enable Style/GlobalVars
179
+
180
+ def test_clean_stuck_jobs_locked
181
+ [true, false].each do |clean|
182
+ Workhorse::DbJob.delete_all
183
+
184
+ Workhorse.clean_stuck_jobs = clean
185
+ start_deamon
186
+ Workhorse.enqueue BasicJob.new(sleep_time: 5)
187
+ sleep 0.2
188
+ kill_deamon_workers
189
+
190
+ assert_equal 1, Workhorse::DbJob.count
191
+
192
+ Workhorse::DbJob.first.update(
193
+ state: 'locked',
194
+ started_at: nil
195
+ )
196
+
197
+ Workhorse::Worker.new.poller.send(:clean_stuck_jobs!) if clean
198
+
199
+ assert_equal 1, Workhorse::DbJob.count
200
+
201
+ Workhorse::DbJob.first.tap do |job|
202
+ if clean
203
+ assert_equal 'waiting', job.state
204
+ assert_nil job.locked_at
205
+ assert_nil job.locked_by
206
+ assert_nil job.started_at
207
+ assert_nil job.last_error
208
+ else
209
+ assert_equal 'locked', job.state
210
+ end
211
+ end
212
+ ensure
213
+ Workhorse.clean_stuck_jobs = false
214
+ end
215
+ end
216
+
217
+ def test_clean_stuck_jobs_running
218
+ [true, false].each do |clean|
219
+ Workhorse::DbJob.delete_all
220
+
221
+ Workhorse.clean_stuck_jobs = true
222
+ start_deamon
223
+ Workhorse.enqueue BasicJob.new(sleep_time: 5)
224
+ sleep 0.2
225
+ kill_deamon_workers
226
+
227
+ assert_equal 1, Workhorse::DbJob.count
228
+ assert_equal 'started', Workhorse::DbJob.first.state
229
+
230
+ work 0.1 if clean
231
+
232
+ assert_equal 1, Workhorse::DbJob.count
233
+
234
+ Workhorse::DbJob.first.tap do |job|
235
+ if clean
236
+ assert_equal 'failed', job.state
237
+ assert_match(/started by PID #{@daemon.workers.first.pid}/, job.last_error)
238
+ assert_match(/on host #{Socket.gethostname}/, job.last_error)
239
+ else
240
+ assert_equal 'started', job.state
241
+ end
242
+ end
243
+ ensure
244
+ Workhorse.clean_stuck_jobs = false
245
+ end
246
+ end
179
247
 
180
248
  private
181
249
 
250
+ def kill_deamon_workers
251
+ @daemon.workers.each do |worker|
252
+ Process.kill 'KILL', worker.pid
253
+ end
254
+ end
255
+
256
+ def start_deamon
257
+ @daemon = Workhorse::Daemon.new(pidfile: 'tmp/pids/test%s.pid') do |d|
258
+ d.worker 'Test Worker' do
259
+ Workhorse::Worker.start_and_wait(
260
+ pool_size: 1,
261
+ polling_interval: 0.1
262
+ )
263
+ end
264
+ end
265
+ @daemon.start
266
+ end
267
+
182
268
  def setup
183
269
  Workhorse::DbJob.delete_all
184
270
  end
data/workhorse.gemspec CHANGED
@@ -1,53 +1,40 @@
1
1
  # -*- encoding: utf-8 -*-
2
- # stub: workhorse 1.2.15 ruby lib
2
+ # stub: workhorse 1.2.16 ruby lib
3
3
 
4
4
  Gem::Specification.new do |s|
5
5
  s.name = "workhorse".freeze
6
- s.version = "1.2.15"
6
+ s.version = "1.2.16"
7
7
 
8
8
  s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
9
9
  s.require_paths = ["lib".freeze]
10
10
  s.authors = ["Sitrox".freeze]
11
- s.date = "2023-08-28"
11
+ s.date = "2023-09-18"
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
- s.rubygems_version = "3.0.3".freeze
13
+ s.rubygems_version = "3.1.2".freeze
14
14
  s.summary = "Multi-threaded job backend with database queuing for ruby.".freeze
15
15
  s.test_files = ["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]
16
16
 
17
17
  if s.respond_to? :specification_version then
18
18
  s.specification_version = 4
19
+ end
19
20
 
20
- if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
21
- s.add_development_dependency(%q<bundler>.freeze, [">= 0"])
22
- s.add_development_dependency(%q<rake>.freeze, [">= 0"])
23
- s.add_development_dependency(%q<rubocop>.freeze, ["= 0.51.0"])
24
- s.add_development_dependency(%q<minitest>.freeze, [">= 0"])
25
- s.add_development_dependency(%q<mysql2>.freeze, [">= 0"])
26
- s.add_development_dependency(%q<colorize>.freeze, [">= 0"])
27
- s.add_development_dependency(%q<benchmark-ips>.freeze, [">= 0"])
28
- s.add_development_dependency(%q<activejob>.freeze, [">= 0"])
29
- s.add_development_dependency(%q<pry>.freeze, [">= 0"])
30
- s.add_runtime_dependency(%q<activesupport>.freeze, [">= 0"])
31
- s.add_runtime_dependency(%q<activerecord>.freeze, [">= 0"])
32
- s.add_runtime_dependency(%q<concurrent-ruby>.freeze, [">= 0"])
33
- else
34
- s.add_dependency(%q<bundler>.freeze, [">= 0"])
35
- s.add_dependency(%q<rake>.freeze, [">= 0"])
36
- s.add_dependency(%q<rubocop>.freeze, ["= 0.51.0"])
37
- s.add_dependency(%q<minitest>.freeze, [">= 0"])
38
- s.add_dependency(%q<mysql2>.freeze, [">= 0"])
39
- s.add_dependency(%q<colorize>.freeze, [">= 0"])
40
- s.add_dependency(%q<benchmark-ips>.freeze, [">= 0"])
41
- s.add_dependency(%q<activejob>.freeze, [">= 0"])
42
- s.add_dependency(%q<pry>.freeze, [">= 0"])
43
- s.add_dependency(%q<activesupport>.freeze, [">= 0"])
44
- s.add_dependency(%q<activerecord>.freeze, [">= 0"])
45
- s.add_dependency(%q<concurrent-ruby>.freeze, [">= 0"])
46
- end
21
+ if s.respond_to? :add_runtime_dependency then
22
+ s.add_development_dependency(%q<bundler>.freeze, [">= 0"])
23
+ s.add_development_dependency(%q<rake>.freeze, [">= 0"])
24
+ s.add_development_dependency(%q<rubocop>.freeze, ["~> 1.28.0"])
25
+ s.add_development_dependency(%q<minitest>.freeze, [">= 0"])
26
+ s.add_development_dependency(%q<mysql2>.freeze, [">= 0"])
27
+ s.add_development_dependency(%q<colorize>.freeze, [">= 0"])
28
+ s.add_development_dependency(%q<benchmark-ips>.freeze, [">= 0"])
29
+ s.add_development_dependency(%q<activejob>.freeze, [">= 0"])
30
+ s.add_development_dependency(%q<pry>.freeze, [">= 0"])
31
+ s.add_runtime_dependency(%q<activesupport>.freeze, [">= 0"])
32
+ s.add_runtime_dependency(%q<activerecord>.freeze, [">= 0"])
33
+ s.add_runtime_dependency(%q<concurrent-ruby>.freeze, [">= 0"])
47
34
  else
48
35
  s.add_dependency(%q<bundler>.freeze, [">= 0"])
49
36
  s.add_dependency(%q<rake>.freeze, [">= 0"])
50
- s.add_dependency(%q<rubocop>.freeze, ["= 0.51.0"])
37
+ s.add_dependency(%q<rubocop>.freeze, ["~> 1.28.0"])
51
38
  s.add_dependency(%q<minitest>.freeze, [">= 0"])
52
39
  s.add_dependency(%q<mysql2>.freeze, [">= 0"])
53
40
  s.add_dependency(%q<colorize>.freeze, [">= 0"])
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.15
4
+ version: 1.2.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sitrox
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-08-28 00:00:00.000000000 Z
11
+ date: 2023-09-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -42,16 +42,16 @@ dependencies:
42
42
  name: rubocop
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - '='
45
+ - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: 0.51.0
47
+ version: 1.28.0
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - '='
52
+ - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: 0.51.0
54
+ version: 1.28.0
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: minitest
57
57
  requirement: !ruby/object:Gem::Requirement