polyrun 1.2.0 → 1.4.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,128 @@
1
+ require "monitor"
2
+
3
+ require_relative "../log"
4
+
5
+ module Polyrun
6
+ class Hooks
7
+ # Ruby DSL for +hooks.ruby+ / +hooks.ruby_file+ in +polyrun.yml+ (see README).
8
+ #
9
+ # Example file (+config/polyrun_hooks.rb+):
10
+ #
11
+ # before(:suite) { |env| puts env["POLYRUN_HOOK_PHASE"] }
12
+ # after(:each) { |env| }
13
+ #
14
+ # Blocks receive a hash with string keys (same env as shell hooks).
15
+ module Dsl
16
+ class Registry
17
+ def initialize
18
+ @phases = Hash.new { |h, k| h[k] = [] }
19
+ end
20
+
21
+ def add(phase, proc)
22
+ @phases[phase.to_sym] << proc
23
+ end
24
+
25
+ # @param env [Hash] string-keyed env
26
+ def run(phase, env)
27
+ @phases[phase.to_sym].each { |pr| pr.call(env) }
28
+ end
29
+
30
+ def any?(phase)
31
+ @phases[phase.to_sym].any?
32
+ end
33
+
34
+ def empty?
35
+ @phases.values.all?(&:empty?)
36
+ end
37
+
38
+ def worker_hooks?
39
+ any?(:before_worker) || any?(:after_worker)
40
+ end
41
+ end
42
+
43
+ # Evaluates a hook file with +before+ / +after+ (+before(:suite)+, etc.).
44
+ class FileContext
45
+ def initialize(path)
46
+ @path = path
47
+ @registry = Registry.new
48
+ end
49
+
50
+ attr_reader :registry
51
+
52
+ def load_file
53
+ instance_eval(File.read(@path), @path)
54
+ @registry
55
+ end
56
+
57
+ def before(sym, &block)
58
+ @registry.add(map_before(sym), block)
59
+ end
60
+
61
+ def after(sym, &block)
62
+ @registry.add(map_after(sym), block)
63
+ end
64
+
65
+ private
66
+
67
+ def map_before(sym)
68
+ case sym.to_sym
69
+ when :suite then :before_suite
70
+ when :all then :before_shard
71
+ when :each then :before_worker
72
+ else
73
+ raise ArgumentError, "hooks DSL: before(#{sym.inspect}) — use :suite, :all, or :each"
74
+ end
75
+ end
76
+
77
+ def map_after(sym)
78
+ case sym.to_sym
79
+ when :suite then :after_suite
80
+ when :all then :after_shard
81
+ when :each then :after_worker
82
+ else
83
+ raise ArgumentError, "hooks DSL: after(#{sym.inspect}) — use :suite, :all, or :each"
84
+ end
85
+ end
86
+ end
87
+
88
+ class << self
89
+ # @return [Registry, nil]
90
+ def load_registry(path)
91
+ return nil if path.nil? || path.to_s.strip.empty?
92
+
93
+ full = File.expand_path(path.to_s, Dir.pwd)
94
+ return nil unless File.file?(full)
95
+
96
+ mtime = File.mtime(full)
97
+ cache_mu.synchronize do
98
+ hit = registry_cache[full]
99
+ return hit[:registry] if hit && hit[:mtime] == mtime
100
+
101
+ reg = FileContext.new(full).load_file
102
+ registry_cache[full] = {mtime: mtime, registry: reg}
103
+ reg
104
+ end
105
+ rescue => e
106
+ Polyrun::Log.warn "polyrun hooks: failed to load #{full}: #{e.class}: #{e.message}"
107
+ nil
108
+ end
109
+
110
+ def clear_cache!
111
+ cache_mu.synchronize { registry_cache.clear }
112
+ end
113
+
114
+ private
115
+
116
+ # rubocop:disable ThreadSafety/ClassInstanceVariable -- single-threaded hook load; Monitor protects cache
117
+ def cache_mu
118
+ @cache_mu ||= Monitor.new
119
+ end
120
+
121
+ def registry_cache
122
+ @registry_cache ||= {}
123
+ end
124
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,27 @@
1
+ require_relative "../log"
2
+
3
+ module Polyrun
4
+ class Hooks
5
+ # Invoked by worker shell wrapper (+ruby -e+). Requires +POLYRUN_HOOKS_RUBY_FILE+.
6
+ module WorkerRunner
7
+ module_function
8
+
9
+ # @return [Integer] exit code (0 on success)
10
+ def run!(phase)
11
+ phase = phase.to_sym
12
+ path = ENV["POLYRUN_HOOKS_RUBY_FILE"]
13
+ return 0 if path.nil? || path.empty?
14
+
15
+ registry = Dsl.load_registry(path)
16
+ return 0 if registry.nil? || !registry.any?(phase)
17
+
18
+ env = ENV.to_h
19
+ registry.run(phase, env)
20
+ 0
21
+ rescue => e
22
+ Polyrun::Log.warn "polyrun hooks worker #{phase}: #{e.class}: #{e.message}"
23
+ 1
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,50 @@
1
+ require "shellwords"
2
+ require "rbconfig"
3
+
4
+ module Polyrun
5
+ class Hooks
6
+ # Builds +sh -c+ script for worker processes (shell + Ruby +before_worker+ / +after_worker+).
7
+ module WorkerShell
8
+ # @param cmd [Array<String>] argv before paths
9
+ # @param paths [Array<String>]
10
+ # @return [String] shell script body for +sh -c+ (worker process)
11
+ # rubocop:disable Metrics/AbcSize -- shell + ruby worker hook branches
12
+ def build_worker_shell_script(cmd, paths)
13
+ main = Shellwords.join(cmd + paths)
14
+ rb = RbConfig.ruby
15
+ bw_shell = commands_for(:before_worker)
16
+ aw_shell = commands_for(:after_worker)
17
+ bw_ruby = ruby_registry&.any?(:before_worker)
18
+ aw_ruby = ruby_registry&.any?(:after_worker)
19
+
20
+ lines = []
21
+ lines << "export POLYRUN_HOOK_PHASE=before_worker"
22
+ if bw_ruby || bw_shell.any?
23
+ lines << "set -e"
24
+ lines << worker_ruby_line(rb, :before_worker) if bw_ruby
25
+ bw_shell.each { |c| lines << c }
26
+ end
27
+ lines << "set +e"
28
+ lines << main
29
+ lines << "ec=$?"
30
+ lines << "export POLYRUN_HOOK_PHASE=after_worker"
31
+ if aw_ruby || aw_shell.any?
32
+ lines << "set +e"
33
+ aw_shell.each { |c| lines << "( #{c} ) || true" }
34
+ lines << worker_ruby_line(rb, :after_worker, wrap_allow_fail: true) if aw_ruby
35
+ end
36
+ lines << "exit $ec"
37
+ lines.join("\n")
38
+ end
39
+ # rubocop:enable Metrics/AbcSize
40
+
41
+ private
42
+
43
+ def worker_ruby_line(rb_exe, phase, wrap_allow_fail: false)
44
+ code = %(require "polyrun"; Polyrun::Hooks::WorkerRunner.run!(:#{phase}))
45
+ line = "#{rb_exe} -e #{Shellwords.escape(code)}"
46
+ wrap_allow_fail ? "( #{line} ) || true" : line
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,185 @@
1
+ require "shellwords"
2
+
3
+ require_relative "log"
4
+ require_relative "hooks/dsl"
5
+ require_relative "hooks/worker_runner"
6
+ require_relative "hooks/worker_shell"
7
+
8
+ module Polyrun
9
+ # Shell and Ruby DSL hooks around parallel orchestration, named like RSpec lifecycle callbacks:
10
+ # +before_suite+ / +after_suite+ (+before(:suite)+ / +after(:suite)+),
11
+ # +before_shard+ / +after_shard+ (parent process, per partition index),
12
+ # +before_worker+ / +after_worker+ (inside each worker process, around the test command).
13
+ #
14
+ # Configure under +hooks:+ in +polyrun.yml+: shell strings, and/or +ruby:+ path to a Ruby DSL file.
15
+ # Run manually: +polyrun hook run <phase>+ (see {CLI}).
16
+ #
17
+ # Orchestration respects +POLYRUN_HOOKS_DISABLE=1+ (+run_phase_if_enabled+); +polyrun hook run+ always runs {#run_phase}.
18
+ class Hooks
19
+ include WorkerShell
20
+
21
+ PHASES = %i[
22
+ before_suite after_suite
23
+ before_shard after_shard
24
+ before_worker after_worker
25
+ ].freeze
26
+
27
+ attr_reader :ruby_file
28
+
29
+ def self.disabled?
30
+ v = ENV["POLYRUN_HOOKS_DISABLE"].to_s.downcase
31
+ %w[1 true yes].include?(v)
32
+ end
33
+
34
+ # When +POLYRUN_SHARD_TOTAL+ is greater than 1 (+ci-shard-run+ matrix), suite hooks are skipped by default; set
35
+ # +POLYRUN_HOOKS_SUITE_PER_MATRIX_JOB=1+ to run +before_suite+ / +after_suite+ on every matrix job (legacy).
36
+ def self.suite_per_matrix_job?
37
+ v = ENV["POLYRUN_HOOKS_SUITE_PER_MATRIX_JOB"].to_s.downcase
38
+ %w[1 true yes].include?(v)
39
+ end
40
+
41
+ def self.from_config(cfg)
42
+ raw = cfg.respond_to?(:hooks) ? cfg.hooks : {}
43
+ new(raw.is_a?(Hash) ? raw : {})
44
+ end
45
+
46
+ # Maps CLI or YAML-style names (+before_suite+, +"before(:suite)"+) to a phase symbol or +nil+.
47
+ def self.parse_phase(str)
48
+ new({}).send(:canonical_key, str)
49
+ end
50
+
51
+ # @param raw [Hash] +hooks+ block from YAML
52
+ def initialize(raw)
53
+ @ruby_file = extract_ruby_file(raw)
54
+ h = {}
55
+ raw.each do |k, v|
56
+ next if %w[ruby ruby_file].include?(k.to_s)
57
+
58
+ ck = canonical_key(k)
59
+ next if ck.nil?
60
+
61
+ h[ck] = v
62
+ end
63
+ @raw = h.freeze
64
+ @ruby_registry_loaded = false
65
+ @ruby_registry = nil
66
+ end
67
+
68
+ def empty?
69
+ no_shell = PHASES.all? { |p| commands_for(p).empty? }
70
+ return false unless no_shell
71
+
72
+ return true if @ruby_file.nil? || @ruby_file.to_s.strip.empty?
73
+ return true unless File.file?(File.expand_path(@ruby_file, Dir.pwd))
74
+
75
+ reg = ruby_registry
76
+ reg.nil? || reg.empty?
77
+ end
78
+
79
+ def worker_hooks?
80
+ return true if commands_for(:before_worker).any? || commands_for(:after_worker).any?
81
+
82
+ !!ruby_registry&.worker_hooks?
83
+ end
84
+
85
+ # Merges +POLYRUN_HOOKS_RUBY_FILE+ when a DSL file is configured (for worker +ruby -e+).
86
+ def merge_worker_ruby_env(env)
87
+ return env unless @ruby_file
88
+ abs = File.expand_path(@ruby_file, Dir.pwd)
89
+ return env unless File.file?(abs)
90
+
91
+ env.merge("POLYRUN_HOOKS_RUBY_FILE" => abs)
92
+ end
93
+
94
+ # @param phase [Symbol]
95
+ # @return [Array<String>]
96
+ def commands_for(phase)
97
+ v = @raw[phase.to_sym]
98
+ case v
99
+ when nil then []
100
+ when Array then v.map(&:to_s).map(&:strip).reject(&:empty?)
101
+ else
102
+ s = v.to_s.strip
103
+ s.empty? ? [] : [s]
104
+ end
105
+ end
106
+
107
+ # Runs Ruby DSL blocks (if any), then shell commands for +phase+.
108
+ # @return [Integer] exit code (0 if no commands)
109
+ def run_phase(phase, env)
110
+ return 0 unless PHASES.include?(phase.to_sym)
111
+
112
+ merged = stringify_env_for_hook(env).merge(
113
+ "POLYRUN_HOOK_PHASE" => phase.to_s,
114
+ "POLYRUN_HOOK" => "1"
115
+ )
116
+
117
+ reg = ruby_registry
118
+ if reg&.any?(phase)
119
+ begin
120
+ reg.run(phase, merged)
121
+ rescue => e
122
+ Polyrun::Log.warn "polyrun hooks: #{phase} ruby hook failed: #{e.class}: #{e.message}"
123
+ return 1
124
+ end
125
+ end
126
+
127
+ commands_for(phase).each do |cmd|
128
+ ok = system(merged, "sh", "-c", cmd)
129
+ return $?.exitstatus unless ok
130
+ end
131
+ 0
132
+ end
133
+
134
+ # Like {#run_phase}, but no-ops when {disabled?} (+POLYRUN_HOOKS_DISABLE=1+). Used by run-shards / ci-shard orchestration.
135
+ def run_phase_if_enabled(phase, env)
136
+ return 0 if self.class.disabled?
137
+
138
+ run_phase(phase, env)
139
+ end
140
+
141
+ private
142
+
143
+ def extract_ruby_file(raw)
144
+ v = raw["ruby"] || raw[:ruby] || raw["ruby_file"] || raw[:ruby_file]
145
+ return nil if v.nil?
146
+
147
+ s = v.to_s.strip
148
+ s.empty? ? nil : s
149
+ end
150
+
151
+ def ruby_registry
152
+ return @ruby_registry if @ruby_registry_loaded
153
+
154
+ @ruby_registry_loaded = true
155
+ @ruby_registry = Dsl.load_registry(@ruby_file)
156
+ end
157
+
158
+ def stringify_env_for_hook(env)
159
+ h = {}
160
+ env.each { |k, v| h[k.to_s] = v }
161
+ h
162
+ end
163
+
164
+ # Accept RSpec-style quoted keys from YAML, e.g. +"before(:suite)"+.
165
+ def canonical_key(k)
166
+ s = k.to_s.strip
167
+ sym = if s.match?(/\Abefore\(\s*:suite\s*\)\z/i)
168
+ :before_suite
169
+ elsif s.match?(/\Aafter\(\s*:suite\s*\)\z/i)
170
+ :after_suite
171
+ elsif s.match?(/\Abefore\(\s*:all\s*\)\z/i)
172
+ :before_shard
173
+ elsif s.match?(/\Aafter\(\s*:all\s*\)\z/i)
174
+ :after_shard
175
+ elsif s.match?(/\Abefore\(\s*:each\s*\)\z/i)
176
+ :before_worker
177
+ elsif s.match?(/\Aafter\(\s*:each\s*\)\z/i)
178
+ :after_worker
179
+ else
180
+ s.downcase.tr("-", "_").to_sym
181
+ end
182
+ PHASES.include?(sym) ? sym : nil
183
+ end
184
+ end
185
+ end
@@ -3,8 +3,9 @@
3
3
  # bundle exec polyrun -c polyrun.yml ci-shard-run -- bundle exec rspec
4
4
  # (or ci-shard-rspec; or e.g. ci-shard-run -- bundle exec polyrun quick).
5
5
  # Equivalent to build-paths, plan --shard/--total, then run that command with this slice's paths.
6
- # A separate CI job downloads coverage/polyrun-fragment-*.json and runs merge-coverage.
7
- # Do not use parallel-rspec with multiple workers inside the same matrix row unless you intend nested parallelism.
6
+ # A separate CI job downloads coverage/polyrun-fragment-*.json (e.g. shard<S>-worker<W>.json per N×M process) and runs merge-coverage.
7
+ # For N×M (N matrix jobs × M processes per job): set shard_processes: M or POLYRUN_SHARD_PROCESSES,
8
+ # or pass --shard-processes M to ci-shard-run / ci-shard-rspec (local split is round-robin).
8
9
  # See: docs/SETUP_PROFILE.md
9
10
 
10
11
  partition:
@@ -1,3 +1,3 @@
1
1
  module Polyrun
2
- VERSION = "1.2.0"
2
+ VERSION = "1.4.0"
3
3
  end
data/lib/polyrun.rb CHANGED
@@ -25,6 +25,7 @@ require_relative "polyrun/database/shard"
25
25
  require_relative "polyrun/database/url_builder"
26
26
  require_relative "polyrun/database/provision"
27
27
  require_relative "polyrun/database/clone_shards"
28
+ require_relative "polyrun/hooks"
28
29
  require_relative "polyrun/env/ci"
29
30
  require_relative "polyrun/timing/merge"
30
31
  require_relative "polyrun/timing/summary"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: polyrun
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -166,7 +166,9 @@ files:
166
166
  - docs/SETUP_PROFILE.md
167
167
  - lib/polyrun.rb
168
168
  - lib/polyrun/cli.rb
169
+ - lib/polyrun/cli/ci_shard_hooks.rb
169
170
  - lib/polyrun/cli/ci_shard_run_command.rb
171
+ - lib/polyrun/cli/ci_shard_run_parse.rb
170
172
  - lib/polyrun/cli/config_command.rb
171
173
  - lib/polyrun/cli/coverage_commands.rb
172
174
  - lib/polyrun/cli/coverage_merge_io.rb
@@ -175,6 +177,7 @@ files:
175
177
  - lib/polyrun/cli/env_commands.rb
176
178
  - lib/polyrun/cli/help.rb
177
179
  - lib/polyrun/cli/helpers.rb
180
+ - lib/polyrun/cli/hooks_command.rb
178
181
  - lib/polyrun/cli/init_command.rb
179
182
  - lib/polyrun/cli/plan_command.rb
180
183
  - lib/polyrun/cli/prepare_command.rb
@@ -183,6 +186,7 @@ files:
183
186
  - lib/polyrun/cli/quick_command.rb
184
187
  - lib/polyrun/cli/report_commands.rb
185
188
  - lib/polyrun/cli/run_shards_command.rb
189
+ - lib/polyrun/cli/run_shards_parallel_children.rb
186
190
  - lib/polyrun/cli/run_shards_plan_boot_phases.rb
187
191
  - lib/polyrun/cli/run_shards_plan_options.rb
188
192
  - lib/polyrun/cli/run_shards_planning.rb
@@ -196,6 +200,7 @@ files:
196
200
  - lib/polyrun/coverage/cobertura_zero_lines.rb
197
201
  - lib/polyrun/coverage/collector.rb
198
202
  - lib/polyrun/coverage/collector_finish.rb
203
+ - lib/polyrun/coverage/collector_fragment_meta.rb
199
204
  - lib/polyrun/coverage/filter.rb
200
205
  - lib/polyrun/coverage/formatter.rb
201
206
  - lib/polyrun/coverage/merge.rb
@@ -223,6 +228,10 @@ files:
223
228
  - lib/polyrun/database/url_builder/template_prepare.rb
224
229
  - lib/polyrun/debug.rb
225
230
  - lib/polyrun/env/ci.rb
231
+ - lib/polyrun/hooks.rb
232
+ - lib/polyrun/hooks/dsl.rb
233
+ - lib/polyrun/hooks/worker_runner.rb
234
+ - lib/polyrun/hooks/worker_shell.rb
226
235
  - lib/polyrun/log.rb
227
236
  - lib/polyrun/minitest.rb
228
237
  - lib/polyrun/partition/constraints.rb