Skip to content

Fix deadlock when pushing multiple gem versions concurrently - #6658

Open
girachawda wants to merge 1 commit into
rubygems:masterfrom
Shopify:fix-deadlock
Open

Fix deadlock when pushing multiple gem versions concurrently#6658
girachawda wants to merge 1 commit into
rubygems:masterfrom
Shopify:fix-deadlock

Conversation

@girachawda

@girachawda girachawda commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

What

Why

Concurrent gem pushes trigger the after_save :reorder_versions callback simultaneously, causing PostgreSQL deadlocks when multiple transactions try to lock the same version rows in different orders.

Flow

push/yank/restore
  -> write version/indexed state
  -> enqueue ReorderVersionsJob
      -> reorder versions
      -> set latest correctly
      -> enqueue Indexer
      -> enqueue SetLinksetHomeJob

Indexer runs after ReorderVersionsJob because the legacy index depends on the freshly-updated latest flags. SetLinksetHomeJob is also chained after reorder for the same reason.

Tophat

  1. Run the reproduction script to verify deadlocks are eliminated. This script drains the default queue in-process; no separate worker is required for the synthetic tophat data:
Script
require "securerandom"; require "digest"

gem_name = "test-concurrent-push-#{SecureRandom.hex(4)}"
rubygem  = Rubygem.create!(name: gem_name)
count    = 10
threads  = 4   # keep below dev DB pool (5)

# 1. Create version rows serially (avoids pool storm + new-gem race)
versions = count.times.map do |i|
  rubygem.versions.create!(
    number: "#{i}.0.0", platform: "ruby", gem_platform: "ruby",
    indexed: false, authors: ["Test"], summary: "Test",
    sha256: Digest::SHA256.base64digest("test#{i}"), size: 1000
  )
end

# 2. Fire AfterVersionWriteJob CONCURRENTLY (the path that used to deadlock)
errors = Queue.new
versions.each_slice(threads) do |batch|
  batch.map do |v|
    Thread.new do
      ActiveRecord::Base.connection_pool.with_connection { AfterVersionWriteJob.new.perform(version: v) }
    rescue => e
      errors << "#{e.class}: #{e.message}"
    end
  end.each(&:join)
end

# 3. Report deadlocks
errs = []; errs << errors.pop until errors.empty?
deadlocks = errs.grep(/Deadlocked/)
puts deadlocks.empty? ? "✓ NO DEADLOCKS" : "✗ DEADLOCKS: #{deadlocks}"
puts "other noise (pool/setup): #{errs - deadlocks}" unless (errs - deadlocks).empty?

# 4. Drain the enqueued ReorderVersionsJob(s) in-process.
# This synthetic script creates Version rows without real .gem files, so only
# drain the default queue; the version_contents queue expects real gem files.
GoodJob.perform_inline("default")

# 5. Check final state
rubygem.reload
puts "positions: #{rubygem.versions.order(:position).pluck(:number, :position).inspect}"
puts "latest:    #{rubygem.versions.where(latest: true).pluck(:number).inspect}"
puts "nil positions: #{rubygem.versions.where(position: nil).count}"

expected = rubygem.versions.pluck(:number).sort_by { |n| Gem::Version.new(n) }.reverse
actual   = rubygem.versions.order(:position).pluck(:number)
ok = deadlocks.empty? && actual == expected &&
     rubygem.versions.where(latest: true).pluck(:number) == [expected.first] &&
     rubygem.versions.where(position: nil).count.zero?
puts ok ? "✅ PASS" : "❌ FAIL"
  1. Confirm version positions are correct after job completes
Before After
Screenshot 2026-06-25 at 3 15 30 PM Screenshot 2026-06-25 at 3 14 51 PM

@girachawda
girachawda force-pushed the fix-deadlock branch 3 times, most recently from 8d1f860 to 11992b8 Compare June 30, 2026 20:35
@girachawda
girachawda marked this pull request as ready for review June 30, 2026 20:42
@girachawda

girachawda commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Confirm the deadlock doesn't occur. The nil is because we don't have workers in console scripts.

Screenshot 2026-06-30 at 5 13 07 PM

@jenshenny

Copy link
Copy Markdown
Member

I'll take a closer look at the PR on Thursday!

Confirm the deadlock doesn't occur. The nil is because we don't have workers in console scripts.

btw bundle exec good_job start will start a worker to execute any jobs enqueued.

@girachawda

Copy link
Copy Markdown
Contributor Author

Tophat ✅ with workers running in console:
Screenshot 2026-07-02 at 11 30 40 AM

Comment thread test/functional/api/v1/rubygems_controller_test.rb Outdated

@jenshenny jenshenny left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think extracting to a separate job is the correct approach to solve this issue 👍

Comment thread app/jobs/after_version_write_job.rb Outdated
Comment thread test/functional/api/v1/rubygems_controller_test.rb Outdated
Comment thread test/factories/version.rb Outdated
Comment thread test/test_helper.rb Outdated
Comment thread app/models/deletion.rb Outdated
Comment thread app/jobs/reorder_versions_job.rb Outdated
Comment thread app/jobs/reorder_versions_job.rb Outdated
Comment thread app/jobs/set_linkset_home_job.rb
@OughtPuts

Copy link
Copy Markdown
Contributor

Successfully repro'd locally ✅ Looking at the code next

Comment thread test/jobs/reorder_versions_job_test.rb Outdated

@OughtPuts OughtPuts left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is complex stuff @girachawda - nice work! :) A few thoughts that it would be great to discuss.

Comment thread app/jobs/reorder_versions_job.rb Outdated
Comment thread app/jobs/after_version_write_job.rb Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't had time to look into the implications of this fully, but have been having a quick look and think about the other jobs in perform in case any of them are affected by the removal of the hook and the later place reordering (+ related ops) now happen.

I saw that Indexer calls rows_for_latest_index which relies on .latest so I think it would be good to double check we are happy that this is now using a stale version of latest now (until the ReorderVersionsJob fires later in the flow... but then Indexer isn't called again of course...).

@girachawda girachawda Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks! You're right that with reorder now async there was no guarantee Indexer ran after it, so it could publish a stale latest index and not get re-run until the next push.

I looked at closing this by chaining Indexer off ReorderVersionsJob, but it meant moving Indexer out of AfterVersionWriteJob and coupling indexing to reordering, which rippled into a bunch of unrelated push/yank integration tests. It feels like too much scope creep for a deadlock fix. WDYT? I'd love to hear your thoughts @jenshenny

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Indexer manages the legacy index specs for clients to use so we should ensure correctness here. I would think chaining Indexer off ReorderVersionsJob would be fine so I'm curious on what this rippling is.

@girachawda
girachawda requested review from OughtPuts and jenshenny July 8, 2026 20:03
@girachawda
girachawda force-pushed the fix-deadlock branch 3 times, most recently from ac77de4 to 56cacda Compare July 8, 2026 21:05
Comment thread test/test_helper.rb
WebAuthn.configuration.allowed_origins = ["http://localhost:31337"]

class ActiveSupport::TestCase
include ActiveJob::TestHelper

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest we make this change separately. There's many include ActiveJob::TestHelper in specific test files that would need to be removed with this.

Comment thread app/jobs/after_version_write_job.rb Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Indexer manages the legacy index specs for clients to use so we should ensure correctness here. I would think chaining Indexer off ReorderVersionsJob would be fine so I'm curious on what this rippling is.

end
end

context "after_save" do

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should check if the reorder job is enqueued here.

Comment thread app/models/deletion.rb Outdated

def reindex
Indexer.perform_later
# Reorder asynchronously (like the push path) to avoid deadlocks.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that we're adding a lot of code comments, and they seem to be adding noise rather than value. Can you go through and see which comments if any are valuable to keep?

Comment thread app/models/rubygem.rb
Rubygem.where(
id: Dependency.where(rubygem_id: id)
.joins(:version)
.where(versions: { indexed: true, position: 0 })

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you refresh my memory on why position: 0 doesn't work anymore?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

perform_enqueued_jobs
perform_enqueued_jobs

get rubygem_path("sandworm")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling perform_enqueued_jobs twice is a smell, can we wrap get rubygem_path("sandworm") in a perform_enqueued_jobs do block?

@girachawda
girachawda force-pushed the fix-deadlock branch 2 times, most recently from 618754e to 56cacda Compare August 4, 2026 20:46
@colby-swandale
colby-swandale requested review from colby-swandale and removed request for OughtPuts August 6, 2026 00:34
Assisted-By: devx/3789dae8-fc4d-4cf5-8114-296b69295713
logger.info { "Reordering versions for gem: #{rubygem.name} (#{rubygem.id})" }

StatsD.measure("reorder_versions.duration") do
rubygem.reorder_versions

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method needs to be called inside a transaction

Comment thread app/models/rubygem.rb
Rubygem.where(
id: Dependency.where(rubygem_id: id)
.joins(:version)
.where(versions: { indexed: true, position: 0 })

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

latest_version = rubygem.reload.most_recent_version
SetLinksetHomeJob.perform_later(version: latest_version) if latest_version

logger.info { "Reordering complete for #{rubygem.name}" }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need to call GemCachePurger.call here so we can serve the freshly ordered gem page to Fastly


queue_as :default

retry_on ActiveRecord::Deadlocked, wait: :polynomially_longer, attempts: 3

Copy link
Copy Markdown
Member

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, Indexer and SetLinksetHomeJob never 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 at position: nil, which keeps it out of latest_specs.4.8.gz even 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.discarded on 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: 3 lowers the app-wide default of 5 from ApplicationJob, which I don't think was intended, so it can just be removed. And the rescue StandardError duplicates what ApplicationJob's after_discard already reports, so it could go too. No strong feelings on that one.

Comment thread app/models/rubygem.rb
has_one :most_recent_version,
lambda {
order(
# During the async reorder window, a freshly pushed version can have a nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 latest: true release, the old latest keeps winning until ReorderVersionsJob runs. The fresh nil-position version isn't treated as newest. NULLS FIRST only decides anything when no latest flag is in play, so all-prerelease gems, or ties between concurrently pushed versions.

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 most_recent_version returns for some gems though, like a prerelease-only gem whose newest version was yanked, and that feeds the gem page title and the API payload.

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: 0 → latest: true change and let's pull it into its own PR.

end
end

should "discard job if rubygem no longer exists" do

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 ReorderVersionsJob the right way to verify the tests intention.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deadlock when pushing mutliple gems at once

4 participants