diff --git a/Gemfile b/Gemfile index a8b1e05..90810de 100644 --- a/Gemfile +++ b/Gemfile @@ -9,3 +9,4 @@ gem "irb" gem "rake", "~> 13.0" gem "minitest", "~> 6.0" +gem "benchmark", group: :development diff --git a/Gemfile.lock b/Gemfile.lock index 081b327..ce1478d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,12 @@ PATH remote: . specs: - vers (1.2.0) + vers (1.2.1) GEM remote: https://rubygems.org/ specs: + benchmark (0.5.0) date (3.5.1) drb (2.2.3) erb (6.0.2) @@ -40,12 +41,14 @@ PLATFORMS ruby DEPENDENCIES + benchmark irb minitest (~> 6.0) rake (~> 13.0) vers! CHECKSUMS + benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b @@ -61,7 +64,7 @@ CHECKSUMS reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f - vers (1.2.0) + vers (1.2.1) BUNDLED WITH 4.0.3 diff --git a/Rakefile b/Rakefile index ad19d17..ed5ebc5 100644 --- a/Rakefile +++ b/Rakefile @@ -187,74 +187,122 @@ namespace :benchmark do task :memory do require "benchmark" require "json" + require "objspace" require_relative "lib/vers" - - puts "💾 VERS Memory Usage Benchmarks" + + puts "VERS Memory & Allocation Benchmarks" puts "=" * 50 - - # Load sample ranges + test_data_file = File.join(__dir__, "test-suite-data.json") - + unless File.exist?(test_data_file) - puts "❌ test-suite-data.json not found. Using fallback examples." + puts "test-suite-data.json not found. Using fallback examples." sample_ranges = [ - { input: "^1.2.3", scheme: "npm" }, - { input: "~> 1.2", scheme: "gem" }, - { input: ">=1.0,<2.0", scheme: "pypi" } + { "input" => "^1.2.3", "scheme" => "npm" }, + { "input" => "~> 1.2", "scheme" => "gem" }, + { "input" => ">=1.0,<2.0", "scheme" => "pypi" } ] else test_data = JSON.parse(File.read(test_data_file)) sample_ranges = test_data.select { |data| !data["is_invalid"] }.first(100) end - - puts "📊 Testing with #{sample_ranges.length} version ranges" + + puts "Sample size: #{sample_ranges.length} version ranges" puts - - # Parse all ranges and store objects - puts "🔍 Parsing and storing #{sample_ranges.length} VersionRange objects..." + + # Measure object allocations during cold parsing (no cache) + Vers::Version.class_variable_set(:@@version_cache, {}) + Vers::Constraint.class_variable_set(:@@constraint_cache, {}) + Vers::Parser.class_variable_set(:@@parser_cache, {}) + + GC.start + GC.disable + before = ObjectSpace.count_objects.dup + version_ranges = [] - - parsing_time = Benchmark.realtime do - sample_ranges.each do |range| - begin - parsed = Vers.parse_native(range['input'], range['scheme']) - version_ranges << parsed - rescue - # Skip invalid ranges - end - end + sample_ranges.each do |range| + parsed = Vers.parse_native(range['input'], range['scheme']) rescue nil + version_ranges << parsed if parsed end - - puts " Parsing completed in #{(parsing_time * 1000).round(2)}ms" - puts " Successfully parsed #{version_ranges.length} ranges" + + after = ObjectSpace.count_objects + GC.enable + + string_alloc = after[:T_STRING] - before[:T_STRING] + array_alloc = after[:T_ARRAY] - before[:T_ARRAY] + hash_alloc = after[:T_HASH] - before[:T_HASH] + object_alloc = after[:T_OBJECT] - before[:T_OBJECT] + match_alloc = (after[:T_MATCH] || 0) - (before[:T_MATCH] || 0) + total_alloc = string_alloc + array_alloc + hash_alloc + object_alloc + match_alloc + + puts "Cold parse allocations (#{version_ranges.length} ranges, no cache):" + puts " Total objects: #{total_alloc}" + puts " Strings: #{string_alloc}" + puts " Arrays: #{array_alloc}" + puts " Hashes: #{hash_alloc}" + puts " Objects: #{object_alloc}" + puts " MatchData: #{match_alloc}" + puts " Per range: #{(total_alloc.to_f / version_ranges.length).round(1)}" puts - - # Estimate memory usage - estimated_memory = version_ranges.length * 300 # ~300 bytes per VersionRange object estimate - puts "💾 Memory Usage Estimation:" - puts " #{version_ranges.length} VersionRange objects: ~#{estimated_memory} bytes" - puts " Average per object: ~300 bytes" + + # Measure cached parse allocations (everything already cached) + GC.start + GC.disable + before = ObjectSpace.count_objects.dup + + sample_ranges.each do |range| + Vers.parse_native(range['input'], range['scheme']) rescue nil + end + + after = ObjectSpace.count_objects + GC.enable + + cached_strings = after[:T_STRING] - before[:T_STRING] + cached_arrays = after[:T_ARRAY] - before[:T_ARRAY] + cached_objects = after[:T_OBJECT] - before[:T_OBJECT] + cached_match = (after[:T_MATCH] || 0) - (before[:T_MATCH] || 0) + cached_alloc = cached_strings + cached_arrays + cached_objects + cached_match + + puts "Cached parse allocations (#{version_ranges.length} ranges, warm cache):" + puts " Total objects: #{cached_alloc}" + puts " Strings: #{cached_strings}" + puts " MatchData: #{cached_match}" + puts " Per range: #{(cached_alloc.to_f / version_ranges.length).round(1)}" puts - - # Test repeated operations - puts "🔄 Repeated Operations Test:" - + + # Measure memory size of retained objects + total_memsize = version_ranges.sum { |r| ObjectSpace.memsize_of(r) } + puts "Retained memory:" + puts " #{version_ranges.length} VersionRange objects: #{total_memsize} bytes" + puts " Average per object: #{(total_memsize.to_f / version_ranges.length).round(0)} bytes" + puts + + # Repeated operation allocations + puts "Repeated operation allocations (#{version_ranges.length} calls each):" + operations = { - "to_s conversion" => proc { version_ranges.each(&:to_s) }, - "contains? check" => proc { version_ranges.each { |r| r.contains?("1.5.0") } }, - "empty? check" => proc { version_ranges.each(&:empty?) }, - "unbounded? check" => proc { version_ranges.each(&:unbounded?) } + "to_s" => proc { version_ranges.each(&:to_s) }, + "contains?" => proc { version_ranges.each { |r| r.contains?("1.5.0") } }, + "empty?" => proc { version_ranges.each(&:empty?) }, } - - operations.each do |op_name, op_proc| - time = Benchmark.realtime { op_proc.call } - ops_per_second = version_ranges.length / time - - puts " #{op_name.ljust(20)}: #{(time * 1000).round(2)}ms (#{ops_per_second.round(0)} ops/sec)" + + operations.each do |name, op| + # Warm up + op.call + + GC.start + GC.disable + before = ObjectSpace.count_objects.dup + op.call + after = ObjectSpace.count_objects + GC.enable + + allocs = [:T_STRING, :T_ARRAY, :T_OBJECT, :T_MATCH, :T_HASH].sum { |k| (after[k] || 0) - (before[k] || 0) } + puts " #{name.ljust(15)}: #{allocs} allocations" end - + puts - puts "✅ Memory benchmark completed!" + puts "Memory benchmark completed!" end desc "Run complexity stress tests" diff --git a/lib/vers/constraint.rb b/lib/vers/constraint.rb index 9eeba1c..1eb2724 100644 --- a/lib/vers/constraint.rb +++ b/lib/vers/constraint.rb @@ -23,7 +23,7 @@ class Constraint # Cache for parsed constraints @@constraint_cache = {} - @@cache_size_limit = 500 + @@cache_size_limit = 1000 attr_reader :operator, :version @@ -54,14 +54,13 @@ def initialize(operator, version) # Vers::Constraint.parse("!=2.0.0") # => # # def self.parse(constraint_string) - # Limit cache size to prevent memory bloat + return @@constraint_cache[constraint_string] if @@constraint_cache.key?(constraint_string) + if @@constraint_cache.size >= @@cache_size_limit - @@constraint_cache.clear + keys = @@constraint_cache.keys + keys.first(keys.size / 2).each { |k| @@constraint_cache.delete(k) } end - - # Return cached constraint if available - return @@constraint_cache[constraint_string] if @@constraint_cache.key?(constraint_string) - + constraint = parse_uncached(constraint_string) @@constraint_cache[constraint_string] = constraint constraint diff --git a/lib/vers/interval.rb b/lib/vers/interval.rb index bb9ae45..485721f 100644 --- a/lib/vers/interval.rb +++ b/lib/vers/interval.rb @@ -12,8 +12,7 @@ def initialize(min: nil, max: nil, min_inclusive: true, max_inclusive: true, sch @min_inclusive = min_inclusive @max_inclusive = max_inclusive @scheme = scheme - - validate_bounds! + @empty = compute_empty end def self.empty(scheme: nil) @@ -37,9 +36,7 @@ def self.less_than(version, inclusive: false, scheme: nil) end def empty? - return true if min && max && version_compare(min, max) > 0 - return true if min && max && version_compare(min, max) == 0 && (!min_inclusive || !max_inclusive) - false + @empty end def unbounded? @@ -180,7 +177,22 @@ def union(other) def overlaps?(other) return false if empty? || other.empty? - !intersect(other).empty? + return true if unbounded? || other.unbounded? + + # Check if the intervals can't overlap by comparing bounds directly + if max && other.min + cmp = version_compare(max, other.min) + return false if cmp < 0 + return false if cmp == 0 && (!max_inclusive || !other.min_inclusive) + end + + if min && other.max + cmp = version_compare(min, other.max) + return false if cmp > 0 + return false if cmp == 0 && (!min_inclusive || !other.max_inclusive) + end + + true end def adjacent?(other) @@ -211,14 +223,12 @@ def to_s private - def validate_bounds! - return unless min && max - - comparison = version_compare(min, max) - if comparison > 0 - return - elsif comparison == 0 && (!min_inclusive || !max_inclusive) - return + def compute_empty + if min && max + cmp = version_compare(min, max) + cmp > 0 || (cmp == 0 && (!min_inclusive || !max_inclusive)) + else + false end end diff --git a/lib/vers/parser.rb b/lib/vers/parser.rb index 93b5979..f625758 100644 --- a/lib/vers/parser.rb +++ b/lib/vers/parser.rb @@ -26,10 +26,11 @@ class Parser NPM_HYPHEN_REGEX = /\A(.+?)\s+-\s+(.+)\z/ NPM_X_RANGE_MAJOR_REGEX = /\A(\d+)\.x\z/ NPM_X_RANGE_MINOR_REGEX = /\A(\d+)\.(\d+)\.x\z/ + OPERATOR_PREFIX_REGEX = /\A[><=!]+/ # Cache for parsed ranges to improve performance @@parser_cache = {} - @@cache_size_limit = 200 + @@cache_size_limit = 500 ## # Parses a vers URI string into a VersionRange @@ -151,7 +152,7 @@ def to_vers_string(version_range, scheme) private def sort_key_for_constraint(constraint) - version = constraint.sub(/\A[><=!]+/, '') + version = constraint.sub(OPERATOR_PREFIX_REGEX, '') v = Version.cached_new(version) [v, constraint] end @@ -231,15 +232,12 @@ def parse_npm_range(range_string) end def parse_npm_single_range(range_string) - # Check cache first cache_key = "npm:#{range_string}" - if @@parser_cache.key?(cache_key) - return @@parser_cache[cache_key] - end - - # Limit cache size + return @@parser_cache[cache_key] if @@parser_cache.key?(cache_key) + if @@parser_cache.size >= @@cache_size_limit - @@parser_cache.clear + keys = @@parser_cache.keys + keys.first(keys.size / 2).each { |k| @@parser_cache.delete(k) } end result = case range_string diff --git a/lib/vers/version.rb b/lib/vers/version.rb index c6215e5..c955a5e 100644 --- a/lib/vers/version.rb +++ b/lib/vers/version.rb @@ -18,7 +18,7 @@ module Vers class Version # Cache for parsed versions to avoid repeated parsing @@version_cache = {} - @@cache_size_limit = 1000 + @@cache_size_limit = 2000 # Regex for parsing semantic version components including build metadata SEMANTIC_VERSION_REGEX = /\A(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([^+]+))?(?:\+(.+))?\z/ @@ -41,11 +41,12 @@ def initialize(version_string) # @return [Version] Cached or new Version object # def self.cached_new(version_string) - # Limit cache size to prevent memory bloat if @@version_cache.size >= @@cache_size_limit - @@version_cache.clear + # Keep the most recent half instead of clearing everything + keys = @@version_cache.keys + keys.first(keys.size / 2).each { |k| @@version_cache.delete(k) } end - + @@version_cache[version_string] ||= new(version_string) end @@ -145,15 +146,17 @@ def <=>(other) # @return [String] The normalized version string # def to_s - version = "#{major || 0}" - version += ".#{minor || 0}" - version += ".#{patch || 0}" - version += "-#{prerelease}" if prerelease - version + @to_s ||= begin + version = "#{major || 0}" + version += ".#{minor || 0}" + version += ".#{patch || 0}" + version += "-#{prerelease}" if prerelease + version.freeze + end end def ==(other) - other.is_a?(Version) && self <=> other == 0 + other.is_a?(Version) && (self <=> other) == 0 end def <(other) @@ -320,7 +323,7 @@ def parse_version @original = @original.sub(/\Av/i, '') # Handle simple numeric versions (optimized case) - if @original.match(/^\d+$/) + if @original.match?(/^\d+$/) @major = @original.to_i return end @@ -382,7 +385,7 @@ def compare_prerelease(pre_a, pre_b) return 1 if part_b.nil? # Try numeric comparison first - if part_a.match(/^\d+$/) && part_b.match(/^\d+$/) + if part_a.match?(/^\d+$/) && part_b.match?(/^\d+$/) numeric_cmp = part_a.to_i <=> part_b.to_i return numeric_cmp unless numeric_cmp == 0 else diff --git a/lib/vers/version_range.rb b/lib/vers/version_range.rb index 91e813f..3557107 100644 --- a/lib/vers/version_range.rb +++ b/lib/vers/version_range.rb @@ -9,7 +9,7 @@ class VersionRange def initialize(intervals = [], raw_constraints: nil, scheme: nil) @scheme = scheme - @intervals = intervals.compact.reject(&:empty?) + @intervals = intervals.select { |i| i && !i.empty? } if @scheme @intervals.sort! { |a, b| compare_interval_bounds(a, b) } else diff --git a/test/test_interval.rb b/test/test_interval.rb index 7570e3d..915f1b0 100644 --- a/test/test_interval.rb +++ b/test/test_interval.rb @@ -108,6 +108,26 @@ def test_interval_overlaps refute interval1.overlaps?(interval3) end + def test_interval_overlaps_empty + empty = Vers::Interval.empty + normal = Vers::Interval.new(min: "1.0.0", max: "2.0.0") + refute empty.overlaps?(normal) + refute normal.overlaps?(empty) + end + + def test_interval_overlaps_unbounded + unbounded = Vers::Interval.unbounded + normal = Vers::Interval.new(min: "1.0.0", max: "2.0.0") + assert unbounded.overlaps?(normal) + assert normal.overlaps?(unbounded) + end + + def test_interval_overlaps_touching_exclusive + a = Vers::Interval.new(min: "1.0.0", max: "2.0.0", max_inclusive: false) + b = Vers::Interval.new(min: "2.0.0", max: "3.0.0", min_inclusive: false) + refute a.overlaps?(b) + end + def test_interval_adjacent interval1 = Vers::Interval.new(min: "1.0.0", max: "2.0.0", max_inclusive: false) interval2 = Vers::Interval.new(min: "2.0.0", max: "3.0.0", min_inclusive: true) @@ -145,6 +165,18 @@ def test_interval_empty_conditions assert interval3.empty? end + def test_interval_empty_consistent_across_calls + empty = Vers::Interval.empty + assert_equal empty.empty?, empty.empty? + + non_empty = Vers::Interval.new(min: "1.0.0", max: "2.0.0") + assert_equal non_empty.empty?, non_empty.empty? + + exact = Vers::Interval.exact("1.0.0") + refute exact.empty? + refute exact.empty? + end + def test_interval_unbounded_conditions interval1 = Vers::Interval.new assert interval1.unbounded? diff --git a/test/test_version.rb b/test/test_version.rb index 7c8059f..d93eb33 100644 --- a/test/test_version.rb +++ b/test/test_version.rb @@ -62,6 +62,11 @@ def test_version_to_s assert_equal "1.2.3-alpha", Vers::Version.new("1.2.3-alpha").to_s end + def test_version_to_s_returns_same_object + v = Vers::Version.new("1.2.3") + assert_same v.to_s, v.to_s + end + def test_version_compare_class_method assert_equal(-1, Vers::Version.compare("1.2.3", "1.2.4")) assert_equal(1, Vers::Version.compare("2.0.0", "1.9.9")) @@ -104,6 +109,19 @@ def test_version_equality refute_equal v1, v3 end + def test_version_equality_different_representations + assert_equal Vers::Version.new("1.0"), Vers::Version.new("1.0.0") + refute_equal Vers::Version.new("1.0.0"), Vers::Version.new("1.0.1") + end + + def test_version_equality_with_non_version + v = Vers::Version.new("1.2.3") + refute_equal v, "1.2.3" + refute_equal v, nil + refute_equal v, 0 + refute_equal v, 123 + end + def test_version_hash v1 = Vers::Version.new("1.2.3") v2 = Vers::Version.new("1.2.3") diff --git a/test/test_version_range.rb b/test/test_version_range.rb index afebcad..39a54ba 100644 --- a/test/test_version_range.rb +++ b/test/test_version_range.rb @@ -132,12 +132,24 @@ def test_version_range_complex_operations def test_version_range_empty_intervals_filtered empty_interval = Vers::Interval.empty valid_interval = Vers::Interval.new(min: "1.0.0", max: "2.0.0") - + range = Vers::VersionRange.new([empty_interval, valid_interval]) assert_equal 1, range.intervals.length assert_equal valid_interval, range.intervals.first end + def test_version_range_nil_intervals_filtered + valid_interval = Vers::Interval.new(min: "1.0.0", max: "2.0.0") + range = Vers::VersionRange.new([nil, valid_interval, nil]) + assert_equal 1, range.intervals.length + assert range.contains?("1.5.0") + end + + def test_version_range_all_nil_and_empty_intervals + range = Vers::VersionRange.new([nil, Vers::Interval.empty, nil]) + assert range.empty? + end + def test_version_range_sorting interval1 = Vers::Interval.new(min: "3.0.0", max: "4.0.0") interval2 = Vers::Interval.new(min: "1.0.0", max: "2.0.0")