java-topology/defects/ruby/patch/ruby-0003-rubygems-dependent-gems-reverse-index.md
2026-03-29 22:19:47 -04:00

4.2 KiB
Raw Blame History

ruby-0003: RubyGems Gem::Specification#dependent_gems — O(N²×D) nested scan

Severity: MEDIUM

Location

  • lib/rubygems/specification.rb:1746def dependent_gems(check_dev = true)
  • lib/rubygems/specification.rb:1748Gem::Specification.each do |spec|
  • lib/rubygems/specification.rb:1752find_all_satisfiers(dep) do |sat|
  • lib/rubygems/specification.rb:1872def find_all_satisfiers(dep)
  • lib/rubygems/specification.rb:1873Gem::Specification.each do |spec|

Description

Gem::Specification#dependent_gems computes which installed specs depend on self. It does this with two nested Gem::Specification.each loops:

# specification.rb:1746-1759
def dependent_gems(check_dev = true)
  out = []
  Gem::Specification.each do |spec|          # O(N) outer scan
    deps = check_dev ? spec.dependencies : spec.runtime_dependencies
    deps.each do |dep|
      next unless satisfies_requirement?(dep)
      sats = []
      find_all_satisfiers(dep) do |sat|      # O(N) inner scan per dep
        sats << sat
      end
      out << [spec, dep, sats]
    end
  end
  out
end

def find_all_satisfiers(dep)
  Gem::Specification.each do |spec|          # O(N) full scan
    yield spec if spec.satisfies_requirement? dep
  end
end

For N installed gems, each with D dependencies:

  • Outer loop: O(N)
  • For each spec's D deps: find_all_satisfiers runs a full O(N) scan
  • Total: O(N × D × N) = O(N² × D)

dependent_gems is called from Gem::Uninstaller#ask_if_ok during gem uninstall to warn about broken dependencies. For large gem environments (Ruby on Rails applications, CI servers, rbenv setups with 300800 installed gems), this triggers hundreds of thousands of comparisons.

Root Cause

find_all_satisfiers scans ALL specs linearly to find which ones satisfy a given dependency requirement. There is no reverse index from gem name → specs, so each satisfies_requirement? check performs a full scan.

Fix

Build a reverse index once: map gem_name → [spec, ...]. Since satisfies_requirement? checks the gem name first (version check is secondary), the index reduces find_all_satisfiers from O(N) to O(matching_name_count). dependent_gems itself avoids the inner find_all_satisfiers loop:

--- a/lib/rubygems/specification.rb
+++ b/lib/rubygems/specification.rb
@@ -1746,18 +1746,20 @@ class Gem::Specification
   def dependent_gems(check_dev = true)
     out = []
-    Gem::Specification.each do |spec|
-      deps = check_dev ? spec.dependencies : spec.runtime_dependencies
-      deps.each do |dep|
-        next unless satisfies_requirement?(dep)
-        sats = []
-        find_all_satisfiers(dep) do |sat|
-          sats << sat
-        end
-        out << [spec, dep, sats]
+    # Build a reverse index: gem_name -> [specs that provide it]
+    name_index = Hash.new { |h, k| h[k] = [] }
+    Gem::Specification.each { |s| name_index[s.name] << s }
+
+    Gem::Specification.each do |spec|
+      deps = check_dev ? spec.dependencies : spec.runtime_dependencies
+      deps.each do |dep|
+        next unless satisfies_requirement?(dep)
+        # Only check specs with the right name — O(matches) not O(N)
+        sats = name_index[dep.name].select { |s| s.satisfies_requirement?(dep) }
+        out << [spec, dep, sats]
       end
     end
     out
   end

Complexity

N = installed gem count, D = avg dependencies per gem

N Before (ops) After (ops) Ratio
100 ~5,000 ~200 25×
300 ~45,000 ~600 75×
800 ~320,000 ~1,600 200×

Assumptions: D=5 deps/gem, avg 1 satisfier per dep (same gem name, single version).

Impact

gem uninstall <gem>ask_if_okdependent_gems:

  • Development machines with rbenv/rvm typically have 200500 gems installed
  • CI servers running bundler-audit or bundle exec gem commands: 300800 gems
  • With 500 gems and D=5 deps, the fix reduces ~1.25M comparisons to ~2,500

The find_all_satisfiers private method should also be updated to use the index for consistency, though it is primarily used through dependent_gems.