Fix deadlock when pushing multiple gem versions concurrently - #6658
Fix deadlock when pushing multiple gem versions concurrently#6658girachawda wants to merge 1 commit into
Conversation
8d1f860 to
11992b8
Compare
|
I'll take a closer look at the PR on Thursday!
btw |
jenshenny
left a comment
There was a problem hiding this comment.
I think extracting to a separate job is the correct approach to solve this issue 👍
|
Successfully repro'd locally ✅ Looking at the code next |
OughtPuts
left a comment
There was a problem hiding this comment.
This is complex stuff @girachawda - nice work! :) A few thoughts that it would be great to discuss.
There was a problem hiding this comment.
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...).
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
ac77de4 to
56cacda
Compare
| WebAuthn.configuration.allowed_origins = ["http://localhost:31337"] | ||
|
|
||
| class ActiveSupport::TestCase | ||
| include ActiveJob::TestHelper |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
We should check if the reorder job is enqueued here.
|
|
||
| def reindex | ||
| Indexer.perform_later | ||
| # Reorder asynchronously (like the push path) to avoid deadlocks. |
There was a problem hiding this comment.
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?
| Rubygem.where( | ||
| id: Dependency.where(rubygem_id: id) | ||
| .joins(:version) | ||
| .where(versions: { indexed: true, position: 0 }) |
There was a problem hiding this comment.
Can you refresh my memory on why position: 0 doesn't work anymore?
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
Calling perform_enqueued_jobs twice is a smell, can we wrap get rubygem_path("sandworm") in a perform_enqueued_jobs do block?
618754e to
56cacda
Compare
Assisted-By: devx/3789dae8-fc4d-4cf5-8114-296b69295713
bcbb5f9 to
7161d52
Compare
| logger.info { "Reordering versions for gem: #{rubygem.name} (#{rubygem.id})" } | ||
|
|
||
| StatsD.measure("reorder_versions.duration") do | ||
| rubygem.reorder_versions |
There was a problem hiding this comment.
This method needs to be called inside a transaction
| Rubygem.where( | ||
| id: Dependency.where(rubygem_id: id) | ||
| .joins(:version) | ||
| .where(versions: { indexed: true, position: 0 }) |
There was a problem hiding this comment.
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}" } |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| has_one :most_recent_version, | ||
| lambda { | ||
| order( | ||
| # During the async reorder window, a freshly pushed version can have a nil |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.


What
Move version reordering from a synchronous
after_savecallback to an asynchronous background job with GoodJob concurrency control.Why
Concurrent gem pushes trigger the
after_save :reorder_versionscallback simultaneously, causing PostgreSQL deadlocks when multiple transactions try to lock the same version rows in different orders.Flow
Indexerruns afterReorderVersionsJobbecause the legacy index depends on the freshly-updatedlatestflags.SetLinksetHomeJobis also chained after reorder for the same reason.Tophat
defaultqueue in-process; no separate worker is required for the synthetic tophat data:Script