exekutor 0.1.0
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 +7 -0
- checksums.yaml.gz.sig +3 -0
- data/LICENSE.txt +21 -0
- data/exe/exekutor +7 -0
- data/lib/active_job/queue_adapters/exekutor_adapter.rb +14 -0
- data/lib/exekutor/asynchronous.rb +188 -0
- data/lib/exekutor/cleanup.rb +56 -0
- data/lib/exekutor/configuration.rb +373 -0
- data/lib/exekutor/hook.rb +172 -0
- data/lib/exekutor/info/worker.rb +20 -0
- data/lib/exekutor/internal/base_record.rb +11 -0
- data/lib/exekutor/internal/callbacks.rb +138 -0
- data/lib/exekutor/internal/cli/app.rb +173 -0
- data/lib/exekutor/internal/cli/application_loader.rb +36 -0
- data/lib/exekutor/internal/cli/cleanup.rb +96 -0
- data/lib/exekutor/internal/cli/daemon.rb +108 -0
- data/lib/exekutor/internal/cli/default_option_value.rb +29 -0
- data/lib/exekutor/internal/cli/info.rb +126 -0
- data/lib/exekutor/internal/cli/manager.rb +260 -0
- data/lib/exekutor/internal/configuration_builder.rb +113 -0
- data/lib/exekutor/internal/database_connection.rb +21 -0
- data/lib/exekutor/internal/executable.rb +75 -0
- data/lib/exekutor/internal/executor.rb +242 -0
- data/lib/exekutor/internal/hooks.rb +87 -0
- data/lib/exekutor/internal/listener.rb +176 -0
- data/lib/exekutor/internal/logger.rb +74 -0
- data/lib/exekutor/internal/provider.rb +308 -0
- data/lib/exekutor/internal/reserver.rb +95 -0
- data/lib/exekutor/internal/status_server.rb +132 -0
- data/lib/exekutor/job.rb +31 -0
- data/lib/exekutor/job_error.rb +11 -0
- data/lib/exekutor/job_options.rb +95 -0
- data/lib/exekutor/plugins/appsignal.rb +46 -0
- data/lib/exekutor/plugins.rb +13 -0
- data/lib/exekutor/queue.rb +141 -0
- data/lib/exekutor/version.rb +6 -0
- data/lib/exekutor/worker.rb +219 -0
- data/lib/exekutor.rb +49 -0
- data/lib/generators/exekutor/configuration_generator.rb +18 -0
- data/lib/generators/exekutor/install_generator.rb +43 -0
- data/lib/generators/exekutor/templates/install/functions/job_notifier.sql +7 -0
- data/lib/generators/exekutor/templates/install/functions/requeue_orphaned_jobs.sql +7 -0
- data/lib/generators/exekutor/templates/install/initializers/exekutor.rb.erb +14 -0
- data/lib/generators/exekutor/templates/install/migrations/create_exekutor_schema.rb.erb +83 -0
- data/lib/generators/exekutor/templates/install/triggers/notify_workers.sql +6 -0
- data/lib/generators/exekutor/templates/install/triggers/requeue_orphaned_jobs.sql +5 -0
- data.tar.gz.sig +0 -0
- metadata +403 -0
- metadata.gz.sig +0 -0
@@ -0,0 +1,308 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "executable"
|
4
|
+
require_relative "callbacks"
|
5
|
+
|
6
|
+
module Exekutor
|
7
|
+
# @private
|
8
|
+
module Internal
|
9
|
+
# Reserves jobs and provides them to an executor
|
10
|
+
class Provider
|
11
|
+
include Executable, Callbacks, Logger
|
12
|
+
|
13
|
+
define_callbacks :on_queue_empty, freeze: true
|
14
|
+
|
15
|
+
# Represents an unknown value
|
16
|
+
UNKNOWN = Object.new.freeze
|
17
|
+
private_constant "UNKNOWN"
|
18
|
+
|
19
|
+
MAX_WAIT_TIMEOUT = 300
|
20
|
+
private_constant "MAX_WAIT_TIMEOUT"
|
21
|
+
|
22
|
+
# Creates a new provider
|
23
|
+
# @param reserver [Reserver] the job reserver
|
24
|
+
# @param executor [Executor] the job executor
|
25
|
+
# @param pool [ThreadPoolExecutor] the thread pool to use
|
26
|
+
# @param polling_interval [Integer] the polling interval
|
27
|
+
# @param interval_jitter [Float] the polling interval jitter
|
28
|
+
def initialize(reserver:, executor:, pool:, polling_interval: 60,
|
29
|
+
interval_jitter: polling_interval.to_i > 1 ? polling_interval * 0.1 : 0)
|
30
|
+
super()
|
31
|
+
@reserver = reserver
|
32
|
+
@executor = executor
|
33
|
+
@pool = pool
|
34
|
+
|
35
|
+
@polling_interval = polling_interval.freeze
|
36
|
+
@interval_jitter = interval_jitter.to_f.freeze
|
37
|
+
|
38
|
+
@event = Concurrent::Event.new
|
39
|
+
@thread_running = Concurrent::AtomicBoolean.new false
|
40
|
+
|
41
|
+
@next_job_scheduled_at = Concurrent::AtomicReference.new UNKNOWN
|
42
|
+
@next_poll_at = Concurrent::AtomicReference.new nil
|
43
|
+
end
|
44
|
+
|
45
|
+
# Starts the provider.
|
46
|
+
def start
|
47
|
+
return false unless compare_and_set_state :pending, :started
|
48
|
+
|
49
|
+
# Always poll at startup to fill up threads, use small jitter so workers started at the same time dont hit
|
50
|
+
# the db at the same time
|
51
|
+
@next_poll_at.set (1 + 2 * Kernel.rand).second.from_now
|
52
|
+
start_thread
|
53
|
+
true
|
54
|
+
end
|
55
|
+
|
56
|
+
# Stops the provider
|
57
|
+
def stop
|
58
|
+
set_state :stopped
|
59
|
+
@event.set
|
60
|
+
end
|
61
|
+
|
62
|
+
# Makes the provider poll for jobs
|
63
|
+
def poll
|
64
|
+
raise Exekutor::Error, "Provider is not running" unless running?
|
65
|
+
|
66
|
+
@next_poll_at.set Time.now
|
67
|
+
@event.set
|
68
|
+
end
|
69
|
+
|
70
|
+
# Updates the timestamp for when the next job is scheduled. Gets the earliest scheduled_at from the DB if no
|
71
|
+
# argument is given. Updates the timestamp for the earliest job is a timestamp is given and that timestamp is
|
72
|
+
# before the known timestamp. Does nothing if a timestamp is given and the earliest job timestamp is not known.
|
73
|
+
# @param scheduled_at [Time,Numeric] the time a job is scheduled at
|
74
|
+
# @return [Time] the timestamp for the next job, or +nil+ if the timestamp is unknown or no jobs are pending
|
75
|
+
def update_earliest_scheduled_at(scheduled_at = UNKNOWN)
|
76
|
+
overwrite_unknown = false
|
77
|
+
case scheduled_at
|
78
|
+
when UNKNOWN
|
79
|
+
# If we fetch the value from the DB, we can safely overwrite the UNKNOWN value
|
80
|
+
overwrite_unknown = true
|
81
|
+
scheduled_at = @reserver.earliest_scheduled_at
|
82
|
+
when Numeric
|
83
|
+
scheduled_at = Time.at(scheduled_at)
|
84
|
+
when Time
|
85
|
+
# All good
|
86
|
+
else
|
87
|
+
raise ArgumentError, "scheduled_at must be a Time or Numeric"
|
88
|
+
end
|
89
|
+
|
90
|
+
updated = false
|
91
|
+
scheduled_at = @next_job_scheduled_at.update do |current|
|
92
|
+
if current == UNKNOWN
|
93
|
+
if overwrite_unknown || scheduled_at <= Time.now
|
94
|
+
updated = true
|
95
|
+
scheduled_at
|
96
|
+
else
|
97
|
+
current
|
98
|
+
end
|
99
|
+
elsif current.nil? || scheduled_at.nil? || current > scheduled_at
|
100
|
+
updated = true
|
101
|
+
scheduled_at
|
102
|
+
else
|
103
|
+
current
|
104
|
+
end
|
105
|
+
end
|
106
|
+
if scheduled_at == UNKNOWN
|
107
|
+
nil
|
108
|
+
else
|
109
|
+
@event.set if updated && scheduled_at.present?
|
110
|
+
scheduled_at
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
private
|
115
|
+
|
116
|
+
# Starts the provision thread
|
117
|
+
def start_thread
|
118
|
+
@pool.post(&method(:run)) if running?
|
119
|
+
end
|
120
|
+
|
121
|
+
# Does the provisioning of jobs to the executor. Blocks until the provider is stopped.
|
122
|
+
def run
|
123
|
+
return unless running? && @thread_running.make_true
|
124
|
+
|
125
|
+
DatabaseConnection.ensure_active!
|
126
|
+
perform_pending_job_updates
|
127
|
+
restart_abandoned_jobs
|
128
|
+
catch(:shutdown) do
|
129
|
+
while running?
|
130
|
+
wait_for_event
|
131
|
+
next unless reserve_jobs_now?
|
132
|
+
|
133
|
+
reserve_and_execute_jobs
|
134
|
+
consecutive_errors.value = 0
|
135
|
+
end
|
136
|
+
end
|
137
|
+
rescue StandardError => err
|
138
|
+
Exekutor.on_fatal_error err, "[Provider] Runtime error!"
|
139
|
+
consecutive_errors.increment
|
140
|
+
if running?
|
141
|
+
delay = restart_delay
|
142
|
+
logger.info "Restarting in %0.1f seconds…" % [delay]
|
143
|
+
Concurrent::ScheduledTask.execute(delay, executor: @pool, &method(:run))
|
144
|
+
end
|
145
|
+
ensure
|
146
|
+
BaseRecord.connection_pool.release_connection
|
147
|
+
@thread_running.make_false
|
148
|
+
end
|
149
|
+
|
150
|
+
# Waits for any event to happen. An event could be:
|
151
|
+
# - The listener was notified of a new job;
|
152
|
+
# - The next job is scheduled for the current time;
|
153
|
+
# - The polling interval;
|
154
|
+
# - A call to {#poll}
|
155
|
+
def wait_for_event
|
156
|
+
timeout = wait_timeout
|
157
|
+
return unless timeout.positive?
|
158
|
+
|
159
|
+
@event.wait timeout
|
160
|
+
rescue StandardError => err
|
161
|
+
Exekutor.on_fatal_error err, "[Provider] An error occurred while waiting"
|
162
|
+
sleep 0.1 if running?
|
163
|
+
ensure
|
164
|
+
throw :shutdown unless running?
|
165
|
+
@event.reset
|
166
|
+
end
|
167
|
+
|
168
|
+
# Reserves jobs and posts them to the executor
|
169
|
+
def reserve_and_execute_jobs
|
170
|
+
available_workers = @executor.available_threads
|
171
|
+
return unless available_workers.positive?
|
172
|
+
|
173
|
+
jobs = @reserver.reserve available_workers
|
174
|
+
unless jobs.nil?
|
175
|
+
begin
|
176
|
+
logger.debug "Reserved #{jobs.size} job(s)"
|
177
|
+
jobs.each(&@executor.method(:post))
|
178
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
179
|
+
# Try to release all jobs before re-raising
|
180
|
+
begin
|
181
|
+
Exekutor::Job.where(id: jobs.collect { |job| job[:id] }, status: "e")
|
182
|
+
.update_all(status: "p", worker_id: nil)
|
183
|
+
rescue # rubocop:disable Lint/RescueStandardError
|
184
|
+
# ignored
|
185
|
+
end
|
186
|
+
raise
|
187
|
+
end
|
188
|
+
end
|
189
|
+
if jobs.nil? || jobs.size.to_i < available_workers
|
190
|
+
# If we ran out of work, update the earliest scheduled at
|
191
|
+
update_earliest_scheduled_at
|
192
|
+
|
193
|
+
run_callbacks :on, :queue_empty if jobs.nil?
|
194
|
+
|
195
|
+
elsif @next_job_scheduled_at.get == UNKNOWN
|
196
|
+
# If the next job timestamp is still unknown, set it to now to indicate there's still work to do
|
197
|
+
@next_job_scheduled_at.set Time.now
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
def perform_pending_job_updates
|
202
|
+
updates = @executor.pending_job_updates
|
203
|
+
while (id, attrs = updates.shift).present?
|
204
|
+
begin
|
205
|
+
if attrs == :destroy
|
206
|
+
Exekutor::Job.destroy(id)
|
207
|
+
else
|
208
|
+
Exekutor::Job.where(id: id).update_all(attrs)
|
209
|
+
end
|
210
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
|
211
|
+
unless Exekutor::Job.connection.active?
|
212
|
+
# Connection lost again, requeue update and avoid trying further updates
|
213
|
+
updates[id] ||= attrs
|
214
|
+
return
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
# Restarts all jobs that have the 'executing' status but are no longer running. Releases the jobs if the
|
221
|
+
# execution thread pool is full.
|
222
|
+
def restart_abandoned_jobs
|
223
|
+
jobs = @reserver.get_abandoned_jobs(@executor.active_job_ids)
|
224
|
+
return if jobs&.size.to_i.zero?
|
225
|
+
|
226
|
+
logger.info "Restarting #{jobs.size} abandoned job#{"s" if jobs.size > 1}"
|
227
|
+
jobs.each(&@executor.method(:post))
|
228
|
+
end
|
229
|
+
|
230
|
+
# @return [Boolean] Whether the polling is enabled. Ie. whether a polling interval is set.
|
231
|
+
def polling_enabled?
|
232
|
+
@polling_interval.present?
|
233
|
+
end
|
234
|
+
|
235
|
+
# @return [Time,nil] the 'scheduled at' value for the next job, or nil if unknown or if there is no pending job
|
236
|
+
def next_job_scheduled_at
|
237
|
+
at = @next_job_scheduled_at.get
|
238
|
+
if at == UNKNOWN
|
239
|
+
nil
|
240
|
+
else
|
241
|
+
# noinspection RubyMismatchedReturnType
|
242
|
+
at
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
# @return [Time,nil] When the next poll is scheduled, or nil if polling is disabled
|
247
|
+
def next_poll_scheduled_at
|
248
|
+
if polling_enabled?
|
249
|
+
@next_poll_at.update { |planned_at| planned_at || Time.now + polling_interval }
|
250
|
+
else
|
251
|
+
# noinspection RubyMismatchedReturnType
|
252
|
+
@next_poll_at.get
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
# @return [Numeric] The timeout to wait until the next event
|
257
|
+
def wait_timeout
|
258
|
+
return MAX_WAIT_TIMEOUT if @executor.available_threads.zero?
|
259
|
+
|
260
|
+
next_job_at = next_job_scheduled_at
|
261
|
+
next_poll_at = next_poll_scheduled_at
|
262
|
+
|
263
|
+
timeout = [MAX_WAIT_TIMEOUT].tap do |timeouts|
|
264
|
+
# noinspection RubyMismatchedArgumentType
|
265
|
+
timeouts.append next_job_at - Time.now if next_job_at
|
266
|
+
# noinspection RubyMismatchedArgumentType
|
267
|
+
timeouts.append next_poll_at - Time.now if next_poll_at
|
268
|
+
end.min
|
269
|
+
|
270
|
+
if timeout <= 0.001
|
271
|
+
0
|
272
|
+
else
|
273
|
+
# noinspection RubyMismatchedReturnType
|
274
|
+
timeout
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
# @return [Boolean] Whether the `reserver` should be called.
|
279
|
+
def reserve_jobs_now?
|
280
|
+
next_poll_at = next_poll_scheduled_at
|
281
|
+
if next_poll_at && next_poll_at - Time.now <= 0.001
|
282
|
+
@next_poll_at.update { Time.now + polling_interval if polling_enabled? }
|
283
|
+
return true
|
284
|
+
end
|
285
|
+
|
286
|
+
next_job_at = next_job_scheduled_at
|
287
|
+
next_job_at && next_job_at <= Time.now
|
288
|
+
end
|
289
|
+
|
290
|
+
# @return [Float] Gets the polling interval jitter
|
291
|
+
def polling_interval_jitter
|
292
|
+
@interval_jitter
|
293
|
+
end
|
294
|
+
|
295
|
+
# Get the polling interval. If a jitter is configured, the interval is reduced or increased by `0.5 * jitter`.
|
296
|
+
# @return [Float] The amount of seconds before the next poll
|
297
|
+
def polling_interval
|
298
|
+
raise "Polling is disabled" unless @polling_interval.present?
|
299
|
+
|
300
|
+
@polling_interval + if polling_interval_jitter.zero?
|
301
|
+
0
|
302
|
+
else
|
303
|
+
(Kernel.rand - 0.5) * polling_interval_jitter
|
304
|
+
end
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|
308
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Exekutor
|
3
|
+
# @private
|
4
|
+
module Internal
|
5
|
+
# Reserves jobs to be executed by the current worker
|
6
|
+
class Reserver
|
7
|
+
# The name to use for the SQL log message
|
8
|
+
ACTION_NAME = "Exekutor::Reserve"
|
9
|
+
|
10
|
+
# Creates a new Reserver
|
11
|
+
# @param worker_id [String] the id of the worker
|
12
|
+
# @param queues [Array<String>] the queues to watch
|
13
|
+
def initialize(worker_id, queues)
|
14
|
+
@worker_id = worker_id
|
15
|
+
@queue_filter_sql = build_queue_filter_sql(queues)
|
16
|
+
@json_serializer = Exekutor.config.load_json_serializer
|
17
|
+
end
|
18
|
+
|
19
|
+
# Reserves pending jobs
|
20
|
+
# @param limit [Integer] the number of jobs to reserve
|
21
|
+
# @return [Array<Job>,nil] the reserved jobs, or nil if no jobs were reserved
|
22
|
+
def reserve(limit)
|
23
|
+
return unless limit.positive?
|
24
|
+
|
25
|
+
results = Exekutor::Job.connection.exec_query <<~SQL, ACTION_NAME, [@worker_id, limit], prepare: true
|
26
|
+
UPDATE exekutor_jobs SET worker_id = $1, status = 'e' WHERE id IN (
|
27
|
+
SELECT id FROM exekutor_jobs
|
28
|
+
WHERE scheduled_at <= now() AND "status"='p' #{@queue_filter_sql}
|
29
|
+
ORDER BY priority, scheduled_at, enqueued_at
|
30
|
+
FOR UPDATE SKIP LOCKED
|
31
|
+
LIMIT $2
|
32
|
+
) RETURNING "id", "payload", "options", "scheduled_at"
|
33
|
+
SQL
|
34
|
+
return unless results&.length&.positive?
|
35
|
+
|
36
|
+
parse_jobs results
|
37
|
+
end
|
38
|
+
|
39
|
+
def get_abandoned_jobs(active_job_ids)
|
40
|
+
jobs = Exekutor::Job.executing.where(worker_id: @worker_id)
|
41
|
+
jobs = jobs.where.not(id: active_job_ids) if active_job_ids.present?
|
42
|
+
attrs = %i[id payload options scheduled_at]
|
43
|
+
jobs.pluck(*attrs).map { |p| attrs.zip(p).to_h }
|
44
|
+
end
|
45
|
+
|
46
|
+
# Gets the earliest scheduled at of all pending jobs in the watched queues
|
47
|
+
# @return [Time,nil] The earliest scheduled at, or nil if the queues are empty
|
48
|
+
def earliest_scheduled_at
|
49
|
+
jobs = Exekutor::Job.pending
|
50
|
+
jobs.where! @queue_filter_sql.gsub(/^\s*AND\s+/, "") unless @queue_filter_sql.nil?
|
51
|
+
jobs.minimum(:scheduled_at)
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
# Parses jobs from the SQL results
|
57
|
+
def parse_jobs(sql_results)
|
58
|
+
sql_results.map do |result|
|
59
|
+
{ id: result["id"],
|
60
|
+
payload: parse_json(result["payload"]),
|
61
|
+
options: parse_json(result["options"]),
|
62
|
+
scheduled_at: result['scheduled_at'] }
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Parses JSON using the configured serializer
|
67
|
+
def parse_json(str)
|
68
|
+
@json_serializer.load str unless str.nil?
|
69
|
+
end
|
70
|
+
|
71
|
+
# Builds SQL filter for the given queues
|
72
|
+
def build_queue_filter_sql(queues)
|
73
|
+
return nil if queues.nil? || (queues.is_a?(Array) && queues.empty?)
|
74
|
+
unless queues.is_a?(String) || queues.is_a?(Symbol) || queues.is_a?(Array)
|
75
|
+
raise ArgumentError, "queues must be nil, a String, Symbol, or an array of Strings or Symbols"
|
76
|
+
end
|
77
|
+
|
78
|
+
queues = queues.first if queues.is_a?(Array) && queues.one?
|
79
|
+
if queues.is_a? Array
|
80
|
+
unless queues.all? { |q| (q.is_a?(String) || q.is_a?(Symbol)) && !q.blank? }
|
81
|
+
raise ArgumentError, "queues contains an invalid value"
|
82
|
+
end
|
83
|
+
|
84
|
+
Exekutor::Job.sanitize_sql_for_conditions(["AND queue IN (?)", queues])
|
85
|
+
else
|
86
|
+
raise ArgumentError, "queue name cannot be empty" if queues.blank?
|
87
|
+
|
88
|
+
Exekutor::Job.sanitize_sql_for_conditions(["AND queue = ?", queues])
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
@@ -0,0 +1,132 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Exekutor
|
3
|
+
module Internal
|
4
|
+
# Serves a simple health check app
|
5
|
+
class StatusServer
|
6
|
+
include Internal::Logger
|
7
|
+
include Internal::Executable
|
8
|
+
|
9
|
+
DEFAULT_HANDLER = "webrick"
|
10
|
+
|
11
|
+
def initialize(worker:, pool:, port:, handler: DEFAULT_HANDLER, heartbeat_timeout: 30)
|
12
|
+
super()
|
13
|
+
@worker = worker
|
14
|
+
@pool = pool
|
15
|
+
@port = port
|
16
|
+
@handler = Rack::Handler.get(handler)
|
17
|
+
@heartbeat_timeout = heartbeat_timeout
|
18
|
+
@thread_running = Concurrent::AtomicBoolean.new false
|
19
|
+
@server = Concurrent::AtomicReference.new
|
20
|
+
end
|
21
|
+
|
22
|
+
def start
|
23
|
+
return false unless compare_and_set_state :pending, :started
|
24
|
+
|
25
|
+
start_thread
|
26
|
+
end
|
27
|
+
|
28
|
+
def running?
|
29
|
+
super && @thread_running.value
|
30
|
+
end
|
31
|
+
|
32
|
+
def stop
|
33
|
+
set_state :stopped
|
34
|
+
return unless @thread_running.value
|
35
|
+
|
36
|
+
server = @server.value
|
37
|
+
if server&.respond_to? :shutdown
|
38
|
+
server.shutdown
|
39
|
+
elsif server&.respond_to? :stop
|
40
|
+
server.stop
|
41
|
+
elsif server
|
42
|
+
Exekutor.say! "Cannot shutdown status server, #{server.class.name} does not respond to shutdown or stop"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
protected
|
47
|
+
|
48
|
+
def run(worker, port)
|
49
|
+
return unless state == :started && @thread_running.make_true
|
50
|
+
|
51
|
+
Exekutor.say "Starting status server at 0.0.0.0:#{port}… (Timeout: #{@heartbeat_timeout} minutes)"
|
52
|
+
@handler.run(App.new(worker, @heartbeat_timeout), Port: port, Host: "0.0.0.0", Silent: true,
|
53
|
+
Logger: ::Logger.new(File.open(File::NULL, "w")), AccessLog: []) do |server|
|
54
|
+
@server.set server
|
55
|
+
end
|
56
|
+
rescue StandardError => err
|
57
|
+
Exekutor.on_fatal_error err, "[HealthServer] Runtime error!"
|
58
|
+
if running?
|
59
|
+
logger.info "Restarting in 10 seconds…"
|
60
|
+
Concurrent::ScheduledTask.execute(10.0, executor: @pool, &method(:start_thread))
|
61
|
+
end
|
62
|
+
ensure
|
63
|
+
@thread_running.make_false
|
64
|
+
end
|
65
|
+
|
66
|
+
# The Rack-app for the health-check server
|
67
|
+
class App
|
68
|
+
|
69
|
+
def initialize(worker, heartbeat_timeout)
|
70
|
+
@worker = worker
|
71
|
+
@heartbeat_timeout = heartbeat_timeout
|
72
|
+
end
|
73
|
+
|
74
|
+
def flatlined?
|
75
|
+
last_heartbeat = @worker.last_heartbeat
|
76
|
+
last_heartbeat.nil? || last_heartbeat < @heartbeat_timeout.minutes.ago
|
77
|
+
end
|
78
|
+
|
79
|
+
def call(env)
|
80
|
+
case Rack::Request.new(env).path
|
81
|
+
when "/"
|
82
|
+
[200, {}, [
|
83
|
+
<<~RESPONSE
|
84
|
+
[Exekutor]
|
85
|
+
- Use GET /ready to check whether the worker is running and connected to the DB
|
86
|
+
- Use GET /live to check whether the worker is running and is not hanging
|
87
|
+
- Use GET /threads to check thread usage
|
88
|
+
RESPONSE
|
89
|
+
]]
|
90
|
+
when "/ready"
|
91
|
+
running = @worker.running?
|
92
|
+
if running
|
93
|
+
Exekutor::Job.connection_pool.with_connection do |connection|
|
94
|
+
running = connection.active?
|
95
|
+
end
|
96
|
+
end
|
97
|
+
running = false if running && flatlined?
|
98
|
+
[(running ? 200 : 503), { "Content-Type" => "text/plain" }, [
|
99
|
+
"#{running ? "[OK]" : "[Service unavailable]"} ID: #{@worker.id}; State: #{@worker.state}"
|
100
|
+
]]
|
101
|
+
when "/live"
|
102
|
+
running = @worker.running?
|
103
|
+
last_heartbeat = if running
|
104
|
+
@worker.last_heartbeat
|
105
|
+
end
|
106
|
+
if running && (last_heartbeat.nil? || last_heartbeat < @heartbeat_timeout.minutes.ago)
|
107
|
+
running = false
|
108
|
+
end
|
109
|
+
[(running ? 200 : 503), { "Content-Type" => "text/plain" }, [
|
110
|
+
"#{running ? "[OK]" : "[Service unavailable]"} ID: #{@worker.id}; State: #{@worker.state}; Heartbeat: #{last_heartbeat&.iso8601 || "null"}"
|
111
|
+
]]
|
112
|
+
when "/threads"
|
113
|
+
if @worker.running?
|
114
|
+
info = @worker.thread_stats
|
115
|
+
[(info ? 200 : 503), { "Content-Type" => "application/json" }, [info.to_json]]
|
116
|
+
else
|
117
|
+
[503, {"Content-Type" => "application/json"}, [{ error: "Worker not running" }.to_json]]
|
118
|
+
end
|
119
|
+
else
|
120
|
+
[404, {}, ["Not found"]]
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
private
|
126
|
+
|
127
|
+
def start_thread
|
128
|
+
@pool.post(@worker, @port, &method(:run)) if state == :started
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
data/lib/exekutor/job.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "internal/base_record"
|
4
|
+
|
5
|
+
module Exekutor
|
6
|
+
# Active record instance for a job
|
7
|
+
class Job < Internal::BaseRecord
|
8
|
+
self.implicit_order_column = :enqueued_at
|
9
|
+
|
10
|
+
belongs_to :worker, optional: true, class_name: "Info::Worker"
|
11
|
+
has_many :execution_errors, class_name: "JobError"
|
12
|
+
|
13
|
+
enum status: { pending: "p", executing: "e", completed: "c", failed: "f", discarded: "d" }
|
14
|
+
|
15
|
+
# Sets the status to pending and clears the assigned worker
|
16
|
+
def release!
|
17
|
+
update! status: "p", worker_id: nil
|
18
|
+
end
|
19
|
+
|
20
|
+
# Sets the status to pending, clears the assigned worker, and schedules execution at the indicated time.
|
21
|
+
# @param at [Time] when the job should be executed
|
22
|
+
def reschedule!(at: Time.current)
|
23
|
+
update! status: "p", scheduled_at: at, worker_id: nil, runtime: nil
|
24
|
+
end
|
25
|
+
|
26
|
+
# Sets the status to discarded.
|
27
|
+
def discard!
|
28
|
+
update! status: "d"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "internal/base_record"
|
4
|
+
|
5
|
+
module Exekutor
|
6
|
+
# Active record instance for errors raised by jobs
|
7
|
+
class JobError < Internal::BaseRecord
|
8
|
+
self.implicit_order_column = :created_at
|
9
|
+
belongs_to :job
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
module Exekutor
|
2
|
+
# Mixin which defines custom job options for Exekutor. This module should be included in your job class.
|
3
|
+
# You can define the following options after including this module:
|
4
|
+
#
|
5
|
+
# ==== Queue timeout
|
6
|
+
# MyJob.set(queue_timeout: 1.hour).perform_later
|
7
|
+
# How long the job is allowed to be in the queue. If a job is not performed before this timeout, it will be discarded.
|
8
|
+
# The value should be a +ActiveSupport::Duration+.
|
9
|
+
#
|
10
|
+
# ==== Execution timeout
|
11
|
+
# MyJob.set(execution_timeout: 1.minute).perform_later
|
12
|
+
# How long the job is allowed to run. If a job is taking longer than this timeout, it will be killed and discarded.
|
13
|
+
# The value should be a +ActiveSupport::Duration+. Be aware that +Timeout::timeout+ is used internally for this, which
|
14
|
+
# can raise an error at any line of code in your application. <em>Use with caution</em>
|
15
|
+
#
|
16
|
+
# == Usage
|
17
|
+
# === +#set+
|
18
|
+
# You can specify the options per job when enqueueing the job using +#set+.
|
19
|
+
# MyJob.set(option_name: @option_value).perform_later
|
20
|
+
#
|
21
|
+
# === +#exekutor_options+
|
22
|
+
# You can also specify options that apply to all instances of a job by calling {#exekutor_options}.
|
23
|
+
# class MyOtherJob < ActiveJob::Base
|
24
|
+
# include Exekutor::JobOptions
|
25
|
+
# exekutor_options execution_timeout: 10.seconds
|
26
|
+
# end
|
27
|
+
#
|
28
|
+
# *NB* These options only work for jobs that are scheduled with +#perform_later+, the options are ignored when you
|
29
|
+
# perform the job immediately using +#perform_now+.
|
30
|
+
module JobOptions
|
31
|
+
extend ActiveSupport::Concern
|
32
|
+
|
33
|
+
# @private
|
34
|
+
VALID_EXEKUTOR_OPTIONS = %i[queue_timeout execution_timeout].freeze
|
35
|
+
private_constant "VALID_EXEKUTOR_OPTIONS"
|
36
|
+
|
37
|
+
# @return [Hash<Symbol, Object>] the exekutor options for this job
|
38
|
+
attr_reader :exekutor_options
|
39
|
+
|
40
|
+
# @!visibility private
|
41
|
+
def enqueue(options = {})
|
42
|
+
# :nodoc:
|
43
|
+
@exekutor_options = self.class.exekutor_job_options || {}
|
44
|
+
job_options = options&.slice(*VALID_EXEKUTOR_OPTIONS)
|
45
|
+
if job_options
|
46
|
+
self.class.validate_exekutor_options! job_options
|
47
|
+
@exekutor_options = @exekutor_options.merge job_options
|
48
|
+
end
|
49
|
+
super(options)
|
50
|
+
end
|
51
|
+
|
52
|
+
class_methods do
|
53
|
+
# Sets the exekutor options that apply to all instances of this job. These options can be overwritten with +#set+.
|
54
|
+
# @param options [Hash<Symbol, Object>] the exekutor options
|
55
|
+
# @option options [ActiveSupport::Duration] :queue_timeout The queue timeout
|
56
|
+
# @option options [ActiveSupport::Duration] :execution_timeout The execution timeout
|
57
|
+
# @return [void]
|
58
|
+
def exekutor_options(options)
|
59
|
+
validate_exekutor_options! options
|
60
|
+
@exekutor_job_options = options
|
61
|
+
end
|
62
|
+
|
63
|
+
# Gets the exekutor options that apply to all instances of this job. These options may be overwritten by +#set+.
|
64
|
+
# @return [Hash<Symbol, Object>] the options
|
65
|
+
def exekutor_job_options
|
66
|
+
@exekutor_job_options if defined?(@exekutor_job_options)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Validates the exekutor job options passed to {#exekutor_options} and +#set+
|
70
|
+
# @param options [Hash<Symbol, Object>] the options to validate
|
71
|
+
# @raise [InvalidOption] if any of the options are invalid
|
72
|
+
# @private
|
73
|
+
# @return [void]
|
74
|
+
def validate_exekutor_options!(options)
|
75
|
+
return unless options.present?
|
76
|
+
|
77
|
+
invalid_options = options.keys - VALID_EXEKUTOR_OPTIONS
|
78
|
+
if invalid_options.present?
|
79
|
+
raise InvalidOption, "Invalid option#{"s" if invalid_options.many?}: " \
|
80
|
+
"#{invalid_options.map(&:inspect).join(", ")}. " \
|
81
|
+
"Valid options are: #{VALID_EXEKUTOR_OPTIONS.map(&:inspect).join(", ")}"
|
82
|
+
end
|
83
|
+
if options[:queue_timeout]
|
84
|
+
raise InvalidOption, ":queue_timeout must be an interval" unless options[:queue_timeout].is_a? ActiveSupport::Duration
|
85
|
+
end
|
86
|
+
if options[:execution_timeout]
|
87
|
+
raise InvalidOption, ":execution_timeout must be an interval" unless options[:execution_timeout].is_a? ActiveSupport::Duration
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Raised when invalid options are given
|
93
|
+
class InvalidOption < ::Exekutor::Error; end
|
94
|
+
end
|
95
|
+
end
|