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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +128 -0
- data/LICENSE.md +10 -0
- data/README.md +597 -0
- data/lib/runtime_groups/grouping.rb +96 -0
- data/lib/runtime_groups/options.rb +34 -0
- data/lib/runtime_groups/pids.rb +101 -0
- data/lib/runtime_groups/runtime_log.rb +56 -0
- data/lib/runtime_groups/version.rb +11 -0
- data/lib/runtime_groups.rb +69 -0
- data/sig/runtime_groups.rbs +6 -0
- data.tar.gz.sig +2 -0
- metadata +323 -0
- metadata.gz.sig +0 -0
|
@@ -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
|
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��
|