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.
Files changed (105) hide show
  1. checksums.yaml +7 -0
  2. data/CODE_OF_CONDUCT.md +31 -0
  3. data/CONTRIBUTING.md +84 -0
  4. data/LICENSE +21 -0
  5. data/README.md +140 -0
  6. data/SECURITY.md +27 -0
  7. data/bin/polyrun +6 -0
  8. data/docs/SETUP_PROFILE.md +106 -0
  9. data/lib/polyrun/cli/coverage_commands.rb +150 -0
  10. data/lib/polyrun/cli/coverage_merge_io.rb +124 -0
  11. data/lib/polyrun/cli/database_commands.rb +149 -0
  12. data/lib/polyrun/cli/env_commands.rb +43 -0
  13. data/lib/polyrun/cli/helpers.rb +113 -0
  14. data/lib/polyrun/cli/init_command.rb +99 -0
  15. data/lib/polyrun/cli/plan_command.rb +134 -0
  16. data/lib/polyrun/cli/prepare_command.rb +71 -0
  17. data/lib/polyrun/cli/prepare_recipe.rb +77 -0
  18. data/lib/polyrun/cli/queue_command.rb +101 -0
  19. data/lib/polyrun/cli/quick_command.rb +13 -0
  20. data/lib/polyrun/cli/report_commands.rb +94 -0
  21. data/lib/polyrun/cli/run_shards_command.rb +88 -0
  22. data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +91 -0
  23. data/lib/polyrun/cli/run_shards_plan_options.rb +45 -0
  24. data/lib/polyrun/cli/run_shards_planning.rb +124 -0
  25. data/lib/polyrun/cli/run_shards_run.rb +168 -0
  26. data/lib/polyrun/cli/start_bootstrap.rb +99 -0
  27. data/lib/polyrun/cli/timing_command.rb +31 -0
  28. data/lib/polyrun/cli.rb +184 -0
  29. data/lib/polyrun/config.rb +61 -0
  30. data/lib/polyrun/coverage/cobertura_zero_lines.rb +32 -0
  31. data/lib/polyrun/coverage/collector.rb +184 -0
  32. data/lib/polyrun/coverage/collector_finish.rb +95 -0
  33. data/lib/polyrun/coverage/filter.rb +22 -0
  34. data/lib/polyrun/coverage/formatter.rb +115 -0
  35. data/lib/polyrun/coverage/merge/formatters.rb +181 -0
  36. data/lib/polyrun/coverage/merge/formatters_html.rb +55 -0
  37. data/lib/polyrun/coverage/merge.rb +127 -0
  38. data/lib/polyrun/coverage/merge_fragment_meta.rb +47 -0
  39. data/lib/polyrun/coverage/merge_merge_two.rb +117 -0
  40. data/lib/polyrun/coverage/rails.rb +128 -0
  41. data/lib/polyrun/coverage/reporting.rb +41 -0
  42. data/lib/polyrun/coverage/result.rb +18 -0
  43. data/lib/polyrun/coverage/track_files.rb +141 -0
  44. data/lib/polyrun/data/cached_fixtures.rb +122 -0
  45. data/lib/polyrun/data/factory_counts.rb +35 -0
  46. data/lib/polyrun/data/factory_instrumentation.rb +50 -0
  47. data/lib/polyrun/data/fixtures.rb +68 -0
  48. data/lib/polyrun/data/parallel_provisioning.rb +93 -0
  49. data/lib/polyrun/data/snapshot.rb +84 -0
  50. data/lib/polyrun/database/clone_shards.rb +81 -0
  51. data/lib/polyrun/database/provision.rb +72 -0
  52. data/lib/polyrun/database/shard.rb +63 -0
  53. data/lib/polyrun/database/url_builder/connection/infer.rb +49 -0
  54. data/lib/polyrun/database/url_builder/connection/url_builders.rb +43 -0
  55. data/lib/polyrun/database/url_builder/connection.rb +191 -0
  56. data/lib/polyrun/database/url_builder/template_prepare.rb +21 -0
  57. data/lib/polyrun/database/url_builder.rb +160 -0
  58. data/lib/polyrun/debug.rb +81 -0
  59. data/lib/polyrun/env/ci.rb +65 -0
  60. data/lib/polyrun/log.rb +70 -0
  61. data/lib/polyrun/minitest.rb +17 -0
  62. data/lib/polyrun/partition/constraints.rb +69 -0
  63. data/lib/polyrun/partition/hrw.rb +33 -0
  64. data/lib/polyrun/partition/min_heap.rb +64 -0
  65. data/lib/polyrun/partition/paths.rb +28 -0
  66. data/lib/polyrun/partition/paths_build.rb +128 -0
  67. data/lib/polyrun/partition/plan.rb +189 -0
  68. data/lib/polyrun/partition/plan_lpt.rb +49 -0
  69. data/lib/polyrun/partition/plan_sharding.rb +48 -0
  70. data/lib/polyrun/partition/stable_shuffle.rb +18 -0
  71. data/lib/polyrun/prepare/artifacts.rb +40 -0
  72. data/lib/polyrun/prepare/assets.rb +57 -0
  73. data/lib/polyrun/queue/file_store.rb +199 -0
  74. data/lib/polyrun/queue/file_store_pending.rb +48 -0
  75. data/lib/polyrun/quick/assertions.rb +32 -0
  76. data/lib/polyrun/quick/errors.rb +6 -0
  77. data/lib/polyrun/quick/example_group.rb +66 -0
  78. data/lib/polyrun/quick/example_runner.rb +93 -0
  79. data/lib/polyrun/quick/matchers.rb +156 -0
  80. data/lib/polyrun/quick/reporter.rb +42 -0
  81. data/lib/polyrun/quick/runner.rb +180 -0
  82. data/lib/polyrun/quick.rb +1 -0
  83. data/lib/polyrun/railtie.rb +7 -0
  84. data/lib/polyrun/reporting/junit.rb +125 -0
  85. data/lib/polyrun/reporting/junit_emit.rb +58 -0
  86. data/lib/polyrun/reporting/rspec_junit.rb +39 -0
  87. data/lib/polyrun/rspec.rb +15 -0
  88. data/lib/polyrun/templates/POLYRUN.md +45 -0
  89. data/lib/polyrun/templates/ci_matrix.polyrun.yml +14 -0
  90. data/lib/polyrun/templates/minimal_gem.polyrun.yml +13 -0
  91. data/lib/polyrun/templates/rails_prepare.polyrun.yml +31 -0
  92. data/lib/polyrun/timing/merge.rb +35 -0
  93. data/lib/polyrun/timing/summary.rb +25 -0
  94. data/lib/polyrun/version.rb +3 -0
  95. data/lib/polyrun.rb +58 -0
  96. data/polyrun.gemspec +37 -0
  97. data/sig/polyrun/cli.rbs +6 -0
  98. data/sig/polyrun/config.rbs +20 -0
  99. data/sig/polyrun/debug.rbs +12 -0
  100. data/sig/polyrun/log.rbs +12 -0
  101. data/sig/polyrun/minitest.rbs +5 -0
  102. data/sig/polyrun/quick.rbs +19 -0
  103. data/sig/polyrun/rspec.rbs +5 -0
  104. data/sig/polyrun.rbs +11 -0
  105. 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