workhorse 1.5.2 → 2.0.0.rc1

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +137 -1
  3. data/CHANGELOG.md +150 -0
  4. data/Gemfile +16 -1
  5. data/README.md +316 -72
  6. data/Rakefile +1 -0
  7. data/VERSION +1 -1
  8. data/bin/rubocop +5 -1
  9. data/lib/generators/workhorse/install_generator.rb +10 -1
  10. data/lib/generators/workhorse/templates/config/initializers/workhorse.rb +55 -0
  11. data/lib/generators/workhorse/templates/create_table_jobs.rb +15 -2
  12. data/lib/generators/workhorse/templates/create_table_workhorse_schedules.rb +42 -0
  13. data/lib/workhorse/daemon/shell_handler.rb +4 -1
  14. data/lib/workhorse/daemon.rb +57 -7
  15. data/lib/workhorse/db_job.rb +98 -8
  16. data/lib/workhorse/enqueuer.rb +51 -8
  17. data/lib/workhorse/jobs/cleanup_succeeded_jobs.rb +26 -8
  18. data/lib/workhorse/jobs/detect_late_schedules_job.rb +59 -0
  19. data/lib/workhorse/notifiers/base.rb +55 -0
  20. data/lib/workhorse/notifiers/file_system.rb +66 -0
  21. data/lib/workhorse/notifiers/none.rb +8 -0
  22. data/lib/workhorse/notifiers/redis.rb +227 -0
  23. data/lib/workhorse/performer.rb +29 -2
  24. data/lib/workhorse/poller.rb +303 -21
  25. data/lib/workhorse/pool.rb +12 -6
  26. data/lib/workhorse/schedule.rb +288 -0
  27. data/lib/workhorse/schedules.rb +197 -0
  28. data/lib/workhorse/worker.rb +102 -31
  29. data/lib/workhorse.rb +136 -0
  30. data/test/lib/db_schema.rb +36 -3
  31. data/test/lib/jobs.rb +29 -0
  32. data/test/lib/test_helper.rb +113 -20
  33. data/test/workhorse/daemon_test.rb +33 -0
  34. data/test/workhorse/db_job_test.rb +2 -4
  35. data/test/workhorse/notifier_test.rb +487 -0
  36. data/test/workhorse/performer_test.rb +7 -9
  37. data/test/workhorse/poller_test.rb +97 -23
  38. data/test/workhorse/schedule_test.rb +967 -0
  39. data/test/workhorse/worker_test.rb +201 -76
  40. data/workhorse.gemspec +6 -5
  41. 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
@@ -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
  # ---------------------------------------------------------------
@@ -137,9 +165,8 @@ module Workhorse
137
165
  def deserialized_job
138
166
  # The source is safe as long as jobs are always enqueued using
139
167
  # Workhorse::Enqueuer so it is ok to use Marshal.load.
140
- # rubocop: disable Security/MarshalLoad
168
+ # rubocop: disable-next Security/MarshalLoad
141
169
  Marshal.load(@db_job.handler)
142
- # rubocop: enable Security/MarshalLoad
143
170
  end
144
171
  end
145
172
  end