solid_queue 1.5.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +163 -6
  3. data/UPGRADING.md +12 -0
  4. data/app/models/solid_queue/batch/callbacks.rb +50 -0
  5. data/app/models/solid_queue/batch/clearable.rb +23 -0
  6. data/app/models/solid_queue/batch/status.rb +64 -0
  7. data/app/models/solid_queue/batch/sweepable.rb +64 -0
  8. data/app/models/solid_queue/batch.rb +133 -0
  9. data/app/models/solid_queue/batch_execution.rb +52 -0
  10. data/app/models/solid_queue/claimed_execution.rb +1 -0
  11. data/app/models/solid_queue/failed_execution/batchable.rb +22 -0
  12. data/app/models/solid_queue/failed_execution.rb +1 -1
  13. data/app/models/solid_queue/job/batchable.rb +50 -0
  14. data/app/models/solid_queue/job/executable.rb +5 -1
  15. data/app/models/solid_queue/job.rb +11 -3
  16. data/lib/active_job/batch_id.rb +57 -0
  17. data/lib/generators/solid_queue/install/templates/db/queue_schema.rb +31 -0
  18. data/lib/generators/solid_queue/update/templates/db/add_batches_to_solid_queue.rb +39 -0
  19. data/lib/solid_queue/configuration.rb +76 -11
  20. data/lib/solid_queue/dispatcher/concurrency_maintenance.rb +4 -37
  21. data/lib/solid_queue/dispatcher/maintenance.rb +79 -0
  22. data/lib/solid_queue/dispatcher.rb +13 -9
  23. data/lib/solid_queue/engine.rb +4 -0
  24. data/lib/solid_queue/fiber_pool.rb +130 -0
  25. data/lib/solid_queue/fork_supervisor.rb +13 -4
  26. data/lib/solid_queue/log_subscriber.rb +16 -1
  27. data/lib/solid_queue/pool.rb +46 -25
  28. data/lib/solid_queue/processes/runnable.rb +2 -5
  29. data/lib/solid_queue/processes/supervised.rb +7 -0
  30. data/lib/solid_queue/supervisor/signals.rb +3 -0
  31. data/lib/solid_queue/supervisor.rb +29 -16
  32. data/lib/solid_queue/thread_pool.rb +28 -0
  33. data/lib/solid_queue/version.rb +1 -1
  34. data/lib/solid_queue/worker.rb +9 -3
  35. data/lib/solid_queue.rb +1 -0
  36. metadata +29 -2
@@ -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
@@ -57,8 +57,7 @@ module SolidQueue
57
57
  terminated_fork.mark_as_reaped
58
58
 
59
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)
60
+ attempt_to_release_claimed_jobs_by(terminated_fork, status)
62
61
  end
63
62
  end
64
63
 
@@ -73,14 +72,24 @@ module SolidQueue
73
72
  if terminated_fork = process_instances.delete(pid)
74
73
  terminated_fork.mark_as_reaped
75
74
  payload[:fork] = terminated_fork
76
- error = Processes::ProcessExitError.new(status)
77
- release_claimed_jobs_by(terminated_fork, with_error: error)
75
+
76
+ attempt_to_release_claimed_jobs_by(terminated_fork, status)
78
77
 
79
78
  start_process(configured_processes.delete(pid))
80
79
  end
81
80
  end
82
81
  end
83
82
 
83
+ # The database may be unreachable — likely the same reason the fork
84
+ # terminated. Neither starting a replacement nor shutting down can depend
85
+ # on it: the jobs claimed by the terminated fork will be failed when its
86
+ # stale registration is pruned once the database is back.
87
+ def attempt_to_release_claimed_jobs_by(terminated_fork, status)
88
+ release_claimed_jobs_by(terminated_fork, with_error: Processes::ProcessExitError.new(status))
89
+ rescue StandardError => error
90
+ handle_thread_error(error)
91
+ end
92
+
84
93
  def all_processes_terminated?
85
94
  process_instances.empty?
86
95
  end
@@ -16,7 +16,10 @@ class SolidQueue::LogSubscriber < ActiveSupport::LogSubscriber
16
16
  end
17
17
 
18
18
  def fail_many_claimed(event)
19
- warn formatted_event(event, action: "Fail claimed jobs", **event.payload.slice(:job_ids, :process_ids))
19
+ attributes = event.payload.slice(:job_ids, :process_ids)
20
+ attributes[:error] = formatted_error(event.payload[:error]) if event.payload[:error]
21
+
22
+ warn formatted_event(event, action: "Fail claimed jobs", **attributes)
20
23
  end
21
24
 
22
25
  def release_claimed(event)
@@ -39,6 +42,18 @@ class SolidQueue::LogSubscriber < ActiveSupport::LogSubscriber
39
42
  debug formatted_event(event, action: "Discard job", **event.payload.slice(:job_id, :status))
40
43
  end
41
44
 
45
+ def finish_batch(event)
46
+ info formatted_event(event, action: "Finish batch", **event.payload.slice(:batch_id, :total_jobs, :completed_jobs, :failed_jobs))
47
+ end
48
+
49
+ def sweep_stalled_batches(event)
50
+ debug formatted_event(event, action: "Sweep stalled batches", **event.payload.slice(:stale_executions, :finished_batches, :started_batches))
51
+ end
52
+
53
+ def batch_progress_error(event)
54
+ error formatted_event(event, action: "Error updating batch progress", **event.payload.slice(:batch_id, :job_id), error: formatted_error(event.payload[:error]))
55
+ end
56
+
42
57
  def release_many_blocked(event)
43
58
  debug formatted_event(event, action: "Unblock jobs", **event.payload.slice(:limit, :size))
44
59
  end
@@ -4,51 +4,72 @@ module SolidQueue
4
4
  class Pool
5
5
  include AppExecutor
6
6
 
7
- attr_reader :size
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
- delegate :shutdown, :shutdown?, :wait_for_termination, to: :executor
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
- @available_threads = Concurrent::AtomicFixnum.new(size)
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
- available_threads.decrement
20
-
21
- Concurrent::Promises.future_on(executor, execution) do |thread_execution|
22
- wrap_in_app_executor do
23
- thread_execution.perform
24
- ensure
25
- available_threads.increment
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 idle_threads
34
- available_threads.value
35
+ def available_capacity
36
+ mutex.synchronize { @available_capacity }
35
37
  end
36
38
 
37
39
  def idle?
38
- idle_threads > 0
40
+ available_capacity.positive?
39
41
  end
40
42
 
41
43
  private
42
- attr_reader :available_threads, :on_idle, :mutex
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
- DEFAULT_OPTIONS = {
45
- min_threads: 0,
46
- idletime: 60,
47
- fallback_policy: :abort
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
- def executor
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
@@ -50,7 +50,7 @@ module SolidQueue::Processes
50
50
  case
51
51
  when running_as_fork?
52
52
  @boot_guard = BootGuards::ForkGuard.new
53
- fork(&block).tap { @boot_guard.start }
53
+ create_fork(&block).tap { @boot_guard.start }
54
54
  when running_async?
55
55
  @boot_guard = BootGuards::NullGuard.new
56
56
  @thread = create_thread(&block)
@@ -64,10 +64,7 @@ module SolidQueue::Processes
64
64
  def boot
65
65
  SolidQueue.instrument(:start_process, process: self) do
66
66
  run_callbacks(:boot) do
67
- if running_as_fork?
68
- register_signal_handlers
69
- set_procline
70
- end
67
+ set_procline if running_as_fork?
71
68
  end
72
69
  end
73
70
 
@@ -25,6 +25,13 @@ module SolidQueue::Processes
25
25
  supervisor.present?
26
26
  end
27
27
 
28
+ def create_fork(&block)
29
+ fork do
30
+ register_signal_handlers
31
+ block.call
32
+ end
33
+ end
34
+
28
35
  def register_signal_handlers
29
36
  %w[ INT TERM ].each do |signal|
30
37
  trap(signal) do
@@ -29,6 +29,9 @@ module SolidQueue
29
29
  end
30
30
 
31
31
  def process_signal_queue
32
+ # Embedded supervisors don't own their process's signals
33
+ return unless standalone?
34
+
32
35
  while signal = signal_queue.shift
33
36
  handle_signal(signal)
34
37
  end
@@ -39,9 +39,13 @@ module SolidQueue
39
39
  run_start_hooks
40
40
 
41
41
  start_processes
42
- launch_maintenance_task
43
42
 
44
- supervise
43
+ if stopped?
44
+ shutdown
45
+ else
46
+ launch_maintenance_task
47
+ supervise
48
+ end
45
49
  end
46
50
 
47
51
  def stop
@@ -65,27 +69,33 @@ module SolidQueue
65
69
  end
66
70
 
67
71
  def start_processes
68
- configuration.configured_processes.each { |configured_process| start_process(configured_process) }
72
+ configuration.configured_processes.each do |configured_process|
73
+ # Honour signals that arrive during boot or start hooks: a queued TERM
74
+ # stops us here, before starting children, instead of in #supervise,
75
+ # after all of them have been started
76
+ break if time_to_stop?
77
+
78
+ start_process(configured_process)
79
+ end
69
80
  end
70
81
 
71
82
  def supervise
72
- loop do
73
- break if stopped?
74
-
75
- if standalone?
76
- set_procline
77
- process_signal_queue
78
- end
79
-
80
- unless stopped?
81
- check_and_replace_terminated_processes
82
- interruptible_sleep(1.second)
83
- end
83
+ until time_to_stop?
84
+ set_procline
85
+ check_and_replace_terminated_processes
86
+ interruptible_sleep(1.second)
84
87
  end
85
88
  ensure
86
89
  shutdown
87
90
  end
88
91
 
92
+ # Process any signals queued while we were busy and report whether
93
+ # we've been asked to stop
94
+ def time_to_stop?
95
+ process_signal_queue
96
+ stopped?
97
+ end
98
+
89
99
  def start_process(configured_process)
90
100
  process_instance = configured_process.instantiate.tap do |instance|
91
101
  instance.supervised_by process
@@ -139,7 +149,10 @@ module SolidQueue
139
149
  end
140
150
 
141
151
  def set_procline
142
- procline "supervising #{configured_processes.keys.join(", ")}"
152
+ # Embedded supervisors don't own their process's title
153
+ if standalone?
154
+ procline "supervising #{configured_processes.keys.join(", ")}"
155
+ end
143
156
  end
144
157
 
145
158
  def sync_std_streams
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class ThreadPool < Pool
5
+ delegate :shutdown, :shutdown?, :wait_for_termination, to: :executor
6
+
7
+ private
8
+ DEFAULT_OPTIONS = {
9
+ min_threads: 0,
10
+ idletime: 60,
11
+ fallback_policy: :abort
12
+ }
13
+
14
+ def schedule(execution)
15
+ Concurrent::Promises.future_on(executor, execution) do |thread_execution|
16
+ perform_execution(thread_execution)
17
+ end.on_rejection! do |error|
18
+ # Backstop for errors raised outside perform_execution's own rescue,
19
+ # such as when restoring capacity or waking up the worker
20
+ handle_thread_error(error)
21
+ end
22
+ end
23
+
24
+ def executor
25
+ @executor ||= Concurrent::ThreadPoolExecutor.new DEFAULT_OPTIONS.merge(max_threads: size, max_queue: size)
26
+ end
27
+ end
28
+ end
@@ -1,5 +1,5 @@
1
1
  module SolidQueue
2
- VERSION = "1.5.1"
2
+ VERSION = "1.7.0"
3
3
 
4
4
  def self.next_major_version
5
5
  Gem::Version.new(VERSION).segments.first + 1
@@ -11,18 +11,24 @@ module SolidQueue
11
11
  attr_reader :queues, :pool
12
12
 
13
13
  def initialize(**options)
14
+ execution_pool_type = options.key?(:fibers) ? :fiber : :thread
15
+
14
16
  options = options.dup.with_defaults(SolidQueue::Configuration::WORKER_DEFAULTS)
17
+ execution_pool_size = execution_pool_type == :fiber ? options[:fibers] : options[:threads]
15
18
 
16
19
  # Ensure that the queues array is deep frozen to prevent accidental modification
17
20
  @queues = Array(options[:queues]).map(&:freeze).freeze
18
21
 
19
- @pool = Pool.new(options[:threads], on_idle: -> { wake_up })
22
+ @pool = Pool.build \
23
+ type: execution_pool_type,
24
+ size: execution_pool_size,
25
+ on_idle: -> { wake_up }
20
26
 
21
27
  super(**options)
22
28
  end
23
29
 
24
30
  def metadata
25
- super.merge(queues: queues.join(","), thread_pool_size: pool.size)
31
+ super.merge(queues: queues.join(","), pool_type: pool.type, pool_size: pool.size)
26
32
  end
27
33
 
28
34
  private
@@ -38,7 +44,7 @@ module SolidQueue
38
44
 
39
45
  def claim_executions
40
46
  with_polling_volume do
41
- SolidQueue::ReadyExecution.claim(queues, pool.idle_threads, process_id)
47
+ SolidQueue::ReadyExecution.claim(queues, pool.available_capacity, process_id)
42
48
  end
43
49
  end
44
50
 
data/lib/solid_queue.rb CHANGED
@@ -5,6 +5,7 @@ require "solid_queue/engine"
5
5
 
6
6
  require "active_job"
7
7
  require "active_job/queue_adapters"
8
+ require "active_job/batch_id"
8
9
 
9
10
  require "active_support"
10
11
  require "active_support/core_ext/numeric/time"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_queue
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.1
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rosa Gutierrez
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-29 00:00:00.000000000 Z
11
+ date: 2026-08-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -122,6 +122,20 @@ dependencies:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
124
  version: '1.9'
125
+ - !ruby/object:Gem::Dependency
126
+ name: async
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '2.24'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '2.24'
125
139
  - !ruby/object:Gem::Dependency
126
140
  name: minitest
127
141
  requirement: !ruby/object:Gem::Requirement
@@ -273,13 +287,21 @@ files:
273
287
  - README.md
274
288
  - UPGRADING.md
275
289
  - app/jobs/solid_queue/recurring_job.rb
290
+ - app/models/solid_queue/batch.rb
291
+ - app/models/solid_queue/batch/callbacks.rb
292
+ - app/models/solid_queue/batch/clearable.rb
293
+ - app/models/solid_queue/batch/status.rb
294
+ - app/models/solid_queue/batch/sweepable.rb
295
+ - app/models/solid_queue/batch_execution.rb
276
296
  - app/models/solid_queue/blocked_execution.rb
277
297
  - app/models/solid_queue/claimed_execution.rb
278
298
  - app/models/solid_queue/execution.rb
279
299
  - app/models/solid_queue/execution/dispatching.rb
280
300
  - app/models/solid_queue/execution/job_attributes.rb
281
301
  - app/models/solid_queue/failed_execution.rb
302
+ - app/models/solid_queue/failed_execution/batchable.rb
282
303
  - app/models/solid_queue/job.rb
304
+ - app/models/solid_queue/job/batchable.rb
283
305
  - app/models/solid_queue/job/clearable.rb
284
306
  - app/models/solid_queue/job/concurrency_controls.rb
285
307
  - app/models/solid_queue/job/executable.rb
@@ -301,6 +323,7 @@ files:
301
323
  - app/models/solid_queue/scheduled_execution.rb
302
324
  - app/models/solid_queue/semaphore.rb
303
325
  - config/routes.rb
326
+ - lib/active_job/batch_id.rb
304
327
  - lib/active_job/concurrency_controls.rb
305
328
  - lib/active_job/queue_adapters/solid_queue_adapter.rb
306
329
  - lib/generators/solid_queue/install/USAGE
@@ -309,6 +332,7 @@ files:
309
332
  - lib/generators/solid_queue/install/templates/config/queue.yml
310
333
  - lib/generators/solid_queue/install/templates/config/recurring.yml
311
334
  - lib/generators/solid_queue/install/templates/db/queue_schema.rb
335
+ - lib/generators/solid_queue/update/templates/db/add_batches_to_solid_queue.rb
312
336
  - lib/generators/solid_queue/update/update_generator.rb
313
337
  - lib/puma/plugin/solid_queue.rb
314
338
  - lib/solid_queue.rb
@@ -318,7 +342,9 @@ files:
318
342
  - lib/solid_queue/configuration.rb
319
343
  - lib/solid_queue/dispatcher.rb
320
344
  - lib/solid_queue/dispatcher/concurrency_maintenance.rb
345
+ - lib/solid_queue/dispatcher/maintenance.rb
321
346
  - lib/solid_queue/engine.rb
347
+ - lib/solid_queue/fiber_pool.rb
322
348
  - lib/solid_queue/fork_supervisor.rb
323
349
  - lib/solid_queue/lifecycle_hooks.rb
324
350
  - lib/solid_queue/log_subscriber.rb
@@ -343,6 +369,7 @@ files:
343
369
  - lib/solid_queue/supervisor/pidfiled.rb
344
370
  - lib/solid_queue/supervisor/signals.rb
345
371
  - lib/solid_queue/tasks.rb
372
+ - lib/solid_queue/thread_pool.rb
346
373
  - lib/solid_queue/timer.rb
347
374
  - lib/solid_queue/version.rb
348
375
  - lib/solid_queue/worker.rb