Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions app/jobs/after_version_write_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ def perform(version:)
version.rubygem.push_notifiable_owners.each do |notified_user|
Mailer.gem_pushed(owner, version.id, notified_user.id).deliver_later
end
Indexer.perform_later
UploadVersionsFileJob.perform_later
UploadInfoFileJob.perform_later(rubygem_name: rubygem.name)
UploadNamesFileJob.perform_later
Expand All @@ -21,7 +20,7 @@ def perform(version:)
version.info_checksum_v2 = gem_info.info_checksum
version.save(validate: false)

SetLinksetHomeJob.perform_later(version:)
ReorderVersionsJob.perform_later(rubygem:)
end
end

Expand Down
34 changes: 34 additions & 0 deletions app/jobs/reorder_versions_job.rb
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

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.

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

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

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}" }

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

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
2 changes: 1 addition & 1 deletion app/models/deletion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def restore_to_index
end

def reindex
Indexer.perform_later
ReorderVersionsJob.perform_later(rubygem: version.rubygem)
UploadInfoFileJob.perform_later(rubygem_name: rubygem_name)
UploadVersionsFileJob.perform_later
UploadNamesFileJob.perform_later
Expand Down
15 changes: 9 additions & 6 deletions app/models/rubygem.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,7 +29,7 @@ def unique_reverse_dependencies
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.

.where(versions: { indexed: true, latest: true })
.select("versions.rubygem_id")
)
end
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

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.

# 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
)
},
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion app/models/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ class Version < ApplicationRecord # rubocop:disable Metrics/ClassLength
# TODO: Remove this once we move to GemDownload only
after_create :create_gem_download
after_create :record_push_event
after_save :reorder_versions, if: -> { saved_change_to_indexed? || saved_change_to_id? }
after_save :enqueue_web_hook_jobs, if: -> { saved_change_to_indexed? && (!saved_change_to_id? || indexed?) }
after_save :refresh_rubygem_indexed, if: -> { saved_change_to_indexed? || saved_change_to_id? }

Expand Down Expand Up @@ -255,10 +254,12 @@ def refresh_rubygem_indexed
end

def previous
return nil if position.nil?
rubygem.versions.find_by(position: position + 1)
end

def next
return nil if position.nil?
rubygem.versions.find_by(position: position - 1)
end

Expand Down
2 changes: 2 additions & 0 deletions test/factories/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
checksum = GemInfo.new(version.rubygem.name).info_checksum
version.update_attribute :info_checksum_v2, checksum
end

version.rubygem.reorder_versions
end
end
end
2 changes: 1 addition & 1 deletion test/functional/api/v1/deletions_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ class Api::V1::DeletionsControllerTest < ActionController::TestCase
assert_enqueued_jobs 1, only: NotifyWebHookJob
end
should "have enqueued reindexing job" do
assert_enqueued_jobs 1, only: Indexer
assert_enqueued_jobs 1, only: ReorderVersionsJob
assert_enqueued_jobs 1, only: UploadVersionsFileJob
assert_enqueued_jobs 1, only: UploadNamesFileJob
assert_enqueued_with job: UploadInfoFileJob, args: [rubygem_name: @rubygem.name]
Expand Down
8 changes: 5 additions & 3 deletions test/functional/api/v1/rubygems_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ def self.should_respond_to(format)
assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do
assert_enqueued_jobs 6, only: FastlyPurgeJob do
assert_enqueued_jobs 1, only: NotifyWebHookJob do
assert_enqueued_jobs 1, only: Indexer do
assert_enqueued_jobs 1, only: ReorderVersionsJob do
assert_enqueued_jobs 1, only: ReindexRubygemJob do
post :create, body: gem_file("test-1.0.0.gem", &:read)
end
Expand Down Expand Up @@ -572,7 +572,9 @@ def self.should_respond_to(format)
setup do
@user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api)
@request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now
post :create, body: gem_file("test-1.0.0.gem", &:read)
perform_enqueued_jobs(only: ReorderVersionsJob) do
post :create, body: gem_file("test-1.0.0.gem", &:read)
end
end

should respond_with :success
Expand All @@ -581,7 +583,7 @@ def self.should_respond_to(format)
assert_equal 2, Rubygem.last.versions.count
end
should "disable mfa requirement" do
refute_predicate @rubygem, :metadata_mfa_required?
refute_predicate @rubygem.reload, :metadata_mfa_required?
end
end
end
Expand Down
3 changes: 2 additions & 1 deletion test/integration/push_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")

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?


Expand Down
2 changes: 1 addition & 1 deletion test/integration/pusher_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ def two_cert_chain(signing_key:, root_not_before: Time.current, cert_not_before:
should "enqueue job for email, updating ES index, spec index and purging cdn" do
assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do
assert_enqueued_jobs 6, only: FastlyPurgeJob do
assert_enqueued_jobs 1, only: Indexer do
assert_enqueued_jobs 1, only: ReorderVersionsJob do
assert_enqueued_jobs 1, only: ReindexRubygemJob do
@cutter.save
end
Expand Down
85 changes: 85 additions & 0 deletions test/jobs/reorder_versions_job_test.rb
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

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.

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
6 changes: 4 additions & 2 deletions test/models/deletion_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ class DeletionTest < ActiveSupport::TestCase
should "enque job for updating ES index, spec index and purging cdn" do
assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do
assert_enqueued_jobs 8, only: FastlyPurgeJob do
assert_enqueued_jobs 1, only: Indexer do
assert_enqueued_jobs 1, only: ReorderVersionsJob do
assert_enqueued_jobs 1, only: ReindexRubygemJob do
delete_gem
end
Expand Down Expand Up @@ -213,6 +213,8 @@ class DeletionTest < ActiveSupport::TestCase
end

should "reorder versions" do
perform_enqueued_jobs(only: ReorderVersionsJob)

assert_predicate @version.reload, :latest?
end

Expand Down Expand Up @@ -271,7 +273,7 @@ class DeletionTest < ActiveSupport::TestCase

should "enqueue indexing jobs" do
@deletion = delete_gem
assert_enqueued_jobs 1, only: Indexer do
assert_enqueued_jobs 1, only: ReorderVersionsJob do
assert_enqueued_jobs 1, only: UploadVersionsFileJob do
assert_enqueued_with job: UploadInfoFileJob, args: [rubygem_name: @gem_name] do
@deletion.restore!
Expand Down
22 changes: 0 additions & 22 deletions test/models/version_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

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.

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
2 changes: 1 addition & 1 deletion test/system/avo/versions_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class Avo::VersionsSystemTest < ApplicationSystemTestCase
},
"unchanged" => version_attributes
.except("updated_at", "yanked_info_checksum_v2", "yanked_at", "indexed")
.merge("position" => 0, "latest" => false)
.merge("position" => 0, "latest" => true)
.transform_values(&:as_json)
},
"gid://gemcutter/Rubygem/#{rubygem.id}" =>
Expand Down
1 change: 1 addition & 0 deletions test/system/gem_server_lifecycle_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class GemServerLifecycleTest < ApplicationSystemTestCase

Indexer.perform_now
@subscriber = ActiveSupport::Notifications.subscribe("process_action.action_controller") do
perform_enqueued_jobs only: [ReorderVersionsJob]
perform_enqueued_jobs only: [Indexer]
end

Expand Down
1 change: 1 addition & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
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.

include FactoryBot::Syntax::Methods
include GemHelpers
include EmailHelpers
Expand Down
Loading