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,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class BatchExecution < Execution
5
+ self.assumable_attributes_from_job = [ :batch_id ]
6
+
7
+ belongs_to :batch
8
+
9
+ scope :with_finished_jobs, -> { joins(:job).merge(SolidQueue::Job.finished) }
10
+ scope :with_failed_jobs, -> { joins(job: :failed_execution) }
11
+
12
+ after_commit :finish_batch, on: :destroy
13
+
14
+ class << self
15
+ def create_all_from_jobs(jobs)
16
+ jobs.select(&:batched?).group_by(&:batch_id).each do |batch_id, jobs_in_batch|
17
+ # Update the counter first: inserting tracking rows takes a shared FK lock on
18
+ # the batch row, then incrementing can deadlock concurrent MySQL adders.
19
+ if attempt_to_update_total_jobs(batch_id, jobs_in_batch)
20
+ super jobs_in_batch
21
+ else
22
+ raise Batch::AlreadyFinished, "Can't add jobs into an already finished batch"
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+ def attempt_to_update_total_jobs(batch_id, jobs)
29
+ new_jobs_count = count_new_jobs_among(jobs)
30
+ updated = SolidQueue::Batch.where(id: batch_id).unfinished.update_all([ "total_jobs = total_jobs + ?", new_jobs_count ])
31
+ updated > 0
32
+ end
33
+
34
+ # A job that has executed before was already counted when it first joined
35
+ # the batch: retries keep their active_job_id and batch across re-enqueues.
36
+ # This might undercount jobs whose retries switch to another batch, but that
37
+ # should be a rare enough case. The counter is used only for report/info, so
38
+ # we favour simplicity here
39
+ def count_new_jobs_among(jobs)
40
+ jobs.reject { |job| job.arguments["executions"].to_i > 0 }.map(&:active_job_id).uniq.size
41
+ end
42
+ end
43
+
44
+ private
45
+ def finish_batch
46
+ # Skip the serialized callback and metadata columns on this hot path
47
+ if batch = Batch.select(:id, :finished_at, :enqueued_at).find_by(id: batch_id)
48
+ batch.finish
49
+ end
50
+ end
51
+ end
52
+ end
@@ -48,6 +48,7 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
48
48
  payload[:process_ids] = executions.map(&:process_id).uniq
49
49
  payload[:job_ids] = executions.map(&:job_id).uniq
50
50
  payload[:size] = executions.size
51
+ payload[:error] = error
51
52
  end
52
53
  end
53
54
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class FailedExecution
5
+ # A FailedExecution is created only after retries are exhausted, when the
6
+ # job stops counting as pending in its batch.
7
+ module Batchable
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ after_create :destroy_job_batch_execution, if: -> { Batch.migrated? && job.batch_id? }
12
+ end
13
+
14
+ private
15
+ def destroy_job_batch_execution
16
+ job.batch_execution&.destroy!
17
+ rescue ActiveRecord::ActiveRecordError => e
18
+ SolidQueue.instrument(:batch_progress_error, batch_id: job.batch_id, job_id: job.id, error: e)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module SolidQueue
4
4
  class FailedExecution < Execution
5
- include Dispatching
5
+ include Dispatching, Batchable
6
6
 
7
7
  serialize :error, coder: JSON
8
8
 
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class Job
5
+ module Batchable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ belongs_to :batch, optional: true
10
+ has_one :batch_execution
11
+
12
+ after_create :create_batch_execution, if: :batched?
13
+ after_update :update_batch_progress, if: :batched?
14
+ before_destroy :destroy_batch_execution, if: :batched?
15
+ end
16
+
17
+ class_methods do
18
+ def batch_all(jobs)
19
+ BatchExecution.create_all_from_jobs(jobs) if Batch.migrated?
20
+ end
21
+ end
22
+
23
+ # Also guards against the batches schema not being installed: without
24
+ # its migration, jobs don't even have a batch_id.
25
+ def batched?
26
+ Batch.migrated? && batch_id?
27
+ end
28
+
29
+ private
30
+ def create_batch_execution
31
+ BatchExecution.create_all_from_jobs([ self ])
32
+ end
33
+
34
+ def update_batch_progress
35
+ return unless saved_change_to_finished_at? && finished_at.present?
36
+
37
+ batch_execution&.destroy!
38
+ rescue ActiveRecord::ActiveRecordError => e
39
+ SolidQueue.instrument(:batch_progress_error, batch_id: batch_id, job_id: id, error: e)
40
+ end
41
+
42
+ # Destroy through Active Record instead of relying on the foreign
43
+ # key's cascade, so destroying the tracking row retries the batch
44
+ # completion check.
45
+ def destroy_batch_execution
46
+ batch_execution&.destroy!
47
+ end
48
+ end
49
+ end
50
+ end
@@ -18,6 +18,9 @@ module SolidQueue
18
18
 
19
19
  class_methods do
20
20
  def prepare_all_for_execution(jobs)
21
+ # Track before dispatch so conflict-discarded jobs count like single enqueues.
22
+ batch_all(jobs)
23
+
21
24
  due, not_yet_due = jobs.partition(&:due?)
22
25
  dispatch_all(due) + schedule_all(not_yet_due)
23
26
  end
@@ -78,7 +81,8 @@ module SolidQueue
78
81
 
79
82
  def finished!
80
83
  if SolidQueue.preserve_finished_jobs?
81
- touch(:finished_at)
84
+ # update! rather than touch so the batch tracking callbacks run
85
+ update!(finished_at: Time.current)
82
86
  else
83
87
  destroy!
84
88
  end
@@ -4,13 +4,19 @@ module SolidQueue
4
4
  class Job < Record
5
5
  class EnqueueError < StandardError; end
6
6
 
7
- include Executable, Clearable, Recurrable
7
+ include Executable, Clearable, Recurrable, Batchable
8
8
 
9
9
  serialize :arguments, coder: JSON
10
10
 
11
11
  class << self
12
12
  def enqueue_all(active_jobs)
13
- active_jobs.each { |job| job.scheduled_at ||= Time.current }
13
+ # Bulk enqueues bypass ActiveJob#enqueue, so batch membership is captured here
14
+ current_batch_id = Batch.current_batch_id
15
+
16
+ active_jobs.each do |job|
17
+ job.scheduled_at ||= Time.current
18
+ job.batch_id = current_batch_id || job.batch_id
19
+ end
14
20
  active_jobs_by_job_id = active_jobs.index_by(&:job_id)
15
21
 
16
22
  transaction do
@@ -63,7 +69,9 @@ module SolidQueue
63
69
  class_name: active_job.class.name,
64
70
  arguments: active_job.serialize,
65
71
  concurrency_key: active_job.concurrency_key
66
- }
72
+ }.tap do |attributes|
73
+ attributes[:batch_id] = active_job.batch_id if Batch.migrated?
74
+ end
67
75
  end
68
76
  end
69
77
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Inspired by active_job/core.rb docs
4
+ # https://github.com/rails/rails/blob/1c2529b9a6ba5a1eff58be0d0373d7d9d401015b/activejob/lib/active_job/core.rb#L136
5
+ module ActiveJob
6
+ module BatchId
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ attr_accessor :batch_id
11
+ attr_accessor :callback_batch_id
12
+ end
13
+
14
+ # Seed membership at construction for enqueue paths deferred until after the
15
+ # batch context ends, notably bulk enqueue. Enqueueing inside another batch
16
+ # rebinds membership; enqueueing without a batch preserves it.
17
+ # SolidQueue::Job.enqueue_all repeats this capture because bulk enqueue
18
+ # bypasses #enqueue.
19
+ def initialize(*arguments, **kwargs)
20
+ super
21
+ self.batch_id = SolidQueue::Batch.current_batch_id if solid_queue_job?
22
+ end
23
+
24
+ def enqueue(options = {})
25
+ self.batch_id = SolidQueue::Batch.current_batch_id || batch_id if solid_queue_job?
26
+ super
27
+ end
28
+
29
+ def serialize
30
+ super.tap do |data|
31
+ data["batch_id"] = batch_id if batch_id
32
+ data["callback_batch_id"] = callback_batch_id if callback_batch_id
33
+ end
34
+ end
35
+
36
+ def deserialize(job_data)
37
+ super
38
+ self.batch_id = job_data["batch_id"]
39
+ self.callback_batch_id = job_data["callback_batch_id"]
40
+ end
41
+
42
+ def batch
43
+ batch_id_to_load = callback_batch_id || batch_id
44
+ return if batch_id_to_load.nil?
45
+ return @batch if defined?(@batch) && @loaded_batch_id == batch_id_to_load
46
+
47
+ @loaded_batch_id = batch_id_to_load
48
+ @batch = SolidQueue::Batch.find_by(id: batch_id_to_load)
49
+ end
50
+
51
+ private
52
+
53
+ def solid_queue_job?
54
+ self.class.queue_adapter_name == "solid_queue"
55
+ end
56
+ end
57
+ end
@@ -37,7 +37,9 @@ ActiveRecord::Schema[7.1].define(version: 1) do
37
37
  t.string "concurrency_key"
38
38
  t.datetime "created_at", null: false
39
39
  t.datetime "updated_at", null: false
40
+ t.bigint "batch_id"
40
41
  t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
42
+ t.index [ "batch_id" ], name: "index_solid_queue_jobs_on_batch_id"
41
43
  t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
42
44
  t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
43
45
  t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
@@ -120,6 +122,35 @@ ActiveRecord::Schema[7.1].define(version: 1) do
120
122
  t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
121
123
  end
122
124
 
125
+ create_table "solid_queue_batches", force: :cascade do |t|
126
+ t.string "active_job_batch_id"
127
+ t.string "description"
128
+ t.text "on_finish"
129
+ t.text "on_success"
130
+ t.text "on_failure"
131
+ t.text "metadata"
132
+ t.integer "total_jobs", default: 0, null: false
133
+ t.integer "completed_jobs", default: 0, null: false
134
+ t.integer "failed_jobs", default: 0, null: false
135
+ t.datetime "enqueued_at"
136
+ t.datetime "finished_at"
137
+ t.datetime "failed_at"
138
+ t.datetime "created_at", null: false
139
+ t.datetime "updated_at", null: false
140
+ t.index [ "active_job_batch_id" ], name: "index_solid_queue_batches_on_active_job_batch_id", unique: true
141
+ t.index [ "finished_at" ], name: "index_solid_queue_batches_on_finished_at"
142
+ end
143
+
144
+ create_table "solid_queue_batch_executions", force: :cascade do |t|
145
+ t.bigint "job_id", null: false
146
+ t.bigint "batch_id", null: false
147
+ t.datetime "created_at", null: false
148
+ t.index [ "job_id" ], name: "index_solid_queue_batch_executions_on_job_id", unique: true
149
+ t.index [ "batch_id" ], name: "index_solid_queue_batch_executions_on_batch_id"
150
+ end
151
+
152
+ add_foreign_key "solid_queue_batch_executions", "solid_queue_batches", column: "batch_id", on_delete: :cascade
153
+ add_foreign_key "solid_queue_batch_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
123
154
  add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
124
155
  add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
125
156
  add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
@@ -0,0 +1,39 @@
1
+ class AddBatchesToSolidQueue < ActiveRecord::Migration[7.1]
2
+ def change
3
+ # Fresh installs create all of this with the base schema, so skip
4
+ # anything that already exists
5
+ add_column :solid_queue_jobs, :batch_id, :bigint, if_not_exists: true
6
+ add_index :solid_queue_jobs, :batch_id, if_not_exists: true
7
+
8
+ create_table :solid_queue_batches, if_not_exists: true do |t|
9
+ t.string :active_job_batch_id
10
+ t.string :description
11
+ t.text :on_finish
12
+ t.text :on_success
13
+ t.text :on_failure
14
+ t.text :metadata
15
+ t.integer :total_jobs, default: 0, null: false
16
+ t.integer :completed_jobs, default: 0, null: false
17
+ t.integer :failed_jobs, default: 0, null: false
18
+ t.datetime :enqueued_at
19
+ t.datetime :finished_at
20
+ t.datetime :failed_at
21
+ t.datetime :created_at, null: false
22
+ t.datetime :updated_at, null: false
23
+
24
+ t.index :active_job_batch_id, unique: true
25
+ t.index :finished_at
26
+ end
27
+
28
+ create_table :solid_queue_batch_executions, if_not_exists: true do |t|
29
+ t.bigint :job_id, null: false
30
+ t.bigint :batch_id, null: false
31
+ t.datetime :created_at, null: false
32
+
33
+ t.index :job_id, unique: true
34
+ t.index :batch_id
35
+ t.foreign_key :solid_queue_batches, column: :batch_id, on_delete: :cascade
36
+ t.foreign_key :solid_queue_jobs, column: :job_id, on_delete: :cascade
37
+ end
38
+ end
39
+ end
@@ -6,7 +6,9 @@ module SolidQueue
6
6
  include ActiveModel::Validations::Callbacks
7
7
 
8
8
  validate :ensure_configured_processes, :ensure_valid_recurring_tasks
9
- validate :warn_about_incorrectly_sized_thread_pool, :warn_about_missing_config_files
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
10
12
 
11
13
  before_validation { warnings.clear }
12
14
 
@@ -27,7 +29,8 @@ module SolidQueue
27
29
  batch_size: 500,
28
30
  polling_interval: 1,
29
31
  concurrency_maintenance: true,
30
- concurrency_maintenance_interval: 600
32
+ concurrency_maintenance_interval: 600,
33
+ batch_maintenance: true
31
34
  }
32
35
 
33
36
  SCHEDULER_DEFAULTS = {
@@ -37,6 +40,7 @@ module SolidQueue
37
40
 
38
41
  DEFAULT_CONFIG_FILE_PATH = "config/queue.yml"
39
42
  DEFAULT_RECURRING_SCHEDULE_FILE_PATH = "config/recurring.yml"
43
+ FIBER_QUERY_SCOPED_CONNECTIONS_VERSION = Gem::Version.new("7.2.0")
40
44
 
41
45
  def initialize(**options)
42
46
  @options = options.with_defaults(default_options)
@@ -99,12 +103,12 @@ module SolidQueue
99
103
  end
100
104
  end
101
105
 
102
- def warn_about_incorrectly_sized_thread_pool
106
+ def warn_about_incorrectly_sized_database_pool
103
107
  db_pool_size = SolidQueue::Record.connection_pool&.size
104
108
 
105
- if db_pool_size && db_pool_size < estimated_number_of_threads
106
- warnings.add(:base, "Warning: Solid Queue is configured to use #{estimated_number_of_threads} threads but the " \
107
- "database connection pool is #{db_pool_size}. Increase it in `config/database.yml`")
109
+ if db_pool_size && db_pool_size < estimated_database_pool_size
110
+ warnings.add(:base, "Warning: Solid Queue needs at least #{estimated_database_pool_size} database connections " \
111
+ "for the configured workers but the database connection pool is #{db_pool_size}. Increase it in `config/database.yml`")
108
112
  end
109
113
  rescue ActiveRecord::ActiveRecordError
110
114
  # No usable database connection. Skip the pool-size warning in that case.
@@ -121,6 +125,34 @@ module SolidQueue
121
125
  end
122
126
  end
123
127
 
128
+ def ensure_valid_worker_execution_options
129
+ workers_options.each do |options|
130
+ if options.key?(:threads) && options.key?(:fibers)
131
+ errors.add(:base, "Workers can specify either `threads` or `fibers`, but not both.")
132
+ end
133
+ end
134
+ end
135
+
136
+ def ensure_fiber_workers_have_required_dependency
137
+ return unless workers_options.any? { |options| fiber_worker?(options) }
138
+
139
+ require "async"
140
+ require "async/semaphore"
141
+ rescue LoadError
142
+ errors.add(:base, "Fiber workers require the `async` gem. " \
143
+ "Add `gem \"async\"` to your Gemfile to configure workers with `fibers`.")
144
+ end
145
+
146
+ def ensure_fiber_workers_use_supported_isolation_level
147
+ return unless workers_options.any? { |options| fiber_worker?(options) }
148
+
149
+ unless ActiveSupport::IsolatedExecutionState.isolation_level == :fiber
150
+ errors.add(:base, "Fiber workers require fiber-scoped isolated execution state. " \
151
+ "Set `config.active_support.isolation_level = :fiber` in your Rails configuration " \
152
+ "(or `ActiveSupport::IsolatedExecutionState.isolation_level = :fiber` outside Rails).")
153
+ end
154
+ end
155
+
124
156
  def default_options
125
157
  {
126
158
  mode: ENV["SOLID_QUEUE_SUPERVISOR_MODE"] || :fork,
@@ -162,7 +194,8 @@ module SolidQueue
162
194
  1
163
195
  end
164
196
 
165
- processes.times.map { Process.new(:worker, worker_options.with_defaults(WORKER_DEFAULTS)) }
197
+ defaults = worker_defaults_for(worker_options)
198
+ processes.times.map { Process.new(:worker, worker_options.with_defaults(defaults)) }
166
199
  end
167
200
  end
168
201
 
@@ -256,10 +289,42 @@ module SolidQueue
256
289
  end
257
290
  end
258
291
 
259
- def estimated_number_of_threads
260
- # At most "threads" in each worker + 1 thread for the worker + 1 thread for the heartbeat task
261
- thread_count = workers_options.map { |options| options.fetch(:threads, WORKER_DEFAULTS[:threads]) }.max
262
- (thread_count || 1) + 2
292
+ def estimated_database_pool_size
293
+ worker_pool_size = workers_options.map { |options| estimated_database_pool_size_for_worker(options) }.max
294
+ worker_pool_size || 1
295
+ end
296
+
297
+ def estimated_database_pool_size_for_worker(options)
298
+ # Connections used to execute jobs + 1 for the worker's polling thread + 1 for the heartbeat task
299
+ estimated_execution_connections_for_worker(options) + 2
300
+ end
301
+
302
+ def worker_capacity(options)
303
+ options[:fibers] || options[:threads] || WORKER_DEFAULTS[:threads]
304
+ end
305
+
306
+ def estimated_execution_connections_for_worker(options)
307
+ fiber_worker?(options) ? fiber_execution_connections_for_worker(options) : worker_capacity(options)
308
+ end
309
+
310
+ def fiber_execution_connections_for_worker(options)
311
+ fiber_jobs_release_connections_between_queries? ? 1 : worker_capacity(options)
312
+ end
313
+
314
+ def fiber_jobs_release_connections_between_queries?
315
+ ActiveRecord.gem_version >= FIBER_QUERY_SCOPED_CONNECTIONS_VERSION
316
+ end
317
+
318
+ def fiber_worker?(options)
319
+ options.key?(:fibers)
320
+ end
321
+
322
+ def worker_defaults_for(options)
323
+ if fiber_worker?(options)
324
+ WORKER_DEFAULTS.except(:threads)
325
+ else
326
+ WORKER_DEFAULTS
327
+ end
263
328
  end
264
329
  end
265
330
  end
@@ -1,44 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidQueue
4
- class Dispatcher::ConcurrencyMaintenance
5
- include AppExecutor
6
-
7
- attr_reader :interval, :batch_size
8
-
4
+ # Kept for compatibility: concurrency maintenance runs on the shared
5
+ # Dispatcher::Maintenance timer, together with batch maintenance.
6
+ class Dispatcher::ConcurrencyMaintenance < Dispatcher::Maintenance
9
7
  def initialize(interval, batch_size)
10
- @interval = interval
11
- @batch_size = batch_size
12
- end
13
-
14
- def start
15
- @concurrency_maintenance_task = Concurrent::TimerTask.new(run_now: true, execution_interval: interval) do
16
- expire_semaphores
17
- unblock_blocked_executions
18
- end
19
-
20
- @concurrency_maintenance_task.add_observer do |_, _, error|
21
- handle_thread_error(error) if error
22
- end
23
-
24
- @concurrency_maintenance_task.execute
25
- end
26
-
27
- def stop
28
- @concurrency_maintenance_task&.shutdown
8
+ super(interval, batch_size, concurrency: true, batches: false)
29
9
  end
30
-
31
- private
32
- def expire_semaphores
33
- wrap_in_app_executor do
34
- Semaphore.expired.in_batches(of: batch_size, &:delete_all)
35
- end
36
- end
37
-
38
- def unblock_blocked_executions
39
- wrap_in_app_executor do
40
- BlockedExecution.unblock(batch_size)
41
- end
42
- end
43
10
  end
44
11
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class Dispatcher::Maintenance
5
+ include AppExecutor
6
+
7
+ attr_reader :interval, :batch_size
8
+
9
+ def initialize(interval, batch_size, concurrency:, batches:)
10
+ @interval = interval
11
+ @batch_size = batch_size
12
+ @concurrency = concurrency
13
+ @batches = batches
14
+ end
15
+
16
+ def concurrency?
17
+ @concurrency
18
+ end
19
+
20
+ def batches?
21
+ @batches
22
+ end
23
+
24
+ def metadata
25
+ { concurrency_maintenance_interval: (interval if concurrency?), batch_maintenance: batches? }
26
+ end
27
+
28
+ def start
29
+ @maintenance_task = Concurrent::TimerTask.new(run_now: true, execution_interval: interval) do
30
+ if concurrency?
31
+ expire_semaphores
32
+ unblock_blocked_executions
33
+ end
34
+
35
+ sweep_stalled_batches if batches?
36
+ end
37
+
38
+ @maintenance_task.add_observer do |_, _, error|
39
+ handle_thread_error(error) if error
40
+ end
41
+
42
+ @maintenance_task.execute
43
+ end
44
+
45
+ def stop
46
+ @maintenance_task&.shutdown
47
+ end
48
+
49
+ private
50
+ def expire_semaphores
51
+ wrap_in_app_executor do
52
+ Semaphore.expired.in_batches(of: batch_size, &:delete_all)
53
+ end
54
+ end
55
+
56
+ def unblock_blocked_executions
57
+ wrap_in_app_executor do
58
+ BlockedExecution.unblock(batch_size)
59
+ end
60
+ end
61
+
62
+ def sweep_stalled_batches
63
+ wrap_in_app_executor do
64
+ if Batch.migrated?
65
+ Batch.sweep_stalled(batch_size: batch_size)
66
+ else
67
+ warn_once_about_pending_batch_migrations
68
+ end
69
+ end
70
+ end
71
+
72
+ def warn_once_about_pending_batch_migrations
73
+ unless @warned_about_pending_migrations
74
+ Batch.warn_about_pending_migrations
75
+ @warned_about_pending_migrations = true
76
+ end
77
+ end
78
+ end
79
+ end
@@ -7,8 +7,8 @@ module SolidQueue
7
7
  attr_reader :batch_size
8
8
 
9
9
  after_boot :run_start_hooks
10
- after_boot :start_concurrency_maintenance
11
- before_shutdown :stop_concurrency_maintenance
10
+ after_boot :start_maintenance
11
+ before_shutdown :stop_maintenance
12
12
  before_shutdown :run_stop_hooks
13
13
  after_shutdown :run_exit_hooks
14
14
 
@@ -17,17 +17,21 @@ module SolidQueue
17
17
 
18
18
  @batch_size = options[:batch_size]
19
19
 
20
- @concurrency_maintenance = ConcurrencyMaintenance.new(options[:concurrency_maintenance_interval], options[:batch_size]) if options[:concurrency_maintenance]
20
+ # Run both maintenance routines on one timer instead of another thread.
21
+ if options[:concurrency_maintenance] || options[:batch_maintenance]
22
+ @maintenance = Maintenance.new(options[:concurrency_maintenance_interval], options[:batch_size],
23
+ concurrency: options[:concurrency_maintenance], batches: options[:batch_maintenance])
24
+ end
21
25
 
22
26
  super(**options)
23
27
  end
24
28
 
25
29
  def metadata
26
- super.merge(batch_size: batch_size, concurrency_maintenance_interval: concurrency_maintenance&.interval)
30
+ super.merge(batch_size: batch_size).merge(maintenance&.metadata || {})
27
31
  end
28
32
 
29
33
  private
30
- attr_reader :concurrency_maintenance
34
+ attr_reader :maintenance
31
35
 
32
36
  def poll
33
37
  batch = dispatch_next_batch
@@ -41,12 +45,12 @@ module SolidQueue
41
45
  end
42
46
  end
43
47
 
44
- def start_concurrency_maintenance
45
- concurrency_maintenance&.start
48
+ def start_maintenance
49
+ maintenance&.start
46
50
  end
47
51
 
48
- def stop_concurrency_maintenance
49
- concurrency_maintenance&.stop
52
+ def stop_maintenance
53
+ maintenance&.stop
50
54
  end
51
55
 
52
56
  def all_work_completed?
@@ -41,6 +41,10 @@ module SolidQueue
41
41
  initializer "solid_queue.active_job.extensions" do
42
42
  ActiveSupport.on_load :active_job do
43
43
  include ActiveJob::ConcurrencyControls
44
+
45
+ ActiveSupport.on_load :active_record do
46
+ ActiveJob::Base.include ActiveJob::BatchId
47
+ end
44
48
  end
45
49
  end
46
50