solid_queue 0.1.2 → 0.2.1

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 (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.