solid_queue 1.4.0 → 1.6.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 +4 -4
- data/README.md +60 -9
- data/UPGRADING.md +11 -0
- data/app/models/solid_queue/claimed_execution.rb +23 -14
- data/app/models/solid_queue/job/executable.rb +6 -5
- data/app/models/solid_queue/job/schedulable.rb +2 -1
- data/app/models/solid_queue/queue.rb +1 -3
- data/app/models/solid_queue/queue_selector.rb +2 -2
- data/app/models/solid_queue/record/distinct_values.rb +50 -0
- data/app/models/solid_queue/record.rb +13 -0
- data/app/models/solid_queue/recurring_task.rb +30 -4
- data/app/models/solid_queue/semaphore.rb +7 -1
- data/lib/active_job/queue_adapters/solid_queue_adapter.rb +9 -1
- data/lib/generators/solid_queue/install/templates/config/queue.yml +1 -1
- data/lib/generators/solid_queue/update/update_generator.rb +19 -0
- data/lib/solid_queue/cli.rb +10 -0
- data/lib/solid_queue/configuration.rb +123 -29
- data/lib/solid_queue/engine.rb +10 -0
- data/lib/solid_queue/fiber_pool.rb +130 -0
- data/lib/solid_queue/fork_supervisor.rb +23 -3
- data/lib/solid_queue/log_subscriber.rb +5 -0
- data/lib/solid_queue/pool.rb +46 -25
- data/lib/solid_queue/processes/runnable.rb +90 -3
- data/lib/solid_queue/scheduler/recurring_schedule.rb +7 -5
- data/lib/solid_queue/supervisor/signals.rb +1 -1
- data/lib/solid_queue/supervisor.rb +2 -0
- data/lib/solid_queue/tasks.rb +11 -0
- data/lib/solid_queue/thread_pool.rb +28 -0
- data/lib/solid_queue/timer.rb +3 -4
- data/lib/solid_queue/version.rb +5 -1
- data/lib/solid_queue/worker.rb +9 -3
- data/lib/solid_queue.rb +14 -0
- metadata +21 -3
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
module SolidQueue
|
|
4
4
|
class Configuration
|
|
5
5
|
include ActiveModel::Model
|
|
6
|
+
include ActiveModel::Validations::Callbacks
|
|
6
7
|
|
|
7
|
-
validate :ensure_configured_processes
|
|
8
|
-
validate :
|
|
9
|
-
validate :
|
|
8
|
+
validate :ensure_configured_processes, :ensure_valid_recurring_tasks
|
|
9
|
+
validate :ensure_valid_worker_execution_options
|
|
10
|
+
validate :ensure_fiber_workers_have_required_dependency, :ensure_fiber_workers_use_supported_isolation_level
|
|
11
|
+
validate :warn_about_incorrectly_sized_database_pool, :warn_about_missing_config_files
|
|
12
|
+
|
|
13
|
+
before_validation { warnings.clear }
|
|
10
14
|
|
|
11
15
|
class Process < Struct.new(:kind, :attributes)
|
|
12
16
|
def instantiate
|
|
@@ -35,38 +39,48 @@ module SolidQueue
|
|
|
35
39
|
|
|
36
40
|
DEFAULT_CONFIG_FILE_PATH = "config/queue.yml"
|
|
37
41
|
DEFAULT_RECURRING_SCHEDULE_FILE_PATH = "config/recurring.yml"
|
|
42
|
+
FIBER_QUERY_SCOPED_CONNECTIONS_VERSION = Gem::Version.new("7.2.0")
|
|
38
43
|
|
|
39
44
|
def initialize(**options)
|
|
40
45
|
@options = options.with_defaults(default_options)
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
def configured_processes
|
|
44
|
-
if only_work?
|
|
49
|
+
if only_work?
|
|
50
|
+
workers
|
|
51
|
+
elsif only_recurring?
|
|
52
|
+
schedulers
|
|
45
53
|
else
|
|
46
54
|
dispatchers + workers + schedulers
|
|
47
55
|
end
|
|
48
56
|
end
|
|
49
57
|
|
|
50
|
-
def
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
else
|
|
54
|
-
error_messages = invalid_tasks.map do |task|
|
|
55
|
-
all_messages = task.errors.full_messages.map { |msg| "\t#{msg}" }.join("\n")
|
|
56
|
-
"#{task.key}:\n#{all_messages}"
|
|
57
|
-
end
|
|
58
|
-
.join("\n")
|
|
58
|
+
def mode
|
|
59
|
+
options[:mode].to_s.inquiry
|
|
60
|
+
end
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
def standalone?
|
|
63
|
+
mode.fork? || options[:standalone]
|
|
62
64
|
end
|
|
63
65
|
|
|
64
|
-
def
|
|
65
|
-
@
|
|
66
|
+
def warnings
|
|
67
|
+
@warnings ||= ActiveModel::Errors.new(self)
|
|
66
68
|
end
|
|
67
69
|
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
+
def check
|
|
71
|
+
if valid?
|
|
72
|
+
warnings.full_messages.each { |warning| $stderr.puts warning }
|
|
73
|
+
$stdout.puts "Solid Queue configuration is valid."
|
|
74
|
+
|
|
75
|
+
true
|
|
76
|
+
else
|
|
77
|
+
$stderr.puts "Solid Queue configuration is invalid:"
|
|
78
|
+
(warnings.full_messages + errors.full_messages).each do |message|
|
|
79
|
+
message.each_line { |line| $stderr.puts " #{line.chomp}" }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
false
|
|
83
|
+
end
|
|
70
84
|
end
|
|
71
85
|
|
|
72
86
|
private
|
|
@@ -88,10 +102,53 @@ module SolidQueue
|
|
|
88
102
|
end
|
|
89
103
|
end
|
|
90
104
|
|
|
91
|
-
def
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
105
|
+
def warn_about_incorrectly_sized_database_pool
|
|
106
|
+
db_pool_size = SolidQueue::Record.connection_pool&.size
|
|
107
|
+
|
|
108
|
+
if db_pool_size && db_pool_size < estimated_database_pool_size
|
|
109
|
+
warnings.add(:base, "Warning: Solid Queue needs at least #{estimated_database_pool_size} database connections " \
|
|
110
|
+
"for the configured workers but the database connection pool is #{db_pool_size}. Increase it in `config/database.yml`")
|
|
111
|
+
end
|
|
112
|
+
rescue ActiveRecord::ActiveRecordError
|
|
113
|
+
# No usable database connection. Skip the pool-size warning in that case.
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def warn_about_missing_config_files
|
|
117
|
+
files = [ options[:config_file] ]
|
|
118
|
+
files << options[:recurring_schedule_file] unless skip_recurring_tasks?
|
|
119
|
+
|
|
120
|
+
files.compact.each do |file|
|
|
121
|
+
unless Pathname.new(file).exist?
|
|
122
|
+
warnings.add(:base, "Warning: provided configuration file '#{file}' does not exist. Falling back to default configuration.")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def ensure_valid_worker_execution_options
|
|
128
|
+
workers_options.each do |options|
|
|
129
|
+
if options.key?(:threads) && options.key?(:fibers)
|
|
130
|
+
errors.add(:base, "Workers can specify either `threads` or `fibers`, but not both.")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def ensure_fiber_workers_have_required_dependency
|
|
136
|
+
return unless workers_options.any? { |options| fiber_worker?(options) }
|
|
137
|
+
|
|
138
|
+
require "async"
|
|
139
|
+
require "async/semaphore"
|
|
140
|
+
rescue LoadError
|
|
141
|
+
errors.add(:base, "Fiber workers require the `async` gem. " \
|
|
142
|
+
"Add `gem \"async\"` to your Gemfile to configure workers with `fibers`.")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def ensure_fiber_workers_use_supported_isolation_level
|
|
146
|
+
return unless workers_options.any? { |options| fiber_worker?(options) }
|
|
147
|
+
|
|
148
|
+
unless ActiveSupport::IsolatedExecutionState.isolation_level == :fiber
|
|
149
|
+
errors.add(:base, "Fiber workers require fiber-scoped isolated execution state. " \
|
|
150
|
+
"Set `config.active_support.isolation_level = :fiber` in your Rails configuration " \
|
|
151
|
+
"(or `ActiveSupport::IsolatedExecutionState.isolation_level = :fiber` outside Rails).")
|
|
95
152
|
end
|
|
96
153
|
end
|
|
97
154
|
|
|
@@ -103,6 +160,7 @@ module SolidQueue
|
|
|
103
160
|
recurring_schedule_file: Rails.root.join(ENV["SOLID_QUEUE_RECURRING_SCHEDULE"] || DEFAULT_RECURRING_SCHEDULE_FILE_PATH),
|
|
104
161
|
only_work: false,
|
|
105
162
|
only_dispatch: false,
|
|
163
|
+
only_recurring: ActiveModel::Type::Boolean.new.cast(ENV["SOLID_QUEUE_ONLY_RECURRING"]),
|
|
106
164
|
skip_recurring: ActiveModel::Type::Boolean.new.cast(ENV["SOLID_QUEUE_SKIP_RECURRING"])
|
|
107
165
|
}
|
|
108
166
|
end
|
|
@@ -119,6 +177,10 @@ module SolidQueue
|
|
|
119
177
|
options[:only_dispatch]
|
|
120
178
|
end
|
|
121
179
|
|
|
180
|
+
def only_recurring?
|
|
181
|
+
options[:only_recurring]
|
|
182
|
+
end
|
|
183
|
+
|
|
122
184
|
def skip_recurring_tasks?
|
|
123
185
|
options[:skip_recurring] || only_work?
|
|
124
186
|
end
|
|
@@ -131,7 +193,8 @@ module SolidQueue
|
|
|
131
193
|
1
|
|
132
194
|
end
|
|
133
195
|
|
|
134
|
-
|
|
196
|
+
defaults = worker_defaults_for(worker_options)
|
|
197
|
+
processes.times.map { Process.new(:worker, worker_options.with_defaults(defaults)) }
|
|
135
198
|
end
|
|
136
199
|
end
|
|
137
200
|
|
|
@@ -221,15 +284,46 @@ module SolidQueue
|
|
|
221
284
|
if file.exist?
|
|
222
285
|
ActiveSupport::ConfigurationFile.parse(file).deep_symbolize_keys
|
|
223
286
|
else
|
|
224
|
-
puts "[solid_queue] WARNING: Provided configuration file '#{file}' does not exist. Falling back to default configuration."
|
|
225
287
|
{}
|
|
226
288
|
end
|
|
227
289
|
end
|
|
228
290
|
|
|
229
|
-
def
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
291
|
+
def estimated_database_pool_size
|
|
292
|
+
worker_pool_size = workers_options.map { |options| estimated_database_pool_size_for_worker(options) }.max
|
|
293
|
+
worker_pool_size || 1
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def estimated_database_pool_size_for_worker(options)
|
|
297
|
+
# Connections used to execute jobs + 1 for the worker's polling thread + 1 for the heartbeat task
|
|
298
|
+
estimated_execution_connections_for_worker(options) + 2
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def worker_capacity(options)
|
|
302
|
+
options[:fibers] || options[:threads] || WORKER_DEFAULTS[:threads]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def estimated_execution_connections_for_worker(options)
|
|
306
|
+
fiber_worker?(options) ? fiber_execution_connections_for_worker(options) : worker_capacity(options)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def fiber_execution_connections_for_worker(options)
|
|
310
|
+
fiber_jobs_release_connections_between_queries? ? 1 : worker_capacity(options)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def fiber_jobs_release_connections_between_queries?
|
|
314
|
+
ActiveRecord.gem_version >= FIBER_QUERY_SCOPED_CONNECTIONS_VERSION
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def fiber_worker?(options)
|
|
318
|
+
options.key?(:fibers)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def worker_defaults_for(options)
|
|
322
|
+
if fiber_worker?(options)
|
|
323
|
+
WORKER_DEFAULTS.except(:threads)
|
|
324
|
+
else
|
|
325
|
+
WORKER_DEFAULTS
|
|
326
|
+
end
|
|
233
327
|
end
|
|
234
328
|
end
|
|
235
329
|
end
|
data/lib/solid_queue/engine.rb
CHANGED
|
@@ -16,6 +16,12 @@ module SolidQueue
|
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
initializer "solid_queue.time_zone" do |app|
|
|
20
|
+
unless config.solid_queue.key?(:time_zone)
|
|
21
|
+
SolidQueue.time_zone = app.config.time_zone
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
19
25
|
initializer "solid_queue.app_executor", before: :run_prepare_callbacks do |app|
|
|
20
26
|
config.solid_queue.app_executor ||= app.executor
|
|
21
27
|
config.solid_queue.on_thread_error ||= ->(exception) { Rails.error.report(exception, handled: false) }
|
|
@@ -37,5 +43,9 @@ module SolidQueue
|
|
|
37
43
|
include ActiveJob::ConcurrencyControls
|
|
38
44
|
end
|
|
39
45
|
end
|
|
46
|
+
|
|
47
|
+
initializer "solid_queue.deprecator" do |app|
|
|
48
|
+
app.deprecators[:solid_queue] = SolidQueue.deprecator
|
|
49
|
+
end
|
|
40
50
|
end
|
|
41
51
|
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
class FiberPool < Pool
|
|
5
|
+
def initialize(size, on_idle: nil)
|
|
6
|
+
super
|
|
7
|
+
|
|
8
|
+
@state_mutex = Mutex.new
|
|
9
|
+
@shutdown = false
|
|
10
|
+
@fatal_error = nil
|
|
11
|
+
@boot_queue = Thread::Queue.new
|
|
12
|
+
@pending_executions = Thread::Queue.new
|
|
13
|
+
@reactor_thread = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def post(execution)
|
|
17
|
+
raise_if_fatal_error!
|
|
18
|
+
raise RuntimeError, "Execution pool is shutting down" if shutdown?
|
|
19
|
+
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def available_capacity
|
|
24
|
+
raise_if_fatal_error!
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def shutdown
|
|
29
|
+
state_mutex.synchronize do
|
|
30
|
+
next false if @shutdown
|
|
31
|
+
|
|
32
|
+
@shutdown = true
|
|
33
|
+
end.tap do |shut_down|
|
|
34
|
+
# Wake the reactor: already-queued executions are drained before the
|
|
35
|
+
# blocked pop in +wait_for_executions+ returns nil
|
|
36
|
+
pending_executions.close if shut_down
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def shutdown?
|
|
41
|
+
state_mutex.synchronize { @shutdown }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def wait_for_termination(timeout)
|
|
45
|
+
reactor_thread&.join(timeout)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
attr_reader :boot_queue, :pending_executions, :reactor_thread, :state_mutex
|
|
50
|
+
|
|
51
|
+
def name
|
|
52
|
+
@name ||= "solid_queue-fiber-pool-#{object_id}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def schedule(execution)
|
|
56
|
+
start_reactor_if_needed
|
|
57
|
+
pending_executions << execution
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The reactor thread is started lazily, when the first execution is posted,
|
|
61
|
+
# so that the pool can be safely built before forking: in the default fork
|
|
62
|
+
# supervisor mode, workers are instantiated in the supervisor process, and
|
|
63
|
+
# a thread started there wouldn't survive the fork. The async gem is also
|
|
64
|
+
# required lazily here, so that setups without fiber workers never load it.
|
|
65
|
+
def start_reactor_if_needed
|
|
66
|
+
@reactor_thread ||= begin
|
|
67
|
+
require "async"
|
|
68
|
+
require "async/semaphore"
|
|
69
|
+
|
|
70
|
+
start_reactor.tap do
|
|
71
|
+
boot_result = boot_queue.pop
|
|
72
|
+
raise boot_result if boot_result.is_a?(Exception)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def start_reactor
|
|
78
|
+
create_thread do
|
|
79
|
+
Async do |task|
|
|
80
|
+
semaphore = Async::Semaphore.new(size, parent: task)
|
|
81
|
+
boot_queue << :ready
|
|
82
|
+
|
|
83
|
+
# The reactor exits when all in-flight execution fibers, children
|
|
84
|
+
# of this task, have finished
|
|
85
|
+
wait_for_executions(semaphore)
|
|
86
|
+
end
|
|
87
|
+
rescue Exception => error
|
|
88
|
+
register_fatal_error(error)
|
|
89
|
+
raise
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def wait_for_executions(semaphore)
|
|
94
|
+
# Thread::Queue#pop is fiber-scheduler-aware: it suspends this fiber, letting
|
|
95
|
+
# execution fibers run, and wakes the reactor when the poller thread pushes new
|
|
96
|
+
# work or closes the queue on shutdown, after which it drains any remaining
|
|
97
|
+
# executions and returns nil
|
|
98
|
+
while execution = pending_executions.pop
|
|
99
|
+
semaphore.async(execution) do |_execution_task, scheduled_execution|
|
|
100
|
+
perform_execution(scheduled_execution)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def perform_execution(execution)
|
|
106
|
+
wrap_in_app_executor { execution.perform }
|
|
107
|
+
rescue Async::Stop => error
|
|
108
|
+
handle_thread_error(error)
|
|
109
|
+
register_fatal_error(error)
|
|
110
|
+
rescue Exception => error
|
|
111
|
+
handle_thread_error(error)
|
|
112
|
+
ensure
|
|
113
|
+
restore_capacity
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def register_fatal_error(error)
|
|
117
|
+
state_mutex.synchronize do
|
|
118
|
+
@fatal_error ||= error
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
boot_queue << error if boot_queue.empty?
|
|
122
|
+
on_idle&.call
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def raise_if_fatal_error!
|
|
126
|
+
error = state_mutex.synchronize { @fatal_error }
|
|
127
|
+
raise error if error
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -31,6 +31,21 @@ module SolidQueue
|
|
|
31
31
|
|
|
32
32
|
replace_fork(pid, status)
|
|
33
33
|
end
|
|
34
|
+
|
|
35
|
+
check_boot_timeouts
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def check_boot_timeouts
|
|
39
|
+
process_instances.each do |pid, instance|
|
|
40
|
+
terminate_unready_process(pid) if instance.boot_timed_out?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def terminate_unready_process(pid)
|
|
45
|
+
SolidQueue.instrument(:fork_boot_timeout, process: process_instances[pid], pid: pid) do
|
|
46
|
+
# A child stuck in boot cannot reach its run loop to stop gracefully
|
|
47
|
+
signal_process(pid, :KILL)
|
|
48
|
+
end
|
|
34
49
|
end
|
|
35
50
|
|
|
36
51
|
def reap_terminated_forks
|
|
@@ -38,9 +53,13 @@ module SolidQueue
|
|
|
38
53
|
pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG)
|
|
39
54
|
break unless pid
|
|
40
55
|
|
|
41
|
-
if
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
if terminated_fork = process_instances.delete(pid)
|
|
57
|
+
terminated_fork.mark_as_reaped
|
|
58
|
+
|
|
59
|
+
if !status.exited? || status.exitstatus.to_i > 0
|
|
60
|
+
error = Processes::ProcessExitError.new(status)
|
|
61
|
+
release_claimed_jobs_by(terminated_fork, with_error: error)
|
|
62
|
+
end
|
|
44
63
|
end
|
|
45
64
|
|
|
46
65
|
configured_processes.delete(pid)
|
|
@@ -52,6 +71,7 @@ module SolidQueue
|
|
|
52
71
|
def replace_fork(pid, status)
|
|
53
72
|
SolidQueue.instrument(:replace_fork, supervisor_pid: ::Process.pid, pid: pid, status: status) do |payload|
|
|
54
73
|
if terminated_fork = process_instances.delete(pid)
|
|
74
|
+
terminated_fork.mark_as_reaped
|
|
55
75
|
payload[:fork] = terminated_fork
|
|
56
76
|
error = Processes::ProcessExitError.new(status)
|
|
57
77
|
release_claimed_jobs_by(terminated_fork, with_error: error)
|
|
@@ -161,6 +161,11 @@ class SolidQueue::LogSubscriber < ActiveSupport::LogSubscriber
|
|
|
161
161
|
end
|
|
162
162
|
end
|
|
163
163
|
|
|
164
|
+
def fork_boot_timeout(event)
|
|
165
|
+
process = event.payload[:process]
|
|
166
|
+
warn formatted_event(event, action: "Terminate #{process.kind} that failed to boot in time", **event.payload.slice(:pid).merge(hostname: process.hostname, name: process.name))
|
|
167
|
+
end
|
|
168
|
+
|
|
164
169
|
private
|
|
165
170
|
def formatted_event(event, action:, **attributes)
|
|
166
171
|
"SolidQueue-#{SolidQueue::VERSION} #{action} (#{event.duration.round(1)}ms) #{formatted_attributes(**attributes)}"
|
data/lib/solid_queue/pool.rb
CHANGED
|
@@ -4,51 +4,72 @@ module SolidQueue
|
|
|
4
4
|
class Pool
|
|
5
5
|
include AppExecutor
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
def self.build(type:, size:, on_idle: nil)
|
|
8
|
+
SolidQueue.const_get("#{type.to_s.camelize}Pool").new(size, on_idle: on_idle)
|
|
9
|
+
end
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
attr_reader :size
|
|
10
12
|
|
|
11
13
|
def initialize(size, on_idle: nil)
|
|
12
14
|
@size = size
|
|
13
15
|
@on_idle = on_idle
|
|
14
|
-
@
|
|
16
|
+
@available_capacity = size
|
|
15
17
|
@mutex = Mutex.new
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
def type
|
|
21
|
+
self.class.name.demodulize.delete_suffix("Pool").underscore.to_sym
|
|
22
|
+
end
|
|
23
|
+
|
|
18
24
|
def post(execution)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
mutex.synchronize { on_idle.try(:call) if idle? }
|
|
27
|
-
end
|
|
28
|
-
end.on_rejection! do |e|
|
|
29
|
-
handle_thread_error(e)
|
|
25
|
+
reserve_capacity!
|
|
26
|
+
|
|
27
|
+
begin
|
|
28
|
+
schedule(execution)
|
|
29
|
+
rescue Exception
|
|
30
|
+
restore_capacity
|
|
31
|
+
raise
|
|
30
32
|
end
|
|
31
33
|
end
|
|
32
34
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
+
def available_capacity
|
|
36
|
+
mutex.synchronize { @available_capacity }
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
def idle?
|
|
38
|
-
|
|
40
|
+
available_capacity.positive?
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
private
|
|
42
|
-
attr_reader :
|
|
44
|
+
attr_reader :mutex, :on_idle
|
|
45
|
+
|
|
46
|
+
def schedule(execution)
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def perform_execution(execution)
|
|
51
|
+
wrap_in_app_executor { execution.perform }
|
|
52
|
+
rescue Exception => error
|
|
53
|
+
handle_thread_error(error)
|
|
54
|
+
ensure
|
|
55
|
+
restore_capacity
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reserve_capacity!
|
|
59
|
+
mutex.synchronize do
|
|
60
|
+
raise RuntimeError, "Execution pool is at capacity" if @available_capacity <= 0
|
|
43
61
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
@available_capacity -= 1
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def restore_capacity
|
|
67
|
+
should_notify = mutex.synchronize do
|
|
68
|
+
@available_capacity += 1
|
|
69
|
+
@available_capacity.positive?
|
|
70
|
+
end
|
|
49
71
|
|
|
50
|
-
|
|
51
|
-
@executor ||= Concurrent::ThreadPoolExecutor.new DEFAULT_OPTIONS.merge(max_threads: size, max_queue: size)
|
|
72
|
+
on_idle&.call if should_notify
|
|
52
73
|
end
|
|
53
74
|
end
|
|
54
75
|
end
|
|
@@ -4,7 +4,9 @@ module SolidQueue::Processes
|
|
|
4
4
|
module Runnable
|
|
5
5
|
include Supervised
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
def mode=(value)
|
|
8
|
+
@mode = (value || DEFAULT_MODE).to_s.inquiry
|
|
9
|
+
end
|
|
8
10
|
|
|
9
11
|
def start
|
|
10
12
|
run_in_mode do
|
|
@@ -29,21 +31,32 @@ module SolidQueue::Processes
|
|
|
29
31
|
!running_async? || @thread&.alive?
|
|
30
32
|
end
|
|
31
33
|
|
|
34
|
+
def boot_timed_out?
|
|
35
|
+
@boot_guard.timed_out?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def mark_as_reaped
|
|
39
|
+
@boot_guard.close
|
|
40
|
+
end
|
|
41
|
+
|
|
32
42
|
private
|
|
33
43
|
DEFAULT_MODE = :async
|
|
34
44
|
|
|
35
45
|
def mode
|
|
36
|
-
|
|
46
|
+
@mode ||= DEFAULT_MODE.to_s.inquiry
|
|
37
47
|
end
|
|
38
48
|
|
|
39
49
|
def run_in_mode(&block)
|
|
40
50
|
case
|
|
41
51
|
when running_as_fork?
|
|
42
|
-
|
|
52
|
+
@boot_guard = BootGuards::ForkGuard.new
|
|
53
|
+
fork(&block).tap { @boot_guard.start }
|
|
43
54
|
when running_async?
|
|
55
|
+
@boot_guard = BootGuards::NullGuard.new
|
|
44
56
|
@thread = create_thread(&block)
|
|
45
57
|
@thread.object_id
|
|
46
58
|
else
|
|
59
|
+
@boot_guard = BootGuards::NullGuard.new
|
|
47
60
|
block.call
|
|
48
61
|
end
|
|
49
62
|
end
|
|
@@ -57,6 +70,8 @@ module SolidQueue::Processes
|
|
|
57
70
|
end
|
|
58
71
|
end
|
|
59
72
|
end
|
|
73
|
+
|
|
74
|
+
@boot_guard.complete
|
|
60
75
|
end
|
|
61
76
|
|
|
62
77
|
def shutting_down?
|
|
@@ -93,4 +108,76 @@ module SolidQueue::Processes
|
|
|
93
108
|
mode.fork?
|
|
94
109
|
end
|
|
95
110
|
end
|
|
111
|
+
|
|
112
|
+
module BootGuards
|
|
113
|
+
# Tracks a process that shares memory with its supervisor, whose boot time
|
|
114
|
+
# doesn't need monitoring.
|
|
115
|
+
class NullGuard
|
|
116
|
+
def complete
|
|
117
|
+
@completed = true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def start
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def completed?
|
|
124
|
+
@completed
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def timed_out?
|
|
128
|
+
false
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def close
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Tracks a forked process from the moment it's started until its boot
|
|
136
|
+
# callbacks finish, over a pipe that survives forking: the forked process
|
|
137
|
+
# writes to it when it's done booting, and its supervisor reads from it to
|
|
138
|
+
# decide whether the process is taking too long to boot and needs replacing.
|
|
139
|
+
class ForkGuard
|
|
140
|
+
def initialize
|
|
141
|
+
@reader, @writer = IO.pipe
|
|
142
|
+
@created_at = SolidQueue::Timer.monotonic_time_now
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Runs in the forked process when it has finished booting
|
|
146
|
+
def complete
|
|
147
|
+
reader.close
|
|
148
|
+
writer.write(".")
|
|
149
|
+
rescue Errno::EPIPE
|
|
150
|
+
# The supervisor stopped waiting while this process finished booting
|
|
151
|
+
ensure
|
|
152
|
+
writer.close
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Runs in the parent process right after forking
|
|
156
|
+
def start
|
|
157
|
+
writer.close
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# A byte means boot completed; EOF means the process exited before
|
|
161
|
+
# finishing its boot, and will be replaced when it's reaped
|
|
162
|
+
def completed?
|
|
163
|
+
@completed ||= begin
|
|
164
|
+
completed = reader.read_nonblock(1, exception: false) != :wait_readable
|
|
165
|
+
reader.close if completed
|
|
166
|
+
completed
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def timed_out?
|
|
171
|
+
!completed? && SolidQueue::Timer.monotonic_time_now - created_at >= SolidQueue.fork_boot_timeout
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def close
|
|
175
|
+
reader.close unless reader.closed?
|
|
176
|
+
writer.close unless writer.closed?
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
attr_reader :reader, :writer, :created_at
|
|
181
|
+
end
|
|
182
|
+
end
|
|
96
183
|
end
|
|
@@ -33,8 +33,8 @@ module SolidQueue
|
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def schedule_task(task)
|
|
37
|
-
scheduled_tasks[task.key] = schedule(task)
|
|
36
|
+
def schedule_task(task, run_at: task.next_time)
|
|
37
|
+
scheduled_tasks[task.key] = schedule(task, run_at: run_at)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def unschedule_tasks
|
|
@@ -99,9 +99,11 @@ module SolidQueue
|
|
|
99
99
|
dynamic_tasks_enabled? ? RecurringTask.dynamic.to_a : []
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
-
def schedule(task)
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
def schedule(task, run_at: task.next_time)
|
|
103
|
+
delay = [ (run_at - Time.current).to_f, 0.1 ].max
|
|
104
|
+
|
|
105
|
+
scheduled_task = Concurrent::ScheduledTask.new(delay, args: [ self, task, run_at ]) do |thread_schedule, thread_task, thread_task_run_at|
|
|
106
|
+
thread_schedule.schedule_task(thread_task, run_at: thread_task.next_time_after(thread_task_run_at))
|
|
105
107
|
|
|
106
108
|
wrap_in_app_executor do
|
|
107
109
|
thread_task.enqueue(at: thread_task_run_at)
|