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