-
-
Notifications
You must be signed in to change notification settings - Fork 1k
Fix deadlock when pushing multiple gem versions concurrently #6658
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class ReorderVersionsJob < ApplicationJob | ||
| include GoodJob::ActiveJobExtensions::Concurrency | ||
|
|
||
| good_job_control_concurrency_with( | ||
| perform_limit: 1, | ||
| key: -> { "reorder-versions-#{arguments.first[:rubygem].id}" } | ||
| ) | ||
|
|
||
| queue_as :default | ||
|
|
||
| retry_on ActiveRecord::Deadlocked, wait: :polynomially_longer, attempts: 3 | ||
| discard_on ActiveJob::DeserializationError | ||
|
|
||
| def perform(rubygem:) | ||
| logger.info { "Reordering versions for gem: #{rubygem.name} (#{rubygem.id})" } | ||
|
|
||
| StatsD.measure("reorder_versions.duration") do | ||
| rubygem.reorder_versions | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method needs to be called inside a transaction |
||
| end | ||
| StatsD.increment("reorder_versions.success") | ||
| Indexer.perform_later | ||
|
|
||
| latest_version = rubygem.reload.most_recent_version | ||
| SetLinksetHomeJob.perform_later(version: latest_version) if latest_version | ||
|
|
||
| logger.info { "Reordering complete for #{rubygem.name}" } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We'll need to call |
||
| rescue StandardError => e | ||
| logger.error { "Failed to reorder versions for #{rubygem.name}: #{e.message}" } | ||
| StatsD.increment("reorder_versions.error", tags: { error: e.class.name }) | ||
| raise | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,7 +20,7 @@ class Rubygem < ApplicationRecord | |
| has_many :audits, as: :auditable, inverse_of: :auditable | ||
| has_many :link_verifications, as: :linkable, inverse_of: :linkable, dependent: :destroy | ||
| has_many :oidc_rubygem_trusted_publishers, class_name: "OIDC::RubygemTrustedPublisher", inverse_of: :rubygem, dependent: :destroy | ||
| has_many :incoming_dependencies, -> { where(versions: { indexed: true, position: 0 }) }, class_name: "Dependency", inverse_of: :rubygem | ||
| has_many :incoming_dependencies, -> { where(versions: { indexed: true, latest: true }) }, class_name: "Dependency", inverse_of: :rubygem | ||
| has_many :reverse_dependencies, through: :incoming_dependencies, source: :version_rubygem | ||
| has_many :reverse_development_dependencies, -> { merge(Dependency.development) }, through: :incoming_dependencies, source: :version_rubygem | ||
| has_many :reverse_runtime_dependencies, -> { merge(Dependency.runtime) }, through: :incoming_dependencies, source: :version_rubygem | ||
|
|
@@ -29,7 +29,7 @@ def unique_reverse_dependencies | |
| Rubygem.where( | ||
| id: Dependency.where(rubygem_id: id) | ||
| .joins(:version) | ||
| .where(versions: { indexed: true, position: 0 }) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you refresh my memory on why
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a change in semantics that seems like an improvement, but we should tackle in a separate PR. |
||
| .where(versions: { indexed: true, latest: true }) | ||
| .select("versions.rubygem_id") | ||
| ) | ||
| end | ||
|
|
@@ -38,7 +38,7 @@ def unique_reverse_development_dependencies | |
| Rubygem.where( | ||
| id: Dependency.development.where(rubygem_id: id) | ||
| .joins(:version) | ||
| .where(versions: { indexed: true, position: 0 }) | ||
| .where(versions: { indexed: true, latest: true }) | ||
| .select("versions.rubygem_id") | ||
| ) | ||
| end | ||
|
|
@@ -47,7 +47,7 @@ def unique_reverse_runtime_dependencies | |
| Rubygem.where( | ||
| id: Dependency.runtime.where(rubygem_id: id) | ||
| .joins(:version) | ||
| .where(versions: { indexed: true, position: 0 }) | ||
| .where(versions: { indexed: true, latest: true }) | ||
| .select("versions.rubygem_id") | ||
| ) | ||
| end | ||
|
|
@@ -61,9 +61,12 @@ def unique_reverse_runtime_dependencies | |
| has_one :most_recent_version, | ||
| lambda { | ||
| order( | ||
| # During the async reorder window, a freshly pushed version can have a nil | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think the comment matches the behaviour here. I've had Claude trace through the ordering change. The latest sort key still comes before position, so for a gem that already has a My bigger question is what drove these two ordering changes, the new indexed-first key and NULLS FIRST. As far as I can tell nothing in this PR needs them. During the async window the old ordering just shows the previous latest until the job lands, which is what users saw pre-PR anyway, and by the time this scope is read inside the job the positions are already assigned. The indexed-first key does change what If these were fixing something you hit while tophatting, I'd love to know what. If they're an intentional improvement, same suggestion as the |
||
| # position; treat it as newest until ReorderVersionsJob assigns positions. | ||
| Arel.sql("case when #{quoted_table_name}.indexed then 0 else 1 end"), | ||
| Arel.sql("case when #{quoted_table_name}.latest AND #{quoted_table_name}.platform = 'ruby' then 0 " \ | ||
| "when #{quoted_table_name}.latest then 1 else 2 end"), | ||
| :position, | ||
| Arel.sql("#{quoted_table_name}.position ASC NULLS FIRST"), | ||
| id: :desc | ||
| ) | ||
| }, | ||
|
|
@@ -433,7 +436,7 @@ def bulk_reorder_versions | |
|
|
||
| ids = [] | ||
| positions = [] | ||
| versions.each do |version| | ||
| versions.order(:id).each do |version| | ||
| ids << version.id | ||
| positions << numbers.index(version.number) | ||
| end | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -82,7 +82,8 @@ class PushTest < ActionDispatch::IntegrationTest | |
| push_gem gem_io | ||
|
|
||
| assert_response :success | ||
| perform_enqueued_jobs | ||
| perform_enqueued_jobs(only: ReorderVersionsJob) | ||
| perform_enqueued_jobs(only: SetLinksetHomeJob) | ||
|
|
||
| get rubygem_path("sandworm") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Calling |
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require "test_helper" | ||
|
|
||
| class ReorderVersionsJobTest < ActiveJob::TestCase | ||
| setup do | ||
| @rubygem = create(:rubygem, name: "test-gem") | ||
| @user = create(:user) | ||
| end | ||
|
|
||
| context "reordering versions" do | ||
| should "reorder versions, set the latest flag, and record the success metric" do | ||
| v3 = create(:version, rubygem: @rubygem, number: "3.0.0", indexed: true) | ||
| v1 = create(:version, rubygem: @rubygem, number: "1.0.0", indexed: true) | ||
| v2 = create(:version, rubygem: @rubygem, number: "2.0.0", indexed: true) | ||
|
|
||
| StatsD.stubs(:increment) | ||
| StatsD.stubs(:measure) | ||
| StatsD.expects(:increment).with("reorder_versions.success") | ||
| StatsD.expects(:measure).with("reorder_versions.duration").yields | ||
|
|
||
| assert_enqueued_with(job: Indexer) do | ||
| ReorderVersionsJob.new.perform(rubygem: @rubygem) | ||
| end | ||
|
|
||
| assert_equal 0, v3.reload.position | ||
| assert_equal 1, v2.reload.position | ||
| assert_equal 2, v1.reload.position | ||
|
|
||
| refute v1.reload.latest | ||
| refute v2.reload.latest | ||
| assert v3.reload.latest | ||
| end | ||
|
|
||
| should "handle concurrent reorder attempts gracefully" do | ||
| create(:version, rubygem: @rubygem, number: "1.0.0", indexed: true) | ||
|
|
||
| job1 = ReorderVersionsJob.new | ||
| job2 = ReorderVersionsJob.new | ||
|
|
||
| assert_nothing_raised do | ||
| threads = [ | ||
| Thread.new do | ||
| ActiveRecord::Base.connection_pool.with_connection do | ||
| job1.perform(rubygem: @rubygem) | ||
| end | ||
| end, | ||
| Thread.new do | ||
| ActiveRecord::Base.connection_pool.with_connection do | ||
| job2.perform(rubygem: @rubygem) | ||
| end | ||
| end | ||
| ] | ||
| threads.each(&:join) | ||
| end | ||
|
|
||
| assert_equal 0, @rubygem.versions.first.reload.position | ||
| end | ||
|
|
||
| should "handle errors and increment error metric" do | ||
| create(:version, rubygem: @rubygem, number: "1.0.0", indexed: true) | ||
|
|
||
| @rubygem.stubs(:reorder_versions).raises(StandardError.new("Test error")) | ||
|
|
||
| StatsD.stubs(:increment) | ||
| StatsD.stubs(:measure).yields | ||
| StatsD.expects(:increment).with("reorder_versions.error", tags: { error: "StandardError" }) | ||
|
|
||
| assert_raises(StandardError) do | ||
| ReorderVersionsJob.new.perform(rubygem: @rubygem) | ||
| end | ||
| end | ||
|
|
||
| should "discard job if rubygem no longer exists" do | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you double-check this test is accurate? I don't think this is actually hitting the |
||
| rubygem_id = @rubygem.id | ||
| @rubygem.destroy | ||
|
|
||
| assert_nothing_raised do | ||
| ReorderVersionsJob.perform_now(rubygem: Rubygem.find(rubygem_id)) | ||
| rescue ActiveRecord::RecordNotFound, ActiveJob::DeserializationError => e | ||
| Rails.logger.info "Job discarded as expected: #{e.class}" | ||
| end | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1095,26 +1095,4 @@ class VersionTest < ActiveSupport::TestCase | |
| assert_does_not_contain Version.created_between(@start_time, @end_time), @version | ||
| end | ||
| end | ||
|
|
||
| context "after_save" do | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should check if the reorder job is enqueued here. |
||
| context "reorder versions" do | ||
| setup do | ||
| @version = create(:version) | ||
| end | ||
|
|
||
| context "indexed is updated" do | ||
| should "reorder versions" do | ||
| @version.expects(:reorder_versions).times(1) | ||
| @version.update(indexed: false) | ||
| end | ||
| end | ||
|
|
||
| context "info checksum v2 is updated" do | ||
| should "not reorder versions" do | ||
| @version.expects(:reorder_versions).times(0) | ||
| @version.update(info_checksum_v2: "lala") | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -80,6 +80,7 @@ | |
| WebAuthn.configuration.allowed_origins = ["http://localhost:31337"] | ||
|
|
||
| class ActiveSupport::TestCase | ||
| include ActiveJob::TestHelper | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I suggest we make this change separately. There's many |
||
| include FactoryBot::Syntax::Methods | ||
| include GemHelpers | ||
| include EmailHelpers | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this job exhausts its retries and gets discarded,
IndexerandSetLinksetHomeJobnever run, since they're only enqueued from in here. The full index (specs.4.8.gz and friends) doesn't get regenerated, so old-style gem clients can't see the version at all, while the compact index resolves it fine. The version is also left atposition: nil, which keeps it out oflatest_specs.4.8.gzeven if something else triggers a reindex.That being said, I don't think we need to introduce a reconciler in this PR. Retries should it rare and we can watch
reorder_versions.error/good_job.discardedon our side. Could you add a sentence to the job noting the trade-off, so it's written down somewhere that a discarded reorder means the full index diverges until the next push?Two small things while we're here:
retry_on ActiveRecord::Deadlocked, attempts: 3lowers the app-wide default of 5 fromApplicationJob, which I don't think was intended, so it can just be removed. And therescue StandardErrorduplicates what ApplicationJob'safter_discardalready reports, so it could go too. No strong feelings on that one.