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
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
module Workhorse
|
|
2
|
+
module Notifiers
|
|
3
|
+
# Notifier that announces an enqueued job over a Redis pub/sub channel,
|
|
4
|
+
# for deployments whose workers do not share a filesystem with the
|
|
5
|
+
# application.
|
|
6
|
+
#
|
|
7
|
+
# Redis is a soft dependency: it is not declared in the gemspec and is only
|
|
8
|
+
# required when this notifier is selected. Configure the client and,
|
|
9
|
+
# optionally, the channel:
|
|
10
|
+
#
|
|
11
|
+
# ```ruby
|
|
12
|
+
# Workhorse.setup do |config|
|
|
13
|
+
# config.notifier = :redis
|
|
14
|
+
# config.notification_redis = Redis.new(url: ENV['REDIS_URL'])
|
|
15
|
+
# end
|
|
16
|
+
# ```
|
|
17
|
+
#
|
|
18
|
+
# A subscriber thread per worker process keeps a counter, which pollers
|
|
19
|
+
# read through {#token}. Delivery is best-effort in both directions: a
|
|
20
|
+
# publish that fails and a subscription that drops both cost latency only,
|
|
21
|
+
# as polling still picks the job up.
|
|
22
|
+
class Redis < Base
|
|
23
|
+
# Default channel jobs are announced on.
|
|
24
|
+
DEFAULT_CHANNEL = 'workhorse:jobs'.freeze
|
|
25
|
+
|
|
26
|
+
# Seconds to wait before subscribing again after a failure.
|
|
27
|
+
RECONNECT_DELAY = 1
|
|
28
|
+
|
|
29
|
+
# @param client [Object, nil] Redis client. Defaults to
|
|
30
|
+
# {Workhorse.notification_redis}.
|
|
31
|
+
# @param channel [String, nil] Channel to use. Defaults to
|
|
32
|
+
# {Workhorse.notification_channel} or {DEFAULT_CHANNEL}.
|
|
33
|
+
def initialize(client: nil, channel: nil)
|
|
34
|
+
super()
|
|
35
|
+
@configured_client = client
|
|
36
|
+
@channel = channel
|
|
37
|
+
@counter = Concurrent::AtomicFixnum.new(0)
|
|
38
|
+
@subscribers = 0
|
|
39
|
+
@mutex = Mutex.new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Resolved when used rather than when constructed, so that
|
|
43
|
+
# {Workhorse.notification_channel} can be set in any order relative to
|
|
44
|
+
# {Workhorse.notifier=} - up to the point a worker starts, as the
|
|
45
|
+
# subscriber thread captures the channel it was started with.
|
|
46
|
+
#
|
|
47
|
+
# @return [String] Channel that is published to and subscribed on
|
|
48
|
+
def channel
|
|
49
|
+
return @channel || Workhorse.notification_channel || DEFAULT_CHANNEL
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Object] The configured Redis client
|
|
53
|
+
# @raise [RuntimeError] If no client has been configured
|
|
54
|
+
def client
|
|
55
|
+
return @client if @client
|
|
56
|
+
|
|
57
|
+
# Built under the mutex: #notify runs on every enqueueing thread, and
|
|
58
|
+
# two of them racing here would each build one and orphan the loser.
|
|
59
|
+
@mutex.synchronize { @client ||= build_client }
|
|
60
|
+
|
|
61
|
+
return @client
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns the configured source of clients: either a client, or a
|
|
65
|
+
# callable returning one.
|
|
66
|
+
#
|
|
67
|
+
# @return [Object]
|
|
68
|
+
# @raise [RuntimeError] If nothing has been configured
|
|
69
|
+
# @private
|
|
70
|
+
def client_source
|
|
71
|
+
return @configured_client || Workhorse.notification_redis \
|
|
72
|
+
|| fail('Workhorse.notification_redis must be set to a Redis client, or to something ' \
|
|
73
|
+
'callable returning one, to use the :redis notifier.')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Publishes the queue name on the configured channel.
|
|
77
|
+
#
|
|
78
|
+
# @param queue [String, Symbol, nil] Queue the job was enqueued into
|
|
79
|
+
# @return [void]
|
|
80
|
+
def notify(queue: nil)
|
|
81
|
+
client.publish(channel, queue.to_s)
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
# Best-effort by contract, see Workhorse::Notifiers::Base#notify.
|
|
84
|
+
Workhorse.debug_log("Notification failed: #{e.class}: #{e.message}")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Starts the subscriber thread, unless one is already running for this
|
|
88
|
+
# process.
|
|
89
|
+
#
|
|
90
|
+
# @return [void]
|
|
91
|
+
def start
|
|
92
|
+
@mutex.synchronize do
|
|
93
|
+
@subscribers += 1
|
|
94
|
+
@thread = start_subscriber unless @thread&.alive?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Stops the subscriber thread once the last worker in this process has
|
|
101
|
+
# stopped.
|
|
102
|
+
#
|
|
103
|
+
# @return [void]
|
|
104
|
+
def stop
|
|
105
|
+
@mutex.synchronize do
|
|
106
|
+
@subscribers -= 1 if @subscribers > 0
|
|
107
|
+
next unless @subscribers.zero?
|
|
108
|
+
|
|
109
|
+
@thread&.kill
|
|
110
|
+
@thread = nil
|
|
111
|
+
# The thread held the only reference to it, so nothing else would
|
|
112
|
+
# ever close it.
|
|
113
|
+
@subscriber_client = close(@subscriber_client)
|
|
114
|
+
@subscriber_failed = false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [Integer] Number of notifications received in this process
|
|
121
|
+
def token
|
|
122
|
+
return @counter.value
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
# Subscribes on a thread of its own, reconnecting after a dropped
|
|
128
|
+
# connection.
|
|
129
|
+
#
|
|
130
|
+
# @return [Thread]
|
|
131
|
+
def start_subscriber
|
|
132
|
+
counter = @counter
|
|
133
|
+
chan = channel
|
|
134
|
+
|
|
135
|
+
return Thread.new do
|
|
136
|
+
loop do
|
|
137
|
+
# Kept across iterations and replaced only once the connection it
|
|
138
|
+
# holds has failed, so an outage does not build one client per
|
|
139
|
+
# second and drop each unclosed.
|
|
140
|
+
client = (@subscriber_client ||= subscriber_client)
|
|
141
|
+
|
|
142
|
+
client.subscribe(chan) do |on|
|
|
143
|
+
# Re-armed when the subscription is established, not after
|
|
144
|
+
# #subscribe returns: it blocks for the subscription's whole
|
|
145
|
+
# life, so a later outage would otherwise never be reported.
|
|
146
|
+
on.subscribe { @subscriber_failed = false }
|
|
147
|
+
on.message { |_channel, _message| counter.increment }
|
|
148
|
+
end
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
report_subscriber_failure(e)
|
|
151
|
+
@subscriber_client = close(client)
|
|
152
|
+
Kernel.sleep RECONNECT_DELAY
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Reports a failed subscription. Retrying is silent after the first
|
|
158
|
+
# report: a broker that is down produces one of these per
|
|
159
|
+
# {RECONNECT_DELAY}, and a misconfigured notifier would otherwise retry
|
|
160
|
+
# forever without anyone hearing about it.
|
|
161
|
+
#
|
|
162
|
+
# @param exception [Exception]
|
|
163
|
+
# @return [void]
|
|
164
|
+
def report_subscriber_failure(exception)
|
|
165
|
+
message = "Notification subscriber failed: #{exception.class}: #{exception.message}"
|
|
166
|
+
Workhorse.debug_log(message)
|
|
167
|
+
|
|
168
|
+
return if @subscriber_failed
|
|
169
|
+
|
|
170
|
+
@subscriber_failed = true
|
|
171
|
+
|
|
172
|
+
begin
|
|
173
|
+
Workhorse.on_exception.call(exception)
|
|
174
|
+
rescue Exception => e
|
|
175
|
+
# A reporter that is itself unreachable must not unwind the retry
|
|
176
|
+
# loop and leave the process without a subscriber for good.
|
|
177
|
+
Workhorse.debug_log("on_exception failed: #{e.class}: #{e.message}")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
return
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Closes a client this notifier built, tolerating one that cannot be
|
|
184
|
+
# closed. A client supplied by the application is left alone: it may be
|
|
185
|
+
# a wrapper sharing its connection with the one {#notify} publishes on,
|
|
186
|
+
# which closing would take down.
|
|
187
|
+
#
|
|
188
|
+
# @param client [Object, nil]
|
|
189
|
+
# @return [nil]
|
|
190
|
+
def close(client)
|
|
191
|
+
client.close if @owns_subscriber_client && client.respond_to?(:close)
|
|
192
|
+
|
|
193
|
+
return nil
|
|
194
|
+
rescue StandardError => e
|
|
195
|
+
Workhorse.debug_log("Closing the subscriber client failed: #{e.class}: #{e.message}")
|
|
196
|
+
|
|
197
|
+
return nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# @return [Object] A newly built client
|
|
201
|
+
def build_client
|
|
202
|
+
source = client_source
|
|
203
|
+
|
|
204
|
+
return source.respond_to?(:call) ? source.call : source
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Returns the client to subscribe with. A subscribed connection cannot
|
|
208
|
+
# be used for anything else, so this must not be the client that
|
|
209
|
+
# {#notify} publishes on.
|
|
210
|
+
#
|
|
211
|
+
# Configure {Workhorse.notification_redis} with something callable to
|
|
212
|
+
# get a connection of its own here. Given a client instead, this falls
|
|
213
|
+
# back to `dup`. redis-rb's own `dup` builds a fresh client, but a
|
|
214
|
+
# wrapper that does not define one - a namespacing or instrumenting
|
|
215
|
+
# delegator - yields a shallow copy sharing the connection, which is
|
|
216
|
+
# why such a client is never closed.
|
|
217
|
+
#
|
|
218
|
+
# @return [Object]
|
|
219
|
+
def subscriber_client
|
|
220
|
+
source = client_source
|
|
221
|
+
@owns_subscriber_client = source.respond_to?(:call)
|
|
222
|
+
|
|
223
|
+
return @owns_subscriber_client ? source.call : source.dup
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
data/lib/workhorse/performer.rb
CHANGED
|
@@ -73,6 +73,32 @@ module Workhorse
|
|
|
73
73
|
end
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
# Calls {Workhorse.on_job_late} if the job started later than its
|
|
77
|
+
# `max_lateness` allows.
|
|
78
|
+
#
|
|
79
|
+
# Unlike an expiry, this is not a reason to skip the job: it ran, just
|
|
80
|
+
# not on time. A failing callback must not fail the job either, so it is
|
|
81
|
+
# reported through {Workhorse.on_exception} instead.
|
|
82
|
+
#
|
|
83
|
+
# @return [void]
|
|
84
|
+
# @private
|
|
85
|
+
def report_lateness
|
|
86
|
+
return unless @db_job.has_attribute?(:max_lateness)
|
|
87
|
+
return unless @db_job.max_lateness
|
|
88
|
+
|
|
89
|
+
lateness = @db_job.lateness
|
|
90
|
+
|
|
91
|
+
return if lateness.nil? || lateness <= @db_job.max_lateness
|
|
92
|
+
|
|
93
|
+
log "Started #{lateness.round(1)}s after the intended #{@db_job.perform_at}, " \
|
|
94
|
+
"which exceeds the configured maximum of #{@db_job.max_lateness}s", :warn
|
|
95
|
+
|
|
96
|
+
Workhorse.on_job_late.call(@db_job, lateness)
|
|
97
|
+
rescue Exception => e
|
|
98
|
+
log %(on_job_late failed: #{e.message}), :error
|
|
99
|
+
Workhorse.on_exception.call(e)
|
|
100
|
+
end
|
|
101
|
+
|
|
76
102
|
# Core job execution logic with state transitions.
|
|
77
103
|
# Handles marking job as started, deserializing and executing the job,
|
|
78
104
|
# and marking as succeeded.
|
|
@@ -89,6 +115,8 @@ module Workhorse
|
|
|
89
115
|
@db_job.mark_started!
|
|
90
116
|
end
|
|
91
117
|
|
|
118
|
+
report_lateness
|
|
119
|
+
|
|
92
120
|
# ---------------------------------------------------------------
|
|
93
121
|
# Deserialize and perform job
|
|
94
122
|
# ---------------------------------------------------------------
|