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.
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