workhorse 1.2.14 → 1.2.16

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a1665e70227e5e6fad1115523a94c7457a9b9b7216e10d2092c0326817bfd44
4
- data.tar.gz: f6a1d79bf0f55a8f2756934bb58ab3aefb83ef6369ffec7c783ef8cd6dfa50b0
3
+ metadata.gz: 7c50e38fd05785d30e0dfa783d732f46df2be9b980ac0a54dca1a2d91264ff9d
4
+ data.tar.gz: a4b282a25d39429da15c5380a41a1786b54b24b2b8afb4961786881380eecacf
5
5
  SHA512:
6
- metadata.gz: 8dde03d8f2a8f5c05307787433ef308c99c34ad2ab59d7c2edcc352c0428241a48ee5d0356f4a9a35a0de7dac876d60e1c09cd3c016915929f786a387a9e75e6
7
- data.tar.gz: b380dff403c8c10df7c2472422e462d66d40bff3796cbee09e81c63c11eafe4ba4a0d0dd361828f5a984f738fe30744b6fcea3616b6a54a9d1f7df016b21219f
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,24 @@
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
+
18
+ ## 1.2.15 - 2023-08-28
19
+
20
+ * Add capability to skip transactions for enqueued RailsOps operations.
21
+
3
22
  ## 1.2.14 - 2023-08-23
4
23
 
5
24
  * Add documentation for transaction handling.
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.
@@ -303,8 +304,8 @@ You can turn off transaction wrapping in the following ways:
303
304
  up their own transaction(s) or jobs that explicitly do not need a transaction
304
305
  for whatever reason.
305
306
 
306
- Usage of this feature depends on whether you are dealing with an ActiveJob job
307
- or a plain Workhorse job class.
307
+ Usage of this feature depends on whether you are dealing with an ActiveJob
308
+ job, an enqueued RailsOps operation or a plain Workhorse job class.
308
309
 
309
310
  For ActiveJob:
310
311
 
@@ -329,6 +330,29 @@ You can turn off transaction wrapping in the following ways:
329
330
  end
330
331
  ```
331
332
 
333
+ For enqueuable RailsOps operations:
334
+
335
+ 1. Add the following static method to your operation class:
336
+
337
+ ```ruby
338
+ class MyOp < RailsOps::Operation
339
+ def self.skip_tx?
340
+ true
341
+ end
342
+ end
343
+ ```
344
+
345
+ For plain Workhrose job clases:
346
+
347
+ 1. Add the following static method to your job class:
348
+
349
+ ```ruby
350
+ class MyJob
351
+ def self.skip_tx?
352
+ true
353
+ end
354
+ end
355
+
332
356
  ## Exception handling
333
357
 
334
358
  Per default, exceptions occurring in a worker thread will only be visible in the
@@ -504,6 +528,12 @@ exception is thrown (which may cause a notification if you configured
504
528
  `on_exception` accordingly). See the job's API documentation for more
505
529
  information.
506
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
+
507
537
  ## Frequently asked questions
508
538
 
509
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.14
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
@@ -5,6 +5,10 @@ module Workhorse::Jobs
5
5
  @params = params
6
6
  end
7
7
 
8
+ def job_class
9
+ @cls
10
+ end
11
+
8
12
  def perform
9
13
  @cls.run!(@params)
10
14
  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,18 +158,18 @@ 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
- "and may only be re-triggered if the error happens again *after* the lock has " \
115
- "been solved in the meantime."
171
+ 'and may only be re-triggered if the error happens again *after* the lock has ' \
172
+ 'been solved in the meantime.'
116
173
 
117
174
  worker.log message
118
175
  exception = StandardError.new(message)
@@ -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.14 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.14"
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-23"
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.14
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-23 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
@@ -246,7 +246,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
246
246
  - !ruby/object:Gem::Version
247
247
  version: '0'
248
248
  requirements: []
249
- rubygems_version: 3.4.10
249
+ rubygems_version: 3.4.6
250
250
  signing_key:
251
251
  specification_version: 4
252
252
  summary: Multi-threaded job backend with database queuing for ruby.