-
-
Notifications
You must be signed in to change notification settings - Fork 208
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add concurrency extension for ActiveJob
- Loading branch information
1 parent
2b16bda
commit 69b42bb
Showing
10 changed files
with
233 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
module GoodJob | ||
module ActiveJobExtensions | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
module GoodJob | ||
module ActiveJobExtensions | ||
module Concurrency | ||
extend ActiveSupport::Concern | ||
|
||
ConcurrencyExceededError = Class.new(StandardError) | ||
|
||
included do | ||
class_attribute :good_job_concurrency_config, instance_accessor: false, default: {} | ||
|
||
before_enqueue do |job| | ||
# Always allow jobs to be retried because the current job's execution will complete momentarily | ||
next if CurrentExecution.active_job_id == job.job_id | ||
|
||
limit = job.class.good_job_concurrency_config.fetch(:enqueue_limit, Float::INFINITY) | ||
next if limit.blank? || (0...Float::INFINITY).exclude?(limit) | ||
|
||
key = job.good_job_concurrency_key | ||
next if key.blank? | ||
|
||
GoodJob::Job.new.with_advisory_lock(key: key, function: "pg_advisory_lock") do | ||
# TODO: Why is `unscoped` necessary? Nested scope is bleeding into subsequent query? | ||
enqueue_concurrency = GoodJob::Job.unscoped.where(concurrency_key: key).unfinished.count | ||
# The job has not yet been enqueued, so check if adding it will go over the limit | ||
throw :abort if enqueue_concurrency + 1 > limit | ||
end | ||
end | ||
|
||
retry_on( | ||
GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError, | ||
attempts: Float::INFINITY, | ||
wait: :exponentially_longer | ||
) | ||
|
||
before_perform do |job| | ||
limit = job.class.good_job_concurrency_config.fetch(:perform_limit, Float::INFINITY) | ||
next if limit.blank? || (0...Float::INFINITY).exclude?(limit) | ||
|
||
key = job.good_job_concurrency_key | ||
next if key.blank? | ||
|
||
GoodJob::Job.new.with_advisory_lock(key: key, function: "pg_advisory_lock") do | ||
perform_concurrency = GoodJob::Job.unscoped.where(concurrency_key: key).advisory_locked.count | ||
# The current job has already been locked and will appear in the previous query | ||
raise GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError if perform_concurrency > limit | ||
end | ||
end | ||
end | ||
|
||
class_methods do | ||
def good_job_control_concurrency_with(config) | ||
self.good_job_concurrency_config = config | ||
end | ||
end | ||
|
||
def good_job_concurrency_key | ||
key = self.class.good_job_concurrency_config[:key] | ||
return if key.blank? | ||
|
||
if key.respond_to? :call | ||
instance_exec(&key) | ||
else | ||
key | ||
end | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
64 changes: 64 additions & 0 deletions
64
spec/lib/good_job/active_job_extensions/concurrency_spec.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
require 'rails_helper' | ||
|
||
RSpec.describe GoodJob::ActiveJobExtensions::Concurrency do | ||
before do | ||
ActiveJob::Base.queue_adapter = GoodJob::Adapter.new(execution_mode: :external) | ||
|
||
stub_const 'TestJob', (Class.new(ActiveJob::Base) do | ||
include GoodJob::ActiveJobExtensions::Concurrency | ||
|
||
good_job_control_concurrency_with( | ||
enqueue_limit: 2, | ||
perform_limit: 1, | ||
key: -> { arguments.first[:name] } | ||
) | ||
|
||
def perform(name:) | ||
name && sleep(1) | ||
end | ||
end) | ||
end | ||
|
||
describe '.good_job_control_concurrency_with' do | ||
describe 'enqueue_limit' do | ||
it "does not enqueue if enqueue concurrency limit is exceeded for a particular key" do | ||
expect(TestJob.perform_later(name: "Alice")).to be_present | ||
expect(TestJob.perform_later(name: "Alice")).to be_present | ||
|
||
# Third usage of key does not enqueue | ||
expect(TestJob.perform_later(name: "Alice")).to eq false | ||
|
||
# Usage of different key does enqueue | ||
expect(TestJob.perform_later(name: "Bob")).to be_present | ||
|
||
expect(GoodJob::Job.where(concurrency_key: "Alice").count).to eq 2 | ||
expect(GoodJob::Job.where(concurrency_key: "Bob").count).to eq 1 | ||
end | ||
end | ||
|
||
describe 'perform_limit' do | ||
before do | ||
allow(GoodJob).to receive(:preserve_job_records).and_return(true) | ||
end | ||
|
||
it "will error and retry jobs if concurrency is exceeded" do | ||
TestJob.perform_later(name: "Alice") | ||
TestJob.perform_later(name: "Alice") | ||
TestJob.perform_later(name: "Bob") | ||
|
||
performer = GoodJob::JobPerformer.new('*') | ||
scheduler = GoodJob::Scheduler.new(performer, max_threads: 5) | ||
5.times { scheduler.create_thread } | ||
|
||
sleep_until(max: 10, increments_of: 0.5) do | ||
GoodJob::Job.where(concurrency_key: "Alice").finished.count >= 1 && | ||
GoodJob::Job.where(concurrency_key: "Bob").finished.count == 1 | ||
end | ||
scheduler.shutdown | ||
|
||
expect(GoodJob::Job.count).to be > 3 | ||
expect(GoodJob::Job.where("error LIKE '%GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError%'")).to be_present | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
RSpec.configure do |c| | ||
less_than_rails_6 = Gem::Version.new(Rails.version) < Gem::Version.new('6') | ||
c.filter_run_excluding(:skip_rails_5) if less_than_rails_6 | ||
end |