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,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exekutor
4
+ # @private
5
+ module Internal
6
+ module CLI
7
+ # Manages daemonization of the current process.
8
+ # @private
9
+ class Daemon
10
+ # The path of the generated pidfile.
11
+ # @return [String]
12
+ attr_reader :pidfile
13
+
14
+ # @param pidfile [String] Pidfile path
15
+ def initialize(pidfile:)
16
+ @pidfile = pidfile
17
+ end
18
+
19
+ # Daemonizes the current process and writes out a pidfile.
20
+ # @return [void]
21
+ def daemonize
22
+ validate!
23
+ ::Process.daemon true
24
+ write_pid
25
+ end
26
+
27
+ # The process ID for this daemon, if known
28
+ # @return [Integer,nil] The process ID
29
+ # @raise [Error] if the pid-file is corrupt
30
+ def pid
31
+ return nil unless ::File.exist? pidfile
32
+
33
+ pid = ::File.read(pidfile)
34
+ if pid.to_i.positive?
35
+ pid.to_i
36
+ else
37
+ raise Error, "Corrupt PID-file. Check #{pidfile}"
38
+ end
39
+ end
40
+
41
+ # The process status for this daemon. Possible states are:
42
+ # - +:running+ when the daemon is running;
43
+ # - +:not_running+ when the daemon is not running;
44
+ # - +:dead+ when the daemon is dead. (Ie. the PID is known, but the process is gone);
45
+ # - +:not_owned+ when the daemon cannot be accessed.
46
+ # @return [:running, :not_running, :dead, :not_owned] the status
47
+ def status
48
+ pid = self.pid
49
+ return :not_running if pid.nil?
50
+
51
+ # If sig is 0, then no signal is sent, but error checking is still performed; this can be used to check for the
52
+ # existence of a process ID or process group ID.
53
+ ::Process.kill(0, pid)
54
+ :running
55
+ rescue Errno::ESRCH
56
+ :dead
57
+ rescue Errno::EPERM
58
+ :not_owned
59
+ end
60
+
61
+ # Checks whether {#status} matches any of the given statuses.
62
+ # @param statuses [Symbol...] The statuses to check for.
63
+ # @return [Boolean] whether the status matches
64
+ # @see #status
65
+ def status?(*statuses)
66
+ statuses.include? self.status
67
+ end
68
+
69
+ # Raises an {Error} if a daemon is already running. Deletes the pidfile is the process is dead.
70
+ # @return [void]
71
+ # @raise [Error] when the daemon is running
72
+ def validate!
73
+ case self.status
74
+ when :running, :not_owned
75
+ raise Error, "A worker is already running. Check #{pidfile}"
76
+ else
77
+ delete_pid
78
+ end
79
+ nil
80
+ end
81
+
82
+ private
83
+
84
+ # Writes the current process ID to the pidfile. The pidfile will be deleted upon exit.
85
+ # @return [void]
86
+ # @see #pidfile
87
+ # @raise [Error] is the daemon is already running
88
+ def write_pid
89
+ File.open(pidfile, ::File::CREAT | ::File::EXCL | ::File::WRONLY) { |f| f.write(::Process.pid.to_s) }
90
+ at_exit { delete_pid }
91
+ rescue Errno::EEXIST
92
+ validate!
93
+ retry
94
+ end
95
+
96
+ # Deletes the pidfile
97
+ # @return [void]
98
+ # @see #pidfile
99
+ def delete_pid
100
+ File.delete(pidfile) if File.exist?(pidfile)
101
+ end
102
+
103
+ # Raised when spawning a daemon process fails
104
+ class Error < StandardError; end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,29 @@
1
+ module Exekutor
2
+ # @private
3
+ module Internal
4
+ module CLI
5
+ # Used as a default value for CLI flags.
6
+ # @private
7
+ class DefaultOptionValue
8
+ def initialize(description = nil, value: nil)
9
+ @description = description || value&.to_s || "none"
10
+ @value = value
11
+ end
12
+
13
+ # The value to display in the CLI help message
14
+ def to_s
15
+ @description
16
+ end
17
+
18
+ # The actual value, if set. If the value responds to +call+, it will be called
19
+ def value
20
+ if @value.respond_to? :call
21
+ @value.call
22
+ else
23
+ @value
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,126 @@
1
+ require_relative "application_loader"
2
+ require "terminal-table"
3
+
4
+ module Exekutor
5
+ # @private
6
+ module Internal
7
+ module CLI
8
+ # Prints info for the CLI
9
+ # @private
10
+ class Info
11
+ include ApplicationLoader
12
+
13
+ def initialize(options)
14
+ @global_options = options
15
+ end
16
+
17
+ # Prints Exekutor info to STDOUT
18
+ def print(options)
19
+ load_application(options[:environment], print_message: !quiet?)
20
+
21
+ ActiveSupport.on_load(:active_record, yield: true) do
22
+ # Use system time zone
23
+ Time.zone = Time.new.zone
24
+
25
+ hosts = Exekutor::Info::Worker.distinct.pluck(:hostname)
26
+ job_info = Exekutor::Job.pending.order(:queue).group(:queue)
27
+ .pluck(:queue, Arel.sql("COUNT(*)"), Arel.sql("MIN(scheduled_at)"))
28
+
29
+ clear_application_loading_message unless quiet?
30
+ puts Rainbow("Workers").bright.blue
31
+ if hosts.present?
32
+ total_workers = 0
33
+ hosts.each do |host|
34
+ table = Terminal::Table.new
35
+ table.title = host if hosts.many?
36
+ table.headings = ["id", "Status", "Last heartbeat"]
37
+ worker_count = 0
38
+ Exekutor::Info::Worker.where(hostname: host).each do |worker|
39
+ worker_count += 1
40
+ table << [
41
+ worker.id.split("-").first << "…",
42
+ worker.status,
43
+ if worker.last_heartbeat_at.nil?
44
+ if !worker.running?
45
+ "N/A"
46
+ elsif worker.created_at < 10.minutes.ago
47
+ Rainbow("None").red
48
+ else
49
+ "None"
50
+ end
51
+ elsif worker.last_heartbeat_at > 2.minutes.ago
52
+ worker.last_heartbeat_at.strftime "%R"
53
+ elsif worker.last_heartbeat_at > 10.minutes.ago
54
+ Rainbow(worker.last_heartbeat_at.strftime("%R")).yellow
55
+ else
56
+ Rainbow(worker.last_heartbeat_at.strftime("%D %R")).red
57
+ end
58
+ ]
59
+ # TODO switch / flag to print threads and queues
60
+ end
61
+ total_workers += worker_count
62
+ table.add_separator
63
+ table.add_row [(hosts.many? ? "Subtotal" : "Total"), { value: worker_count, alignment: :right, colspan: 2 }]
64
+ puts table
65
+ end
66
+
67
+ if hosts.many?
68
+ puts Terminal::Table.new rows: [
69
+ ["Total hosts", hosts.size],
70
+ ["Total workers", total_workers]
71
+ ]
72
+ end
73
+ else
74
+ message = Rainbow("There are no active workers")
75
+ message = message.red if job_info.present?
76
+ puts message
77
+ end
78
+
79
+ puts " "
80
+ puts "#{Rainbow("Jobs").bright.blue}"
81
+ if job_info.present?
82
+ table = Terminal::Table.new
83
+ table.headings = ["Queue", "Pending jobs", "Next job scheduled at"]
84
+ total_count = 0
85
+ job_info.each do |queue, count, min_scheduled_at|
86
+ table << [
87
+ queue, count,
88
+ if min_scheduled_at.nil?
89
+ "N/A"
90
+ elsif min_scheduled_at < 30.minutes.ago
91
+ Rainbow(min_scheduled_at.strftime("%D %R")).red
92
+ elsif min_scheduled_at < 1.minute.ago
93
+ Rainbow(min_scheduled_at.strftime("%D %R")).yellow
94
+ else
95
+ min_scheduled_at.strftime("%D %R")
96
+ end
97
+ ]
98
+ total_count += count
99
+ end
100
+ if job_info.many?
101
+ table.add_separator
102
+ table.add_row ["Total", { value: total_count, alignment: :right, colspan: 2 }]
103
+ end
104
+ puts table
105
+ else
106
+ puts Rainbow("No pending jobs").green
107
+ end
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ # @return [Boolean] Whether quiet mode is enabled. Overrides verbose mode.
114
+ def quiet?
115
+ !!@global_options[:quiet]
116
+ end
117
+
118
+ # @return [Boolean] Whether verbose mode is enabled. Always returns false if quiet mode is enabled.
119
+ def verbose?
120
+ !quiet? && !!@global_options[:verbose]
121
+ end
122
+
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,260 @@
1
+ require_relative "application_loader"
2
+ require_relative "default_option_value"
3
+ require_relative "daemon"
4
+
5
+ module Exekutor
6
+ # @private
7
+ module Internal
8
+ module CLI
9
+ # Manager for the CLI
10
+ # @private
11
+ class Manager
12
+ include ApplicationLoader
13
+
14
+ def initialize(options)
15
+ @global_options = options
16
+ end
17
+
18
+ # Starts a new worker
19
+ # @option options [Boolean] :restart Whether the worker is being restarted
20
+ # @option options [Boolean] :daemonize Whether the worker should be daemonized
21
+ # @option options [String] :environment The Rails environment to load
22
+ # @option options [String] :queue The queue(s) to watch
23
+ # @option options [String] :threads The number of threads to use for job execution
24
+ # @option options [Integer] :poll_interval The interval in seconds for job polling
25
+ # @return [Void]
26
+ def start(options)
27
+ daemonize(restarting: options[:restart]) if options[:daemonize]
28
+
29
+ load_application(options[:environment])
30
+
31
+ config_files = if options[:configfile].is_a? DefaultConfigFileValue
32
+ options[:configfile].to_a(@global_options[:identifier])
33
+ else
34
+ options[:configfile]&.map { |path| File.expand_path(path, Rails.root) }
35
+ end
36
+
37
+ worker_options = DEFAULT_CONFIGURATION.dup
38
+
39
+ config_files&.each do |path|
40
+ puts "Loading config file: #{path}" if verbose?
41
+ config = begin
42
+ YAML.safe_load(File.read(path), symbolize_names: true)
43
+ rescue => e
44
+ raise Error, "Cannot read config file: #{path} (#{e.to_s})"
45
+ end
46
+ unless config.keys == [:exekutor]
47
+ raise Error, "Config should have an `exekutor` root node: #{path} (Found: #{config.keys.join(', ')})"
48
+ end
49
+
50
+ # Remove worker specific options before calling Exekutor.config.set
51
+ worker_options.merge! config[:exekutor].extract!(:queue, :status_server_port)
52
+
53
+ begin
54
+ Exekutor.config.set **config[:exekutor]
55
+ rescue => e
56
+ raise Error, "Cannot load config file: #{path} (#{e.to_s})"
57
+ end
58
+ end
59
+
60
+ worker_options.merge! Exekutor.config.worker_options
61
+ worker_options.merge! @global_options.slice(:identifier)
62
+ if verbose?
63
+ worker_options[:verbose] = true
64
+ elsif quiet?
65
+ worker_options[:quiet] = true
66
+ end
67
+ if options[:threads] && !options[:threads].is_a?(DefaultOptionValue)
68
+ min, max = if options[:threads].is_a?(Integer)
69
+ [options[:threads], options[:threads]]
70
+ else
71
+ options[:threads].to_s.split(":")
72
+ end
73
+ if max.nil?
74
+ options[:min_threads] = options[:max_threads] = Integer(min)
75
+ else
76
+ options[:min_threads] = Integer(min)
77
+ options[:max_threads] = Integer(max)
78
+ end
79
+ end
80
+ worker_options.merge!(
81
+ options.slice(:queue, :min_threads, :max_threads, :poll_interval)
82
+ .reject { |_, value| value.is_a? DefaultOptionValue }
83
+ .transform_keys(poll_interval: :polling_interval)
84
+ )
85
+
86
+ worker_options[:queue] = nil if worker_options[:queue] == ["*"]
87
+
88
+ # TODO health check server
89
+
90
+ # Specify `yield: true` to prevent running in the context of the loaded module
91
+ ActiveSupport.on_load(:exekutor, yield: true) do
92
+ ActiveSupport.on_load(:active_record, yield: true) do
93
+ worker = Worker.new(worker_options)
94
+ %w[INT TERM QUIT].each do |signal|
95
+ ::Kernel.trap(signal) { ::Thread.new { worker.stop } }
96
+ end
97
+
98
+ Process.setproctitle "Exekutor worker #{worker.id} [#{Rails.root}]"
99
+ if worker_options[:set_db_connection_name]
100
+ Internal::BaseRecord.connection.class.set_callback(:checkout, :after) do
101
+ Internal::DatabaseConnection.set_application_name raw_connection, worker.id
102
+ end
103
+ Internal::BaseRecord.connection_pool.connections.each do |conn|
104
+ Internal::DatabaseConnection.set_application_name conn.raw_connection, worker.id
105
+ end
106
+ end
107
+
108
+ ActiveSupport.on_load(:active_job, yield: true) do
109
+ puts "Worker #{worker.id} started (Use `#{Rainbow("ctrl + c").magenta}` to stop)" unless quiet?
110
+ puts "#{worker_options.pretty_inspect}" if verbose?
111
+ begin
112
+ worker.start
113
+ worker.join
114
+ ensure
115
+ worker.stop if worker.running?
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ def stop(options)
123
+ daemon = Daemon.new(pidfile: pidfile)
124
+ pid = daemon.pid
125
+ if pid.nil?
126
+ unless quiet?
127
+ if options[:restart]
128
+ puts "Executor was not running"
129
+ else
130
+ puts "Executor is not running (pidfile not found at #{daemon.pidfile})"
131
+ end
132
+ end
133
+ return
134
+ elsif daemon.status? :not_running, :dead
135
+ return
136
+ end
137
+
138
+ Process.kill("INT", pid)
139
+ sleep(0.3)
140
+ wait_until = if options[:shutdown_timeout].nil? || options[:shutdown_timeout] == DEFAULT_FOREVER
141
+ nil
142
+ else
143
+ Time.now + options[:shutdown_timeout]
144
+ end
145
+ while daemon.status?(:running, :not_owned)
146
+ puts "Waiting for worker to finish…" unless quiet?
147
+ if wait_until && wait_until > Time.now
148
+ Process.kill("TERM", pid)
149
+ break
150
+ end
151
+ sleep 0.1
152
+ end
153
+ puts "Worker (PID: #{pid}) stopped." unless quiet?
154
+ end
155
+
156
+ def restart(stop_options, start_options)
157
+ stop stop_options.merge(restart: true)
158
+ start start_options.merge(restart: true, daemonize: true)
159
+ end
160
+
161
+ private
162
+
163
+ # @return [Boolean] Whether quiet mode is enabled. Overrides verbose mode.
164
+ def quiet?
165
+ !!@global_options[:quiet]
166
+ end
167
+
168
+ # @return [Boolean] Whether verbose mode is enabled. Always returns false if quiet mode is enabled.
169
+ def verbose?
170
+ !quiet? && !!@global_options[:verbose]
171
+ end
172
+
173
+ # @return [String] The identifier for this worker
174
+ def identifier
175
+ @global_options[:identifier]
176
+ end
177
+
178
+ # @return [String] The path to the pidfile
179
+ def pidfile
180
+ pidfile = @global_options[:pidfile] || DEFAULT_PIDFILE
181
+ if pidfile == DEFAULT_PIDFILE
182
+ pidfile.for_identifier(identifier)
183
+ elsif identifier && pidfile.include?("%{identifier}")
184
+ pidfile.sub "%{identifier}", identifier
185
+ else
186
+ pidfile
187
+ end
188
+ end
189
+
190
+ # Daemonizes the current process. Do this before loading your application to prevent deadlocks.
191
+ # @return [Void]
192
+ def daemonize(restarting: false)
193
+ daemonizer = Daemon.new(pidfile: pidfile)
194
+ daemonizer.validate!
195
+ unless quiet?
196
+ if restarting
197
+ puts "Restarting worker as a daemon…"
198
+ else
199
+ stop_options = if @global_options[:pidfile] && @global_options[:pidfile] != DEFAULT_PIDFILE
200
+ "--pid #{pidfile} "
201
+ elsif identifier
202
+ "--id #{identifier} "
203
+ end
204
+
205
+ puts "Running worker as a daemon… (Use `#{Rainbow("exekutor #{stop_options}stop").magenta}` to stop)"
206
+ end
207
+ end
208
+ daemonizer.daemonize
209
+ rescue Daemon::Error => e
210
+ puts Rainbow(e.message).red
211
+ raise GLI::CustomExit.new(nil, 1)
212
+ end
213
+
214
+ class DefaultPidFileValue < DefaultOptionValue
215
+ def initialize
216
+ super("tmp/pids/exekutor[.%{identifier}].pid")
217
+ end
218
+
219
+ def for_identifier(identifier)
220
+ if identifier.nil? || identifier.length.zero?
221
+ "tmp/pids/exekutor.pid"
222
+ else
223
+ "tmp/pids/exekutor.#{identifier}.pid"
224
+ end
225
+ end
226
+ end
227
+
228
+ class DefaultConfigFileValue < DefaultOptionValue
229
+ def initialize
230
+ super('"config/exekutor.yml", overridden by "config/exekutor.%{identifier}.yml" if an identifier is specified')
231
+ end
232
+
233
+ def to_a(identifier = nil)
234
+ files = []
235
+ files << %w[config/exekutor.yml config/exekutor.yaml]
236
+ .lazy.map { |path| Rails.root.join(path) }
237
+ .find { |path| File.exists? path }
238
+ if identifier.present?
239
+ files << %W[config/exekutor.#{identifier}.yml config/exekutor.#{identifier}.yaml]
240
+ .lazy.map { |path| Rails.root.join(path) }
241
+ .find { |path| File.exists? path }
242
+ end
243
+ files.compact
244
+ end
245
+ end
246
+
247
+ DEFAULT_PIDFILE = DefaultPidFileValue.new.freeze
248
+ DEFAULT_CONFIG_FILES = DefaultConfigFileValue.new.freeze
249
+
250
+ DEFAULT_THREADS = DefaultOptionValue.new("Minimum: 1, Maximum: Active record pool size minus 1, with a minimum of 1").freeze
251
+ DEFAULT_QUEUE = DefaultOptionValue.new("All queues").freeze
252
+ DEFAULT_FOREVER = DefaultOptionValue.new("Forever").freeze
253
+
254
+ DEFAULT_CONFIGURATION = { set_db_connection_name: true }
255
+
256
+ class Error < StandardError; end
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exekutor
4
+ module Internal
5
+ # DSL for the configuration
6
+ # @private
7
+ module ConfigurationBuilder
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ class_attribute :__option_names, instance_writer: false, default: []
12
+ end
13
+
14
+ # Indicates an unset value
15
+ # @private
16
+ DEFAULT_VALUE = Object.new.freeze
17
+ private_constant "DEFAULT_VALUE"
18
+
19
+ # Sets option values in bulk
20
+ # @return [self]
21
+ def set(**options)
22
+ invalid_options = options.keys - __option_names
23
+ if invalid_options.present?
24
+ raise error_class, "Invalid option#{"s" if invalid_options.many?}: #{invalid_options.map(&:inspect).join(", ")}"
25
+ end
26
+
27
+ options.each do |name, value|
28
+ send "#{name}=", value
29
+ end
30
+ self
31
+ end
32
+
33
+ module ClassMethods
34
+ # Defines a configuration option with the given name.
35
+ # @param name [Symbol] the name of the option
36
+ # @param required [Boolean] whether a value is required. If +true+, any +nil+ or +blank?+ value will not be allowed.
37
+ # @param type [Class,Array<Class>] the allowed value types. If set the value must be an instance of any of the given classes.
38
+ # @param enum [Array<Any>] the allowed values. If set the value must be one of the given values.
39
+ # @param range [Range] the allowed value range. If set the value must be included in this range.
40
+ # @param default [Any] the default value
41
+ # @param reader [Symbol] the name of the reader method
42
+ def define_option(name, required: false, type: nil, enum: nil, range: nil, default: DEFAULT_VALUE,
43
+ reader: name)
44
+ __option_names << name
45
+ if reader
46
+ define_method reader do
47
+ if instance_variable_defined? :"@#{name}"
48
+ instance_variable_get :"@#{name}"
49
+ elsif default.respond_to? :call
50
+ default.call
51
+ elsif default != DEFAULT_VALUE
52
+ default
53
+ end
54
+ end
55
+ end
56
+ define_method "#{name}=" do |value|
57
+ validate_option_presence! name, value if required
58
+ validate_option_type! name, value, *type if type.present?
59
+ validate_option_enum! name, value, *enum if enum.present?
60
+ validate_option_range! name, value, range if range.present?
61
+ yield value if block_given?
62
+
63
+ instance_variable_set :"@#{name}", value
64
+ self
65
+ end
66
+ end
67
+ end
68
+
69
+ # Validates whether the option is present for configuration values that are required
70
+ # raise [StandardError] if the value is nil or blank
71
+ def validate_option_presence!(name, value)
72
+ unless value.present? || value.is_a?(FalseClass)
73
+ raise error_class, "##{name} cannot be #{value.nil? ? "nil" : "blank"}"
74
+ end
75
+ end
76
+
77
+ # Validates whether the value class is allowed
78
+ # @raise [StandardError] if the type of value is not allowed
79
+ def validate_option_type!(name, value, *allowed_types)
80
+ return if allowed_types.include?(value.class)
81
+
82
+ raise error_class, "##{name} should be an instance of #{allowed_types.to_sentence(last_word_connector: ', or ')} (Actual: #{value.class})"
83
+ end
84
+
85
+ # Validates whether the value is a valid enum option
86
+ # @raise [StandardError] if the value is not included in the allowed values
87
+ def validate_option_enum!(name, value, *allowed_values)
88
+ return if allowed_values.include?(value)
89
+
90
+ raise error_class, "##{name} should be one of #{allowed_values.map(&:inspect).to_sentence(last_word_connector: ', or ')}"
91
+ end
92
+
93
+ # Validates whether the value falls in the allowed range
94
+ # @raise [StandardError] if the value is not included in the allowed range
95
+ def validate_option_range!(name, value, allowed_range)
96
+ return if allowed_range.include?(value)
97
+
98
+ raise error_class, "##{name} should be between #{allowed_range.first} and #{allowed_range.last}#{
99
+ if allowed_range.respond_to?(:exclude_end?) && allowed_range.exclude_end?
100
+ " (exclusive)"
101
+ end}"
102
+ end
103
+
104
+ protected
105
+
106
+ # The error class to raise when an invalid option value is set
107
+ # @return [Class<StandardError>]
108
+ def error_class
109
+ raise "Implementing class should override #error_class"
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,21 @@
1
+ module Exekutor
2
+ # @private
3
+ module Internal
4
+ # Helper methods for the DB connection name
5
+ module DatabaseConnection
6
+ # Sets the connection name
7
+ def self.set_application_name(pg_conn, id, process = nil)
8
+ pg_conn.exec("SET application_name = #{pg_conn.escape_identifier(application_name(id, process))}")
9
+ end
10
+
11
+ # The connection name for the specified worker id and process
12
+ def self.application_name(id, process = nil)
13
+ "Exekutor[id: #{id}]#{" #{process}" if process}"
14
+ end
15
+
16
+ def self.ensure_active!(connection = BaseRecord.connection)
17
+ connection.reconnect! unless connection.active?
18
+ end
19
+ end
20
+ end
21
+ end