exekutor 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|