exekutor 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +3 -0
  3. data/LICENSE.txt +21 -0
  4. data/exe/exekutor +7 -0
  5. data/lib/active_job/queue_adapters/exekutor_adapter.rb +14 -0
  6. data/lib/exekutor/asynchronous.rb +188 -0
  7. data/lib/exekutor/cleanup.rb +56 -0
  8. data/lib/exekutor/configuration.rb +373 -0
  9. data/lib/exekutor/hook.rb +172 -0
  10. data/lib/exekutor/info/worker.rb +20 -0
  11. data/lib/exekutor/internal/base_record.rb +11 -0
  12. data/lib/exekutor/internal/callbacks.rb +138 -0
  13. data/lib/exekutor/internal/cli/app.rb +173 -0
  14. data/lib/exekutor/internal/cli/application_loader.rb +36 -0
  15. data/lib/exekutor/internal/cli/cleanup.rb +96 -0
  16. data/lib/exekutor/internal/cli/daemon.rb +108 -0
  17. data/lib/exekutor/internal/cli/default_option_value.rb +29 -0
  18. data/lib/exekutor/internal/cli/info.rb +126 -0
  19. data/lib/exekutor/internal/cli/manager.rb +260 -0
  20. data/lib/exekutor/internal/configuration_builder.rb +113 -0
  21. data/lib/exekutor/internal/database_connection.rb +21 -0
  22. data/lib/exekutor/internal/executable.rb +75 -0
  23. data/lib/exekutor/internal/executor.rb +242 -0
  24. data/lib/exekutor/internal/hooks.rb +87 -0
  25. data/lib/exekutor/internal/listener.rb +176 -0
  26. data/lib/exekutor/internal/logger.rb +74 -0
  27. data/lib/exekutor/internal/provider.rb +308 -0
  28. data/lib/exekutor/internal/reserver.rb +95 -0
  29. data/lib/exekutor/internal/status_server.rb +132 -0
  30. data/lib/exekutor/job.rb +31 -0
  31. data/lib/exekutor/job_error.rb +11 -0
  32. data/lib/exekutor/job_options.rb +95 -0
  33. data/lib/exekutor/plugins/appsignal.rb +46 -0
  34. data/lib/exekutor/plugins.rb +13 -0
  35. data/lib/exekutor/queue.rb +141 -0
  36. data/lib/exekutor/version.rb +6 -0
  37. data/lib/exekutor/worker.rb +219 -0
  38. data/lib/exekutor.rb +49 -0
  39. data/lib/generators/exekutor/configuration_generator.rb +18 -0
  40. data/lib/generators/exekutor/install_generator.rb +43 -0
  41. data/lib/generators/exekutor/templates/install/functions/job_notifier.sql +7 -0
  42. data/lib/generators/exekutor/templates/install/functions/requeue_orphaned_jobs.sql +7 -0
  43. data/lib/generators/exekutor/templates/install/initializers/exekutor.rb.erb +14 -0
  44. data/lib/generators/exekutor/templates/install/migrations/create_exekutor_schema.rb.erb +83 -0
  45. data/lib/generators/exekutor/templates/install/triggers/notify_workers.sql +6 -0
  46. data/lib/generators/exekutor/templates/install/triggers/requeue_orphaned_jobs.sql +5 -0
  47. data.tar.gz.sig +0 -0
  48. metadata +403 -0
  49. 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