exekutor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +3 -0
- data/LICENSE.txt +21 -0
- data/exe/exekutor +7 -0
- data/lib/active_job/queue_adapters/exekutor_adapter.rb +14 -0
- data/lib/exekutor/asynchronous.rb +188 -0
- data/lib/exekutor/cleanup.rb +56 -0
- data/lib/exekutor/configuration.rb +373 -0
- data/lib/exekutor/hook.rb +172 -0
- data/lib/exekutor/info/worker.rb +20 -0
- data/lib/exekutor/internal/base_record.rb +11 -0
- data/lib/exekutor/internal/callbacks.rb +138 -0
- data/lib/exekutor/internal/cli/app.rb +173 -0
- data/lib/exekutor/internal/cli/application_loader.rb +36 -0
- data/lib/exekutor/internal/cli/cleanup.rb +96 -0
- data/lib/exekutor/internal/cli/daemon.rb +108 -0
- data/lib/exekutor/internal/cli/default_option_value.rb +29 -0
- data/lib/exekutor/internal/cli/info.rb +126 -0
- data/lib/exekutor/internal/cli/manager.rb +260 -0
- data/lib/exekutor/internal/configuration_builder.rb +113 -0
- data/lib/exekutor/internal/database_connection.rb +21 -0
- data/lib/exekutor/internal/executable.rb +75 -0
- data/lib/exekutor/internal/executor.rb +242 -0
- data/lib/exekutor/internal/hooks.rb +87 -0
- data/lib/exekutor/internal/listener.rb +176 -0
- data/lib/exekutor/internal/logger.rb +74 -0
- data/lib/exekutor/internal/provider.rb +308 -0
- data/lib/exekutor/internal/reserver.rb +95 -0
- data/lib/exekutor/internal/status_server.rb +132 -0
- data/lib/exekutor/job.rb +31 -0
- data/lib/exekutor/job_error.rb +11 -0
- data/lib/exekutor/job_options.rb +95 -0
- data/lib/exekutor/plugins/appsignal.rb +46 -0
- data/lib/exekutor/plugins.rb +13 -0
- data/lib/exekutor/queue.rb +141 -0
- data/lib/exekutor/version.rb +6 -0
- data/lib/exekutor/worker.rb +219 -0
- data/lib/exekutor.rb +49 -0
- data/lib/generators/exekutor/configuration_generator.rb +18 -0
- data/lib/generators/exekutor/install_generator.rb +43 -0
- data/lib/generators/exekutor/templates/install/functions/job_notifier.sql +7 -0
- data/lib/generators/exekutor/templates/install/functions/requeue_orphaned_jobs.sql +7 -0
- data/lib/generators/exekutor/templates/install/initializers/exekutor.rb.erb +14 -0
- data/lib/generators/exekutor/templates/install/migrations/create_exekutor_schema.rb.erb +83 -0
- data/lib/generators/exekutor/templates/install/triggers/notify_workers.sql +6 -0
- data/lib/generators/exekutor/templates/install/triggers/requeue_orphaned_jobs.sql +5 -0
- data.tar.gz.sig +0 -0
- metadata +403 -0
- metadata.gz.sig +0 -0
@@ -0,0 +1,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
|