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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +102 -0
- data/README.md +303 -74
- data/Rakefile +1 -0
- data/VERSION +1 -1
- data/bin/rubocop +5 -1
- data/lib/generators/workhorse/install_generator.rb +10 -1
- data/lib/generators/workhorse/templates/config/initializers/workhorse.rb +55 -0
- data/lib/generators/workhorse/templates/create_table_jobs.rb +11 -13
- data/lib/generators/workhorse/templates/create_table_workhorse_schedules.rb +29 -0
- data/lib/workhorse/daemon.rb +52 -6
- data/lib/workhorse/db_job.rb +58 -7
- data/lib/workhorse/enqueuer.rb +51 -8
- data/lib/workhorse/jobs/cleanup_succeeded_jobs.rb +26 -8
- data/lib/workhorse/jobs/detect_late_schedules_job.rb +59 -0
- data/lib/workhorse/notifiers/base.rb +55 -0
- data/lib/workhorse/notifiers/file_system.rb +66 -0
- data/lib/workhorse/notifiers/none.rb +8 -0
- data/lib/workhorse/notifiers/redis.rb +227 -0
- data/lib/workhorse/performer.rb +28 -0
- data/lib/workhorse/poller.rb +289 -47
- data/lib/workhorse/pool.rb +12 -6
- data/lib/workhorse/schedule.rb +288 -0
- data/lib/workhorse/schedules.rb +197 -0
- data/lib/workhorse/worker.rb +77 -27
- data/lib/workhorse.rb +136 -0
- data/test/lib/db_schema.rb +21 -1
- data/test/lib/jobs.rb +29 -0
- data/test/lib/test_helper.rb +9 -14
- data/test/workhorse/daemon_test.rb +33 -0
- data/test/workhorse/db_job_test.rb +1 -1
- data/test/workhorse/notifier_test.rb +500 -0
- data/test/workhorse/poller_test.rb +8 -4
- data/test/workhorse/schedule_test.rb +967 -0
- data/test/workhorse/worker_test.rb +92 -0
- data/workhorse.gemspec +6 -5
- 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'
|
data/test/lib/db_schema.rb
CHANGED
|
@@ -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,
|
|
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
|
data/test/lib/test_helper.rb
CHANGED
|
@@ -69,20 +69,15 @@ class WorkhorseTest < ActiveSupport::TestCase
|
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
def clear_locks_and_db_threads!
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|