solid_queue 0.1.2 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +1 -1
  3. data/README.md +30 -26
  4. data/app/models/solid_queue/blocked_execution.rb +1 -1
  5. data/app/models/solid_queue/claimed_execution.rb +13 -1
  6. data/app/models/solid_queue/execution/dispatching.rb +20 -0
  7. data/app/models/solid_queue/execution/job_attributes.rb +12 -5
  8. data/app/models/solid_queue/execution.rb +55 -1
  9. data/app/models/solid_queue/failed_execution.rb +25 -19
  10. data/app/models/solid_queue/job/clearable.rb +4 -1
  11. data/app/models/solid_queue/job/concurrency_controls.rb +17 -1
  12. data/app/models/solid_queue/job/executable.rb +60 -21
  13. data/app/models/solid_queue/job/schedulable.rb +48 -0
  14. data/app/models/solid_queue/job.rb +50 -27
  15. data/app/models/solid_queue/queue.rb +1 -1
  16. data/app/models/solid_queue/ready_execution.rb +11 -1
  17. data/app/models/solid_queue/scheduled_execution.rb +4 -44
  18. data/app/models/solid_queue/semaphore.rb +75 -46
  19. data/db/migrate/20240110143450_add_missing_index_to_blocked_executions.rb +5 -0
  20. data/lib/active_job/concurrency_controls.rb +4 -0
  21. data/lib/active_job/queue_adapters/solid_queue_adapter.rb +6 -6
  22. data/lib/active_job/uniqueness.rb +41 -0
  23. data/lib/generators/solid_queue/install/USAGE +2 -2
  24. data/lib/generators/solid_queue/install/install_generator.rb +2 -4
  25. data/lib/generators/solid_queue/install/templates/config.yml +1 -1
  26. data/lib/puma/plugin/solid_queue.rb +10 -7
  27. data/lib/solid_queue/dispatcher/scheduled_executions_dispatcher.rb +6 -0
  28. data/lib/solid_queue/processes/runnable.rb +5 -3
  29. data/lib/solid_queue/version.rb +1 -1
  30. data/lib/solid_queue/worker.rb +1 -1
  31. metadata +26 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e1ff04f60a3eee3be19e692691ef7a70cd1cfc1a2edd5f019f6bbab9b8614e6
4
- data.tar.gz: bc96a464a966379d434b4e8c45c4ad24c30bf3aeed6d3b6acc60d94e1386a14e
3
+ metadata.gz: 0eb31439e2768af5f5d3fb9289ac51056570c762ad140bc54aba81ee770d5a12
4
+ data.tar.gz: 4d3e4c2608b0a3ed2de82c7712c8a524e8745bc4e2ddf6907de8b3723560ab04
5
5
  SHA512:
6
- metadata.gz: 27d7bf13f3342cfccd54aa3cc3db18284d68b7fc56ae62b2c9ca66710aadb0eb8e37c48b8b268169fa37aac523cbe1cdb2dd7146fd160499d82e4521cb35d2ba
7
- data.tar.gz: df8f520b23ab98bdcea47ffab06df3bfa1e946072070d251ecd0d5e44a6f7e723fdd6278344b4dbf88a8d184ce661f97440ba188267d004df83a5df66945bedc
6
+ metadata.gz: 0f4271aaf7b55b86d81f97bf19bbfa2b76de1ff994a6b71bb097b89ab9585212f28c0fdbd4fbb86b74c18b49cf229e7913eaa724784b3a62f000d60c2ade3263
7
+ data.tar.gz: a84d91b13db4a3ec96d1afa5fb5f39abc9ea0bf4bc216c14e20f4bf7e1bf2ff414e08f78166377950351b8170cbd734aa69322feb9076a67a3f9939ebacf6573
data/MIT-LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2023 37signals
1
+ Copyright (c) 37signals, LLC
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -2,29 +2,11 @@
2
2
 
3
3
  Solid Queue is a DB-based queuing backend for [Active Job](https://edgeguides.rubyonrails.org/active_job_basics.html), designed with simplicity and performance in mind.
4
4
 
5
- Besides regular job enqueuing and processing, Solid Queue supports delayed jobs, concurrency controls, pausing queues, numeric priorities per job, and priorities by queue order. _Proper support for `perform_all_later`, improvements to logging and instrumentation, a better CLI tool, a way to run within an existing process in "async" mode, unique jobs and recurring, cron-like tasks are coming very soon._
5
+ Besides regular job enqueuing and processing, Solid Queue supports delayed jobs, concurrency controls, pausing queues, numeric priorities per job, priorities by queue order, and bulk enqueuing (`enqueue_all` for Active Job's `perform_all_later`). _Improvements to logging and instrumentation, a better CLI tool, a way to run within an existing process in "async" mode, unique jobs and recurring, cron-like tasks are coming very soon._
6
6
 
7
7
  Solid Queue can be used with SQL databases such as MySQL, PostgreSQL or SQLite, and it leverages the `FOR UPDATE SKIP LOCKED` clause, if available, to avoid blocking and waiting on locks when polling jobs. It relies on Active Job for retries, discarding, error handling, serialization, or delays, and it's compatible with Ruby on Rails multi-threading.
8
8
 
9
- ## Usage
10
- To set Solid Queue as your Active Job's queue backend, you should add this to your environment config:
11
- ```ruby
12
- # config/environments/production.rb
13
- config.active_job.queue_adapter = :solid_queue
14
- ```
15
-
16
- Alternatively, you can set only specific jobs to use Solid Queue as their backend if you're migrating from another adapter and want to move jobs progressively:
17
-
18
- ```ruby
19
- # app/jobs/my_job.rb
20
-
21
- class MyJob < ApplicationJob
22
- self.queue_adapter = :solid_queue
23
- # ...
24
- end
25
- ```
26
-
27
- ## Installation
9
+ ## Installation and usage
28
10
  Add this line to your application's Gemfile:
29
11
 
30
12
  ```ruby
@@ -41,23 +23,43 @@ Or install it yourself as:
41
23
  $ gem install solid_queue
42
24
  ```
43
25
 
44
- Install Migrations and Set Up Active Job Adapter
45
- Now, you need to install the necessary migrations and configure the Active Job's adapter. Run the following commands:
26
+ Now, you need to install the necessary migrations and configure the Active Job's adapter. You can do both at once using the provided generator:
27
+
46
28
  ```bash
47
29
  $ bin/rails generate solid_queue:install
48
30
  ```
49
31
 
50
- or add the only the migration to your app and run it:
32
+ This will set `solid_queue` as the Active Job's adapter in production, and will copy the required migration over to your app.
33
+
34
+ Alternatively, you can add only the migration to your app:
51
35
  ```bash
52
36
  $ bin/rails solid_queue:install:migrations
53
37
  ```
54
38
 
55
- Run the Migrations (required after either of the above steps):
39
+ And set Solid Queue as your Active Job's queue backend manually, in your environment config:
40
+ ```ruby
41
+ # config/environments/production.rb
42
+ config.active_job.queue_adapter = :solid_queue
43
+ ```
44
+
45
+ Alternatively, you can set only specific jobs to use Solid Queue as their backend if you're migrating from another adapter and want to move jobs progressively:
46
+
47
+ ```ruby
48
+ # app/jobs/my_job.rb
49
+
50
+ class MyJob < ApplicationJob
51
+ self.queue_adapter = :solid_queue
52
+ # ...
53
+ end
54
+ ```
55
+
56
+ Finally, you need to run the migrations:
57
+
56
58
  ```bash
57
59
  $ bin/rails db:migrate
58
60
  ```
59
61
 
60
- With this, you'll be ready to enqueue jobs using Solid Queue, but you need to start Solid Queue's supervisor to run them.
62
+ After this, you'll be ready to enqueue jobs using Solid Queue, but you need to start Solid Queue's supervisor to run them.
61
63
  ```bash
62
64
  $ bundle exec rake solid_queue:start
63
65
  ```
@@ -65,7 +67,7 @@ $ bundle exec rake solid_queue:start
65
67
  This will start processing jobs in all queues using the default configuration. See [below](#configuration) to learn more about configuring Solid Queue.
66
68
 
67
69
  ## Requirements
68
- Besides Rails 7, Solid Queue works best with MySQL 8+ or PostgreSQL 9.5+, as they support `FOR UPDATE SKIP LOCKED`. You can use it with older versions, but in that case, you might run into lock waits if you run multiple workers for the same queue.
70
+ Besides Rails 7.1, Solid Queue works best with MySQL 8+ or PostgreSQL 9.5+, as they support `FOR UPDATE SKIP LOCKED`. You can use it with older versions, but in that case, you might run into lock waits if you run multiple workers for the same queue.
69
71
 
70
72
  ## Configuration
71
73
 
@@ -83,6 +85,7 @@ production:
83
85
  dispatchers:
84
86
  - polling_interval: 1
85
87
  batch_size: 500
88
+ concurrency_maintenance_interval: 300
86
89
  workers:
87
90
  - queues: "*"
88
91
  threads: 3
@@ -97,6 +100,7 @@ Everything is optional. If no configuration is provided, Solid Queue will run wi
97
100
 
98
101
  - `polling_interval`: the time interval in seconds that workers and dispatchers will wait before checking for more jobs. This time defaults to `1` second for dispatchers and `0.1` seconds for workers.
99
102
  - `batch_size`: the dispatcher will dispatch jobs in batches of this size. The default is 500.
103
+ - `concurrency_maintenance_interval`: the time interval in seconds that the dispatcher will wait before checking for blocked jobs that can be unblocked. Read more about [concurrency controls](#concurrency-controls) to learn more about this setting. It defaults to `600` seconds.
100
104
  - `queues`: the list of queues that workers will pick jobs from. You can use `*` to indicate all queues (which is also the default and the behaviour you'll get if you omit this). You can provide a single queue, or a list of queues as an array. Jobs will be polled from those queues in order, so for example, with `[ real_time, background ]`, no jobs will be taken from `background` unless there aren't any more jobs waiting in `real_time`. You can also provide a prefix with a wildcard to match queues starting with a prefix. For example:
101
105
 
102
106
  ```yml
@@ -2,7 +2,7 @@
2
2
 
3
3
  module SolidQueue
4
4
  class BlockedExecution < Execution
5
- assume_attributes_from_job :concurrency_key
5
+ assumes_attributes_from_job :concurrency_key
6
6
  before_create :set_expires_at
7
7
 
8
8
  has_one :semaphore, foreign_key: :key, primary_key: :concurrency_key
@@ -23,6 +23,14 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
23
23
  def release_all
24
24
  includes(:job).each(&:release)
25
25
  end
26
+
27
+ def discard_all_in_batches(*)
28
+ raise UndiscardableError, "Can't discard jobs in progress"
29
+ end
30
+
31
+ def discard_all_from_jobs(*)
32
+ raise UndiscardableError, "Can't discard jobs in progress"
33
+ end
26
34
  end
27
35
 
28
36
  def perform
@@ -39,11 +47,15 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
39
47
 
40
48
  def release
41
49
  transaction do
42
- job.prepare_for_execution
50
+ job.dispatch_bypassing_concurrency_limits
43
51
  destroy!
44
52
  end
45
53
  end
46
54
 
55
+ def discard
56
+ raise UndiscardableError, "Can't discard a job in progress"
57
+ end
58
+
47
59
  private
48
60
  def execute
49
61
  ActiveJob::Base.execute(job.arguments)
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class Execution
5
+ module Dispatching
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def dispatch_jobs(job_ids)
10
+ jobs = Job.where(id: job_ids)
11
+
12
+ Job.dispatch_all(jobs).map(&:id).tap do |dispatched_job_ids|
13
+ where(job_id: dispatched_job_ids).delete_all
14
+ SolidQueue.logger.info("[SolidQueue] Dispatched #{dispatched_job_ids.size} jobs")
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -5,17 +5,24 @@ module SolidQueue
5
5
  module JobAttributes
6
6
  extend ActiveSupport::Concern
7
7
 
8
- ASSUMIBLE_ATTRIBUTES_FROM_JOB = %i[ queue_name priority ]
8
+ included do
9
+ class_attribute :assumable_attributes_from_job, instance_accessor: false, default: %i[ queue_name priority ]
10
+ end
9
11
 
10
12
  class_methods do
11
- def assume_attributes_from_job(*attributes)
12
- before_create -> { assume_attributes_from_job(ASSUMIBLE_ATTRIBUTES_FROM_JOB | attributes) }
13
+ def assumes_attributes_from_job(*attribute_names)
14
+ self.assumable_attributes_from_job |= attribute_names
15
+ before_create -> { assume_attributes_from_job }
16
+ end
17
+
18
+ def attributes_from_job(job)
19
+ job.attributes.symbolize_keys.slice(*assumable_attributes_from_job)
13
20
  end
14
21
  end
15
22
 
16
23
  private
17
- def assume_attributes_from_job(attributes)
18
- attributes.each do |attribute|
24
+ def assume_attributes_from_job
25
+ self.class.assumable_attributes_from_job.each do |attribute|
19
26
  send("#{attribute}=", job.send(attribute))
20
27
  end
21
28
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module SolidQueue
4
4
  class Execution < Record
5
+ class UndiscardableError < StandardError; end
6
+
5
7
  include JobAttributes
6
8
 
7
9
  self.abstract_class = true
@@ -10,6 +12,58 @@ module SolidQueue
10
12
 
11
13
  belongs_to :job
12
14
 
13
- alias_method :discard, :destroy
15
+ class << self
16
+ def create_all_from_jobs(jobs)
17
+ insert_all execution_data_from_jobs(jobs)
18
+ end
19
+
20
+ def execution_data_from_jobs(jobs)
21
+ jobs.collect do |job|
22
+ attributes_from_job(job).merge(job_id: job.id)
23
+ end
24
+ end
25
+
26
+ def discard_all_in_batches(batch_size: 500)
27
+ pending = count
28
+ discarded = 0
29
+
30
+ loop do
31
+ transaction do
32
+ job_ids = limit(batch_size).order(:job_id).lock.pluck(:job_id)
33
+
34
+ discard_jobs job_ids
35
+ discarded = where(job_id: job_ids).delete_all
36
+ pending -= discarded
37
+ end
38
+
39
+ break if pending <= 0 || discarded == 0
40
+ end
41
+ end
42
+
43
+ def discard_all_from_jobs(jobs)
44
+ transaction do
45
+ job_ids = lock_all_from_jobs(jobs)
46
+
47
+ discard_jobs job_ids
48
+ where(job_id: job_ids).delete_all
49
+ end
50
+ end
51
+
52
+ private
53
+ def lock_all_from_jobs(jobs)
54
+ where(job_id: jobs.map(&:id)).order(:job_id).lock.pluck(:job_id)
55
+ end
56
+
57
+ def discard_jobs(job_ids)
58
+ Job.where(id: job_ids).delete_all
59
+ end
60
+ end
61
+
62
+ def discard
63
+ with_lock do
64
+ job.destroy
65
+ destroy
66
+ end
67
+ end
14
68
  end
15
69
  end
@@ -1,31 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class SolidQueue::FailedExecution < SolidQueue::Execution
4
- if Gem::Version.new(Rails.version) >= Gem::Version.new("7.1")
3
+ module SolidQueue
4
+ class FailedExecution < Execution
5
+ include Dispatching
6
+
5
7
  serialize :error, coder: JSON
6
- else
7
- serialize :error, JSON
8
- end
9
8
 
10
- before_create :expand_error_details_from_exception
9
+ before_create :expand_error_details_from_exception
10
+
11
+ attr_accessor :exception
11
12
 
12
- attr_accessor :exception
13
+ def self.retry_all(jobs)
14
+ transaction do
15
+ dispatch_jobs lock_all_from_jobs(jobs)
16
+ end
17
+ end
13
18
 
14
- def retry
15
- transaction do
16
- job.prepare_for_execution
17
- destroy!
19
+ def retry
20
+ with_lock do
21
+ job.prepare_for_execution
22
+ destroy!
23
+ end
18
24
  end
19
- end
20
25
 
21
- %i[ exception_class message backtrace ].each do |attribute|
22
- define_method(attribute) { error.with_indifferent_access[attribute] }
23
- end
26
+ %i[ exception_class message backtrace ].each do |attribute|
27
+ define_method(attribute) { error.with_indifferent_access[attribute] }
28
+ end
24
29
 
25
- private
26
- def expand_error_details_from_exception
27
- if exception
28
- self.error = { exception_class: exception.class.name, message: exception.message, backtrace: exception.backtrace }
30
+ private
31
+ def expand_error_details_from_exception
32
+ if exception
33
+ self.error = { exception_class: exception.class.name, message: exception.message, backtrace: exception.backtrace }
34
+ end
29
35
  end
30
36
  end
31
37
  end
@@ -11,7 +11,10 @@ module SolidQueue
11
11
 
12
12
  class_methods do
13
13
  def clear_finished_in_batches(batch_size: 500, finished_before: SolidQueue.clear_finished_jobs_after.ago)
14
- clearable(finished_before: finished_before).in_batches(of: batch_size).delete_all
14
+ loop do
15
+ records_deleted = clearable(finished_before: finished_before).limit(batch_size).delete_all
16
+ break if records_deleted == 0
17
+ end
15
18
  end
16
19
  end
17
20
  end
@@ -6,9 +6,17 @@ module SolidQueue
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  included do
9
- has_one :blocked_execution, dependent: :destroy
9
+ has_one :blocked_execution
10
10
 
11
11
  delegate :concurrency_limit, :concurrency_duration, to: :job_class
12
+
13
+ before_destroy :unblock_next_blocked_job, if: -> { concurrency_limited? && ready? }
14
+ end
15
+
16
+ class_methods do
17
+ def release_all_concurrency_locks(jobs)
18
+ Semaphore.signal_all(jobs.select(&:concurrency_limited?))
19
+ end
12
20
  end
13
21
 
14
22
  def unblock_next_blocked_job
@@ -21,6 +29,10 @@ module SolidQueue
21
29
  concurrency_key.present?
22
30
  end
23
31
 
32
+ def blocked?
33
+ blocked_execution.present?
34
+ end
35
+
24
36
  private
25
37
  def acquire_concurrency_lock
26
38
  return true unless concurrency_limited?
@@ -45,6 +57,10 @@ module SolidQueue
45
57
  def job_class
46
58
  @job_class ||= class_name.safe_constantize
47
59
  end
60
+
61
+ def execution
62
+ super || blocked_execution
63
+ end
48
64
  end
49
65
  end
50
66
  end
@@ -6,20 +6,56 @@ module SolidQueue
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  included do
9
- include Clearable, ConcurrencyControls
9
+ include Clearable, ConcurrencyControls, Schedulable
10
10
 
11
- has_one :ready_execution, dependent: :destroy
12
- has_one :claimed_execution, dependent: :destroy
13
- has_one :failed_execution, dependent: :destroy
14
-
15
- has_one :scheduled_execution, dependent: :destroy
11
+ has_one :ready_execution
12
+ has_one :claimed_execution
13
+ has_one :failed_execution
16
14
 
17
15
  after_create :prepare_for_execution
18
16
 
19
17
  scope :finished, -> { where.not(finished_at: nil) }
18
+ scope :failed, -> { includes(:failed_execution).where.not(failed_execution: { id: nil }) }
20
19
  end
21
20
 
22
- %w[ ready claimed failed scheduled ].each do |status|
21
+ class_methods do
22
+ def prepare_all_for_execution(jobs)
23
+ due, not_yet_due = jobs.partition(&:due?)
24
+ dispatch_all(due) + schedule_all(not_yet_due)
25
+ end
26
+
27
+ def dispatch_all(jobs)
28
+ with_concurrency_limits, without_concurrency_limits = jobs.partition(&:concurrency_limited?)
29
+
30
+ dispatch_all_at_once(without_concurrency_limits)
31
+ dispatch_all_one_by_one(with_concurrency_limits)
32
+
33
+ successfully_dispatched(jobs)
34
+ end
35
+
36
+ private
37
+ def dispatch_all_at_once(jobs)
38
+ ReadyExecution.create_all_from_jobs jobs
39
+ end
40
+
41
+ def dispatch_all_one_by_one(jobs)
42
+ jobs.each(&:dispatch)
43
+ end
44
+
45
+ def successfully_dispatched(jobs)
46
+ dispatched_and_ready(jobs) + dispatched_and_blocked(jobs)
47
+ end
48
+
49
+ def dispatched_and_ready(jobs)
50
+ where(id: ReadyExecution.where(job_id: jobs.map(&:id)).pluck(:job_id))
51
+ end
52
+
53
+ def dispatched_and_blocked(jobs)
54
+ where(id: BlockedExecution.where(job_id: jobs.map(&:id)).pluck(:job_id))
55
+ end
56
+ end
57
+
58
+ %w[ ready claimed failed ].each do |status|
23
59
  define_method("#{status}?") { public_send("#{status}_execution").present? }
24
60
  end
25
61
 
@@ -37,6 +73,10 @@ module SolidQueue
37
73
  end
38
74
  end
39
75
 
76
+ def dispatch_bypassing_concurrency_limits
77
+ ready
78
+ end
79
+
40
80
  def finished!
41
81
  if preserve_finished_jobs?
42
82
  touch(:finished_at)
@@ -49,35 +89,34 @@ module SolidQueue
49
89
  finished_at.present?
50
90
  end
51
91
 
52
- def failed_with(exception)
53
- FailedExecution.create_or_find_by!(job_id: id, exception: exception)
54
- end
55
-
56
- def discard
57
- destroy unless claimed?
92
+ def status
93
+ if finished?
94
+ :finished
95
+ elsif execution.present?
96
+ execution.model_name.element.sub("_execution", "").to_sym
97
+ end
58
98
  end
59
99
 
60
100
  def retry
61
101
  failed_execution&.retry
62
102
  end
63
103
 
104
+ def discard
105
+ execution&.discard
106
+ end
107
+
64
108
  def failed_with(exception)
65
109
  FailedExecution.create_or_find_by!(job_id: id, exception: exception)
66
110
  end
67
111
 
68
112
  private
69
- def due?
70
- scheduled_at.nil? || scheduled_at <= Time.current
71
- end
72
-
73
- def schedule
74
- ScheduledExecution.create_or_find_by!(job_id: id)
75
- end
76
-
77
113
  def ready
78
114
  ReadyExecution.create_or_find_by!(job_id: id)
79
115
  end
80
116
 
117
+ def execution
118
+ %w[ ready claimed failed ].reduce(nil) { |acc, status| acc || public_send("#{status}_execution") }
119
+ end
81
120
 
82
121
  def preserve_finished_jobs?
83
122
  SolidQueue.preserve_finished_jobs
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class Job
5
+ module Schedulable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ has_one :scheduled_execution
10
+
11
+ scope :scheduled, -> { where.not(finished_at: nil) }
12
+ end
13
+
14
+ class_methods do
15
+ def schedule_all(jobs)
16
+ schedule_all_at_once(jobs)
17
+ successfully_scheduled(jobs)
18
+ end
19
+
20
+ private
21
+ def schedule_all_at_once(jobs)
22
+ ScheduledExecution.create_all_from_jobs(jobs)
23
+ end
24
+
25
+ def successfully_scheduled(jobs)
26
+ where(id: ScheduledExecution.where(job_id: jobs.map(&:id)).pluck(:job_id))
27
+ end
28
+ end
29
+
30
+ def due?
31
+ scheduled_at.nil? || scheduled_at <= Time.current
32
+ end
33
+
34
+ def scheduled?
35
+ scheduled_execution.present?
36
+ end
37
+
38
+ private
39
+ def schedule
40
+ ScheduledExecution.create_or_find_by!(job_id: id)
41
+ end
42
+
43
+ def execution
44
+ super || scheduled_execution
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,38 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class SolidQueue::Job < SolidQueue::Record
4
- include Executable
3
+ module SolidQueue
4
+ class Job < Record
5
+ include Executable
5
6
 
6
- if Gem::Version.new(Rails.version) >= Gem::Version.new("7.1")
7
7
  serialize :arguments, coder: JSON
8
- else
9
- serialize :arguments, JSON
10
- end
11
8
 
12
- DEFAULT_PRIORITY = 0
13
- DEFAULT_QUEUE_NAME = "default"
14
-
15
- class << self
16
- def enqueue_active_job(active_job, scheduled_at: Time.current)
17
- enqueue \
18
- queue_name: active_job.queue_name,
19
- active_job_id: active_job.job_id,
20
- priority: active_job.priority,
21
- scheduled_at: scheduled_at,
22
- class_name: active_job.class.name,
23
- arguments: active_job.serialize,
24
- concurrency_key: active_job.try(:concurrency_key)
25
- end
9
+ class << self
10
+ def enqueue_all(active_jobs)
11
+ active_jobs_by_job_id = active_jobs.index_by(&:job_id)
12
+
13
+ transaction do
14
+ jobs = create_all_from_active_jobs(active_jobs)
15
+ prepare_all_for_execution(jobs).tap do |enqueued_jobs|
16
+ enqueued_jobs.each do |enqueued_job|
17
+ active_jobs_by_job_id[enqueued_job.active_job_id].provider_job_id = enqueued_job.id
18
+ active_jobs_by_job_id[enqueued_job.active_job_id].successfully_enqueued = true
19
+ end
20
+ end
21
+ end
26
22
 
27
- def enqueue(**kwargs)
28
- create!(**kwargs.compact.with_defaults(defaults)).tap do
29
- SolidQueue.logger.debug "[SolidQueue] Enqueued job #{kwargs}"
23
+ active_jobs.count(&:successfully_enqueued?)
30
24
  end
31
- end
32
25
 
33
- private
34
- def defaults
35
- { queue_name: DEFAULT_QUEUE_NAME, priority: DEFAULT_PRIORITY }
26
+ def enqueue(active_job, scheduled_at: Time.current)
27
+ active_job.scheduled_at = scheduled_at
28
+
29
+ create_from_active_job(active_job).tap do |enqueued_job|
30
+ active_job.provider_job_id = enqueued_job.id
31
+ end
36
32
  end
33
+
34
+ private
35
+ DEFAULT_PRIORITY = 0
36
+ DEFAULT_QUEUE_NAME = "default"
37
+
38
+ def create_from_active_job(active_job)
39
+ create!(**attributes_from_active_job(active_job))
40
+ end
41
+
42
+ def create_all_from_active_jobs(active_jobs)
43
+ job_rows = active_jobs.map { |job| attributes_from_active_job(job) }
44
+ insert_all(job_rows)
45
+ where(active_job_id: active_jobs.map(&:job_id))
46
+ end
47
+
48
+ def attributes_from_active_job(active_job)
49
+ {
50
+ queue_name: active_job.queue_name || DEFAULT_QUEUE_NAME,
51
+ active_job_id: active_job.job_id,
52
+ priority: active_job.priority || DEFAULT_PRIORITY,
53
+ scheduled_at: active_job.scheduled_at,
54
+ class_name: active_job.class.name,
55
+ arguments: active_job.serialize,
56
+ concurrency_key: active_job.concurrency_key
57
+ }
58
+ end
59
+ end
37
60
  end
38
61
  end
@@ -33,7 +33,7 @@ module SolidQueue
33
33
  end
34
34
 
35
35
  def clear
36
- Job.where(queue_name: name).each(&:discard)
36
+ ReadyExecution.queued_as(name).discard_all_in_batches
37
37
  end
38
38
 
39
39
  def size
@@ -4,7 +4,7 @@ module SolidQueue
4
4
  class ReadyExecution < Execution
5
5
  scope :queued_as, ->(queue_name) { where(queue_name: queue_name) }
6
6
 
7
- assume_attributes_from_job
7
+ assumes_attributes_from_job
8
8
 
9
9
  class << self
10
10
  def claim(queue_list, limit, process_id)
@@ -15,6 +15,10 @@ module SolidQueue
15
15
  end
16
16
  end
17
17
 
18
+ def aggregated_count_across(queue_list)
19
+ QueueSelector.new(queue_list, self).scoped_relations.map(&:count).sum
20
+ end
21
+
18
22
  private
19
23
  def select_and_lock(queue_relation, process_id, limit)
20
24
  return [] if limit <= 0
@@ -36,6 +40,12 @@ module SolidQueue
36
40
  where(job_id: claimed.pluck(:job_id)).delete_all
37
41
  end
38
42
  end
43
+
44
+
45
+ def discard_jobs(job_ids)
46
+ Job.release_all_concurrency_locks Job.where(id: job_ids)
47
+ super
48
+ end
39
49
  end
40
50
  end
41
51
  end
@@ -2,11 +2,13 @@
2
2
 
3
3
  module SolidQueue
4
4
  class ScheduledExecution < Execution
5
+ include Dispatching
6
+
5
7
  scope :due, -> { where(scheduled_at: ..Time.current) }
6
8
  scope :ordered, -> { order(scheduled_at: :asc, priority: :asc) }
7
9
  scope :next_batch, ->(batch_size) { due.ordered.limit(batch_size) }
8
10
 
9
- assume_attributes_from_job :scheduled_at
11
+ assumes_attributes_from_job :scheduled_at
10
12
 
11
13
  class << self
12
14
  def dispatch_next_batch(batch_size)
@@ -14,52 +16,10 @@ module SolidQueue
14
16
  job_ids = next_batch(batch_size).non_blocking_lock.pluck(:job_id)
15
17
  if job_ids.empty? then []
16
18
  else
17
- dispatch_batch(job_ids)
19
+ dispatch_jobs(job_ids)
18
20
  end
19
21
  end
20
22
  end
21
-
22
- private
23
- def dispatch_batch(job_ids)
24
- jobs = Job.where(id: job_ids)
25
- with_concurrency_limits, without_concurrency_limits = jobs.partition(&:concurrency_limited?)
26
-
27
- dispatch_at_once(without_concurrency_limits)
28
- dispatch_one_by_one(with_concurrency_limits)
29
-
30
- successfully_dispatched(job_ids).tap do |dispatched_job_ids|
31
- where(job_id: dispatched_job_ids).delete_all
32
- SolidQueue.logger.info("[SolidQueue] Dispatched scheduled batch with #{dispatched_job_ids.size} jobs")
33
- end
34
- end
35
-
36
- def dispatch_at_once(jobs)
37
- ReadyExecution.insert_all ready_rows_from_batch(jobs)
38
- end
39
-
40
- def dispatch_one_by_one(jobs)
41
- jobs.each(&:dispatch)
42
- end
43
-
44
- def ready_rows_from_batch(jobs)
45
- prepared_at = Time.current
46
-
47
- jobs.map do |job|
48
- { job_id: job.id, queue_name: job.queue_name, priority: job.priority, created_at: prepared_at }
49
- end
50
- end
51
-
52
- def successfully_dispatched(job_ids)
53
- dispatched_and_ready(job_ids) + dispatched_and_blocked(job_ids)
54
- end
55
-
56
- def dispatched_and_ready(job_ids)
57
- ReadyExecution.where(job_id: job_ids).pluck(:job_id)
58
- end
59
-
60
- def dispatched_and_blocked(job_ids)
61
- BlockedExecution.where(job_id: job_ids).pluck(:job_id)
62
- end
63
23
  end
64
24
  end
65
25
  end
@@ -1,65 +1,94 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class SolidQueue::Semaphore < SolidQueue::Record
4
- scope :available, -> { where("value > 0") }
5
- scope :expired, -> { where(expires_at: ...Time.current) }
3
+ module SolidQueue
4
+ class Semaphore < Record
5
+ scope :available, -> { where("value > 0") }
6
+ scope :expired, -> { where(expires_at: ...Time.current) }
6
7
 
7
- class << self
8
- def wait(job)
9
- Proxy.new(job, self).wait
10
- end
11
-
12
- def signal(job)
13
- Proxy.new(job, self).signal
14
- end
15
- end
16
-
17
- class Proxy
18
- def initialize(job, proxied_class)
19
- @job = job
20
- @proxied_class = proxied_class
21
- end
8
+ class << self
9
+ def wait(job)
10
+ Proxy.new(job).wait
11
+ end
22
12
 
23
- def wait
24
- if semaphore = proxied_class.find_by(key: key)
25
- semaphore.value > 0 && attempt_decrement
26
- else
27
- attempt_creation
13
+ def signal(job)
14
+ Proxy.new(job).signal
28
15
  end
29
- end
30
16
 
31
- def signal
32
- attempt_increment
17
+ def signal_all(jobs)
18
+ Proxy.signal_all(jobs)
19
+ end
33
20
  end
34
21
 
35
- private
36
- attr_reader :job, :proxied_class
37
-
38
- def attempt_creation
39
- proxied_class.create!(key: key, value: limit - 1, expires_at: expires_at)
40
- true
41
- rescue ActiveRecord::RecordNotUnique
42
- attempt_decrement
22
+ class Proxy
23
+ def self.signal_all(jobs)
24
+ Semaphore.where(key: jobs.map(&:concurrency_key)).update_all("value = value + 1")
43
25
  end
44
26
 
45
- def attempt_decrement
46
- proxied_class.available.where(key: key).update_all([ "value = value - 1, expires_at = ?", expires_at ]) > 0
27
+ def initialize(job)
28
+ @job = job
29
+ @retries = 0
47
30
  end
48
31
 
49
- def attempt_increment
50
- proxied_class.where(key: key, value: ...limit).update_all([ "value = value + 1, expires_at = ?", expires_at ]) > 0
32
+ def wait
33
+ if semaphore = Semaphore.find_by(key: key)
34
+ semaphore.value > 0 && attempt_decrement
35
+ else
36
+ attempt_creation
37
+ end
51
38
  end
52
39
 
53
- def key
54
- job.concurrency_key
40
+ def signal
41
+ attempt_increment
55
42
  end
56
43
 
57
- def expires_at
58
- job.concurrency_duration.from_now
59
- end
44
+ private
45
+ attr_accessor :job, :retries
60
46
 
61
- def limit
62
- job.concurrency_limit
63
- end
47
+ def attempt_creation
48
+ Semaphore.create!(key: key, value: limit - 1, expires_at: expires_at)
49
+ true
50
+ rescue ActiveRecord::RecordNotUnique
51
+ attempt_decrement
52
+ end
53
+
54
+ def attempt_decrement
55
+ Semaphore.available.where(key: key).update_all([ "value = value - 1, expires_at = ?", expires_at ]) > 0
56
+ rescue ActiveRecord::Deadlocked
57
+ if retriable? then attempt_retry
58
+ else
59
+ raise
60
+ end
61
+ end
62
+
63
+ def attempt_increment
64
+ Semaphore.where(key: key, value: ...limit).update_all([ "value = value + 1, expires_at = ?", expires_at ]) > 0
65
+ end
66
+
67
+ def attempt_retry
68
+ self.retries += 1
69
+
70
+ if semaphore = Semaphore.find_by(key: key)
71
+ semaphore.value > 0 && attempt_decrement
72
+ end
73
+ end
74
+
75
+ MAX_RETRIES = 1
76
+
77
+ def retriable?
78
+ retries < MAX_RETRIES
79
+ end
80
+
81
+ def key
82
+ job.concurrency_key
83
+ end
84
+
85
+ def expires_at
86
+ job.concurrency_duration.from_now
87
+ end
88
+
89
+ def limit
90
+ job.concurrency_limit
91
+ end
92
+ end
64
93
  end
65
94
  end
@@ -0,0 +1,5 @@
1
+ class AddMissingIndexToBlockedExecutions < ActiveRecord::Migration[7.1]
2
+ def change
3
+ add_index :solid_queue_blocked_executions, [ :concurrency_key, :priority, :job_id ], name: "index_solid_queue_blocked_executions_for_release"
4
+ end
5
+ end
@@ -36,6 +36,10 @@ module ActiveJob
36
36
  end
37
37
  end
38
38
 
39
+ def concurrency_limited?
40
+ concurrency_key.present?
41
+ end
42
+
39
43
  private
40
44
  def concurrency_group
41
45
  compute_concurrency_parameter(self.class.concurrency_group)
@@ -9,15 +9,15 @@ module ActiveJob
9
9
  # Rails.application.config.active_job.queue_adapter = :solid_queue
10
10
  class SolidQueueAdapter
11
11
  def enqueue(active_job) # :nodoc:
12
- SolidQueue::Job.enqueue_active_job(active_job).tap do |job|
13
- active_job.provider_job_id = job.id
14
- end
12
+ SolidQueue::Job.enqueue(active_job)
15
13
  end
16
14
 
17
15
  def enqueue_at(active_job, timestamp) # :nodoc:
18
- SolidQueue::Job.enqueue_active_job(active_job, scheduled_at: Time.at(timestamp)).tap do |job|
19
- active_job.provider_job_id = job.id
20
- end
16
+ SolidQueue::Job.enqueue(active_job, scheduled_at: Time.at(timestamp))
17
+ end
18
+
19
+ def enqueue_all(active_jobs) # :nodoc:
20
+ SolidQueue::Job.enqueue_all(active_jobs)
21
21
  end
22
22
  end
23
23
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Uniqueness
5
+ extend ActiveSupport::Concern
6
+
7
+ DEFAULT_UNIQUENESS_GROUP = ->(*) { self.class.name }
8
+
9
+ included do
10
+ class_attribute :uniqueness_key, instance_accessor: false
11
+ class_attribute :uniqueness_group, default: DEFAULT_UNIQUENESS_GROUP, instance_accessor: false
12
+
13
+ class_attribute :uniqueness_duration
14
+ end
15
+
16
+ class_methods do
17
+ def enqueued_uniquely_by(key:, group: DEFAULT_UNIQUENESS_GROUP, duration: SolidQueue.default_uniqueness_period)
18
+ self.uniqueness_key = key
19
+ self.uniqueness_group = group
20
+ self.uniqueness_duration = duration
21
+ end
22
+ end
23
+
24
+ def uniqueness_key
25
+ if self.class.uniqueness_key
26
+ param = compute_concurrency_parameter(self.class.concurrency_key)
27
+
28
+ case param
29
+ when ActiveRecord::Base
30
+ [ concurrency_group, param.class.name, param.id ]
31
+ else
32
+ [ concurrency_group, param ]
33
+ end.compact.join("/")
34
+ end
35
+ end
36
+
37
+ def enqueued_uniquely?
38
+ uniqueness_key.present?
39
+ end
40
+ end
41
+ end
@@ -4,6 +4,6 @@ Description:
4
4
  Example:
5
5
  bin/rails generate solid_queue:install
6
6
 
7
- This will create:
7
+ This will perform the following:
8
8
  Installs solid_queue migrations
9
- Replaces Active Job's adapter in envionment configuration
9
+ Replaces Active Job's adapter in environment configuration
@@ -6,10 +6,8 @@ class SolidQueue::InstallGenerator < Rails::Generators::Base
6
6
  class_option :skip_migrations, type: :boolean, default: nil, desc: "Skip migrations"
7
7
 
8
8
  def add_solid_queue
9
- %w[ development test production ].each do |env_name|
10
- if (env_config = Pathname(destination_root).join("config/environments/#{env_name}.rb")).exist?
11
- gsub_file env_config, /(# )?config\.active_job\.queue_adapter\s+=.*/, "config.active_job.queue_adapter = :solid_queue"
12
- end
9
+ if (env_config = Pathname(destination_root).join("config/environments/production.rb")).exist?
10
+ gsub_file env_config, /(# )?config\.active_job\.queue_adapter\s+=.*/, "config.active_job.queue_adapter = :solid_queue"
13
11
  end
14
12
 
15
13
  copy_file "config.yml", "config/solid_queue.yml"
@@ -1,4 +1,4 @@
1
- #default: &default
1
+ # default: &default
2
2
  # dispatchers:
3
3
  # - polling_interval: 1
4
4
  # batch_size: 500
@@ -6,16 +6,19 @@ Puma::Plugin.create do
6
6
  def start(launcher)
7
7
  @log_writer = launcher.log_writer
8
8
  @puma_pid = $$
9
- @solid_queue_pid = fork do
10
- Thread.new { monitor_puma }
11
- SolidQueue::Supervisor.start(mode: :all)
12
- end
13
9
 
14
- launcher.events.on_stopped { stop_solid_queue }
10
+ launcher.events.on_booted do
11
+ @solid_queue_pid = fork do
12
+ Thread.new { monitor_puma }
13
+ SolidQueue::Supervisor.start(mode: :all)
14
+ end
15
15
 
16
- in_background do
17
- monitor_solid_queue
16
+ in_background do
17
+ monitor_solid_queue
18
+ end
18
19
  end
20
+
21
+ launcher.events.on_stopped { stop_solid_queue }
19
22
  end
20
23
 
21
24
  private
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class Dispatcher::ScheduledExecutionsDispatcher < Dispatcher
5
+ end
6
+ end
@@ -4,6 +4,8 @@ module SolidQueue::Processes
4
4
  module Runnable
5
5
  include Supervised
6
6
 
7
+ attr_writer :mode
8
+
7
9
  def start
8
10
  @stopping = false
9
11
 
@@ -19,8 +21,6 @@ module SolidQueue::Processes
19
21
  end
20
22
 
21
23
  private
22
- attr_writer :mode
23
-
24
24
  DEFAULT_MODE = :async
25
25
 
26
26
  def mode
@@ -44,7 +44,9 @@ module SolidQueue::Processes
44
44
  loop do
45
45
  break if shutting_down?
46
46
 
47
- run
47
+ wrap_in_app_executor do
48
+ run
49
+ end
48
50
  end
49
51
  ensure
50
52
  run_callbacks(:shutdown) { shutdown }
@@ -1,3 +1,3 @@
1
1
  module SolidQueue
2
- VERSION = "0.1.2"
2
+ VERSION = "0.2.1"
3
3
  end
@@ -44,7 +44,7 @@ module SolidQueue
44
44
  end
45
45
 
46
46
  def all_work_completed?
47
- SolidQueue::ReadyExecution.queued_as(queues).empty?
47
+ SolidQueue::ReadyExecution.aggregated_count_across(queues).zero?
48
48
  end
49
49
 
50
50
  def metadata
metadata CHANGED
@@ -1,29 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_queue
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rosa Gutierrez
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-12-21 00:00:00.000000000 Z
11
+ date: 2024-01-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
17
+ - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 7.0.3.1
19
+ version: '7.1'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - ">="
24
+ - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 7.0.3.1
26
+ version: '7.1'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: debug
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: puma
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
55
69
  description: Database-backed Active Job backend.
56
70
  email:
57
71
  - rosa@37signals.com
@@ -65,12 +79,14 @@ files:
65
79
  - app/models/solid_queue/blocked_execution.rb
66
80
  - app/models/solid_queue/claimed_execution.rb
67
81
  - app/models/solid_queue/execution.rb
82
+ - app/models/solid_queue/execution/dispatching.rb
68
83
  - app/models/solid_queue/execution/job_attributes.rb
69
84
  - app/models/solid_queue/failed_execution.rb
70
85
  - app/models/solid_queue/job.rb
71
86
  - app/models/solid_queue/job/clearable.rb
72
87
  - app/models/solid_queue/job/concurrency_controls.rb
73
88
  - app/models/solid_queue/job/executable.rb
89
+ - app/models/solid_queue/job/schedulable.rb
74
90
  - app/models/solid_queue/pause.rb
75
91
  - app/models/solid_queue/process.rb
76
92
  - app/models/solid_queue/process/prunable.rb
@@ -82,8 +98,10 @@ files:
82
98
  - app/models/solid_queue/semaphore.rb
83
99
  - config/routes.rb
84
100
  - db/migrate/20231211200639_create_solid_queue_tables.rb
101
+ - db/migrate/20240110143450_add_missing_index_to_blocked_executions.rb
85
102
  - lib/active_job/concurrency_controls.rb
86
103
  - lib/active_job/queue_adapters/solid_queue_adapter.rb
104
+ - lib/active_job/uniqueness.rb
87
105
  - lib/generators/solid_queue/install/USAGE
88
106
  - lib/generators/solid_queue/install/install_generator.rb
89
107
  - lib/generators/solid_queue/install/templates/config.yml
@@ -92,6 +110,7 @@ files:
92
110
  - lib/solid_queue/app_executor.rb
93
111
  - lib/solid_queue/configuration.rb
94
112
  - lib/solid_queue/dispatcher.rb
113
+ - lib/solid_queue/dispatcher/scheduled_executions_dispatcher.rb
95
114
  - lib/solid_queue/engine.rb
96
115
  - lib/solid_queue/pool.rb
97
116
  - lib/solid_queue/processes/base.rb
@@ -128,7 +147,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
128
147
  - !ruby/object:Gem::Version
129
148
  version: '0'
130
149
  requirements: []
131
- rubygems_version: 3.4.20
150
+ rubygems_version: 3.4.10
132
151
  signing_key:
133
152
  specification_version: 4
134
153
  summary: Database-backed Active Job backend.