workhorse 1.5.2 → 2.0.0.rc0

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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -0
  3. data/README.md +303 -74
  4. data/Rakefile +1 -0
  5. data/VERSION +1 -1
  6. data/bin/rubocop +5 -1
  7. data/lib/generators/workhorse/install_generator.rb +10 -1
  8. data/lib/generators/workhorse/templates/config/initializers/workhorse.rb +55 -0
  9. data/lib/generators/workhorse/templates/create_table_jobs.rb +11 -13
  10. data/lib/generators/workhorse/templates/create_table_workhorse_schedules.rb +29 -0
  11. data/lib/workhorse/daemon.rb +52 -6
  12. data/lib/workhorse/db_job.rb +58 -7
  13. data/lib/workhorse/enqueuer.rb +51 -8
  14. data/lib/workhorse/jobs/cleanup_succeeded_jobs.rb +26 -8
  15. data/lib/workhorse/jobs/detect_late_schedules_job.rb +59 -0
  16. data/lib/workhorse/notifiers/base.rb +55 -0
  17. data/lib/workhorse/notifiers/file_system.rb +66 -0
  18. data/lib/workhorse/notifiers/none.rb +8 -0
  19. data/lib/workhorse/notifiers/redis.rb +227 -0
  20. data/lib/workhorse/performer.rb +28 -0
  21. data/lib/workhorse/poller.rb +289 -47
  22. data/lib/workhorse/pool.rb +12 -6
  23. data/lib/workhorse/schedule.rb +288 -0
  24. data/lib/workhorse/schedules.rb +197 -0
  25. data/lib/workhorse/worker.rb +77 -27
  26. data/lib/workhorse.rb +136 -0
  27. data/test/lib/db_schema.rb +21 -1
  28. data/test/lib/jobs.rb +29 -0
  29. data/test/lib/test_helper.rb +9 -14
  30. data/test/workhorse/daemon_test.rb +33 -0
  31. data/test/workhorse/db_job_test.rb +1 -1
  32. data/test/workhorse/notifier_test.rb +500 -0
  33. data/test/workhorse/poller_test.rb +8 -4
  34. data/test/workhorse/schedule_test.rb +967 -0
  35. data/test/workhorse/worker_test.rb +92 -0
  36. data/workhorse.gemspec +6 -5
  37. metadata +29 -3
data/lib/workhorse.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require 'active_record'
2
2
  require 'active_support/all'
3
3
  require 'concurrent'
4
+ require 'fugit'
4
5
  require 'socket'
5
6
  require 'uri'
6
7
 
@@ -84,6 +85,43 @@ module Workhorse
84
85
  mattr_accessor :silence_watcher
85
86
  self.silence_watcher = false
86
87
 
88
+ # Callback invoked when a job passed its `expires_at` before any worker got
89
+ # to it. The job is in state `expired` and will not be performed.
90
+ #
91
+ # Workhorse logs an expiry at `warn` regardless of this callback. Set the
92
+ # callback to report it wherever failures belong, e.g.
93
+ #
94
+ # ```ruby
95
+ # config.on_job_expired = proc do |db_job|
96
+ # ExceptionNotifier.notify_exception(
97
+ # StandardError.new("Job #{db_job.id} (#{db_job.description}) expired")
98
+ # )
99
+ # end
100
+ # ```
101
+ #
102
+ # Called outside the global lock, but on the poller thread: a slow callback
103
+ # delays this worker's next poll. Anything it raises is passed to
104
+ # {.on_exception} and does not affect the worker.
105
+ #
106
+ # @return [Proc] The expiry callback
107
+ mattr_accessor :on_job_expired
108
+ self.on_job_expired = proc do |db_job|
109
+ # Do something with this job, i.e. notify about it
110
+ end
111
+
112
+ # Callback invoked when a job started later than its `max_lateness` allows.
113
+ # In contrast to {.on_job_expired} the job does run; it was simply late.
114
+ #
115
+ # Called on the worker thread that performs the job, just before it starts,
116
+ # and receives the job and its lateness in seconds. Anything it raises is
117
+ # passed to {.on_exception}.
118
+ #
119
+ # @return [Proc] The lateness callback
120
+ mattr_accessor :on_job_late
121
+ self.on_job_late = proc do |db_job, lateness|
122
+ # Do something with this job, i.e. notify about it
123
+ end
124
+
87
125
  # Controls whether jobs are performed within database transactions.
88
126
  # Individual job classes can override this with skip_tx?.
89
127
  #
@@ -105,6 +143,55 @@ module Workhorse
105
143
  mattr_accessor :max_worker_memory_mb
106
144
  self.max_worker_memory_mb = 0
107
145
 
146
+ # Channel a {Workhorse::Notifiers::Redis} notifier publishes on. Defaults to
147
+ # {Workhorse::Notifiers::Redis::DEFAULT_CHANNEL} when nil.
148
+ #
149
+ # @return [String, nil] Channel name
150
+ mattr_accessor :notification_channel
151
+ self.notification_channel = nil
152
+
153
+ # Redis client used by a {Workhorse::Notifiers::Redis} notifier.
154
+ #
155
+ # @return [Object, nil] A Redis client
156
+ mattr_accessor :notification_redis
157
+ self.notification_redis = nil
158
+
159
+ # Path of the file a {Workhorse::Notifiers::FileSystem} notifier touches.
160
+ # Every process that enqueues jobs and every worker must agree on it.
161
+ #
162
+ # Defaults to `tmp/pids/workhorse.wake` below the Rails root, or below the
163
+ # working directory outside of Rails.
164
+ #
165
+ # @return [String] Path of the notification file
166
+ def self.notification_path
167
+ return @notification_path if @notification_path
168
+
169
+ root = defined?(Rails) ? Rails.root : Dir.pwd
170
+
171
+ return ::File.join(root.to_s, 'tmp', 'pids', 'workhorse.wake')
172
+ end
173
+
174
+ # Sets the path of the notification file, see {.notification_path}.
175
+ #
176
+ # @param value [String, nil] Path, or nil to restore the default
177
+ # @return [void]
178
+ def self.notification_path=(value)
179
+ @notification_path = value
180
+ end
181
+
182
+ # Seconds the daemon's `stop` waits for a worker to shut down gracefully
183
+ # before killing it.
184
+ #
185
+ # A worker normally goes away as soon as the job it is performing finishes,
186
+ # so this only takes effect for one that is wedged or performing something
187
+ # very long. Without a limit `stop` waits forever, and so does whatever is
188
+ # waiting on it. Set to nil to wait indefinitely, which was the behaviour
189
+ # before this setting existed.
190
+ #
191
+ # @return [Numeric, nil] Timeout in seconds, or nil for no limit
192
+ mattr_accessor :shutdown_timeout
193
+ self.shutdown_timeout = 300
194
+
108
195
  # Path to a debug log file for diagnosing log rotation and signal handling issues.
109
196
  # When set, Workhorse writes timestamped debug entries to this file at key points
110
197
  # (worker startup, HUP signal handling, restart-logging command flow).
@@ -130,6 +217,48 @@ module Workhorse
130
217
  rescue Exception # rubocop:disable Lint/SuppressedException
131
218
  end
132
219
 
220
+ # Notifier that lets a worker start an enqueued job without waiting for its
221
+ # next poll. Polling stays the floor, so a notification that is not
222
+ # delivered costs latency and nothing else.
223
+ #
224
+ # Set to `:none` (the default), `:file`, `:redis`, or an instance of a
225
+ # {Workhorse::Notifiers::Base} subclass.
226
+ #
227
+ # @return [Workhorse::Notifiers::Base] The configured notifier
228
+ def self.notifier
229
+ return @notifier ||= Workhorse::Notifiers::None.new
230
+ end
231
+
232
+ # Sets the notifier, see {.notifier}.
233
+ #
234
+ # @param value [Symbol, Workhorse::Notifiers::Base] The notifier to use
235
+ # @return [void]
236
+ def self.notifier=(value)
237
+ @notifier = case value
238
+ when :none, nil then Workhorse::Notifiers::None.new
239
+ when :file then Workhorse::Notifiers::FileSystem.new
240
+ when :redis then Workhorse::Notifiers::Redis.new
241
+ when Symbol then fail(ArgumentError, "Unknown notifier #{value.inspect}, use :none, :file or :redis.")
242
+ else value
243
+ end
244
+ end
245
+
246
+ # Registers scheduled jobs, see {Workhorse::Schedules::Dsl#schedule}.
247
+ #
248
+ # ```ruby
249
+ # Workhorse.schedules do
250
+ # schedule 'cleanup_jobs',
251
+ # job: 'Workhorse::Jobs::CleanupSucceededJobs',
252
+ # cron: '10 0 * * *'
253
+ # end
254
+ # ```
255
+ #
256
+ # @yield Block evaluated against {Workhorse::Schedules::Dsl}
257
+ # @return [void]
258
+ def self.schedules(&block)
259
+ Workhorse::Schedules.define(&block)
260
+ end
261
+
133
262
  # Configuration method for setting up Workhorse options.
134
263
  #
135
264
  # @yield [self] Configuration block
@@ -142,7 +271,13 @@ module Workhorse
142
271
  end
143
272
  end
144
273
 
274
+ require 'workhorse/notifiers/base'
275
+ require 'workhorse/notifiers/none'
276
+ require 'workhorse/notifiers/file_system'
277
+ require 'workhorse/notifiers/redis'
145
278
  require 'workhorse/db_job'
279
+ require 'workhorse/schedules'
280
+ require 'workhorse/schedule'
146
281
  require 'workhorse/performer'
147
282
  require 'workhorse/poller'
148
283
  require 'workhorse/pool'
@@ -151,6 +286,7 @@ require 'workhorse/jobs/run_rails_op'
151
286
  require 'workhorse/jobs/run_active_job'
152
287
  require 'workhorse/jobs/cleanup_succeeded_jobs'
153
288
  require 'workhorse/jobs/detect_stale_jobs_job'
289
+ require 'workhorse/jobs/detect_late_schedules_job'
154
290
 
155
291
  # Daemon functionality is not available on java platforms
156
292
  if RUBY_PLATFORM != 'java'
@@ -17,6 +17,8 @@ ActiveRecord::Schema.define do
17
17
 
18
18
  t.integer :priority, null: false
19
19
  t.datetime :perform_at, null: true
20
+ t.datetime :expires_at, null: true
21
+ t.integer :max_lateness, null: true
20
22
 
21
23
  t.string :description, null: true
22
24
 
@@ -24,6 +26,24 @@ ActiveRecord::Schema.define do
24
26
  end
25
27
 
26
28
  add_index :jobs, :queue, length: 191
27
- add_index :jobs, :state, length: 191
29
+ add_index :jobs, %i[state perform_at], length: { state: 191 }, name: 'idx_jobs_state_perform_at'
30
+ add_index :jobs, %i[state priority created_at], length: { state: 191 }, name: 'idx_jobs_state_prio_created'
31
+ add_index :jobs, %i[state expires_at], length: { state: 191 }, name: 'idx_jobs_state_expires_at'
28
32
  add_index :jobs, :perform_at
33
+
34
+ create_table :workhorse_schedules, force: true do |t|
35
+ t.string :key, null: false
36
+ t.string :cron, null: false
37
+ t.string :timezone, null: true
38
+ t.boolean :enabled, null: false, default: true
39
+ t.datetime :next_at, null: false
40
+ t.datetime :last_enqueued_at, null: true
41
+ t.datetime :last_occurrence, null: true
42
+ t.integer :last_job_id, null: true
43
+
44
+ t.timestamps null: false
45
+ end
46
+
47
+ add_index :workhorse_schedules, :key, unique: true, length: 191, name: 'idx_wh_schedules_key'
48
+ add_index :workhorse_schedules, %i[enabled next_at], name: 'idx_wh_schedules_due'
29
49
  end
data/test/lib/jobs.rb CHANGED
@@ -67,3 +67,32 @@ class DummyRailsOpsOp
67
67
  results << @params
68
68
  end
69
69
  end
70
+
71
+ class ScheduledActiveJob < ActiveJob::Base
72
+ def perform(*); end
73
+ end
74
+
75
+ # Minimal stand-in for RailsOps, which workhorse only soft-depends on. Its
76
+ # presence is what selects the operation branch of Workhorse::Enqueuer.
77
+ module RailsOps
78
+ class Operation
79
+ def self.results
80
+ return @results ||= Concurrent::Array.new
81
+ end
82
+
83
+ # Workhorse::Jobs::RunRailsOp calls the class method, as RailsOps does.
84
+ def self.run!(params = {})
85
+ return new(params).run!
86
+ end
87
+
88
+ def initialize(params = {})
89
+ @params = params
90
+ end
91
+
92
+ def run!
93
+ self.class.results << @params
94
+ end
95
+ end
96
+ end
97
+
98
+ class DummyScheduledOp < RailsOps::Operation; end
@@ -69,20 +69,15 @@ class WorkhorseTest < ActiveSupport::TestCase
69
69
  end
70
70
 
71
71
  def clear_locks_and_db_threads!
72
- Workhorse::DbJob.connection.execute('SELECT RELEASE_ALL_LOCKS()')
73
-
74
- # Use `select_values` rather than `execute`, as the latter does not return a
75
- # result set on every adapter.
76
- pids = Workhorse::DbJob.connection.select_values(<<~SQL.squish)
77
- SELECT ID FROM INFORMATION_SCHEMA.PROCESSLIST WHERE ID != CONNECTION_ID()
78
- SQL
79
-
80
- begin
81
- pids.each { |pid| Workhorse::DbJob.connection.execute("KILL QUERY #{pid}") }
82
- rescue ActiveRecord::StatementInvalid
83
- # Ignore
84
- end
85
-
72
+ # Releases the locks held by *this* connection, which is all a fresh run
73
+ # needs. It used to kill the queries of every other connection as well, to
74
+ # clear one left behind by a crashed run - but KILL QUERY only aborts a
75
+ # query and does not release a named lock, which is held by the connection
76
+ # rather than the statement, so it never achieved that. What it did
77
+ # achieve was killing queries belonging to the current run, surfacing as
78
+ # spurious "Lost connection to server during query" failures. Should a
79
+ # crashed run ever leave a lock behind, kill its *connection*, which does
80
+ # release it.
86
81
  Workhorse::DbJob.connection.execute('SELECT RELEASE_ALL_LOCKS()')
87
82
  end
88
83
 
@@ -1,6 +1,39 @@
1
1
  require 'test_helper'
2
2
 
3
3
  class Workhorse::DaemonTest < WorkhorseTest
4
+ # A worker that ignores TERM used to leave `stop` - and whatever is waiting
5
+ # on it, a deployment usually - looping forever.
6
+ def test_stop_kills_a_worker_that_ignores_term
7
+ previous = Workhorse.shutdown_timeout
8
+ Workhorse.shutdown_timeout = 2
9
+
10
+ daemon = Workhorse::Daemon.new(pidfile: 'tmp/pids/stubborn%s.pid') do |d|
11
+ d.worker 'Stubborn' do
12
+ Signal.trap('TERM') { nil }
13
+ Signal.trap('INT') { nil }
14
+ # A bare sleep returns as soon as a handler runs, so it has to be
15
+ # re-entered to actually ignore the signal.
16
+ loop { sleep 1 }
17
+ end
18
+ end
19
+
20
+ daemon.start(quiet: true)
21
+ pid = daemon.workers.first.pid
22
+
23
+ with_retries(50, interval: 0.1) { assert process?(pid) }
24
+
25
+ capture_stderr do
26
+ Timeout.timeout(30) { daemon.stop(quiet: true) }
27
+ end
28
+
29
+ wait_for_process_exit(pid)
30
+
31
+ refute process?(pid)
32
+ ensure
33
+ Workhorse.shutdown_timeout = previous
34
+ FileUtils.rm_f Dir['tmp/pids/stubborn*.pid']
35
+ end
36
+
4
37
  def setup
5
38
  remove_pids!
6
39
  end
@@ -26,7 +26,7 @@ class Workhorse::DbJobTest < WorkhorseTest
26
26
  err = assert_raises do
27
27
  job.reset!
28
28
  end
29
- assert_equal %(Job #{job.id} is not in state [:succeeded, :failed] but in state "locked".), err.message
29
+ assert_equal %(Job #{job.id} is not in state [:succeeded, :failed, :expired] but in state "locked".), err.message
30
30
  end
31
31
 
32
32
  def test_forced_reset