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,75 @@
|
|
1
|
+
module Exekutor
|
2
|
+
# Contains internal classes
|
3
|
+
# @private
|
4
|
+
module Internal
|
5
|
+
# Mixin for an executable
|
6
|
+
module Executable
|
7
|
+
# Possible states
|
8
|
+
STATES = %i[pending started stopped crashed killed].freeze
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@state = Concurrent::AtomicReference.new(:pending)
|
12
|
+
@consecutive_errors = Concurrent::AtomicFixnum.new(0)
|
13
|
+
end
|
14
|
+
|
15
|
+
# The state of this executable. Possible values are:
|
16
|
+
# - +:pending+ the executable has not been started yet
|
17
|
+
# - +:started+ the executable has started
|
18
|
+
# - +:stopped+ the executable has stopped
|
19
|
+
# - +:crashed+ the executable has crashed
|
20
|
+
# - +:killed+ the executable was killed
|
21
|
+
# @return [:pending,:started,:stopped,:crashed,:killed] the state
|
22
|
+
def state
|
23
|
+
@state.get
|
24
|
+
end
|
25
|
+
|
26
|
+
# Whether the state equals +:started+
|
27
|
+
def running?
|
28
|
+
@state.get == :started
|
29
|
+
end
|
30
|
+
|
31
|
+
def consecutive_errors
|
32
|
+
@consecutive_errors
|
33
|
+
end
|
34
|
+
|
35
|
+
# Calculates an exponential delay based on {#consecutive_errors}. The delay ranges from 10 seconds on the first error
|
36
|
+
# to 10 minutes from the 13th error on.
|
37
|
+
# @return [Float] The delay
|
38
|
+
def restart_delay
|
39
|
+
if @consecutive_errors.value > 150
|
40
|
+
error = SystemExit.new "Too many consecutive errors (#{@consecutive_errors.value})"
|
41
|
+
Exekutor.on_fatal_error error
|
42
|
+
raise error
|
43
|
+
end
|
44
|
+
delay = (9 + @consecutive_errors.value ** 2.5)
|
45
|
+
delay += delay * (rand(-5..5) / 100.0)
|
46
|
+
delay.clamp(10.0, 600.0)
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
# Changes the state to the given value if the current state matches the expected state. Does nothing otherwise.
|
52
|
+
# @param expected_state [:pending,:started,:stopped,:crashed] the expected state
|
53
|
+
# @param new_state [:pending,:started,:stopped,:crashed] the state to change to if the current state matches the expected
|
54
|
+
# @raise ArgumentError if an invalid state was passed
|
55
|
+
def compare_and_set_state(expected_state, new_state)
|
56
|
+
validate_state! new_state
|
57
|
+
@state.compare_and_set expected_state, new_state
|
58
|
+
end
|
59
|
+
|
60
|
+
# Updates the state to the given value
|
61
|
+
# @raise ArgumentError if an invalid state was passed
|
62
|
+
def set_state(new_state)
|
63
|
+
validate_state! new_state
|
64
|
+
@state.set new_state
|
65
|
+
end
|
66
|
+
|
67
|
+
# Validates whether +state+ is a valid value
|
68
|
+
# @raise ArgumentError if an invalid state was passed
|
69
|
+
def validate_state!(state)
|
70
|
+
raise ArgumentError, "State must be a symbol (was: #{state.class.name})" unless state.is_a? Symbol
|
71
|
+
raise ArgumentError, "Invalid state: #{state}" unless STATES.include? state
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,242 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "executable"
|
4
|
+
require_relative "callbacks"
|
5
|
+
|
6
|
+
module Exekutor
|
7
|
+
# @private
|
8
|
+
module Internal
|
9
|
+
# Executes jobs from a thread pool
|
10
|
+
class Executor
|
11
|
+
include Logger
|
12
|
+
include Callbacks
|
13
|
+
include Executable
|
14
|
+
|
15
|
+
define_callbacks :after_execute, freeze: true
|
16
|
+
attr_reader :pending_job_updates
|
17
|
+
|
18
|
+
def initialize(min_threads: 1, max_threads: default_max_threads, max_thread_idletime: 180,
|
19
|
+
delete_completed_jobs: false, delete_discarded_jobs: false, delete_failed_jobs: false)
|
20
|
+
super()
|
21
|
+
@executor = ThreadPoolExecutor.new name: "exekutor-job", fallback_policy: :abort, max_queue: max_threads,
|
22
|
+
min_threads: min_threads, max_threads: max_threads,
|
23
|
+
idletime: max_thread_idletime
|
24
|
+
@queued_job_ids = Concurrent::Array.new
|
25
|
+
@active_job_ids = Concurrent::Array.new
|
26
|
+
@pending_job_updates = Concurrent::Hash.new
|
27
|
+
@options = {
|
28
|
+
delete_completed_jobs: delete_completed_jobs,
|
29
|
+
delete_discarded_jobs: delete_discarded_jobs,
|
30
|
+
delete_failed_jobs: delete_failed_jobs
|
31
|
+
}.freeze
|
32
|
+
end
|
33
|
+
|
34
|
+
# Starts the executor
|
35
|
+
def start
|
36
|
+
set_state :started
|
37
|
+
end
|
38
|
+
|
39
|
+
# Stops the executor
|
40
|
+
def stop
|
41
|
+
set_state :stopped
|
42
|
+
|
43
|
+
@executor.shutdown
|
44
|
+
end
|
45
|
+
|
46
|
+
# Kills the executor
|
47
|
+
def kill
|
48
|
+
Thread.new { compare_and_set_state :started, :killed }
|
49
|
+
@executor.kill
|
50
|
+
|
51
|
+
release_assigned_jobs
|
52
|
+
end
|
53
|
+
|
54
|
+
# Executes the job on one of the execution threads. Releases the job if there is no thread available to execute
|
55
|
+
# the job.
|
56
|
+
def post(job)
|
57
|
+
@executor.post job, &method(:execute)
|
58
|
+
@queued_job_ids.append(job[:id])
|
59
|
+
rescue Concurrent::RejectedExecutionError
|
60
|
+
logger.error "Ran out of threads! Releasing job #{job[:id]}"
|
61
|
+
update_job job, status: "p", worker_id: nil
|
62
|
+
end
|
63
|
+
|
64
|
+
# @return [Integer] the number of available threads to execute jobs on. Returns 0 if the executor is not running.
|
65
|
+
def available_threads
|
66
|
+
if @executor.running?
|
67
|
+
@executor.available_threads
|
68
|
+
else
|
69
|
+
0
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# @return [Integer] the minimum number of threads to execute jobs on.
|
74
|
+
def minimum_threads
|
75
|
+
@executor.min_length
|
76
|
+
end
|
77
|
+
|
78
|
+
# @return [Integer] the maximum number of threads to execute jobs on.
|
79
|
+
def maximum_threads
|
80
|
+
@executor.max_length
|
81
|
+
end
|
82
|
+
|
83
|
+
# @return [Array<String>] The ids of the jobs that are currently being executed
|
84
|
+
def active_job_ids
|
85
|
+
@active_job_ids.dup.to_a
|
86
|
+
end
|
87
|
+
|
88
|
+
# Prunes the inactive threads from the pool.
|
89
|
+
def prune_pool
|
90
|
+
@executor.prune_pool
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
# Executes the given job
|
96
|
+
def execute(job)
|
97
|
+
@queued_job_ids.delete(job[:id])
|
98
|
+
@active_job_ids.append(job[:id])
|
99
|
+
Rails.application.reloader.wrap do
|
100
|
+
DatabaseConnection.ensure_active!
|
101
|
+
Internal::Hooks.run :job_execution, job do
|
102
|
+
_execute(job)
|
103
|
+
# Run internal callbacks
|
104
|
+
run_callbacks :after, :execute, job
|
105
|
+
end
|
106
|
+
end
|
107
|
+
ensure
|
108
|
+
@active_job_ids.delete(job[:id])
|
109
|
+
end
|
110
|
+
|
111
|
+
def _execute(job, start_time: Concurrent.monotonic_time)
|
112
|
+
raise Exekutor::DiscardJob, "Maximum queue time expired" if queue_time_expired?(job)
|
113
|
+
|
114
|
+
if (timeout = job[:options] && job[:options]["execution_timeout"]).present?
|
115
|
+
Timeout.timeout Float(timeout), JobExecutionTimeout do
|
116
|
+
ActiveJob::Base.execute(job[:payload])
|
117
|
+
end
|
118
|
+
else
|
119
|
+
ActiveJob::Base.execute(job[:payload])
|
120
|
+
end
|
121
|
+
|
122
|
+
on_job_completed(job, runtime: Concurrent.monotonic_time - start_time)
|
123
|
+
rescue StandardError, JobExecutionTimeout => e
|
124
|
+
on_job_failed(job, e, runtime: Concurrent.monotonic_time - start_time)
|
125
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
126
|
+
# Try to release job when an Exception occurs
|
127
|
+
update_job job, status: "p", worker_id: nil
|
128
|
+
raise
|
129
|
+
end
|
130
|
+
|
131
|
+
def on_job_completed(job, runtime:)
|
132
|
+
if @options[:delete_completed_jobs]
|
133
|
+
delete_job job
|
134
|
+
else
|
135
|
+
update_job job, status: "c", runtime: runtime
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def on_job_failed(job, error, runtime:)
|
140
|
+
discarded = [Exekutor::DiscardJob, JobExecutionTimeout].any?(&error.method(:is_a?))
|
141
|
+
unless discarded
|
142
|
+
Internal::Hooks.on(:job_failure, job, error)
|
143
|
+
log_error error, "Job failed"
|
144
|
+
end
|
145
|
+
|
146
|
+
if lost_db_connection?(error)
|
147
|
+
# Don't consider this as a failure, try again later.
|
148
|
+
update_job job, status: "p", worker_id: nil
|
149
|
+
|
150
|
+
elsif @options[discarded ? :delete_discarded_jobs : :delete_failed_jobs]
|
151
|
+
delete_job job
|
152
|
+
|
153
|
+
else
|
154
|
+
# Try to update the job and create a JobError record if update succeeds
|
155
|
+
if update_job job, status: discarded ? "d" : "f", runtime: runtime
|
156
|
+
JobError.create!(job_id: job[:id], error: error)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
# Updates the active record entity for this job with the given attributes.
|
162
|
+
def update_job(job, **attrs)
|
163
|
+
Exekutor::Job.where(id: job[:id]).update_all(attrs)
|
164
|
+
true
|
165
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished => e
|
166
|
+
unless Exekutor::Job.connection.active?
|
167
|
+
log_error e, "Could not update job"
|
168
|
+
# Save the update for when the connection is back
|
169
|
+
@pending_job_updates.merge!(job[:id] => attrs) do |_k, old, new|
|
170
|
+
if old == :destroy
|
171
|
+
old
|
172
|
+
else
|
173
|
+
old&.merge!(new) || new
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
false
|
178
|
+
end
|
179
|
+
|
180
|
+
def delete_job(job)
|
181
|
+
Exekutor::Job.destroy(job[:id])
|
182
|
+
true
|
183
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished => e
|
184
|
+
unless Exekutor::Job.connection.active?
|
185
|
+
log_error e, "Could not delete job"
|
186
|
+
# Save the deletion for when the connection is back
|
187
|
+
@pending_job_updates[job[:id]] = :destroy
|
188
|
+
end
|
189
|
+
false
|
190
|
+
end
|
191
|
+
|
192
|
+
def release_assigned_jobs
|
193
|
+
@queued_job_ids.each { |id| update_job({ id: id }, status: "p", worker_id: nil) }
|
194
|
+
@active_job_ids.each { |id| update_job({ id: id }, status: "p", worker_id: nil) }
|
195
|
+
end
|
196
|
+
|
197
|
+
def queue_time_expired?(job)
|
198
|
+
job[:options] && job[:options]["start_execution_before"] &&
|
199
|
+
job[:options]["start_execution_before"].to_f <= Time.now.to_f
|
200
|
+
end
|
201
|
+
|
202
|
+
def lost_db_connection?(error)
|
203
|
+
[ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished].any?(&error.method(:kind_of?)) &&
|
204
|
+
!ActiveRecord::Base.connection.active?
|
205
|
+
end
|
206
|
+
|
207
|
+
# The default maximum number of threads. The value is equal to the size of the DB connection pool minus 1, with
|
208
|
+
# a minimum of 1.
|
209
|
+
def default_max_threads
|
210
|
+
connection_pool_size = Exekutor::Job.connection_db_config.pool
|
211
|
+
if connection_pool_size && connection_pool_size > 2
|
212
|
+
connection_pool_size - 1
|
213
|
+
else
|
214
|
+
1
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
# The thread pool to use for executing jobs.
|
219
|
+
class ThreadPoolExecutor < Concurrent::ThreadPoolExecutor
|
220
|
+
# Number of inactive threads available to execute tasks.
|
221
|
+
# https://github.com/ruby-concurrency/concurrent-ruby/issues/684#issuecomment-427594437
|
222
|
+
# @return [Integer]
|
223
|
+
def available_threads
|
224
|
+
synchronize do
|
225
|
+
if Concurrent.on_jruby?
|
226
|
+
@executor.getMaximumPoolSize - @executor.getActiveCount
|
227
|
+
else
|
228
|
+
workers_still_to_be_created = @max_length - @pool.length
|
229
|
+
workers_created_but_waiting = @ready.length
|
230
|
+
workers_still_to_be_created + workers_created_but_waiting
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
# Thrown when the job execution timeout expires. Inherits from Exception so it's less likely to be caught by
|
237
|
+
# rescue statements.
|
238
|
+
class JobExecutionTimeout < Exception # rubocop:disable Lint/InheritException
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
module Exekutor
|
2
|
+
module Internal
|
3
|
+
class Hooks
|
4
|
+
include Internal::Callbacks
|
5
|
+
|
6
|
+
define_callbacks :before_enqueue, :around_enqueue, :after_enqueue,
|
7
|
+
:before_job_execution, :around_job_execution, :after_job_execution,
|
8
|
+
:on_job_failure, :on_fatal_error,
|
9
|
+
:before_startup, :after_startup,
|
10
|
+
:before_shutdown, :after_shutdown,
|
11
|
+
freeze: true
|
12
|
+
|
13
|
+
# Registers a hook to be called.
|
14
|
+
def register(callback = nil, &block)
|
15
|
+
if callback
|
16
|
+
callback = callback.new if callback.is_a? Class
|
17
|
+
raise 'callback must respond to #callbacks' unless callback.respond_to? :callbacks
|
18
|
+
callback.callbacks.each do |type, callbacks|
|
19
|
+
callbacks.each { |callback| add_callback! type, [], callback }
|
20
|
+
end
|
21
|
+
elsif block.arity == 1
|
22
|
+
block.call self
|
23
|
+
else
|
24
|
+
instance_eval(&block)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# @see #register
|
29
|
+
def <<(callback)
|
30
|
+
register callback
|
31
|
+
end
|
32
|
+
|
33
|
+
# Executes an +:on+ callback with the given type.
|
34
|
+
def self.on(type, *args)
|
35
|
+
::Exekutor.hooks.send(:run_callbacks, :on, type, *args)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Executes the +:before+, +:around+, and +:after+ callbacks with the given type.
|
39
|
+
def self.run(type, *args, &block)
|
40
|
+
::Exekutor.hooks.send(:with_callbacks, type, *args, &block)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Prints the error to STDERR and the log, and calls the :on_fatal_error hooks.
|
46
|
+
def self.on_fatal_error(error, message = nil)
|
47
|
+
Exekutor.print_error(error, message)
|
48
|
+
return if defined?(@calling_fatal_error_hook) && @calling_fatal_error_hook
|
49
|
+
|
50
|
+
@calling_fatal_error_hook = true
|
51
|
+
Internal::Hooks.on(:fatal_error, error)
|
52
|
+
ensure
|
53
|
+
@calling_fatal_error_hook = false
|
54
|
+
end
|
55
|
+
|
56
|
+
# Exekutor.hooks.register do
|
57
|
+
# after_job_failure do |error, job|
|
58
|
+
# Appsignal.add_exception error
|
59
|
+
# end
|
60
|
+
# end
|
61
|
+
|
62
|
+
# Exekutor.hooks.after_job_failure do error
|
63
|
+
# Appsignal.add_exception error
|
64
|
+
# end
|
65
|
+
|
66
|
+
# class ExekutorHooks < ::Exekutor::Hook
|
67
|
+
# around_job_execution :instrument
|
68
|
+
# after_job_failure :report_error
|
69
|
+
# after_fatal_error :report_error
|
70
|
+
#
|
71
|
+
# def instrument(job)
|
72
|
+
# Appsignal.monitor_transaction … { yield }
|
73
|
+
# end
|
74
|
+
#
|
75
|
+
# def send_to_appsignal(error)
|
76
|
+
# Appsignal.add_exception error
|
77
|
+
# end
|
78
|
+
# end
|
79
|
+
#
|
80
|
+
# Exekutor.hooks.register ExekutorHooks
|
81
|
+
|
82
|
+
# @!attribute [r] hooks
|
83
|
+
# @return [Internal::Hooks] The hooks for exekutor.
|
84
|
+
mattr_reader :hooks, default: Internal::Hooks.new
|
85
|
+
|
86
|
+
# TODO register_hook method instead of `hooks.register`?
|
87
|
+
end
|
@@ -0,0 +1,176 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "executable"
|
4
|
+
|
5
|
+
module Exekutor
|
6
|
+
# @private
|
7
|
+
module Internal
|
8
|
+
# Listens for jobs to be executed
|
9
|
+
class Listener
|
10
|
+
include Executable, Logger
|
11
|
+
|
12
|
+
# The PG notification channel for enqueued jobs
|
13
|
+
JOB_ENQUEUED_CHANNEL = "exekutor::job_enqueued"
|
14
|
+
# The PG notification channel for a worker. Must be formatted with the worker ID.
|
15
|
+
PROVIDER_CHANNEL = "exekutor::worker::%s"
|
16
|
+
|
17
|
+
# Creates a new listener
|
18
|
+
# @param worker_id [String] the ID of the worker
|
19
|
+
# @param queues [Array<String>] the queues to watch
|
20
|
+
# @param provider [Provider] the job provider
|
21
|
+
# @param pool [ThreadPoolExecutor] the thread pool to use
|
22
|
+
# @param wait_timeout [Integer] the time to listen for notifications
|
23
|
+
# @param set_db_connection_name [Boolean] whether to set the application name on the DB connection
|
24
|
+
def initialize(worker_id:, queues: nil, provider:, pool:, wait_timeout: 60, set_db_connection_name: false)
|
25
|
+
super()
|
26
|
+
@config = {
|
27
|
+
worker_id: worker_id,
|
28
|
+
queues: queues || [],
|
29
|
+
wait_timeout: wait_timeout,
|
30
|
+
set_db_connection_name: set_db_connection_name
|
31
|
+
}
|
32
|
+
|
33
|
+
@provider = provider
|
34
|
+
@pool = pool
|
35
|
+
|
36
|
+
@thread_running = Concurrent::AtomicBoolean.new false
|
37
|
+
@listening = Concurrent::AtomicBoolean.new false
|
38
|
+
end
|
39
|
+
|
40
|
+
# Starts the listener
|
41
|
+
def start
|
42
|
+
return false unless compare_and_set_state :pending, :started
|
43
|
+
|
44
|
+
start_thread
|
45
|
+
true
|
46
|
+
end
|
47
|
+
|
48
|
+
# Stops the listener
|
49
|
+
def stop
|
50
|
+
set_state :stopped
|
51
|
+
begin
|
52
|
+
Exekutor::Job.connection.execute(%(NOTIFY "#{provider_channel}"))
|
53
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
|
54
|
+
#ignored
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
# The PG notification channel for a worker
|
61
|
+
def provider_channel
|
62
|
+
PROVIDER_CHANNEL % @config[:worker_id]
|
63
|
+
end
|
64
|
+
|
65
|
+
# Whether this listener is listening to the given queue
|
66
|
+
# @return [Boolean]
|
67
|
+
def listening_to_queue?(queue)
|
68
|
+
queues = @config[:queues]
|
69
|
+
queues.empty? || queues.include?(queue)
|
70
|
+
end
|
71
|
+
|
72
|
+
# Starts the listener thread
|
73
|
+
def start_thread
|
74
|
+
@pool.post(&method(:run)) if running?
|
75
|
+
end
|
76
|
+
|
77
|
+
# Sets up the PG notifications and listens for new jobs
|
78
|
+
def run
|
79
|
+
return unless running? && @thread_running.make_true
|
80
|
+
|
81
|
+
with_pg_connection do |connection|
|
82
|
+
begin
|
83
|
+
connection.exec(%(LISTEN "#{provider_channel}"))
|
84
|
+
connection.exec(%(LISTEN "#{JOB_ENQUEUED_CHANNEL}"))
|
85
|
+
consecutive_errors.value = 0
|
86
|
+
catch(:shutdown) { wait_for_jobs(connection) }
|
87
|
+
ensure
|
88
|
+
connection.exec("UNLISTEN *")
|
89
|
+
end
|
90
|
+
end
|
91
|
+
rescue StandardError => err
|
92
|
+
Exekutor.on_fatal_error err, "[Listener] Runtime error!"
|
93
|
+
set_state :crashed if err.is_a? UnsupportedDatabase
|
94
|
+
|
95
|
+
if running?
|
96
|
+
consecutive_errors.increment
|
97
|
+
delay = restart_delay
|
98
|
+
logger.info "Restarting in %0.1f seconds…" % [delay]
|
99
|
+
Concurrent::ScheduledTask.execute(delay, executor: @pool, &method(:run))
|
100
|
+
end
|
101
|
+
ensure
|
102
|
+
@thread_running.make_false
|
103
|
+
@listening.make_false
|
104
|
+
end
|
105
|
+
|
106
|
+
# Listens for jobs. Blocks until the listener is stopped
|
107
|
+
def wait_for_jobs(connection)
|
108
|
+
while running?
|
109
|
+
@listening.make_true
|
110
|
+
connection.wait_for_notify(@config[:wait_timeout]) do |channel, _pid, payload|
|
111
|
+
throw :shutdown unless running?
|
112
|
+
next unless channel == JOB_ENQUEUED_CHANNEL
|
113
|
+
|
114
|
+
job_info = begin
|
115
|
+
payload.split(";").map { |el| el.split(":") }.to_h
|
116
|
+
rescue
|
117
|
+
logger.error "Invalid notification payload: #{payload}"
|
118
|
+
next
|
119
|
+
end
|
120
|
+
unless %w[id q t].all? { |n| job_info[n].present? }
|
121
|
+
logger.error "[Listener] Notification payload is missing #{%w[id q t].select { |n| job_info[n].blank? }.join(", ")}"
|
122
|
+
next
|
123
|
+
end
|
124
|
+
next unless listening_to_queue? job_info["q"]
|
125
|
+
|
126
|
+
scheduled_at = job_info["t"].to_f
|
127
|
+
@provider.update_earliest_scheduled_at(scheduled_at)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# Gets a DB connection and removes it from the pool. Sets the application name if +set_db_connection_name+ is true.
|
133
|
+
# Closes the connection after yielding it to the given block.
|
134
|
+
# (Grabbed from PG adapter for action cable)
|
135
|
+
# @yield yields the connection
|
136
|
+
# @yieldparam connection [PG::Connection] the DB connection
|
137
|
+
def with_pg_connection # :nodoc:
|
138
|
+
ar_conn = Exekutor::Job.connection_pool.checkout.tap do |conn|
|
139
|
+
# Action Cable is taking ownership over this database connection, and
|
140
|
+
# will perform the necessary cleanup tasks
|
141
|
+
ActiveRecord::Base.connection_pool.remove(conn)
|
142
|
+
end
|
143
|
+
DatabaseConnection.ensure_active! ar_conn
|
144
|
+
pg_conn = ar_conn.raw_connection
|
145
|
+
|
146
|
+
verify!(pg_conn)
|
147
|
+
if @config[:set_db_connection_name]
|
148
|
+
DatabaseConnection.set_application_name pg_conn, @config[:worker_id], :listener
|
149
|
+
end
|
150
|
+
yield pg_conn
|
151
|
+
ensure
|
152
|
+
ar_conn.disconnect!
|
153
|
+
end
|
154
|
+
|
155
|
+
# Verifies the connection
|
156
|
+
# @raise [Error] if the connection is not an instance of +PG::Connection+ or is invalid.
|
157
|
+
def verify!(pg_conn)
|
158
|
+
unless pg_conn.is_a?(PG::Connection)
|
159
|
+
raise UnsupportedDatabase,
|
160
|
+
"The raw connection of the active record connection adapter must be an instance of PG::Connection"
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# For testing purposes
|
165
|
+
def listening?
|
166
|
+
@listening.true?
|
167
|
+
end
|
168
|
+
|
169
|
+
# Raised when an error occurs in the listener.
|
170
|
+
class Error < Exekutor::Error; end
|
171
|
+
|
172
|
+
# Raised when the database connection is not an instance of PG::Connection.
|
173
|
+
class UnsupportedDatabase < Exekutor::Error; end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require "rainbow"
|
2
|
+
|
3
|
+
module Exekutor
|
4
|
+
# @private
|
5
|
+
module Internal
|
6
|
+
# Mixin to use the logger
|
7
|
+
module Logger
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
included do
|
11
|
+
# The log tags to use when writing to the log
|
12
|
+
mattr_accessor :log_tags, default: [self.name.demodulize]
|
13
|
+
end
|
14
|
+
|
15
|
+
protected
|
16
|
+
|
17
|
+
# Prints the error to the log
|
18
|
+
# @param err [Exception] the error to print
|
19
|
+
# @param message [String] the message to print above the error
|
20
|
+
# @return [void]
|
21
|
+
def log_error(err, message)
|
22
|
+
@backtrace_cleaner ||= ActiveSupport::BacktraceCleaner.new
|
23
|
+
logger.error message if message
|
24
|
+
logger.error "#{err.class} – #{err.message}\nat #{@backtrace_cleaner.clean(err.backtrace).join("\n ")}"
|
25
|
+
end
|
26
|
+
|
27
|
+
# Gets the logger
|
28
|
+
# @return [ActiveSupport::TaggedLogging]
|
29
|
+
def logger
|
30
|
+
@logger ||= Exekutor.logger.tagged(log_tags.compact)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Prints a message to STDOUT, unless {Exekutor::Configuration#quiet?} is true
|
36
|
+
# @private
|
37
|
+
def self.say(*args)
|
38
|
+
puts(*args) unless config.quiet?
|
39
|
+
end
|
40
|
+
|
41
|
+
# Prints the error in the log and to STDERR (unless {Exekutor::Configuration#quiet?} is true)
|
42
|
+
# @param err [Exception] the error to print
|
43
|
+
# @param message [String] the message to print above the error
|
44
|
+
# @return [void]
|
45
|
+
def self.print_error(err, message = nil)
|
46
|
+
@backtrace_cleaner ||= ActiveSupport::BacktraceCleaner.new
|
47
|
+
error = "#{err.class} – #{err.message}\nat #{@backtrace_cleaner.clean(err.backtrace).join("\n ")}"
|
48
|
+
|
49
|
+
unless config.quiet?
|
50
|
+
$stderr.puts Rainbow(message).bright.red if message
|
51
|
+
$stderr.puts Rainbow(error).red
|
52
|
+
end
|
53
|
+
unless ActiveSupport::Logger.logger_outputs_to?(logger, $stdout)
|
54
|
+
logger.error message if message
|
55
|
+
logger.error error
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Gets the logger
|
60
|
+
# @return [ActiveSupport::TaggedLogging]
|
61
|
+
mattr_reader :logger, default: ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new($stdout))
|
62
|
+
|
63
|
+
# Sets the logger
|
64
|
+
# @param logger [ActiveSupport::Logger]
|
65
|
+
def self.logger=(logger)
|
66
|
+
raise ArgumentError, "logger must be a logger" unless logger.is_a? Logger
|
67
|
+
|
68
|
+
@logger = if logger.is_a? ActiveSupport::TaggedLogging
|
69
|
+
logger
|
70
|
+
else
|
71
|
+
ActiveSupport::TaggedLogging.new logger
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|