exekutor 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|