runtime_groups 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuntimeGroups
4
+ # Raised when runtime grouping cannot proceed because the runtime log does
5
+ # not cover enough of the given files (the `allowed_missing` budget).
6
+ class RuntimeLogTooSmallError < Error; end
7
+
8
+ # Balanced grouping of test files into worker groups.
9
+ #
10
+ # Mirrors the semantics of `parallel_tests` 3.5.2 through 5.8.0 (identical
11
+ # across those versions for this surface): weights come from a {RuntimeLog}
12
+ # when present, files missing from the log get the average of the known
13
+ # runtimes (or 1.0 when nothing is known) once the `allowed_missing` budget
14
+ # permits, and groups are balanced largest-smallest — every file, in
15
+ # descending weight order, goes to the group with the smallest accumulated
16
+ # weight. Tie-breaking by filename makes the split deterministic
17
+ # (`parallel_tests` tie order is unspecified).
18
+ module Grouping
19
+ module_function
20
+
21
+ # Compute a weight for every file.
22
+ #
23
+ # @param files [Array<String>] test file paths
24
+ # @param group_by [:runtime, :filesize, :found]
25
+ # @param runtime_log [String, nil] runtime log path (for :runtime)
26
+ # @param allowed_missing [Float] fraction of files allowed to be missing
27
+ # from the runtime log before {RuntimeLogTooSmallError} is raised
28
+ # @return [Hash{String => Float}] file => weight
29
+ # @raise [RuntimeGroups::RuntimeLogTooSmallError] when too many files are
30
+ # missing from the runtime log
31
+ def with_size(files, group_by: :runtime, runtime_log: nil, allowed_missing: 1.0)
32
+ case group_by
33
+ when :found
34
+ files.each_with_object({}) { |file, memo| memo[file] = 1.0 }
35
+ when :filesize
36
+ files.each_with_object({}) { |file, memo| memo[file] = File.stat(file).size.to_f }
37
+ when :runtime
38
+ runtime_weights(files, runtime_log, allowed_missing)
39
+ else
40
+ raise Error, "invalid group-by mode #{group_by.inspect}; expected one of: runtime, filesize, found"
41
+ end
42
+ end
43
+
44
+ # Split files into balanced groups.
45
+ #
46
+ # @param files [Array<String>] test file paths
47
+ # @param num_groups [Integer] positive group count; groups may be empty
48
+ # when there are fewer files than groups
49
+ # @param group_by [:runtime, :filesize, :found] weighting mode
50
+ # @param runtime_log [String, nil] runtime log path (for :runtime)
51
+ # @param allowed_missing [Float] fraction of files allowed to be missing
52
+ # from the runtime log
53
+ # @return [Array<Array<String>>] exactly `num_groups` arrays of file paths
54
+ # @raise [RuntimeGroups::Error] when num_groups or group_by is invalid
55
+ def in_groups(files, num_groups, group_by: :runtime, runtime_log: nil, allowed_missing: 1.0)
56
+ groups = Array.new(RuntimeGroups.parse_count!(num_groups)) { { weight: 0.0, files: [] } }
57
+ weights = with_size(files, group_by: group_by, runtime_log: runtime_log, allowed_missing: allowed_missing)
58
+
59
+ files.sort_by { |file| [-weights[file], file] }.each do |file|
60
+ target = groups.min_by { |group| group[:weight] }
61
+ target[:weight] += weights[file]
62
+ target[:files] << file
63
+ end
64
+
65
+ groups.map { |group| group[:files] }
66
+ end
67
+
68
+ # Runtime weights with the missing-file budget and average-fill semantics
69
+ # retained from `parallel_tests`.
70
+ #
71
+ # @return [Hash{String => Float}]
72
+ def runtime_weights(files, runtime_log, allowed_missing)
73
+ budget = files.size * allowed_missing
74
+ runtimes = RuntimeLog.read(runtime_log)
75
+ known, unknown = [], []
76
+
77
+ files.each do |file|
78
+ if runtimes.key?(file)
79
+ known << [file, runtimes[file]]
80
+ else
81
+ budget -= 1
82
+ if budget.negative?
83
+ raise RuntimeLogTooSmallError,
84
+ "Runtime log file '#{runtime_log}' does not contain sufficient data to sort #{files.size} test files, please update or remove it."
85
+ end
86
+ unknown << file
87
+ end
88
+ end
89
+
90
+ weights = known.each_with_object({}) { |(file, seconds), memo| memo[file] = seconds }
91
+ fill = known.empty? ? 1.0 : known.map(&:last).sum / known.size
92
+ unknown.each { |file| weights[file] = fill }
93
+ weights
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuntimeGroups
4
+ # Options-file discovery for RSpec option passthrough, mirroring
5
+ # `ParallelTests::RSpec::Runner.spec_opts` (identical in parallel_tests
6
+ # 3.5.2 through 5.8.0). Exposed here as public API so runners no longer
7
+ # need to reach into parallel_tests internals.
8
+ module Options
9
+ # Options files checked, in priority order.
10
+ SPEC_OPTS_FILES = [".rspec_parallel", "spec/parallel_spec.opts", "spec/spec.opts"].freeze
11
+
12
+ module_function
13
+
14
+ # First existing options file, in priority order.
15
+ #
16
+ # @param files [Array<String>] candidate paths (defaults to
17
+ # {SPEC_OPTS_FILES})
18
+ # @return [String, nil]
19
+ def options_file(files = SPEC_OPTS_FILES)
20
+ files.detect { |file| File.file?(file) }
21
+ end
22
+
23
+ # RSpec argument string selecting the options file.
24
+ #
25
+ # @param files [Array<String>] candidate paths (defaults to
26
+ # {SPEC_OPTS_FILES})
27
+ # @return [String, nil] e.g. "-O .rspec_parallel", or nil when no
28
+ # options file exists
29
+ def spec_opts(files = SPEC_OPTS_FILES)
30
+ found = options_file(files)
31
+ "-O #{found}" if found
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module RuntimeGroups
6
+ # A file-backed ledger of live worker pids.
7
+ #
8
+ # Unlike the pid bookkeeping in `parallel_tests` (whose `Pids` instances do
9
+ # not read before writing, so every fresh instance overwrites the file with
10
+ # an empty in-memory list), every mutation here re-reads the file first and
11
+ # writes the merged set. The file can therefore be shared safely between
12
+ # the parent process and any number of tracking call sites.
13
+ class Pids
14
+ attr_reader :path
15
+
16
+ # @param path [String] file path backing the ledger
17
+ def initialize(path)
18
+ @path = path
19
+ end
20
+
21
+ # @return [Array<Integer>] the pids currently recorded, deduplicated and
22
+ # sorted ascending; empty when the file does not exist yet
23
+ def to_a
24
+ return [] unless File.file?(@path)
25
+
26
+ File.readlines(@path).map do |line|
27
+ begin
28
+ Integer(line.strip, 10)
29
+ rescue ArgumentError
30
+ nil
31
+ end
32
+ end.compact.uniq.sort
33
+ end
34
+
35
+ # Record a pid. Idempotent.
36
+ #
37
+ # @param pid [Integer]
38
+ # @return [Array<Integer>] the recorded pids after the add
39
+ def add(pid)
40
+ current = to_a
41
+ return current if current.include?(pid)
42
+
43
+ write(current + [pid])
44
+ end
45
+
46
+ # Remove a pid. Missing pids are ignored.
47
+ #
48
+ # @param pid [Integer]
49
+ # @return [Array<Integer>] the recorded pids after the delete
50
+ def delete(pid)
51
+ write(to_a - [pid])
52
+ end
53
+
54
+ # Remove every recorded pid.
55
+ #
56
+ # @return [Array<Integer>] always empty
57
+ def clear
58
+ write([])
59
+ end
60
+
61
+ private
62
+
63
+ def write(pids)
64
+ FileUtils.mkdir_p(File.dirname(@path))
65
+ File.open(@path, "w") do |f|
66
+ pids.uniq.sort.each { |pid| f.puts(pid) }
67
+ end
68
+ pids
69
+ end
70
+ end
71
+
72
+ # Scoped management of a pid ledger file for the duration of a parallel
73
+ # fan-out.
74
+ module PidFile
75
+ module_function
76
+
77
+ # Default ledger path, unique per process.
78
+ #
79
+ # @param dir [String, nil] directory for the file (default: Dir.tmpdir)
80
+ # @return [String]
81
+ def path(dir: nil)
82
+ require "tmpdir"
83
+ File.join(dir || Dir.tmpdir, "runtime_groups_pids_#{Process.pid}")
84
+ end
85
+
86
+ # Create (or truncate) a pid ledger file, yield its path for worker
87
+ # spawning, then always remove the file.
88
+ #
89
+ # @param dir [String, nil] directory for the file (default: Dir.tmpdir)
90
+ # @yieldparam path [String] the ledger path to hand to {Pids}
91
+ # @return [Object] the block's return value
92
+ def with(dir: nil)
93
+ path = path(dir: dir)
94
+ FileUtils.mkdir_p(File.dirname(path))
95
+ File.write(path, "")
96
+ yield path
97
+ ensure
98
+ File.delete(path) if File.exist?(path)
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module RuntimeGroups
6
+ # Reader and writer for a runtime log: `path:seconds` per line, recording
7
+ # how long each test file took in a previous run. The log is what makes
8
+ # {Grouping} balance groups by measured runtime instead of file size.
9
+ #
10
+ # The writer merges with the minimum: a recorded runtime only gets slower
11
+ # in the log when a subsequent run of that file is actually slower, so one
12
+ # slow run (GC pause, cold cache) does not permanently poison the balance.
13
+ class RuntimeLog
14
+ # Read a runtime log.
15
+ #
16
+ # @param path [String, nil] log file path; nil or a missing file reads as
17
+ # an empty hash
18
+ # @return [Hash{String => Float}] file path => recorded seconds
19
+ def self.read(path)
20
+ return {} if path.nil? || !File.file?(path)
21
+
22
+ File.readlines(path).each_with_object({}) do |line, memo|
23
+ file, seconds = line.strip.split(":", 2)
24
+ next if file.nil? || file.empty? || seconds.nil? || seconds.empty?
25
+
26
+ begin
27
+ memo[file] = Float(seconds)
28
+ rescue ArgumentError
29
+ next
30
+ end
31
+ end
32
+ end
33
+
34
+ # @param path [String] log file path
35
+ def initialize(path)
36
+ @path = path
37
+ end
38
+
39
+ # Record a runtime for a file, merged as the minimum of the recorded and
40
+ # given values. Creates the file (and its parent directory) on first use.
41
+ #
42
+ # @param file [String] test file path (the key later read back)
43
+ # @param seconds [Numeric]
44
+ # @return [Float] the recorded value after the merge
45
+ def log(file, seconds)
46
+ current = self.class.read(@path)
47
+ merged = current.key?(file) ? [current[file], seconds.to_f].min : seconds.to_f
48
+ current[file] = merged
49
+ FileUtils.mkdir_p(File.dirname(@path))
50
+ File.open(@path, "w") do |f|
51
+ current.keys.sort.each { |key| f.puts("#{key}:#{current[key]}") }
52
+ end
53
+ merged
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuntimeGroups
4
+ # Version namespace for this gem.
5
+ module Version
6
+ # Current gem version.
7
+ VERSION = "0.1.0"
8
+ end
9
+ # Current gem version exposed at the traditional constant location.
10
+ VERSION = Version::VERSION # Traditional Constant Location
11
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require_relative "runtime_groups/version"
5
+
6
+ module RuntimeGroups
7
+ class Error < StandardError; end
8
+
9
+ # Environment variable checked for a default worker process count, kept
10
+ # identical to the `parallel_tests` convention so an existing CI
11
+ # configuration keeps working when a runner switches to this gem.
12
+ DEFAULT_PROC_COUNT_ENV = "PARALLEL_TEST_PROCESSORS"
13
+
14
+ module_function
15
+
16
+ # Resolve the worker process count for a parallel run.
17
+ #
18
+ # Resolution order:
19
+ # 1. An explicit `count` argument (must be a positive Integer, or a String
20
+ # parseable as one).
21
+ # 2. `ENV[env_key]` (defaults to "PARALLEL_TEST_PROCESSORS"); blank values
22
+ # are ignored.
23
+ # 3. `Etc.nprocessors` — the machine's core count. Works on every engine
24
+ # (MRI, JRuby, TruffleRuby): JRuby reports the JVM's available
25
+ # processors.
26
+ #
27
+ # The caller is responsible for resource budgeting: on JVM engines each
28
+ # worker is a full JVM whose default heap can be 1/4 of machine RAM, so
29
+ # the core-count default can exhaust a constrained runner. Cap it
30
+ # explicitly there (see turbo_tests2's resource-constrained CI docs).
31
+ #
32
+ # @param count [Integer, String, nil]
33
+ # @param env [Hash] environment mapping (defaults to ENV)
34
+ # @param env_key [String]
35
+ # @return [Integer] positive worker count
36
+ # @raise [RuntimeGroups::Error] when an explicit count or env value is not
37
+ # a positive integer
38
+ def process_count(count = nil, env: ENV, env_key: DEFAULT_PROC_COUNT_ENV)
39
+ return parse_count!(count) unless count.nil?
40
+
41
+ from_env = env[env_key]
42
+ return parse_count!(from_env) unless from_env.nil? || from_env.strip.empty?
43
+
44
+ Etc.nprocessors
45
+ end
46
+
47
+ # Parse and validate a worker count value.
48
+ #
49
+ # @param value [Integer, String]
50
+ # @return [Integer] positive worker count
51
+ # @raise [RuntimeGroups::Error] when the value is not a positive integer
52
+ def parse_count!(value)
53
+ count = value.is_a?(Integer) ? value : Integer(value, 10)
54
+ return count if count.positive?
55
+
56
+ raise Error, "invalid worker count #{value.inspect}; expected a positive integer"
57
+ rescue ArgumentError
58
+ raise Error, "invalid worker count #{value.inspect}; expected a positive integer"
59
+ end
60
+ end
61
+
62
+ require_relative "runtime_groups/pids"
63
+ require_relative "runtime_groups/runtime_log"
64
+ require_relative "runtime_groups/options"
65
+ require_relative "runtime_groups/grouping"
66
+
67
+ RuntimeGroups::Version.class_eval do
68
+ extend VersionGem::Basic
69
+ end
@@ -0,0 +1,6 @@
1
+ module RuntimeGroups
2
+ module Version
3
+ VERSION: String
4
+ end
5
+ VERSION: String
6
+ end
data.tar.gz.sig ADDED
@@ -0,0 +1,2 @@
1
+ ��Ҧ�r�������dGƝ��'���-�ϰE��Ҍ�p�����~�9��A�b?�#�n��=�f����� 0n��K�%�S�u��1����1i� ^��28�����Tܸ��d�W:<w���;e�f�V�]�+GQ�K
2
+ ��(�?���]�J����Z��`�w���|�TV��JQ°����ʬ,���qIo3�-@��g�����gV�d����^��ᰞ\�r$�܅‡�#�.�J�.� �/1�������+�|>�$zò@�m/�|�TI6��%=o� ����0�<Ɵ�_�N�1��8�i�Q�G�ZK�Q�K�*����W��y��