polyrun 1.0.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
- data/CODE_OF_CONDUCT.md +31 -0
- data/CONTRIBUTING.md +84 -0
- data/LICENSE +21 -0
- data/README.md +140 -0
- data/SECURITY.md +27 -0
- data/bin/polyrun +6 -0
- data/docs/SETUP_PROFILE.md +106 -0
- data/lib/polyrun/cli/coverage_commands.rb +150 -0
- data/lib/polyrun/cli/coverage_merge_io.rb +124 -0
- data/lib/polyrun/cli/database_commands.rb +149 -0
- data/lib/polyrun/cli/env_commands.rb +43 -0
- data/lib/polyrun/cli/helpers.rb +113 -0
- data/lib/polyrun/cli/init_command.rb +99 -0
- data/lib/polyrun/cli/plan_command.rb +134 -0
- data/lib/polyrun/cli/prepare_command.rb +71 -0
- data/lib/polyrun/cli/prepare_recipe.rb +77 -0
- data/lib/polyrun/cli/queue_command.rb +101 -0
- data/lib/polyrun/cli/quick_command.rb +13 -0
- data/lib/polyrun/cli/report_commands.rb +94 -0
- data/lib/polyrun/cli/run_shards_command.rb +88 -0
- data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +91 -0
- data/lib/polyrun/cli/run_shards_plan_options.rb +45 -0
- data/lib/polyrun/cli/run_shards_planning.rb +124 -0
- data/lib/polyrun/cli/run_shards_run.rb +168 -0
- data/lib/polyrun/cli/start_bootstrap.rb +99 -0
- data/lib/polyrun/cli/timing_command.rb +31 -0
- data/lib/polyrun/cli.rb +184 -0
- data/lib/polyrun/config.rb +61 -0
- data/lib/polyrun/coverage/cobertura_zero_lines.rb +32 -0
- data/lib/polyrun/coverage/collector.rb +184 -0
- data/lib/polyrun/coverage/collector_finish.rb +95 -0
- data/lib/polyrun/coverage/filter.rb +22 -0
- data/lib/polyrun/coverage/formatter.rb +115 -0
- data/lib/polyrun/coverage/merge/formatters.rb +181 -0
- data/lib/polyrun/coverage/merge/formatters_html.rb +55 -0
- data/lib/polyrun/coverage/merge.rb +127 -0
- data/lib/polyrun/coverage/merge_fragment_meta.rb +47 -0
- data/lib/polyrun/coverage/merge_merge_two.rb +117 -0
- data/lib/polyrun/coverage/rails.rb +128 -0
- data/lib/polyrun/coverage/reporting.rb +41 -0
- data/lib/polyrun/coverage/result.rb +18 -0
- data/lib/polyrun/coverage/track_files.rb +141 -0
- data/lib/polyrun/data/cached_fixtures.rb +122 -0
- data/lib/polyrun/data/factory_counts.rb +35 -0
- data/lib/polyrun/data/factory_instrumentation.rb +50 -0
- data/lib/polyrun/data/fixtures.rb +68 -0
- data/lib/polyrun/data/parallel_provisioning.rb +93 -0
- data/lib/polyrun/data/snapshot.rb +84 -0
- data/lib/polyrun/database/clone_shards.rb +81 -0
- data/lib/polyrun/database/provision.rb +72 -0
- data/lib/polyrun/database/shard.rb +63 -0
- data/lib/polyrun/database/url_builder/connection/infer.rb +49 -0
- data/lib/polyrun/database/url_builder/connection/url_builders.rb +43 -0
- data/lib/polyrun/database/url_builder/connection.rb +191 -0
- data/lib/polyrun/database/url_builder/template_prepare.rb +21 -0
- data/lib/polyrun/database/url_builder.rb +160 -0
- data/lib/polyrun/debug.rb +81 -0
- data/lib/polyrun/env/ci.rb +65 -0
- data/lib/polyrun/log.rb +70 -0
- data/lib/polyrun/minitest.rb +17 -0
- data/lib/polyrun/partition/constraints.rb +69 -0
- data/lib/polyrun/partition/hrw.rb +33 -0
- data/lib/polyrun/partition/min_heap.rb +64 -0
- data/lib/polyrun/partition/paths.rb +28 -0
- data/lib/polyrun/partition/paths_build.rb +128 -0
- data/lib/polyrun/partition/plan.rb +189 -0
- data/lib/polyrun/partition/plan_lpt.rb +49 -0
- data/lib/polyrun/partition/plan_sharding.rb +48 -0
- data/lib/polyrun/partition/stable_shuffle.rb +18 -0
- data/lib/polyrun/prepare/artifacts.rb +40 -0
- data/lib/polyrun/prepare/assets.rb +57 -0
- data/lib/polyrun/queue/file_store.rb +199 -0
- data/lib/polyrun/queue/file_store_pending.rb +48 -0
- data/lib/polyrun/quick/assertions.rb +32 -0
- data/lib/polyrun/quick/errors.rb +6 -0
- data/lib/polyrun/quick/example_group.rb +66 -0
- data/lib/polyrun/quick/example_runner.rb +93 -0
- data/lib/polyrun/quick/matchers.rb +156 -0
- data/lib/polyrun/quick/reporter.rb +42 -0
- data/lib/polyrun/quick/runner.rb +180 -0
- data/lib/polyrun/quick.rb +1 -0
- data/lib/polyrun/railtie.rb +7 -0
- data/lib/polyrun/reporting/junit.rb +125 -0
- data/lib/polyrun/reporting/junit_emit.rb +58 -0
- data/lib/polyrun/reporting/rspec_junit.rb +39 -0
- data/lib/polyrun/rspec.rb +15 -0
- data/lib/polyrun/templates/POLYRUN.md +45 -0
- data/lib/polyrun/templates/ci_matrix.polyrun.yml +14 -0
- data/lib/polyrun/templates/minimal_gem.polyrun.yml +13 -0
- data/lib/polyrun/templates/rails_prepare.polyrun.yml +31 -0
- data/lib/polyrun/timing/merge.rb +35 -0
- data/lib/polyrun/timing/summary.rb +25 -0
- data/lib/polyrun/version.rb +3 -0
- data/lib/polyrun.rb +58 -0
- data/polyrun.gemspec +37 -0
- data/sig/polyrun/cli.rbs +6 -0
- data/sig/polyrun/config.rbs +20 -0
- data/sig/polyrun/debug.rbs +12 -0
- data/sig/polyrun/log.rbs +12 -0
- data/sig/polyrun/minitest.rbs +5 -0
- data/sig/polyrun/quick.rbs +19 -0
- data/sig/polyrun/rspec.rbs +5 -0
- data/sig/polyrun.rbs +11 -0
- metadata +288 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require_relative "../polyrun"
|
|
2
|
+
|
|
3
|
+
module Polyrun
|
|
4
|
+
# Optional Minitest-oriented wiring (require +polyrun/minitest+ explicitly).
|
|
5
|
+
#
|
|
6
|
+
# Does not load the +minitest+ gem. Call {install_parallel_provisioning!} from +test/test_helper.rb+
|
|
7
|
+
# after Rails / DB configuration (same timing as a direct call to
|
|
8
|
+
# {Data::ParallelProvisioning.run_suite_hooks!}).
|
|
9
|
+
module Minitest
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Runs {Data::ParallelProvisioning.run_suite_hooks!} (serial vs shard worker hooks).
|
|
13
|
+
def install_parallel_provisioning!
|
|
14
|
+
Polyrun::Data::ParallelProvisioning.run_suite_hooks!
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module Partition
|
|
3
|
+
# Hard constraints for plan assignment (spec_queue.md): pins, serial globs.
|
|
4
|
+
# Pins win over serial_glob. First matching pin glob wins.
|
|
5
|
+
class Constraints
|
|
6
|
+
attr_reader :pin_map, :serial_globs, :serial_shard
|
|
7
|
+
|
|
8
|
+
# @param pin_map [Hash{String=>Integer}] glob or exact path => shard index
|
|
9
|
+
# @param serial_globs [Array<String>] fnmatch patterns forced to +serial_shard+ (default 0) unless pinned
|
|
10
|
+
# @param root [String] project root for expanding relative paths
|
|
11
|
+
def initialize(pin_map: {}, serial_globs: [], serial_shard: 0, root: nil)
|
|
12
|
+
@pin_map = pin_map.transform_keys(&:to_s).transform_values { |v| Integer(v) }
|
|
13
|
+
@serial_globs = Array(serial_globs).map(&:to_s)
|
|
14
|
+
@serial_shard = Integer(serial_shard)
|
|
15
|
+
@root = root ? File.expand_path(root) : Dir.pwd
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.from_hash(h, root: nil)
|
|
19
|
+
h = h.transform_keys(&:to_s) if h.is_a?(Hash)
|
|
20
|
+
return new(root: root) unless h.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
pins = h["pin"] || h["pins"] || {}
|
|
23
|
+
serial = h["serial_glob"] || h["serial_globs"] || []
|
|
24
|
+
serial_shard = h["serial_shard"] || 0
|
|
25
|
+
new(
|
|
26
|
+
pin_map: pins.is_a?(Hash) ? pins : {},
|
|
27
|
+
serial_globs: serial.is_a?(Array) ? serial : [],
|
|
28
|
+
serial_shard: serial_shard,
|
|
29
|
+
root: root
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns Integer shard index if constrained, or nil if free to place by LPT/HRW.
|
|
34
|
+
def forced_shard_for(path)
|
|
35
|
+
rel = path.to_s
|
|
36
|
+
abs = File.expand_path(rel, @root)
|
|
37
|
+
|
|
38
|
+
@pin_map.each do |pattern, shard|
|
|
39
|
+
next if pattern.to_s.empty?
|
|
40
|
+
|
|
41
|
+
if match_pattern?(pattern.to_s, rel, abs)
|
|
42
|
+
return shard
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@serial_globs.each do |g|
|
|
47
|
+
if match_pattern?(g, rel, abs)
|
|
48
|
+
return @serial_shard
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def any?
|
|
56
|
+
@pin_map.any? || @serial_globs.any?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def match_pattern?(pattern, rel, abs)
|
|
62
|
+
p = pattern.to_s
|
|
63
|
+
File.fnmatch?(p, rel, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
|
|
64
|
+
File.fnmatch?(p, abs, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
|
|
65
|
+
p == rel || p == abs
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module Polyrun
|
|
4
|
+
module Partition
|
|
5
|
+
# Rendezvous / highest-hash shard assignment (spec_queue.md): stateless, stable when m changes.
|
|
6
|
+
module Hrw
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# @return [Integer] shard index in 0...m
|
|
10
|
+
def shard_for(path:, total_shards:, seed: "")
|
|
11
|
+
m = Integer(total_shards)
|
|
12
|
+
raise Polyrun::Error, "total_shards must be >= 1" if m < 1
|
|
13
|
+
|
|
14
|
+
best_j = 0
|
|
15
|
+
best = -1
|
|
16
|
+
salt = seed.to_s
|
|
17
|
+
p = path.to_s
|
|
18
|
+
m.times do |j|
|
|
19
|
+
h = score(p, j, salt)
|
|
20
|
+
if h > best
|
|
21
|
+
best = h
|
|
22
|
+
best_j = j
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
best_j
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def score(path, shard_index, salt)
|
|
29
|
+
Digest::SHA256.digest("#{salt}\n#{path}\n#{shard_index}").unpack1("H*").hex
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module Partition
|
|
3
|
+
# Binary min-heap of [load, shard_index] for LPT placement (tie-break: lower shard index).
|
|
4
|
+
class MinHeap
|
|
5
|
+
def initialize
|
|
6
|
+
@a = []
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def push(load, shard_index)
|
|
10
|
+
@a << [load.to_f, Integer(shard_index)]
|
|
11
|
+
sift_up(@a.size - 1)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def pop_min
|
|
15
|
+
return nil if @a.empty?
|
|
16
|
+
|
|
17
|
+
min = @a[0]
|
|
18
|
+
last = @a.pop
|
|
19
|
+
@a[0] = last if @a.any?
|
|
20
|
+
sift_down(0) if @a.size > 1
|
|
21
|
+
min
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def empty?
|
|
25
|
+
@a.empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def sift_up(i)
|
|
31
|
+
while i.positive?
|
|
32
|
+
p = (i - 1) / 2
|
|
33
|
+
break unless less(@a[i], @a[p])
|
|
34
|
+
|
|
35
|
+
@a[i], @a[p] = @a[p], @a[i]
|
|
36
|
+
i = p
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def sift_down(i)
|
|
41
|
+
n = @a.size
|
|
42
|
+
loop do
|
|
43
|
+
l = 2 * i + 1
|
|
44
|
+
r = l + 1
|
|
45
|
+
smallest = i
|
|
46
|
+
smallest = l if l < n && less(@a[l], @a[smallest])
|
|
47
|
+
smallest = r if r < n && less(@a[r], @a[smallest])
|
|
48
|
+
break if smallest == i
|
|
49
|
+
|
|
50
|
+
@a[i], @a[smallest] = @a[smallest], @a[i]
|
|
51
|
+
i = smallest
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Compare [load, j]: lower load wins; tie -> lower j
|
|
56
|
+
def less(x, y)
|
|
57
|
+
return true if x[0] < y[0]
|
|
58
|
+
return false if x[0] > y[0]
|
|
59
|
+
|
|
60
|
+
x[1] < y[1]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module Partition
|
|
3
|
+
# Shared spec path listing for run-shards, queue init, and plan helpers.
|
|
4
|
+
module Paths
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def read_lines(path)
|
|
8
|
+
File.read(File.expand_path(path.to_s, Dir.pwd)).split("\n").map(&:strip).reject(&:empty?)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# When +paths_file+ is set but missing, returns +{ error: "..." }+.
|
|
12
|
+
# Otherwise returns +{ items:, source: }+ (human-readable source label).
|
|
13
|
+
def resolve_run_shard_items(paths_file: nil, cwd: Dir.pwd)
|
|
14
|
+
if paths_file
|
|
15
|
+
abs = File.expand_path(paths_file.to_s, cwd)
|
|
16
|
+
unless File.file?(abs)
|
|
17
|
+
return {error: "paths file not found: #{abs}"}
|
|
18
|
+
end
|
|
19
|
+
{items: read_lines(abs), source: paths_file.to_s}
|
|
20
|
+
elsif File.file?(File.join(cwd, "spec", "spec_paths.txt"))
|
|
21
|
+
{items: read_lines(File.join(cwd, "spec", "spec_paths.txt")), source: "spec/spec_paths.txt"}
|
|
22
|
+
else
|
|
23
|
+
{items: Dir.glob(File.join(cwd, "spec/**/*_spec.rb")).sort, source: "spec/**/*_spec.rb glob"}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "pathname"
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Polyrun
|
|
6
|
+
module Partition
|
|
7
|
+
# Writes +partition.paths_file+ from +partition.paths_build+
|
|
8
|
+
module PathsBuild
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# @return [Integer] 0 on success or skip, 2 on configuration error
|
|
12
|
+
def apply!(partition:, cwd: Dir.pwd)
|
|
13
|
+
return 0 if skip_paths_build?
|
|
14
|
+
|
|
15
|
+
pb = partition["paths_build"] || partition[:paths_build]
|
|
16
|
+
return 0 unless pb.is_a?(Hash) && !pb.empty?
|
|
17
|
+
|
|
18
|
+
paths_file = (partition["paths_file"] || partition[:paths_file] || "spec/spec_paths.txt").to_s
|
|
19
|
+
out_abs = File.expand_path(paths_file, cwd)
|
|
20
|
+
lines = build_ordered_paths(pb, cwd)
|
|
21
|
+
FileUtils.mkdir_p(File.dirname(out_abs))
|
|
22
|
+
File.write(out_abs, lines.join("\n") + "\n")
|
|
23
|
+
Polyrun::Log.warn "polyrun paths-build: wrote #{lines.size} path(s) → #{paths_file}"
|
|
24
|
+
0
|
|
25
|
+
rescue Polyrun::Error => e
|
|
26
|
+
Polyrun::Log.warn "polyrun paths-build: #{e.message}"
|
|
27
|
+
2
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def skip_paths_build?
|
|
31
|
+
v = ENV["POLYRUN_SKIP_PATHS_BUILD"].to_s.downcase
|
|
32
|
+
%w[1 true yes].include?(v)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Builds ordered path strings relative to +cwd+ (forward slashes).
|
|
36
|
+
def build_ordered_paths(pb, cwd)
|
|
37
|
+
pb = stringify_keys(pb)
|
|
38
|
+
all_glob = pb["all_glob"].to_s
|
|
39
|
+
all_glob = "spec/**/*_spec.rb" if all_glob.empty?
|
|
40
|
+
|
|
41
|
+
pool = glob_under_cwd(all_glob, cwd)
|
|
42
|
+
pool.uniq!
|
|
43
|
+
stages = Array(pb["stages"])
|
|
44
|
+
return sort_paths(pool) if stages.empty?
|
|
45
|
+
|
|
46
|
+
apply_stages_to_pool(stages, pool, cwd)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def apply_stages_to_pool(stages, pool, cwd)
|
|
50
|
+
remaining = Set.new(pool)
|
|
51
|
+
out = []
|
|
52
|
+
stages.each do |raw|
|
|
53
|
+
st = stringify_keys(raw)
|
|
54
|
+
taken =
|
|
55
|
+
if st["glob"]
|
|
56
|
+
take_glob_paths(st, remaining, cwd)
|
|
57
|
+
elsif st["regex"]
|
|
58
|
+
take_regex_paths(st, remaining)
|
|
59
|
+
else
|
|
60
|
+
raise Polyrun::Error, 'paths_build stage needs "glob" or "regex"'
|
|
61
|
+
end
|
|
62
|
+
out.concat(taken)
|
|
63
|
+
remaining.subtract(taken)
|
|
64
|
+
end
|
|
65
|
+
out.concat(sort_paths(remaining.to_a))
|
|
66
|
+
out
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def take_glob_paths(st, remaining, cwd)
|
|
70
|
+
taken = glob_under_cwd(st["glob"].to_s, cwd).select { |p| remaining.include?(p) }
|
|
71
|
+
if st["sort_by_substring_order"]
|
|
72
|
+
subs = Array(st["sort_by_substring_order"]).map(&:to_s)
|
|
73
|
+
def_prio = int_or(st["default_priority"], int_or(st["default_sort_key"], 99))
|
|
74
|
+
taken.sort_by! { |p| [substring_priority(p, subs, def_prio), p] }
|
|
75
|
+
else
|
|
76
|
+
sort_paths!(taken)
|
|
77
|
+
end
|
|
78
|
+
taken
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def take_regex_paths(st, remaining)
|
|
82
|
+
ic = st["ignore_case"]
|
|
83
|
+
ignore_case = ic == true || %w[1 true yes].include?(ic.to_s.downcase)
|
|
84
|
+
rx = Regexp.new(st["regex"].to_s, ignore_case ? Regexp::IGNORECASE : 0)
|
|
85
|
+
taken = remaining.to_a.select { |p| rx.match?(p) || rx.match?(File.basename(p)) }
|
|
86
|
+
sort_paths!(taken)
|
|
87
|
+
taken
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def glob_under_cwd(pattern, cwd)
|
|
91
|
+
root = File.expand_path(cwd)
|
|
92
|
+
Dir.glob(File.join(root, pattern)).map { |p| normalize_rel(p, cwd) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def normalize_rel(path, cwd)
|
|
96
|
+
abs = File.expand_path(path, cwd)
|
|
97
|
+
Pathname.new(abs).relative_path_from(Pathname.new(File.expand_path(cwd))).to_s.tr("\\", "/")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def sort_paths(paths)
|
|
101
|
+
paths.sort
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def sort_paths!(paths)
|
|
105
|
+
paths.sort!
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def substring_priority(path, substrings, default)
|
|
109
|
+
substrings.each_with_index do |s, i|
|
|
110
|
+
return i if path.include?(s)
|
|
111
|
+
end
|
|
112
|
+
default
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def stringify_keys(h)
|
|
116
|
+
return {} unless h.is_a?(Hash)
|
|
117
|
+
|
|
118
|
+
h.each_with_object({}) { |(k, v), o| o[k.to_s] = v }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def int_or(v, fallback)
|
|
122
|
+
Integer(v)
|
|
123
|
+
rescue ArgumentError, TypeError
|
|
124
|
+
fallback
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
require_relative "constraints"
|
|
4
|
+
require_relative "hrw"
|
|
5
|
+
require_relative "min_heap"
|
|
6
|
+
require_relative "stable_shuffle"
|
|
7
|
+
|
|
8
|
+
module Polyrun
|
|
9
|
+
module Partition
|
|
10
|
+
# Assigns discrete items (e.g. spec paths) to shards (spec_queue.md).
|
|
11
|
+
#
|
|
12
|
+
# Strategies:
|
|
13
|
+
# - +round_robin+ — sorted paths, assign by index mod +total_shards+.
|
|
14
|
+
# - +random_round_robin+ — Fisher–Yates shuffle (optional +seed+), then same mod assignment.
|
|
15
|
+
# - +cost_binpack+ (+cost+, +binpack+, +timing+) — LPT greedy binpack using per-path weights;
|
|
16
|
+
# optional {Constraints} for pins / serial globs before LPT on the rest.
|
|
17
|
+
# - +hrw+ (+rendezvous+) — rendezvous hashing for minimal remapping when m changes; optional constraints.
|
|
18
|
+
class Plan
|
|
19
|
+
COST_STRATEGIES = %w[cost cost_binpack binpack timing].freeze
|
|
20
|
+
HRW_STRATEGIES = %w[hrw rendezvous].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :items, :total_shards, :strategy, :seed, :constraints
|
|
23
|
+
|
|
24
|
+
def initialize(items:, total_shards:, strategy: "round_robin", seed: nil, costs: nil, constraints: nil, root: nil)
|
|
25
|
+
@items = items.map(&:to_s).freeze
|
|
26
|
+
@total_shards = Integer(total_shards)
|
|
27
|
+
raise Polyrun::Error, "total_shards must be >= 1" if @total_shards < 1
|
|
28
|
+
|
|
29
|
+
@strategy = strategy.to_s
|
|
30
|
+
@seed = seed
|
|
31
|
+
@root = root ? File.expand_path(root) : Dir.pwd
|
|
32
|
+
@constraints = normalize_constraints(constraints)
|
|
33
|
+
@costs = normalize_costs(costs)
|
|
34
|
+
|
|
35
|
+
validate_constraints_strategy_combo!
|
|
36
|
+
if cost_strategy? && (@costs.nil? || @costs.empty?)
|
|
37
|
+
raise Polyrun::Error,
|
|
38
|
+
"strategy #{@strategy} requires a timing map (path => seconds), e.g. merged polyrun_timing.json"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ordered_items
|
|
43
|
+
@ordered_items ||= case strategy
|
|
44
|
+
when "round_robin"
|
|
45
|
+
items.sort
|
|
46
|
+
when "random_round_robin"
|
|
47
|
+
StableShuffle.call(items.sort, random_seed)
|
|
48
|
+
when "cost", "cost_binpack", "binpack", "timing"
|
|
49
|
+
items.sort
|
|
50
|
+
when "hrw", "rendezvous"
|
|
51
|
+
items.sort
|
|
52
|
+
else
|
|
53
|
+
raise Polyrun::Error, "unknown partition strategy: #{strategy}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def shard(shard_index)
|
|
58
|
+
idx = Integer(shard_index)
|
|
59
|
+
raise Polyrun::Error, "shard_index out of range" if idx < 0 || idx >= total_shards
|
|
60
|
+
|
|
61
|
+
if cost_strategy?
|
|
62
|
+
cost_shards[idx]
|
|
63
|
+
elsif hrw_strategy?
|
|
64
|
+
hrw_shards[idx]
|
|
65
|
+
else
|
|
66
|
+
mod_shards[idx]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def shard_weight_totals
|
|
71
|
+
if cost_strategy?
|
|
72
|
+
cost_shards.map { |paths| paths.sum { |p| weight_for(p) } }
|
|
73
|
+
elsif hrw_strategy?
|
|
74
|
+
hrw_shards.map { |paths| paths.sum { |p| weight_for_optional(p) } }
|
|
75
|
+
else
|
|
76
|
+
[]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def manifest(shard_index)
|
|
81
|
+
m = {
|
|
82
|
+
"shard_index" => Integer(shard_index),
|
|
83
|
+
"shard_total" => total_shards,
|
|
84
|
+
"strategy" => strategy,
|
|
85
|
+
"seed" => seed,
|
|
86
|
+
"paths" => shard(shard_index)
|
|
87
|
+
}
|
|
88
|
+
secs = shard_weight_totals
|
|
89
|
+
m["shard_seconds"] = secs if cost_strategy? || (hrw_strategy? && secs.any? { |x| x > 0 })
|
|
90
|
+
m
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.load_timing_costs(path)
|
|
94
|
+
abs = File.expand_path(path.to_s, Dir.pwd)
|
|
95
|
+
return {} unless File.file?(abs)
|
|
96
|
+
|
|
97
|
+
data = JSON.parse(File.read(abs))
|
|
98
|
+
return {} unless data.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
out = {}
|
|
101
|
+
data.each do |k, v|
|
|
102
|
+
key = File.expand_path(k.to_s, Dir.pwd)
|
|
103
|
+
out[key] = v.to_f
|
|
104
|
+
end
|
|
105
|
+
out
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.cost_strategy?(name)
|
|
109
|
+
COST_STRATEGIES.include?(name.to_s)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.hrw_strategy?(name)
|
|
113
|
+
HRW_STRATEGIES.include?(name.to_s)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def cost_strategy?
|
|
119
|
+
self.class.cost_strategy?(strategy)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def hrw_strategy?
|
|
123
|
+
self.class.hrw_strategy?(strategy)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def normalize_constraints(c)
|
|
127
|
+
return nil if c.nil?
|
|
128
|
+
|
|
129
|
+
c.is_a?(Constraints) ? c : Constraints.from_hash(c, root: @root)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def normalize_costs(costs)
|
|
133
|
+
return nil if costs.nil?
|
|
134
|
+
|
|
135
|
+
c = {}
|
|
136
|
+
costs.each do |k, v|
|
|
137
|
+
key = File.expand_path(k.to_s, @root)
|
|
138
|
+
c[key] = v.to_f
|
|
139
|
+
end
|
|
140
|
+
c
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def validate_constraints_strategy_combo!
|
|
144
|
+
return unless @constraints&.any?
|
|
145
|
+
return if cost_strategy? || hrw_strategy?
|
|
146
|
+
|
|
147
|
+
raise Polyrun::Error,
|
|
148
|
+
"partition constraints require strategy cost_binpack (with --timing) or hrw/rendezvous"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def default_weight
|
|
152
|
+
return @default_weight if defined?(@default_weight)
|
|
153
|
+
|
|
154
|
+
vals = @costs&.values || []
|
|
155
|
+
@default_weight =
|
|
156
|
+
if vals.empty?
|
|
157
|
+
1.0
|
|
158
|
+
else
|
|
159
|
+
vals.sum / vals.size
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def weight_for(path)
|
|
164
|
+
abs = File.expand_path(path.to_s, @root)
|
|
165
|
+
return @costs[abs] if @costs&.key?(abs)
|
|
166
|
+
|
|
167
|
+
default_weight
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def weight_for_optional(path)
|
|
171
|
+
abs = File.expand_path(path.to_s, @root)
|
|
172
|
+
return @costs[abs] if @costs&.key?(abs)
|
|
173
|
+
|
|
174
|
+
0.0
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def cost_shards
|
|
178
|
+
@cost_shards ||= build_lpt_buckets
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def build_lpt_buckets
|
|
182
|
+
PlanLptBuckets.new(self).build
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
require_relative "plan_sharding"
|
|
189
|
+
require_relative "plan_lpt"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require_relative "min_heap"
|
|
2
|
+
|
|
3
|
+
module Polyrun
|
|
4
|
+
module Partition
|
|
5
|
+
# LPT greedy binpack for cost strategies (extracted from {Plan} for size limits).
|
|
6
|
+
class PlanLptBuckets
|
|
7
|
+
def initialize(plan)
|
|
8
|
+
@plan = plan
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def build
|
|
12
|
+
buckets = Array.new(@plan.total_shards) { [] }
|
|
13
|
+
totals = Array.new(@plan.total_shards, 0.0)
|
|
14
|
+
lpt_fill_forced!(buckets, totals)
|
|
15
|
+
lpt_balance_free!(buckets, totals)
|
|
16
|
+
buckets
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def lpt_fill_forced!(buckets, totals)
|
|
22
|
+
@plan.items.each do |item|
|
|
23
|
+
next unless @plan.constraints && (j = @plan.constraints.forced_shard_for(item))
|
|
24
|
+
|
|
25
|
+
j = Integer(j)
|
|
26
|
+
raise Polyrun::Error, "constraint shard #{j} out of range" if j < 0 || j >= @plan.total_shards
|
|
27
|
+
|
|
28
|
+
buckets[j] << item
|
|
29
|
+
totals[j] += @plan.send(:weight_for, item)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def lpt_balance_free!(buckets, totals)
|
|
34
|
+
free = @plan.items.reject { |item| @plan.constraints&.forced_shard_for(item) }
|
|
35
|
+
pairs = free.map { |p| [p, @plan.send(:weight_for, p)] }
|
|
36
|
+
pairs.sort_by! { |(p, w)| [-w, p] }
|
|
37
|
+
|
|
38
|
+
heap = MinHeap.new
|
|
39
|
+
@plan.total_shards.times { |j| heap.push(totals[j], j) }
|
|
40
|
+
|
|
41
|
+
pairs.each do |path, w|
|
|
42
|
+
load, j = heap.pop_min
|
|
43
|
+
buckets[j] << path
|
|
44
|
+
heap.push(load + w, j)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require_relative "hrw"
|
|
2
|
+
|
|
3
|
+
module Polyrun
|
|
4
|
+
module Partition
|
|
5
|
+
class Plan
|
|
6
|
+
def hrw_shards
|
|
7
|
+
@hrw_shards ||= begin
|
|
8
|
+
buckets = Array.new(total_shards) { [] }
|
|
9
|
+
salt = hrw_salt
|
|
10
|
+
items.each do |path|
|
|
11
|
+
j =
|
|
12
|
+
if @constraints && (fj = @constraints.forced_shard_for(path))
|
|
13
|
+
Integer(fj)
|
|
14
|
+
else
|
|
15
|
+
Hrw.shard_for(path: path, total_shards: total_shards, seed: salt)
|
|
16
|
+
end
|
|
17
|
+
raise Polyrun::Error, "constraint shard out of range" if j < 0 || j >= total_shards
|
|
18
|
+
|
|
19
|
+
buckets[j] << path
|
|
20
|
+
end
|
|
21
|
+
buckets
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# One pass over +ordered_items+ (round_robin / random_round_robin); avoids O(workers × n) rescans in +shard+.
|
|
26
|
+
def mod_shards
|
|
27
|
+
@mod_shards ||= begin
|
|
28
|
+
list = ordered_items
|
|
29
|
+
buckets = Array.new(total_shards) { [] }
|
|
30
|
+
list.each_with_index { |path, i| buckets[i % total_shards] << path }
|
|
31
|
+
buckets
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def hrw_salt
|
|
36
|
+
s = seed
|
|
37
|
+
(s.nil? || s.to_s.empty?) ? "polyrun-hrw" : s.to_s
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def random_seed
|
|
41
|
+
s = seed
|
|
42
|
+
return Integer(s) if s && s != ""
|
|
43
|
+
|
|
44
|
+
0
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module Partition
|
|
3
|
+
# Deterministic Fisher–Yates shuffle (spec_queue.md).
|
|
4
|
+
module StableShuffle
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def call(items, seed)
|
|
8
|
+
rng = Random.new(Integer(seed))
|
|
9
|
+
a = items.dup
|
|
10
|
+
(a.size - 1).downto(1) do |i|
|
|
11
|
+
j = rng.rand(i + 1)
|
|
12
|
+
a[i], a[j] = a[j], a[i]
|
|
13
|
+
end
|
|
14
|
+
a
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "digest/sha2"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Polyrun
|
|
5
|
+
module Prepare
|
|
6
|
+
# Writes +polyrun-artifacts.json+ (spec2 §3) for cache keys and CI upload lists.
|
|
7
|
+
module Artifacts
|
|
8
|
+
VERSION = 1
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# +entries+ is array of hashes: { "path" => ..., "kind" => "file"|"directory", optional "digest", "size" }
|
|
13
|
+
def write!(root:, recipe:, entries:, dry_run: false)
|
|
14
|
+
list = entries.map { |e| normalize_entry(e) }
|
|
15
|
+
doc = {
|
|
16
|
+
"version" => VERSION,
|
|
17
|
+
"recipe" => recipe,
|
|
18
|
+
"dry_run" => dry_run,
|
|
19
|
+
"generated_at" => Time.now.utc.iso8601,
|
|
20
|
+
"artifacts" => list
|
|
21
|
+
}
|
|
22
|
+
path = File.join(root, "polyrun-artifacts.json")
|
|
23
|
+
File.write(path, JSON.pretty_generate(doc))
|
|
24
|
+
path
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def normalize_entry(e)
|
|
28
|
+
h = e.transform_keys(&:to_s)
|
|
29
|
+
p = h["path"].to_s
|
|
30
|
+
kind = h["kind"] || (File.directory?(p) ? "directory" : "file")
|
|
31
|
+
out = {"path" => p, "kind" => kind}
|
|
32
|
+
if File.exist?(p)
|
|
33
|
+
out["size"] = File.size(p) if File.file?(p)
|
|
34
|
+
out["digest"] = h["digest"] || (File.file?(p) ? "sha256:#{Digest::SHA256.file(p).hexdigest}" : nil)
|
|
35
|
+
end
|
|
36
|
+
out.compact
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|