polyrun 1.3.0 → 1.4.1

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,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
@@ -0,0 +1,135 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ module Polyrun
5
+ module Reporting
6
+ # Merge per-worker / per-shard failure fragments (JSONL or RSpec JSON) into one report.
7
+ # Fragment basenames align with {Coverage::CollectorFragmentMeta} (worker index and optional matrix shard).
8
+ module FailureMerge
9
+ DEFAULT_FRAGMENT_DIR = "tmp/polyrun_failures".freeze
10
+ FRAGMENT_GLOB = "polyrun-failure-fragment-*.jsonl".freeze
11
+
12
+ module_function
13
+
14
+ def default_fragment_glob(dir = nil)
15
+ root = File.expand_path(dir || DEFAULT_FRAGMENT_DIR, Dir.pwd)
16
+ File.join(root, FRAGMENT_GLOB)
17
+ end
18
+
19
+ def merge_fragment_paths(quiet: false)
20
+ p = default_fragment_glob
21
+ Dir.glob(p).sort.tap do |paths|
22
+ Polyrun::Log.warn "merge-failures: no files matched #{p}" if paths.empty? && !quiet
23
+ end
24
+ end
25
+
26
+ # @param paths [Array<String>] fragment paths (.jsonl and/or RSpec --format json outputs)
27
+ # @param format [String] "jsonl" or "json"
28
+ # @param output [String] destination path
29
+ # @return [Integer] count of failure rows merged
30
+ def merge_files!(paths, output:, format: "jsonl")
31
+ fmt = format.to_s.downcase
32
+ rows = collect_rows(paths)
33
+ out_abs = File.expand_path(output)
34
+ FileUtils.mkdir_p(File.dirname(out_abs))
35
+ case fmt
36
+ when "json"
37
+ doc = {
38
+ "meta" => {
39
+ "polyrun_merge" => true,
40
+ "inputs" => paths.map { |p| File.expand_path(p) },
41
+ "failure_count" => rows.size
42
+ },
43
+ "failures" => rows
44
+ }
45
+ File.write(out_abs, JSON.generate(doc))
46
+ when "jsonl"
47
+ File.write(out_abs, rows.map { |h| JSON.generate(h) }.join("\n") + (rows.empty? ? "" : "\n"))
48
+ else
49
+ raise Polyrun::Error, "merge-failures: unknown format #{fmt.inspect} (use jsonl or json)"
50
+ end
51
+ rows.size
52
+ end
53
+
54
+ def collect_rows(paths)
55
+ rows = []
56
+ paths.each do |p|
57
+ rows.concat(rows_from_path(p))
58
+ end
59
+ rows
60
+ end
61
+
62
+ def rows_from_path(path)
63
+ ext = File.extname(path).downcase
64
+ if ext == ".jsonl"
65
+ return rows_from_jsonl_file(path)
66
+ end
67
+
68
+ text = File.read(path)
69
+ data =
70
+ begin
71
+ JSON.parse(text)
72
+ rescue JSON::ParserError => e
73
+ raise Polyrun::Error, "merge-failures: #{path} is not valid JSON: #{e.message}"
74
+ end
75
+ if data.is_a?(Hash) && data["examples"].is_a?(Array)
76
+ return failures_from_rspec_examples(data["examples"])
77
+ end
78
+
79
+ hint =
80
+ if data.is_a?(Hash)
81
+ keys = data.keys
82
+ "got JSON object with keys: #{keys.take(12).join(", ")}" + ((keys.size > 12) ? ", …" : "")
83
+ else
84
+ "got #{data.class}"
85
+ end
86
+ raise Polyrun::Error,
87
+ "merge-failures: #{path} is not RSpec JSON (expected top-level \"examples\" array). #{hint}. " \
88
+ "Use RSpec --format json, or polyrun failure JSONL (.jsonl fragments)."
89
+ end
90
+
91
+ def rows_from_jsonl_file(path)
92
+ acc = []
93
+ File.readlines(path, chomp: true).each_with_index do |line, idx|
94
+ line = line.strip
95
+ next if line.empty?
96
+
97
+ acc << parse_jsonl_line!(path, idx + 1, line)
98
+ end
99
+ acc
100
+ end
101
+
102
+ def parse_jsonl_line!(path, line_number, line)
103
+ JSON.parse(line)
104
+ rescue JSON::ParserError => e
105
+ raise Polyrun::Error,
106
+ "merge-failures: invalid JSONL at #{path} line #{line_number}: #{e.message}"
107
+ end
108
+
109
+ def failures_from_rspec_examples(examples)
110
+ examples.each_with_object([]) do |ex, acc|
111
+ next unless ex.is_a?(Hash)
112
+ next unless ex["status"].to_s == "failed"
113
+
114
+ acc << rspec_example_to_row(ex)
115
+ end
116
+ end
117
+
118
+ def rspec_example_to_row(ex)
119
+ ex = ex.transform_keys(&:to_s)
120
+ exc = ex["exception"] || {}
121
+ exc = exc.transform_keys(&:to_s) if exc.is_a?(Hash)
122
+ {
123
+ "id" => ex["id"],
124
+ "full_description" => ex["full_description"],
125
+ "location" => (ex["file_path"] && ex["line_number"]) ? "#{ex["file_path"]}:#{ex["line_number"]}" : ex["full_description"],
126
+ "file_path" => ex["file_path"],
127
+ "line_number" => ex["line_number"],
128
+ "message" => exc["message"] || ex["full_description"],
129
+ "exception_class" => exc["class"],
130
+ "source" => "rspec_json"
131
+ }.compact
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,95 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ module Polyrun
5
+ module Reporting
6
+ # RSpec formatter: appends one JSON object per failed example to the shard fragment file.
7
+ # Enable via +Polyrun::RSpec.install_failure_fragments!+ and +POLYRUN_FAILURE_FRAGMENTS=1+ (set by run-shards --merge-failures).
8
+ #
9
+ # Output: +tmp/polyrun_failures/polyrun-failure-fragment-<workerN|shardM-workerN>.jsonl+
10
+ # (same basename rules as {Coverage::CollectorFragmentMeta}.)
11
+ class RspecFailureFragmentFormatter
12
+ ::RSpec::Core::Formatters.register self, :start, :example_failed
13
+
14
+ attr_reader :output
15
+
16
+ def initialize(output)
17
+ @output = output
18
+ @path = fragment_path
19
+ end
20
+
21
+ def start(_notification)
22
+ FileUtils.mkdir_p(File.dirname(@path))
23
+ File.write(@path, "")
24
+ end
25
+
26
+ def example_failed(notification)
27
+ ex = notification.example
28
+ exc = notification.exception
29
+ row = {
30
+ "id" => ex.id,
31
+ "full_description" => ex.full_description,
32
+ "location" => ex.location,
33
+ "file_path" => ex.file_path,
34
+ "line_number" => example_line_number(ex),
35
+ "message" => exc.message.to_s,
36
+ "exception_class" => exc.class.name,
37
+ "polyrun_shard_index" => ENV["POLYRUN_SHARD_INDEX"],
38
+ "polyrun_shard_total" => ENV["POLYRUN_SHARD_TOTAL"],
39
+ "polyrun_shard_matrix_index" => matrix_env_or_nil("POLYRUN_SHARD_MATRIX_INDEX"),
40
+ "polyrun_shard_matrix_total" => matrix_env_or_nil("POLYRUN_SHARD_MATRIX_TOTAL"),
41
+ "rspec_seed" => seed_if_known,
42
+ "rspec_order" => order_if_known
43
+ }
44
+ trim_backtrace!(row, exc)
45
+ File.open(@path, "a") { |f| f.puts(JSON.generate(row.compact)) }
46
+ end
47
+
48
+ private
49
+
50
+ def example_line_number(ex)
51
+ return ex.line_number if ex.respond_to?(:line_number)
52
+
53
+ ex.metadata[:line_number]
54
+ end
55
+
56
+ def fragment_path
57
+ dir = ENV.fetch("POLYRUN_FAILURE_FRAGMENT_DIR", FailureMerge::DEFAULT_FRAGMENT_DIR)
58
+ base = Polyrun::Coverage::CollectorFragmentMeta.fragment_default_basename_from_env
59
+ File.expand_path(File.join(dir, "polyrun-failure-fragment-#{base}.jsonl"))
60
+ end
61
+
62
+ def matrix_env_or_nil(name)
63
+ v = ENV[name]
64
+ return nil if v.nil? || v.to_s.strip.empty?
65
+
66
+ v
67
+ end
68
+
69
+ def seed_if_known
70
+ return unless defined?(::RSpec) && ::RSpec.respond_to?(:configuration)
71
+
72
+ ::RSpec.configuration.seed
73
+ rescue
74
+ nil
75
+ end
76
+
77
+ def order_if_known
78
+ return unless defined?(::RSpec) && ::RSpec.respond_to?(:configuration)
79
+
80
+ ::RSpec.configuration.order.to_s
81
+ rescue
82
+ nil
83
+ end
84
+
85
+ MAX_BT = 20
86
+
87
+ def trim_backtrace!(row, exc)
88
+ bt = exc.backtrace
89
+ return unless bt.is_a?(Array) && bt.any?
90
+
91
+ row["backtrace"] = bt.first(MAX_BT)
92
+ end
93
+ end
94
+ end
95
+ end
data/lib/polyrun/rspec.rb CHANGED
@@ -30,5 +30,19 @@ module Polyrun
30
30
  config.add_formatter fmt
31
31
  end
32
32
  end
33
+
34
+ # Per-worker failure JSONL fragments for +polyrun run-shards --merge-failures+ (parity with coverage shards).
35
+ # Requires +POLYRUN_FAILURE_FRAGMENTS=1+ (set by the parent when --merge-failures is used) unless +only_if+ overrides.
36
+ # Writes +tmp/polyrun_failures/polyrun-failure-fragment-*.jsonl+ (override dir with +POLYRUN_FAILURE_FRAGMENT_DIR+).
37
+ def install_failure_fragments!(only_if: nil)
38
+ pred = only_if || -> { %w[1 true yes].include?(ENV["POLYRUN_FAILURE_FRAGMENTS"].to_s.downcase) }
39
+ return unless pred.call
40
+
41
+ require "rspec/core"
42
+ require_relative "reporting/rspec_failure_fragment_formatter"
43
+ ::RSpec.configure do |config|
44
+ config.add_formatter Polyrun::Reporting::RspecFailureFragmentFormatter
45
+ end
46
+ end
33
47
  end
34
48
  end
@@ -1,3 +1,3 @@
1
1
  module Polyrun
2
- VERSION = "1.3.0"
2
+ VERSION = "1.4.1"
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.3.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -166,6 +166,7 @@ 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
170
171
  - lib/polyrun/cli/ci_shard_run_parse.rb
171
172
  - lib/polyrun/cli/config_command.rb
@@ -174,8 +175,10 @@ files:
174
175
  - lib/polyrun/cli/database_commands.rb
175
176
  - lib/polyrun/cli/default_run.rb
176
177
  - lib/polyrun/cli/env_commands.rb
178
+ - lib/polyrun/cli/failure_commands.rb
177
179
  - lib/polyrun/cli/help.rb
178
180
  - lib/polyrun/cli/helpers.rb
181
+ - lib/polyrun/cli/hooks_command.rb
179
182
  - lib/polyrun/cli/init_command.rb
180
183
  - lib/polyrun/cli/plan_command.rb
181
184
  - lib/polyrun/cli/prepare_command.rb
@@ -184,6 +187,7 @@ files:
184
187
  - lib/polyrun/cli/quick_command.rb
185
188
  - lib/polyrun/cli/report_commands.rb
186
189
  - lib/polyrun/cli/run_shards_command.rb
190
+ - lib/polyrun/cli/run_shards_parallel_children.rb
187
191
  - lib/polyrun/cli/run_shards_plan_boot_phases.rb
188
192
  - lib/polyrun/cli/run_shards_plan_options.rb
189
193
  - lib/polyrun/cli/run_shards_planning.rb
@@ -225,6 +229,10 @@ files:
225
229
  - lib/polyrun/database/url_builder/template_prepare.rb
226
230
  - lib/polyrun/debug.rb
227
231
  - lib/polyrun/env/ci.rb
232
+ - lib/polyrun/hooks.rb
233
+ - lib/polyrun/hooks/dsl.rb
234
+ - lib/polyrun/hooks/worker_runner.rb
235
+ - lib/polyrun/hooks/worker_shell.rb
228
236
  - lib/polyrun/log.rb
229
237
  - lib/polyrun/minitest.rb
230
238
  - lib/polyrun/partition/constraints.rb
@@ -251,8 +259,10 @@ files:
251
259
  - lib/polyrun/quick/reporter.rb
252
260
  - lib/polyrun/quick/runner.rb
253
261
  - lib/polyrun/railtie.rb
262
+ - lib/polyrun/reporting/failure_merge.rb
254
263
  - lib/polyrun/reporting/junit.rb
255
264
  - lib/polyrun/reporting/junit_emit.rb
265
+ - lib/polyrun/reporting/rspec_failure_fragment_formatter.rb
256
266
  - lib/polyrun/reporting/rspec_junit.rb
257
267
  - lib/polyrun/rspec.rb
258
268
  - lib/polyrun/templates/POLYRUN.md