polyrun 1.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/lib/polyrun/cli/failure_commands.rb +92 -0
- data/lib/polyrun/cli/help.rb +3 -1
- data/lib/polyrun/cli/run_shards_command.rb +4 -1
- data/lib/polyrun/cli/run_shards_parallel_children.rb +8 -1
- data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +37 -2
- data/lib/polyrun/cli/run_shards_plan_options.rb +10 -2
- data/lib/polyrun/cli/run_shards_run.rb +22 -3
- data/lib/polyrun/cli.rb +3 -1
- data/lib/polyrun/config.rb +5 -0
- data/lib/polyrun/reporting/failure_merge.rb +135 -0
- data/lib/polyrun/reporting/rspec_failure_fragment_formatter.rb +95 -0
- data/lib/polyrun/rspec.rb +14 -0
- data/lib/polyrun/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7666af9186562083f29dc56e6c867e48b877acdff6ad28ff8c351e8d3c308582
|
|
4
|
+
data.tar.gz: 503f5435deb22112044f7841a82728e6782a770eb656859419e8412d623dcff0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d0d5d248f1e072c446049bafff111db6e29d784a4a0992528214a6e29cca7b156b67144e6b9c22fc71fa9143f110ac443cda2953219aa816c5562d3247b5e02b
|
|
7
|
+
data.tar.gz: d0be776ce5d4a7a5acacfe237a4d07a47c078562aee6a054fb5b63a31a78e199bc2b5ee276b0a8e77a0f0cacd3e4f03cb72d8fd5037baf782d0efb8a1cdf1b04
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 1.4.1 (2026-04-16)
|
|
4
|
+
|
|
5
|
+
- Add `polyrun merge-failures` and `run-shards --merge-failures` / `--merge-failures-output` / `--merge-failures-format`; merge per-worker JSONL under `tmp/polyrun_failures/polyrun-failure-fragment-*.jsonl` (or RSpec JSON via `-i`). Run merge after all workers exit, including when a shard failed (`--merge-coverage` still runs only after all shards succeed).
|
|
6
|
+
- Add `Polyrun::Reporting::FailureMerge`, `Polyrun::RSpec.install_failure_fragments!`, and `Polyrun::Reporting::RspecFailureFragmentFormatter`; parent sets `POLYRUN_FAILURE_FRAGMENTS=1` on workers when merge-failures is enabled.
|
|
7
|
+
- Add optional `reporting:` in `polyrun.yml` and `Polyrun::Config#reporting` for merge-failures toggles and paths; honor `POLYRUN_MERGE_FAILURES`, `POLYRUN_MERGED_FAILURES_OUT`, `POLYRUN_MERGED_FAILURES_FORMAT`; set `POLYRUN_MERGED_FAILURES_PATH` for `after_suite` when merge wrote a file.
|
|
8
|
+
- On bad JSONL or non-RSpec JSON, raise `Polyrun::Error` with path and line where applicable; `merge-failures` exits 1 without a full stack trace; a failed merge after successful workers forces `run-shards` exit 1.
|
|
9
|
+
- Fix `run_shards_plan_ready_log` to take `cfg` so debug logging for merge-failures does not raise `NameError`.
|
|
10
|
+
|
|
3
11
|
## 1.4.0 (2026-04-16)
|
|
4
12
|
|
|
5
13
|
- Add `hooks:` in `polyrun.yml` — shell commands for `before_suite` / `after_suite`, `before_shard` / `after_shard`, `before_worker` / `after_worker` (RSpec-style YAML keys `before(:suite)`, `before(:all)`, `before(:each)` accepted). Wire hooks into `run-shards`, `parallel-rspec`, and `ci-shard-*`.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
require_relative "../reporting/failure_merge"
|
|
6
|
+
|
|
7
|
+
module Polyrun
|
|
8
|
+
class CLI
|
|
9
|
+
module FailureCommands
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def cmd_merge_failures(argv, _config_path)
|
|
13
|
+
inputs, output, format = merge_failures_parse_argv(argv)
|
|
14
|
+
if inputs.empty?
|
|
15
|
+
Polyrun::Log.warn "merge-failures: need at least one existing -i FILE (after glob expansion)"
|
|
16
|
+
return 2
|
|
17
|
+
end
|
|
18
|
+
Polyrun::Log.warn "merge-failures: merging #{inputs.size} fragment(s)" if @verbose
|
|
19
|
+
n = merge_failures_merge_files_or_warn!(inputs, output: output, format: format)
|
|
20
|
+
return 1 if n.nil?
|
|
21
|
+
|
|
22
|
+
Polyrun::Log.puts File.expand_path(output)
|
|
23
|
+
Polyrun::Log.warn "merge-failures: #{n} failure row(s)" if @verbose
|
|
24
|
+
0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def merge_failures_merge_files_or_warn!(inputs, output:, format:)
|
|
28
|
+
Polyrun::Reporting::FailureMerge.merge_files!(inputs, output: output, format: format)
|
|
29
|
+
rescue Polyrun::Error => e
|
|
30
|
+
Polyrun::Log.warn e.message.to_s
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def merge_failures_parse_argv(argv)
|
|
35
|
+
inputs = []
|
|
36
|
+
output = File.join("tmp", "polyrun_failures", "merged.jsonl")
|
|
37
|
+
format = "jsonl"
|
|
38
|
+
OptionParser.new do |opts|
|
|
39
|
+
opts.banner = "usage: polyrun merge-failures -i FILE [-i FILE] [-o PATH] [--format jsonl|json]"
|
|
40
|
+
opts.on("-i", "--input FILE", "JSONL fragment or RSpec JSON (repeatable; globs ok)") do |f|
|
|
41
|
+
expand_merge_input_pattern(f).each { |x| inputs << x }
|
|
42
|
+
end
|
|
43
|
+
opts.on("-o", "--output PATH", String) { |v| output = v }
|
|
44
|
+
opts.on("--format VAL", "jsonl (default) or json") { |v| format = v }
|
|
45
|
+
end.parse!(argv)
|
|
46
|
+
inputs.uniq!
|
|
47
|
+
inputs.select! { |p| File.file?(p) }
|
|
48
|
+
[inputs, output, format]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# After run-shards workers exit: merge polyrun failure fragments when requested.
|
|
52
|
+
# Runs even when shards failed (unlike --merge-coverage).
|
|
53
|
+
# @return [String, nil] absolute path to merged file, or nil when skipped / nothing written
|
|
54
|
+
def merge_failures_after_shards(ctx)
|
|
55
|
+
return nil unless ctx[:merge_failures]
|
|
56
|
+
|
|
57
|
+
pattern = Polyrun::Reporting::FailureMerge.default_fragment_glob
|
|
58
|
+
files = Dir.glob(pattern).sort
|
|
59
|
+
if files.empty?
|
|
60
|
+
Polyrun::Log.warn "polyrun run-shards: --merge-failures: no #{Polyrun::Reporting::FailureMerge::FRAGMENT_GLOB} under fragment dir (enable Polyrun::RSpec.install_failure_fragments! in spec_helper?)"
|
|
61
|
+
return nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
fmt = merge_failures_resolved_format(ctx)
|
|
65
|
+
out = merge_failures_resolved_output_path(ctx, fmt)
|
|
66
|
+
Polyrun::Log.warn "polyrun run-shards: merging #{files.size} failure fragment(s) → #{out} (#{fmt})"
|
|
67
|
+
Polyrun::Debug.log_kv(merge_failures: "start", output: out, inputs: files, format: fmt)
|
|
68
|
+
n = Polyrun::Reporting::FailureMerge.merge_files!(files, output: out, format: fmt)
|
|
69
|
+
Polyrun::Debug.log_kv(merge_failures: "done", rows: n, output: File.expand_path(out))
|
|
70
|
+
File.expand_path(out)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def merge_failures_resolved_format(ctx)
|
|
74
|
+
f = ctx[:merge_failures_format].to_s.strip.downcase
|
|
75
|
+
return "jsonl" if f.empty?
|
|
76
|
+
return "jsonl" if f == "jsonl"
|
|
77
|
+
return "json" if f == "json"
|
|
78
|
+
|
|
79
|
+
Polyrun::Log.warn "polyrun run-shards: unknown merge_failures_format=#{ctx[:merge_failures_format].inspect}; using jsonl"
|
|
80
|
+
"jsonl"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def merge_failures_resolved_output_path(ctx, fmt)
|
|
84
|
+
raw = ctx[:merge_failures_output]
|
|
85
|
+
return File.expand_path(raw) if raw && !raw.to_s.strip.empty?
|
|
86
|
+
|
|
87
|
+
ext = (fmt == "json") ? "json" : "jsonl"
|
|
88
|
+
File.expand_path(File.join("tmp", "polyrun_failures", "merged.#{ext}"))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/polyrun/cli/help.rb
CHANGED
|
@@ -21,6 +21,7 @@ module Polyrun
|
|
|
21
21
|
Skip start auto-prepare / auto DB provision: POLYRUN_START_SKIP_PREPARE=1, POLYRUN_START_SKIP_DATABASES=1
|
|
22
22
|
Skip writing paths_file from partition.paths_build: POLYRUN_SKIP_PATHS_BUILD=1
|
|
23
23
|
Warn if merge-coverage wall time exceeds N seconds (default 10): POLYRUN_MERGE_SLOW_WARN_SECONDS (0 disables)
|
|
24
|
+
Failure fragments (run-shards --merge-failures): POLYRUN_MERGE_FAILURES=1; parent sets POLYRUN_FAILURE_FRAGMENTS=1 in workers; POLYRUN_FAILURE_FRAGMENT_DIR, POLYRUN_MERGED_FAILURES_OUT, POLYRUN_MERGED_FAILURES_FORMAT; after_suite sets POLYRUN_MERGED_FAILURES_PATH when merge ran
|
|
24
25
|
Parallel RSpec workers: POLYRUN_WORKERS default 5, max 10 (run-shards / parallel-rspec / start); distinct from POLYRUN_SHARD_PROCESSES / ci-shard --shard-processes (local processes per CI matrix job)
|
|
25
26
|
Partition timing granularity (default file): POLYRUN_TIMING_GRANULARITY=file|example (experimental per-example; see partition.timing_granularity)
|
|
26
27
|
|
|
@@ -29,7 +30,8 @@ module Polyrun
|
|
|
29
30
|
plan emit partition manifest JSON
|
|
30
31
|
prepare run prepare recipe: default | assets (optional prepare.command overrides bin/rails assets:precompile) | shell (prepare.command required)
|
|
31
32
|
merge-coverage merge SimpleCov JSON fragments (json/lcov/cobertura/console)
|
|
32
|
-
|
|
33
|
+
merge-failures merge per-shard failure JSONL fragments or RSpec JSON files (jsonl/json)
|
|
34
|
+
run-shards fan out N parallel OS processes (POLYRUN_SHARD_*; not Ruby threads); optional --merge-coverage / --merge-failures
|
|
33
35
|
parallel-rspec run-shards + merge-coverage (defaults to: bundle exec rspec after --)
|
|
34
36
|
start parallel-rspec; auto-runs prepare (shell/assets) and db:setup-* when polyrun.yml configures them; legacy script/build_spec_paths.rb if paths_build absent
|
|
35
37
|
ci-shard-run CI matrix: build-paths + plan for POLYRUN_SHARD_INDEX / POLYRUN_SHARD_TOTAL (or config), then run your command with that shard's paths after --; optional --shard-processes M or --workers M (POLYRUN_SHARD_PROCESSES; not POLYRUN_WORKERS) for N×M jobs × processes on this host
|
|
@@ -2,12 +2,14 @@ require "optparse"
|
|
|
2
2
|
require "rbconfig"
|
|
3
3
|
|
|
4
4
|
require_relative "start_bootstrap"
|
|
5
|
+
require_relative "failure_commands"
|
|
5
6
|
require_relative "run_shards_run"
|
|
6
7
|
|
|
7
8
|
module Polyrun
|
|
8
9
|
class CLI
|
|
9
10
|
module RunShardsCommand
|
|
10
11
|
include StartBootstrap
|
|
12
|
+
include FailureCommands
|
|
11
13
|
include RunShardsRun
|
|
12
14
|
|
|
13
15
|
private
|
|
@@ -114,10 +116,11 @@ module Polyrun
|
|
|
114
116
|
# ENV for a worker process: POLYRUN_SHARD_* plus per-shard database URLs from polyrun.yml or DATABASE_URL.
|
|
115
117
|
# When +matrix_total+ > 1 with multiple local workers, sets +POLYRUN_SHARD_MATRIX_INDEX+ / +POLYRUN_SHARD_MATRIX_TOTAL+
|
|
116
118
|
# so {Coverage::Collector} can name fragments uniquely across CI matrix jobs (NxM sharding).
|
|
117
|
-
def shard_child_env(cfg:, workers:, shard:, matrix_index: nil, matrix_total: nil)
|
|
119
|
+
def shard_child_env(cfg:, workers:, shard:, matrix_index: nil, matrix_total: nil, failure_fragments: false)
|
|
118
120
|
child_env = ENV.to_h.merge(
|
|
119
121
|
Polyrun::Database::Shard.env_map(shard_index: shard, shard_total: workers)
|
|
120
122
|
)
|
|
123
|
+
child_env["POLYRUN_FAILURE_FRAGMENTS"] = "1" if failure_fragments
|
|
121
124
|
mt = matrix_total.nil? ? 0 : Integer(matrix_total)
|
|
122
125
|
if mt > 1
|
|
123
126
|
if matrix_index.nil?
|
|
@@ -34,7 +34,14 @@ module Polyrun
|
|
|
34
34
|
return [pids, code]
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
child_env = shard_child_env(
|
|
37
|
+
child_env = shard_child_env(
|
|
38
|
+
cfg: cfg,
|
|
39
|
+
workers: workers,
|
|
40
|
+
shard: shard,
|
|
41
|
+
matrix_index: mx,
|
|
42
|
+
matrix_total: mt,
|
|
43
|
+
failure_fragments: ctx[:merge_failures]
|
|
44
|
+
)
|
|
38
45
|
child_env = child_env.merge("POLYRUN_HOOK_ORCHESTRATOR" => "0")
|
|
39
46
|
child_env = hook_cfg.merge_worker_ruby_env(child_env)
|
|
40
47
|
|
|
@@ -31,7 +31,7 @@ module Polyrun
|
|
|
31
31
|
costs, strategy, err = run_shards_resolve_costs(o[:timing_path], o[:strategy], o[:timing_granularity])
|
|
32
32
|
return [err, nil] if err
|
|
33
33
|
|
|
34
|
-
run_shards_plan_ready_log(o, strategy, cmd, paths_source, items.size)
|
|
34
|
+
run_shards_plan_ready_log(o, cfg, strategy, cmd, paths_source, items.size)
|
|
35
35
|
|
|
36
36
|
constraints = load_partition_constraints(pc, o[:constraints_path])
|
|
37
37
|
plan = run_shards_make_plan(items, o[:workers], strategy, o[:seed], costs, constraints, o[:timing_granularity])
|
|
@@ -59,12 +59,13 @@ module Polyrun
|
|
|
59
59
|
[run_t0, head, cmd, cfg, cfg.partition]
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
def run_shards_plan_ready_log(o, strategy, cmd, paths_source, item_count)
|
|
62
|
+
def run_shards_plan_ready_log(o, cfg, strategy, cmd, paths_source, item_count)
|
|
63
63
|
Polyrun::Debug.log_kv(
|
|
64
64
|
run_shards: "ready to partition",
|
|
65
65
|
workers: o[:workers],
|
|
66
66
|
strategy: strategy,
|
|
67
67
|
merge_coverage: o[:merge_coverage],
|
|
68
|
+
merge_failures: run_shards_merge_failures_flag(o, cfg),
|
|
68
69
|
command: cmd,
|
|
69
70
|
timing_path: o[:timing_path],
|
|
70
71
|
paths_source: paths_source,
|
|
@@ -72,6 +73,37 @@ module Polyrun
|
|
|
72
73
|
)
|
|
73
74
|
end
|
|
74
75
|
|
|
76
|
+
def run_shards_merge_failures_flag(o, cfg)
|
|
77
|
+
return true if o[:merge_failures]
|
|
78
|
+
return true if %w[1 true yes].include?(ENV["POLYRUN_MERGE_FAILURES"].to_s.downcase)
|
|
79
|
+
|
|
80
|
+
rep = cfg.reporting
|
|
81
|
+
v = rep["merge_failures"] || rep[:merge_failures]
|
|
82
|
+
v == true || %w[1 true yes].include?(v.to_s.downcase)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def run_shards_merge_failures_output_opt(o, cfg)
|
|
86
|
+
x = o[:merge_failures_output]
|
|
87
|
+
return x if x && !x.to_s.strip.empty?
|
|
88
|
+
|
|
89
|
+
x = ENV["POLYRUN_MERGED_FAILURES_OUT"]
|
|
90
|
+
return x if x && !x.to_s.strip.empty?
|
|
91
|
+
|
|
92
|
+
rep = cfg.reporting
|
|
93
|
+
rep["merge_failures_output"] || rep[:merge_failures_output]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def run_shards_merge_failures_format_opt(o, cfg)
|
|
97
|
+
x = o[:merge_failures_format]
|
|
98
|
+
return x if x && !x.to_s.strip.empty?
|
|
99
|
+
|
|
100
|
+
x = ENV["POLYRUN_MERGED_FAILURES_FORMAT"]
|
|
101
|
+
return x if x && !x.to_s.strip.empty?
|
|
102
|
+
|
|
103
|
+
rep = cfg.reporting
|
|
104
|
+
rep["merge_failures_format"] || rep[:merge_failures_format]
|
|
105
|
+
end
|
|
106
|
+
|
|
75
107
|
def run_shards_plan_context_hash(o, cmd, cfg, plan, run_t0, parallel, config_path)
|
|
76
108
|
{
|
|
77
109
|
workers: o[:workers],
|
|
@@ -83,6 +115,9 @@ module Polyrun
|
|
|
83
115
|
merge_coverage: o[:merge_coverage],
|
|
84
116
|
merge_output: o[:merge_output],
|
|
85
117
|
merge_format: o[:merge_format],
|
|
118
|
+
merge_failures: run_shards_merge_failures_flag(o, cfg),
|
|
119
|
+
merge_failures_output: run_shards_merge_failures_output_opt(o, cfg),
|
|
120
|
+
merge_failures_format: run_shards_merge_failures_format_opt(o, cfg),
|
|
86
121
|
config_path: config_path
|
|
87
122
|
}
|
|
88
123
|
end
|
|
@@ -24,7 +24,10 @@ module Polyrun
|
|
|
24
24
|
timing_granularity: nil,
|
|
25
25
|
merge_coverage: false,
|
|
26
26
|
merge_output: nil,
|
|
27
|
-
merge_format: nil
|
|
27
|
+
merge_format: nil,
|
|
28
|
+
merge_failures: false,
|
|
29
|
+
merge_failures_output: nil,
|
|
30
|
+
merge_failures_format: nil
|
|
28
31
|
}
|
|
29
32
|
end
|
|
30
33
|
|
|
@@ -34,8 +37,9 @@ module Polyrun
|
|
|
34
37
|
end.parse!(head)
|
|
35
38
|
end
|
|
36
39
|
|
|
40
|
+
# rubocop:disable Metrics/AbcSize -- one argv block for run-shards
|
|
37
41
|
def run_shards_plan_options_register!(opts, st)
|
|
38
|
-
opts.banner = "usage: polyrun run-shards [--workers N] [--strategy NAME] [--paths-file P] [--timing P] [--timing-granularity VAL] [--constraints P] [--seed S] [--merge-coverage] [--merge-output P] [--merge-format LIST] [--] <command> [args...]"
|
|
42
|
+
opts.banner = "usage: polyrun run-shards [--workers N] [--strategy NAME] [--paths-file P] [--timing P] [--timing-granularity VAL] [--constraints P] [--seed S] [--merge-coverage] [--merge-output P] [--merge-format LIST] [--merge-failures] [--merge-failures-output P] [--merge-failures-format jsonl|json] [--] <command> [args...]"
|
|
39
43
|
opts.on("--workers N", Integer) { |v| st[:workers] = v }
|
|
40
44
|
opts.on("--strategy NAME", String) { |v| st[:strategy] = v }
|
|
41
45
|
opts.on("--seed VAL") { |v| st[:seed] = v }
|
|
@@ -46,7 +50,11 @@ module Polyrun
|
|
|
46
50
|
opts.on("--merge-coverage", "After success, merge coverage/polyrun-fragment-*.json (Polyrun coverage must be enabled)") { st[:merge_coverage] = true }
|
|
47
51
|
opts.on("--merge-output PATH", String) { |v| st[:merge_output] = v }
|
|
48
52
|
opts.on("--merge-format LIST", String) { |v| st[:merge_format] = v }
|
|
53
|
+
opts.on("--merge-failures", "After all workers exit, merge tmp/polyrun_failures/polyrun-failure-fragment-*.jsonl (use Polyrun::RSpec.install_failure_fragments!)") { st[:merge_failures] = true }
|
|
54
|
+
opts.on("--merge-failures-output PATH", String) { |v| st[:merge_failures_output] = v }
|
|
55
|
+
opts.on("--merge-failures-format VAL", "jsonl (default) or json") { |v| st[:merge_failures_format] = v }
|
|
49
56
|
end
|
|
57
|
+
# rubocop:enable Metrics/AbcSize
|
|
50
58
|
end
|
|
51
59
|
end
|
|
52
60
|
end
|
|
@@ -25,6 +25,8 @@ module Polyrun
|
|
|
25
25
|
hook_cfg = Polyrun::Hooks.from_config(ctx[:cfg])
|
|
26
26
|
suite_started = false
|
|
27
27
|
exit_code = 1
|
|
28
|
+
merged_failures_path = nil
|
|
29
|
+
merge_failures_errored = false
|
|
28
30
|
|
|
29
31
|
begin
|
|
30
32
|
env_suite = ENV.to_h.merge(
|
|
@@ -60,8 +62,20 @@ module Polyrun
|
|
|
60
62
|
Polyrun::Log.warn "polyrun run-shards: finished #{pids.size} worker(s)" + (failed.any? ? " (some failed)" : " (exit 0)")
|
|
61
63
|
end
|
|
62
64
|
|
|
65
|
+
if ctx[:merge_failures]
|
|
66
|
+
begin
|
|
67
|
+
merged_failures_path = merge_failures_after_shards(ctx)
|
|
68
|
+
rescue Polyrun::Error => e
|
|
69
|
+
Polyrun::Log.warn e.message.to_s
|
|
70
|
+
merge_failures_errored = true
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
63
74
|
if failed.any?
|
|
64
|
-
run_shards_log_failed_reruns(
|
|
75
|
+
run_shards_log_failed_reruns(
|
|
76
|
+
failed, shard_results, ctx[:plan], ctx[:parallel], ctx[:workers], ctx[:cmd],
|
|
77
|
+
merge_failures: ctx[:merge_failures]
|
|
78
|
+
)
|
|
65
79
|
exit_code = 1
|
|
66
80
|
exit_code = 1 if wait_hook_err != 0
|
|
67
81
|
return exit_code
|
|
@@ -69,13 +83,15 @@ module Polyrun
|
|
|
69
83
|
|
|
70
84
|
exit_code = run_shards_merge_or_hint_coverage(ctx)
|
|
71
85
|
exit_code = 1 if wait_hook_err != 0 && exit_code == 0
|
|
86
|
+
exit_code = 1 if merge_failures_errored && exit_code == 0
|
|
72
87
|
exit_code
|
|
73
88
|
ensure
|
|
74
89
|
if suite_started
|
|
75
90
|
env_after = ENV.to_h.merge(
|
|
76
91
|
"POLYRUN_HOOK_ORCHESTRATOR" => "1",
|
|
77
92
|
"POLYRUN_SHARD_TOTAL" => ctx[:workers].to_s,
|
|
78
|
-
"POLYRUN_SUITE_EXIT_STATUS" => exit_code.to_s
|
|
93
|
+
"POLYRUN_SUITE_EXIT_STATUS" => exit_code.to_s,
|
|
94
|
+
"POLYRUN_MERGED_FAILURES_PATH" => merged_failures_path.to_s
|
|
79
95
|
)
|
|
80
96
|
hook_cfg.run_phase_if_enabled(:after_suite, env_after)
|
|
81
97
|
end
|
|
@@ -139,7 +155,7 @@ module Polyrun
|
|
|
139
155
|
0
|
|
140
156
|
end
|
|
141
157
|
|
|
142
|
-
def run_shards_log_failed_reruns(failed, shard_results, plan, parallel, workers, cmd)
|
|
158
|
+
def run_shards_log_failed_reruns(failed, shard_results, plan, parallel, workers, cmd, merge_failures: false)
|
|
143
159
|
exit_by_shard = shard_results.each_with_object({}) { |r, h| h[r[:shard]] = r[:exitstatus] }
|
|
144
160
|
failed_detail = failed.sort.map { |s| "#{s} (exit #{exit_by_shard[s]})" }.join(", ")
|
|
145
161
|
Polyrun::Log.warn "polyrun run-shards: failed shard(s): #{failed_detail}"
|
|
@@ -154,6 +170,9 @@ module Polyrun
|
|
|
154
170
|
rerun << Shellwords.join(cmd + paths)
|
|
155
171
|
Polyrun::Log.warn "polyrun run-shards: shard #{s} re-run (same spec list, no interleave): #{rerun}"
|
|
156
172
|
end
|
|
173
|
+
unless merge_failures
|
|
174
|
+
Polyrun::Log.warn "polyrun run-shards: one merged failure report — use run-shards --merge-failures with Polyrun::RSpec.install_failure_fragments!; POLYRUN_MERGED_FAILURES_PATH is set on after_suite when merge runs."
|
|
175
|
+
end
|
|
157
176
|
end
|
|
158
177
|
end
|
|
159
178
|
end
|
data/lib/polyrun/cli.rb
CHANGED
|
@@ -28,7 +28,7 @@ module Polyrun
|
|
|
28
28
|
|
|
29
29
|
# Keep in sync with +dispatch_cli_command_subcommands+ (+when+ branches). Used for implicit path routing.
|
|
30
30
|
DISPATCH_SUBCOMMAND_NAMES = %w[
|
|
31
|
-
plan prepare merge-coverage report-coverage report-junit report-timing
|
|
31
|
+
plan prepare merge-coverage merge-failures report-coverage report-junit report-timing
|
|
32
32
|
env config merge-timing db:setup-template db:setup-shard db:clone-shards
|
|
33
33
|
run-shards parallel-rspec start build-paths init queue quick hook
|
|
34
34
|
].freeze
|
|
@@ -145,6 +145,8 @@ module Polyrun
|
|
|
145
145
|
cmd_prepare(argv, config_path)
|
|
146
146
|
when "merge-coverage"
|
|
147
147
|
cmd_merge_coverage(argv, config_path)
|
|
148
|
+
when "merge-failures"
|
|
149
|
+
cmd_merge_failures(argv, config_path)
|
|
148
150
|
when "report-coverage"
|
|
149
151
|
cmd_report_coverage(argv)
|
|
150
152
|
when "report-junit"
|
data/lib/polyrun/config.rb
CHANGED
|
@@ -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
|
data/lib/polyrun/version.rb
CHANGED
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.4.
|
|
4
|
+
version: 1.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Makarov
|
|
@@ -175,6 +175,7 @@ files:
|
|
|
175
175
|
- lib/polyrun/cli/database_commands.rb
|
|
176
176
|
- lib/polyrun/cli/default_run.rb
|
|
177
177
|
- lib/polyrun/cli/env_commands.rb
|
|
178
|
+
- lib/polyrun/cli/failure_commands.rb
|
|
178
179
|
- lib/polyrun/cli/help.rb
|
|
179
180
|
- lib/polyrun/cli/helpers.rb
|
|
180
181
|
- lib/polyrun/cli/hooks_command.rb
|
|
@@ -258,8 +259,10 @@ files:
|
|
|
258
259
|
- lib/polyrun/quick/reporter.rb
|
|
259
260
|
- lib/polyrun/quick/runner.rb
|
|
260
261
|
- lib/polyrun/railtie.rb
|
|
262
|
+
- lib/polyrun/reporting/failure_merge.rb
|
|
261
263
|
- lib/polyrun/reporting/junit.rb
|
|
262
264
|
- lib/polyrun/reporting/junit_emit.rb
|
|
265
|
+
- lib/polyrun/reporting/rspec_failure_fragment_formatter.rb
|
|
263
266
|
- lib/polyrun/reporting/rspec_junit.rb
|
|
264
267
|
- lib/polyrun/rspec.rb
|
|
265
268
|
- lib/polyrun/templates/POLYRUN.md
|