[rubygems/rubygems] Implement pub_grub strategy interface

My application spends more than 30% of time during `bundle update`
comparing versions due to versions being sorted inside
next_package_to_try. This has been addressed in pub_grub by defining a
strategy interface (a `#next_package_and_version` method) which allows
consumers to have finer control over the heuristic to select the next
package to try.

This commit implements the new strategy interface to remove extraneous
version sorting (previously in next_package_to_try) since only the final
count of versions is used.

Combined with a previous change to pub_grub (already applied to
Bundler), this commit results in `bundle update` taking only half the
time it did on 2.6.5.

https://github.com/rubygems/rubygems/commit/62f69e27f0
This commit is contained in:
Hartley McGuire 2025-03-25 23:27:09 -04:00 committed by Hiroshi SHIBATA
parent af594f5166
commit 752a1d7854
7 changed files with 105 additions and 61 deletions

View File

@ -12,6 +12,7 @@ module Bundler
require_relative "resolver/candidate" require_relative "resolver/candidate"
require_relative "resolver/incompatibility" require_relative "resolver/incompatibility"
require_relative "resolver/root" require_relative "resolver/root"
require_relative "resolver/strategy"
include GemHelpers include GemHelpers
@ -78,7 +79,7 @@ module Bundler
end end
def solve_versions(root:, logger:) def solve_versions(root:, logger:)
solver = PubGrub::VersionSolver.new(source: self, root: root, logger: logger) solver = PubGrub::VersionSolver.new(source: self, root: root, strategy: Strategy.new(self), logger: logger)
result = solver.solve result = solver.solve
resolved_specs = result.flat_map {|package, version| version.to_specs(package, @most_specific_locked_platform) } resolved_specs = result.flat_map {|package, version| version.to_specs(package, @most_specific_locked_platform) }
SpecSet.new(resolved_specs).specs_with_additional_variants_from(@base.locked_specs) SpecSet.new(resolved_specs).specs_with_additional_variants_from(@base.locked_specs)
@ -167,15 +168,7 @@ module Bundler
end end
def versions_for(package, range=VersionRange.any) def versions_for(package, range=VersionRange.any)
versions = select_sorted_versions(package, range) range.select_versions(@sorted_versions[package])
# Conditional avoids (among other things) calling
# sort_versions_by_preferred with the root package
if versions.size > 1
sort_versions_by_preferred(package, versions)
else
versions
end
end end
def no_versions_incompatibility_for(package, unsatisfied_term) def no_versions_incompatibility_for(package, unsatisfied_term)
@ -355,6 +348,10 @@ module Bundler
raise GemNotFound, message raise GemNotFound, message
end end
def sort_versions_by_preferred(package, versions)
@gem_version_promoter.sort_versions(package, versions)
end
private private
def filtered_versions_for(package) def filtered_versions_for(package)
@ -414,10 +411,6 @@ module Bundler
requirement.satisfied_by?(spec.version) || spec.source.is_a?(Source::Gemspec) requirement.satisfied_by?(spec.version) || spec.source.is_a?(Source::Gemspec)
end end
def sort_versions_by_preferred(package, versions)
@gem_version_promoter.sort_versions(package, versions)
end
def repository_for(package) def repository_for(package)
source_for(package.name) source_for(package.name)
end end
@ -433,7 +426,7 @@ module Bundler
next [dep_package, dep_constraint] if name == "bundler" next [dep_package, dep_constraint] if name == "bundler"
dep_range = dep_constraint.range dep_range = dep_constraint.range
versions = select_sorted_versions(dep_package, dep_range) versions = versions_for(dep_package, dep_range)
if versions.empty? if versions.empty?
if dep_package.ignores_prereleases? || dep_package.prefer_local? if dep_package.ignores_prereleases? || dep_package.prefer_local?
@all_versions.delete(dep_package) @all_versions.delete(dep_package)
@ -441,7 +434,7 @@ module Bundler
end end
dep_package.consider_prereleases! if dep_package.ignores_prereleases? dep_package.consider_prereleases! if dep_package.ignores_prereleases?
dep_package.consider_remote_versions! if dep_package.prefer_local? dep_package.consider_remote_versions! if dep_package.prefer_local?
versions = select_sorted_versions(dep_package, dep_range) versions = versions_for(dep_package, dep_range)
end end
if versions.empty? && select_all_versions(dep_package, dep_range).any? if versions.empty? && select_all_versions(dep_package, dep_range).any?
@ -456,10 +449,6 @@ module Bundler
end.to_h end.to_h
end end
def select_sorted_versions(package, range)
range.select_versions(@sorted_versions[package])
end
def select_all_versions(package, range) def select_all_versions(package, range)
range.select_versions(@all_versions[package]) range.select_versions(@all_versions[package])
end end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
module Bundler
class Resolver
class Strategy
def initialize(source)
@source = source
end
def next_package_and_version(unsatisfied)
package, range = next_term_to_try_from(unsatisfied)
[package, most_preferred_version_of(package, range).first]
end
private
def next_term_to_try_from(unsatisfied)
unsatisfied.min_by do |package, range|
matching_versions = @source.versions_for(package, range)
higher_versions = @source.versions_for(package, range.upper_invert)
[matching_versions.count <= 1 ? 0 : 1, higher_versions.count]
end
end
def most_preferred_version_of(package, range)
versions = @source.versions_for(package, range)
# Conditional avoids (among other things) calling
# sort_versions_by_preferred with the root package
if versions.size > 1
@source.sort_versions_by_preferred(package, versions)
else
versions
end
end
end
end
end

View File

@ -79,29 +79,17 @@ module Bundler::PubGrub
dependencies_for(@root_package, @root_version) dependencies_for(@root_package, @root_version)
end end
# Override me (maybe)
#
# If not overridden, the order returned by all_versions_for will be used
#
# Returns: Array of versions in preferred order
def sort_versions_by_preferred(package, sorted_versions)
indexes = @version_indexes[package]
sorted_versions.sort_by { |version| indexes[version] }
end
def initialize def initialize
@root_package = Package.root @root_package = Package.root
@root_version = Package.root_version @root_version = Package.root_version
@cached_versions = Hash.new do |h,k| @sorted_versions = Hash.new do |h,k|
if k == @root_package if k == @root_package
h[k] = [@root_version] h[k] = [@root_version]
else else
h[k] = all_versions_for(k) h[k] = all_versions_for(k).sort
end end
end end
@sorted_versions = Hash.new { |h,k| h[k] = @cached_versions[k].sort }
@version_indexes = Hash.new { |h,k| h[k] = @cached_versions[k].each.with_index.to_h }
@cached_dependencies = Hash.new do |packages, package| @cached_dependencies = Hash.new do |packages, package|
if package == @root_package if package == @root_package
@ -117,15 +105,7 @@ module Bundler::PubGrub
end end
def versions_for(package, range=VersionRange.any) def versions_for(package, range=VersionRange.any)
versions = range.select_versions(@sorted_versions[package]) range.select_versions(@sorted_versions[package])
# Conditional avoids (among other things) calling
# sort_versions_by_preferred with the root package
if versions.size > 1
sort_versions_by_preferred(package, versions)
else
versions
end
end end
def no_versions_incompatibility_for(_package, unsatisfied_term) def no_versions_incompatibility_for(_package, unsatisfied_term)

View File

@ -0,0 +1,42 @@
module Bundler::PubGrub
class Strategy
def initialize(source)
@source = source
@root_package = Package.root
@root_version = Package.root_version
@version_indexes = Hash.new do |h,k|
if k == @root_package
h[k] = { @root_version => 0 }
else
h[k] = @source.all_versions_for(k).each.with_index.to_h
end
end
end
def next_package_and_version(unsatisfied)
package, range = next_term_to_try_from(unsatisfied)
[package, most_preferred_version_of(package, range)]
end
private
def most_preferred_version_of(package, range)
versions = @source.versions_for(package, range)
indexes = @version_indexes[package]
versions.min_by { |version| indexes[version] }
end
def next_term_to_try_from(unsatisfied)
unsatisfied.min_by do |package, range|
matching_versions = @source.versions_for(package, range)
higher_versions = @source.versions_for(package, range.upper_invert)
[matching_versions.count <= 1 ? 0 : 1, higher_versions.count]
end
end
end
end

View File

@ -2,17 +2,20 @@ require_relative 'partial_solution'
require_relative 'term' require_relative 'term'
require_relative 'incompatibility' require_relative 'incompatibility'
require_relative 'solve_failure' require_relative 'solve_failure'
require_relative 'strategy'
module Bundler::PubGrub module Bundler::PubGrub
class VersionSolver class VersionSolver
attr_reader :logger attr_reader :logger
attr_reader :source attr_reader :source
attr_reader :solution attr_reader :solution
attr_reader :strategy
def initialize(source:, root: Package.root, logger: Bundler::PubGrub.logger) def initialize(source:, root: Package.root, strategy: Strategy.new(source), logger: Bundler::PubGrub.logger)
@logger = logger @logger = logger
@source = source @source = source
@strategy = strategy
# { package => [incompatibility, ...]} # { package => [incompatibility, ...]}
@incompatibilities = Hash.new do |h, k| @incompatibilities = Hash.new do |h, k|
@ -104,25 +107,15 @@ module Bundler::PubGrub
unsatisfied.package unsatisfied.package
end end
def next_term_to_try_from(unsatisfied_terms)
unsatisfied_terms.min_by do |term|
package = term.package
range = term.constraint.range
matching_versions = source.versions_for(package, range)
higher_versions = source.versions_for(package, range.upper_invert)
[matching_versions.count <= 1 ? 0 : 1, higher_versions.count]
end
end
def choose_package_version_from(unsatisfied_terms) def choose_package_version_from(unsatisfied_terms)
unsatisfied_term = next_term_to_try_from(unsatisfied_terms) remaining = unsatisfied_terms.map { |t| [t.package, t.constraint.range] }.to_h
package = unsatisfied_term.package
package, version = strategy.next_package_and_version(remaining)
version = source.versions_for(package, unsatisfied_term.constraint.range).first
logger.debug { "attempting #{package} #{version}" } logger.debug { "attempting #{package} #{version}" }
if version.nil? if version.nil?
unsatisfied_term = unsatisfied_terms.find { |t| t.package == package }
add_incompatibility source.no_versions_incompatibility_for(package, unsatisfied_term) add_incompatibility source.no_versions_incompatibility_for(package, unsatisfied_term)
return package return package
end end

View File

@ -8,7 +8,7 @@ gem "net-http", "0.6.0"
gem "net-http-persistent", "4.0.4" gem "net-http-persistent", "4.0.4"
gem "net-protocol", "0.2.2" gem "net-protocol", "0.2.2"
gem "optparse", "0.6.0" gem "optparse", "0.6.0"
gem "pub_grub", github: "jhawthorn/pub_grub", ref: "57d4f344366c8b86f7fe506e9bfa08f3c731e397" gem "pub_grub", github: "jhawthorn/pub_grub", ref: "df6add45d1b4d122daff2f959c9bd1ca93d14261"
gem "resolv", "0.6.0" gem "resolv", "0.6.0"
gem "securerandom", "0.4.1" gem "securerandom", "0.4.1"
gem "timeout", "0.4.3" gem "timeout", "0.4.3"

View File

@ -6,8 +6,8 @@ GIT
GIT GIT
remote: https://github.com/jhawthorn/pub_grub.git remote: https://github.com/jhawthorn/pub_grub.git
revision: 57d4f344366c8b86f7fe506e9bfa08f3c731e397 revision: df6add45d1b4d122daff2f959c9bd1ca93d14261
ref: 57d4f344366c8b86f7fe506e9bfa08f3c731e397 ref: df6add45d1b4d122daff2f959c9bd1ca93d14261
specs: specs:
pub_grub (0.5.0) pub_grub (0.5.0)