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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3162ed760c231d4fa78ff396f55f708af13bda62808ba340f3c1494cf2dd97e
4
- data.tar.gz: f401bd075462bafa14905c08a996fa46370025be9872f6a4d7aefbfdde251d2d
3
+ metadata.gz: 7666af9186562083f29dc56e6c867e48b877acdff6ad28ff8c351e8d3c308582
4
+ data.tar.gz: 503f5435deb22112044f7841a82728e6782a770eb656859419e8412d623dcff0
5
5
  SHA512:
6
- metadata.gz: 3d0bf0e8ac88d3d0a007f53ce340c324bcd65c6397b4b67905d428d104ebff3078bc41220873b0b7d741e6cc65be06d5d103f8b8abd64e18ce0dc2d8f3e55bc1
7
- data.tar.gz: 42906169eeeae14b3531d870359ce8fe9bd59261770553a6e7e6872e48e47b7b1013709eedea5c176a94ccee5c1e498551e8d6285ffc68b37c51d05169b184b1
6
+ metadata.gz: d0d5d248f1e072c446049bafff111db6e29d784a4a0992528214a6e29cca7b156b67144e6b9c22fc71fa9143f110ac443cda2953219aa816c5562d3247b5e02b
7
+ data.tar.gz: d0be776ce5d4a7a5acacfe237a4d07a47c078562aee6a054fb5b63a31a78e199bc2b5ee276b0a8e77a0f0cacd3e4f03cb72d8fd5037baf782d0efb8a1cdf1b04
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
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
+
11
+ ## 1.4.0 (2026-04-16)
12
+
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-*`.
14
+ - Add `hooks.ruby` / `hooks.ruby_file` and `Polyrun::Hooks::Dsl` (`before(:suite)` … `after(:each)` blocks); worker Ruby hooks run in the child via `ruby -e` + `POLYRUN_HOOKS_RUBY_FILE`.
15
+ - Add `polyrun hook run <phase>` (`--shard` / `--total` optional). Set `POLYRUN_HOOKS_DISABLE=1` to skip hooks during orchestration only; `hook run` still executes.
16
+ - On `ci-shard-run` / `ci-shard-rspec`, skip automatic `before_suite` / `after_suite` when `POLYRUN_SHARD_TOTAL` > 1 (matrix); run suite hooks once via `polyrun hook run` or set `POLYRUN_HOOKS_SUITE_PER_MATRIX_JOB=1` to run them on every matrix job.
17
+ - Document hook phases, matrix vs suite, and `after_shard` ordering in `README.md`; list `Polyrun::Hooks` and `Polyrun::Hooks::Dsl` in the library section.
18
+
3
19
  ## 1.3.0 (2026-04-15)
4
20
 
5
21
  - Add safe parsing for `ci-shard-run` / `ci-shard-rspec` `--shard-processes` and `--workers` (warn + exit 2 on missing or non-integer values).
data/README.md CHANGED
@@ -25,6 +25,61 @@ Capybara and Playwright stay in your application; Polyrun does not replace brows
25
25
  4. Run workers with `bin/polyrun run-shards --workers N -- bundle exec rspec`: N separate OS processes, each running RSpec with its own file list from `partition.paths_file`, or `spec/spec_paths.txt`, or else `spec/**/*_spec.rb`. Stderr shows where paths came from; after a successful multi-worker run it reminds you to run merge-coverage unless you use `parallel-rspec` or `run-shards --merge-coverage`.
26
26
  5. Merge artifacts with `bin/polyrun merge-coverage` on `coverage/polyrun-fragment-*.json` (one fragment per `POLYRUN_SHARD_INDEX` when coverage is on), or use `bin/polyrun parallel-rspec` or `run-shards --merge-coverage` so Polyrun runs merge for you. Optional: `merge-timing`, `report-timing`, `report-junit`.
27
27
 
28
+ ### Hooks (`hooks:` in `polyrun.yml`)
29
+
30
+ Optional **shell** commands and/or a **Ruby DSL** file for instrumentation (telemetry, Slack, logging, manual debugging). Names mirror RSpec’s API (`before(:suite)`, `before(:all)`, `before(:each)`), but **Polyrun hooks are about process orchestration**, not RSpec example groups. Below, **suite / shard / worker** mean Polyrun’s model unless stated otherwise.
31
+
32
+ #### What “suite”, “shard”, and “worker” mean
33
+
34
+ | Term | Process | Meaning |
35
+ |------|---------|---------|
36
+ | **Suite** | **Parent** only | One **orchestration run** on a single machine: a single `polyrun run-shards` / `parallel-rspec` / `ci-shard-run` (with **one** global shard, see below). `before_suite` runs once before any worker is started; `after_suite` runs once after all workers have exited and (when used) merge-coverage has finished. This is **not** the same as “the whole RSpec suite in one process”—with `--workers N`, RSpec runs in **N separate processes**, each with its own examples. |
37
+ | **Shard** | **Parent** only | One **partition** of the path list for this run, identified by `POLYRUN_SHARD_INDEX` / `POLYRUN_SHARD_TOTAL` **for that parallel layout** (0 … N−1 for `run-shards` with N workers). `before_shard` runs in the parent **immediately before** `Process.spawn` for that index; `after_shard` runs **after** that child has exited. The parent **waits workers in shard index order** (0, then 1, …), so `after_shard` runs in that order—not in “who finished first” order if workers overlap in time. Empty partitions are skipped (no spawn, no hooks). |
38
+ | **Worker** | **Child** OS process | The process that runs your command after `--` (e.g. `bundle exec rspec …`). `before_worker` / `after_worker` run **in that child**, directly before and after the test command. **Individual examples run only after `before_worker` completes** (inside the same process as RSpec/Minitest). |
39
+
40
+ **CI matrix** (`POLYRUN_SHARD_TOTAL` > 1, one job per index): each job is a **global shard**, not a full “suite” in the pipeline sense. **`ci-shard-run` / `ci-shard-rspec` with one process per job do not run `before_suite` / `after_suite` automatically**—otherwise they would run once per matrix cell. Put pipeline-wide setup/teardown in a **separate CI step** (e.g. `bin/polyrun hook run before_suite` and `hook run after_suite` once), or set `POLYRUN_HOOKS_SUITE_PER_MATRIX_JOB=1` to restore the old behaviour (suite hooks on every matrix job). **`before_shard` / `after_shard` / worker hooks still run** per job, with `POLYRUN_SHARD_INDEX` / `POLYRUN_SHARD_TOTAL` set from the matrix. A **single** non-matrix `ci-shard-run` (`POLYRUN_SHARD_TOTAL` is 1) still runs suite hooks like `run-shards --workers 1`. Fan-out on one host (`--shard-processes` > 1) still runs **`before_suite` / `after_suite` once** for that job, around the local workers.
41
+
42
+ #### Lifecycle (typical `run-shards` with multiple workers)
43
+
44
+ ```text
45
+ [Parent] before_suite
46
+ [Parent] for each shard index i that has paths:
47
+ before_shard(i) → spawn worker i
48
+ [Child i] before_worker → test runner starts → examples run → runner exits
49
+ [Parent] after_shard(i) (parent waits children in shard index order 0…N−1, not global finish order)
50
+ [Parent] merge-coverage (if requested)
51
+ [Parent] after_suite
52
+ ```
53
+
54
+ `after_worker` runs in the child after the test command exits, before the parent’s `after_shard`.
55
+
56
+ #### Order and priority within one phase
57
+
58
+ 1. **Ruby DSL, then shell (YAML)** — For the same phase (e.g. `before_suite`), all **Ruby** blocks from `hooks.ruby` run first, then every **shell** command from YAML for that phase.
59
+ 2. **Multiple Ruby blocks** — In one DSL file, registrations run in **source order** (e.g. two `before(:suite)` blocks run top to bottom).
60
+ 3. **Multiple shell commands** — Use a **YAML list**; entries run in list order:
61
+ ```yaml
62
+ before_suite:
63
+ - echo first
64
+ - echo second
65
+ ```
66
+ 4. **Duplicate YAML keys** — Do **not** repeat the same key (e.g. two `before_suite:` lines). Parsers may keep only one value; behaviour is undefined. Prefer a **list** under a single key.
67
+ 5. **Failure** — If any step in a phase fails (non-zero exit from shell; uncaught error in Ruby), orchestration stops or marks failure per existing `run-shards` rules; `after_worker` shell steps use `|| true` so a failing teardown does not mask the test exit code (Ruby `after_worker` is wrapped similarly in the worker script).
68
+
69
+ Environment includes `POLYRUN_HOOK_PHASE`, `POLYRUN_HOOK=1`, `POLYRUN_HOOK_ORCHESTRATOR` (`1` in parent, `0` in workers), `POLYRUN_SHARD_*`, and `POLYRUN_SUITE_EXIT_STATUS` on `after_suite`. Worker children get `POLYRUN_HOOKS_RUBY_FILE` when using the Ruby DSL. Set `POLYRUN_HOOKS_DISABLE=1` to skip hooks during `run-shards` / `parallel-rspec` / `ci-shard-*` (orchestration only); `polyrun hook run` still executes hooks. **`POLYRUN_HOOKS_SUITE_PER_MATRIX_JOB=1`** — when set, run `before_suite` / `after_suite` on every CI matrix job (not recommended for expensive global setup).
70
+
71
+ **YAML keys (shell) and RSpec-style names in YAML:**
72
+
73
+ | YAML key | DSL in `hooks.ruby` | When |
74
+ |----------|----------------------|------|
75
+ | `before_suite` / `after_suite` | `before(:suite)` / `after(:suite)` | Parent: once per orchestration on one host; **skipped** for `ci-shard-run` when `POLYRUN_SHARD_TOTAL` > 1 unless `POLYRUN_HOOKS_SUITE_PER_MATRIX_JOB=1` (see matrix paragraph above) |
76
+ | `before_shard` / `after_shard` | `before(:all)` / `after(:all)` | Parent: per shard index (spawn / after exit) |
77
+ | `before_worker` / `after_worker` | `before(:each)` / `after(:each)` | Child: around the test command |
78
+
79
+ **Ruby DSL (`hooks.ruby` or `hooks.ruby_file`):** path to a `.rb` file (relative to the project root). Blocks receive a `Hash` with **string keys** (same env as shell hooks). Worker-phase Ruby hooks run in the child via `ruby -e 'require "polyrun"; …'`.
80
+
81
+ Run one phase by hand: `bin/polyrun hook run before_suite` (optional `--shard N --total M`). YAML may use quoted keys such as `"before(:suite)"` instead of `before_suite`.
82
+
28
83
  Quick CLI samples:
29
84
 
30
85
  If the current directory already has `polyrun.yml` or `config/polyrun.yml`, you can omit `-c` (same as `Config.load` default discovery). Pass `-c PATH` or set `POLYRUN_CONFIG` when the file lives elsewhere or uses another name.
@@ -41,6 +96,7 @@ bin/polyrun env --shard 0 --total 4 # print DATABASE_URL exports from polyrun.
41
96
  bin/polyrun init --list
42
97
  bin/polyrun init --profile gem -o polyrun.yml # starter YAML; see docs/SETUP_PROFILE.md
43
98
  bin/polyrun quick # Polyrun::Quick examples under spec/polyrun_quick/ or test/polyrun_quick/
99
+ bin/polyrun hook run before_suite # run hooks.before_suite from polyrun.yml (manual / CI)
44
100
  ```
45
101
 
46
102
  ### Matrix shards and timing
@@ -87,6 +143,8 @@ That single require loads the CLI and core library **without** loading RSpec or
87
143
  | `Polyrun::Prepare::Assets` | Digest trees, marker file, `assets:precompile`. |
88
144
  | `Polyrun::Database::Shard` | Shard env map, `%{shard}` DB names, URL path suffix for `postgres://`, `mysql2://`, `mongodb://`, etc. |
89
145
  | `Polyrun::Database::UrlBuilder` | URLs from `polyrun.yml` `databases:` — nested blocks or `adapter:` for common Rails stacks (`postgresql`, `mysql`/`mysql2`, `trilogy`, `sqlserver`/`mssql`, `sqlite3`/`sqlite`, `mongodb`/`mongo`). |
146
+ | `Polyrun::Hooks` | Load from `Config#hooks`; `run_phase` / `run_phase_if_enabled`; `build_worker_shell_script` wraps the worker command. |
147
+ | `Polyrun::Hooks::Dsl` | Ruby hook file (`hooks.ruby`); `before(:suite)` / `after(:each)` etc. in `config/polyrun_hooks.rb` (see README). |
90
148
 
91
149
  ## Development
92
150
 
@@ -0,0 +1,121 @@
1
+ module Polyrun
2
+ class CLI
3
+ # Suite / shard / worker shell hooks for +ci-shard-run+ / +ci-shard-rspec+.
4
+ module CiShardHooks
5
+ private
6
+
7
+ # rubocop:disable Metrics/AbcSize -- suite hooks + spawn/wait + failure paths
8
+ def ci_shard_run_fanout!(ctx)
9
+ hook_cfg = Polyrun::Hooks.from_config(ctx[:cfg])
10
+ suite_started = false
11
+ exit_code = 1
12
+
13
+ begin
14
+ env_suite = ENV.to_h.merge(
15
+ "POLYRUN_HOOK_ORCHESTRATOR" => "1",
16
+ "POLYRUN_SHARD_TOTAL" => ctx[:workers].to_s
17
+ )
18
+ code = hook_cfg.run_phase_if_enabled(:before_suite, env_suite)
19
+ return code if code != 0
20
+
21
+ suite_started = true
22
+
23
+ pids, spawn_err = run_shards_spawn_workers(ctx, hook_cfg)
24
+ if spawn_err
25
+ exit_code = spawn_err
26
+ return spawn_err
27
+ end
28
+ return 1 if pids.empty?
29
+
30
+ run_shards_warn_interleaved(ctx[:parallel], pids.size)
31
+ shard_results, wait_hook_err = run_shards_wait_all_children(pids, hook_cfg, ctx)
32
+ failed = shard_results.reject { |r| r[:success] }.map { |r| r[:shard] }
33
+
34
+ if failed.any?
35
+ Polyrun::Log.warn "polyrun ci-shard: finished #{pids.size} worker(s) (some failed)"
36
+ run_shards_log_failed_reruns(failed, shard_results, ctx[:plan], ctx[:parallel], ctx[:workers], ctx[:cmd])
37
+ exit_code = 1
38
+ exit_code = 1 if wait_hook_err != 0
39
+ return exit_code
40
+ end
41
+
42
+ exit_code = (wait_hook_err == 0) ? 0 : 1
43
+ Polyrun::Log.warn "polyrun ci-shard: finished #{pids.size} worker(s) (exit 0)" if exit_code == 0
44
+ exit_code
45
+ ensure
46
+ if suite_started
47
+ env_after = ENV.to_h.merge(
48
+ "POLYRUN_HOOK_ORCHESTRATOR" => "1",
49
+ "POLYRUN_SHARD_TOTAL" => ctx[:workers].to_s,
50
+ "POLYRUN_SUITE_EXIT_STATUS" => exit_code.to_s
51
+ )
52
+ hook_cfg.run_phase_if_enabled(:after_suite, env_after)
53
+ end
54
+ end
55
+ end
56
+ # rubocop:enable Metrics/AbcSize
57
+
58
+ # One matrix shard, one OS process: same hook phases as +run-shards+ with +--workers 1+ (no +exec+ when hooks exist).
59
+ # rubocop:disable Metrics/AbcSize -- suite / shard / worker lifecycle
60
+ def ci_shard_run_single!(cmd, paths, cfg, pc, _config_path)
61
+ hook_cfg = Polyrun::Hooks.from_config(cfg)
62
+ if hook_cfg.empty? || Polyrun::Hooks.disabled?
63
+ exec(*cmd, *paths)
64
+ end
65
+
66
+ si = Polyrun::Config::Resolver.resolve_shard_index(pc)
67
+ st = Polyrun::Config::Resolver.resolve_shard_total(pc)
68
+ suite_started = false
69
+ exit_code = 1
70
+ # Distributed CI matrix (N > 1 global shards): each job is one shard; suite hooks are pipeline-wide.
71
+ # Run them once via +polyrun hook run before_suite+ / +after_suite+ (e.g. dedicated job), or set
72
+ # +POLYRUN_HOOKS_SUITE_PER_MATRIX_JOB=1+ to run suite hooks on every matrix job.
73
+ matrix_shards = st > 1
74
+ run_suite_hooks = !matrix_shards || Polyrun::Hooks.suite_per_matrix_job?
75
+
76
+ begin
77
+ env_orch = ENV.to_h.merge(
78
+ "POLYRUN_HOOK_ORCHESTRATOR" => "1",
79
+ "POLYRUN_SHARD_INDEX" => si.to_s,
80
+ "POLYRUN_SHARD_TOTAL" => st.to_s
81
+ )
82
+ if run_suite_hooks
83
+ code = hook_cfg.run_phase_if_enabled(:before_suite, env_orch)
84
+ return code if code != 0
85
+
86
+ suite_started = true
87
+ end
88
+
89
+ code = hook_cfg.run_phase_if_enabled(:before_shard, env_orch)
90
+ return code if code != 0
91
+
92
+ mx, mt = ci_shard_matrix_context(pc, 1)
93
+ child_env = shard_child_env(cfg: cfg, workers: 1, shard: 0, matrix_index: mx, matrix_total: mt)
94
+ child_env = child_env.merge("POLYRUN_HOOK_ORCHESTRATOR" => "0")
95
+ child_env = hook_cfg.merge_worker_ruby_env(child_env)
96
+
97
+ if hook_cfg.worker_hooks? && !Polyrun::Hooks.disabled?
98
+ system(child_env, "sh", "-c", hook_cfg.build_worker_shell_script(cmd, paths))
99
+ else
100
+ system(child_env, *cmd, *paths)
101
+ end
102
+ exit_code = $?.exitstatus
103
+
104
+ rc = hook_cfg.run_phase_if_enabled(:after_shard, env_orch.merge(
105
+ "POLYRUN_WORKER_EXIT_STATUS" => exit_code.to_s
106
+ ))
107
+ exit_code = rc if rc != 0
108
+
109
+ exit_code
110
+ ensure
111
+ if suite_started
112
+ hook_cfg.run_phase_if_enabled(:after_suite, env_orch.merge(
113
+ "POLYRUN_SUITE_EXIT_STATUS" => exit_code.to_s
114
+ ))
115
+ end
116
+ end
117
+ end
118
+ # rubocop:enable Metrics/AbcSize
119
+ end
120
+ end
121
+ end
@@ -1,5 +1,7 @@
1
1
  require "shellwords"
2
2
 
3
+ require_relative "ci_shard_hooks"
4
+
3
5
  module Polyrun
4
6
  class CLI
5
7
  # One CI matrix job = one global shard (POLYRUN_SHARD_INDEX / POLYRUN_SHARD_TOTAL), not +run-shards+
@@ -14,6 +16,8 @@ module Polyrun
14
16
  # After +--+, prefer **multiple argv tokens** (+bundle+, +exec+, +rspec+, …). A single token that
15
17
  # contains spaces is split with +Shellwords+ (not a full shell); exotic quoting differs from +sh -c+.
16
18
  module CiShardRunCommand
19
+ include CiShardHooks
20
+
17
21
  private
18
22
 
19
23
  # @return [Array(Array<String>, Integer)] [paths, 0] on success, or [nil, exit_code] on failure
@@ -47,24 +51,6 @@ module Polyrun
47
51
  [resolve_shard_index(pc), n]
48
52
  end
49
53
 
50
- def ci_shard_run_fanout!(ctx)
51
- pids = run_shards_spawn_workers(ctx)
52
- return 1 if pids.empty?
53
-
54
- run_shards_warn_interleaved(ctx[:parallel], pids.size)
55
- shard_results = run_shards_wait_all_children(pids)
56
- failed = shard_results.reject { |r| r[:success] }.map { |r| r[:shard] }
57
-
58
- if failed.any?
59
- Polyrun::Log.warn "polyrun ci-shard: finished #{pids.size} worker(s) (some failed)"
60
- run_shards_log_failed_reruns(failed, shard_results, ctx[:plan], ctx[:parallel], ctx[:workers], ctx[:cmd])
61
- return 1
62
- end
63
-
64
- Polyrun::Log.warn "polyrun ci-shard: finished #{pids.size} worker(s) (exit 0)"
65
- 0
66
- end
67
-
68
54
  def ci_shard_fanout_context(cfg:, pc:, paths:, shard_processes:, cmd:, config_path:)
69
55
  plan = ci_shard_local_plan!(paths, shard_processes)
70
56
  mx, mt = ci_shard_matrix_context(pc, shard_processes)
@@ -113,8 +99,7 @@ module Polyrun
113
99
  return code if code != 0
114
100
 
115
101
  if shard_processes <= 1
116
- exec(*cmd, *paths)
117
- return 0
102
+ return ci_shard_run_single!(cmd, paths, cfg, pc, config_path)
118
103
  end
119
104
 
120
105
  ctx = ci_shard_fanout_context(
@@ -145,8 +130,7 @@ module Polyrun
145
130
  cmd = ["bundle", "exec", "rspec", *rspec_argv]
146
131
 
147
132
  if shard_processes <= 1
148
- exec(*cmd, *paths)
149
- return 0
133
+ return ci_shard_run_single!(cmd, paths, cfg, pc, config_path)
150
134
  end
151
135
 
152
136
  ctx = ci_shard_fanout_context(
@@ -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
@@ -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
- run-shards fan out N parallel OS processes (POLYRUN_SHARD_*; not Ruby threads); optional --merge-coverage
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
@@ -38,6 +40,7 @@ module Polyrun
38
40
  init write a starter polyrun.yml or POLYRUN.md from built-in templates (see docs/SETUP_PROFILE.md)
39
41
  queue file-backed batch queue: init (optional --shard/--total etc. as plan, then claim/ack); M workers share one dir; no duplicate paths across claims
40
42
  quick run Polyrun::Quick (describe/it, before/after, let, expect…to, assert_*; optional capybara!)
43
+ hook run <phase> run one shell hook from polyrun.yml hooks: (e.g. before_suite); optional --shard/--total
41
44
  report-coverage write all coverage formats from one JSON file
42
45
  report-junit RSpec JSON or Polyrun testcase JSON → JUnit XML (CI)
43
46
  report-timing print slow-file summary from merged timing JSON
@@ -0,0 +1,97 @@
1
+ module Polyrun
2
+ class CLI
3
+ # +polyrun hook run <phase>+ — run one lifecycle phase from +polyrun.yml+ +hooks:+ (manual debugging / CI).
4
+ module HooksCommand
5
+ private
6
+
7
+ def cmd_hook(argv, config_path)
8
+ sub = argv.shift
9
+ case sub
10
+ when "run"
11
+ cmd_hook_run(argv, config_path)
12
+ when nil, "help", "-h", "--help"
13
+ print_hook_help
14
+ 0
15
+ else
16
+ Polyrun::Log.warn "polyrun hook: unknown subcommand #{sub.inspect} (try: polyrun hook run <phase>)"
17
+ print_hook_help
18
+ 2
19
+ end
20
+ end
21
+
22
+ def cmd_hook_run(argv, config_path)
23
+ phase = argv.shift
24
+ if phase.nil? || phase == "-h" || phase == "--help"
25
+ print_hook_help
26
+ return 2
27
+ end
28
+
29
+ shard, total = hook_run_parse_shard_flags!(argv)
30
+
31
+ unless argv.empty?
32
+ Polyrun::Log.warn "polyrun hook run: unexpected arguments: #{argv.inspect}"
33
+ return 2
34
+ end
35
+
36
+ phase_sym = Polyrun::Hooks.parse_phase(phase)
37
+ unless phase_sym && Polyrun::Hooks::PHASES.include?(phase_sym)
38
+ Polyrun::Log.warn "polyrun hook run: unknown phase #{phase.inspect} (expected: #{Polyrun::Hooks::PHASES.join(", ")})"
39
+ return 2
40
+ end
41
+
42
+ cfg = Polyrun::Config.load(path: config_path || ENV["POLYRUN_CONFIG"])
43
+ hook_cfg = Polyrun::Hooks.from_config(cfg)
44
+ env = hook_run_env(shard, total)
45
+
46
+ hook_cfg.run_phase(phase_sym, env)
47
+ rescue ArgumentError => e
48
+ Polyrun::Log.warn "polyrun hook run: #{e.message}"
49
+ 2
50
+ end
51
+
52
+ def hook_run_parse_shard_flags!(argv)
53
+ shard = nil
54
+ total = nil
55
+ while (a = argv.first)
56
+ case a
57
+ when "--shard"
58
+ argv.shift
59
+ shard = Integer(argv.shift || (raise ArgumentError, "--shard needs a value"))
60
+ when "--total"
61
+ argv.shift
62
+ total = Integer(argv.shift || (raise ArgumentError, "--total needs a value"))
63
+ else
64
+ break
65
+ end
66
+ end
67
+ [shard, total]
68
+ end
69
+
70
+ def hook_run_env(shard, total)
71
+ env = ENV.to_h.merge(
72
+ "POLYRUN_HOOK_ORCHESTRATOR" => "1",
73
+ "POLYRUN_HOOK_CLI" => "1"
74
+ )
75
+ env["POLYRUN_SHARD_INDEX"] = shard.to_s unless shard.nil?
76
+ env["POLYRUN_SHARD_TOTAL"] = total.to_s unless total.nil?
77
+ env
78
+ end
79
+
80
+ def print_hook_help
81
+ Polyrun::Log.puts <<~HELP
82
+ usage: polyrun hook run <phase> [--shard N] [--total M]
83
+
84
+ Runs hook(s) from polyrun.yml: Ruby DSL (+hooks.ruby+) then shell strings for <phase> (same names as RSpec lifecycle:
85
+ before_suite / after_suite as before(:suite) / after(:suite); before_shard / after_shard as
86
+ before(:all) / after(:all); before_worker / after_worker as before(:each) / after(:each)).
87
+
88
+ Phases: #{Polyrun::Hooks::PHASES.join(", ")}
89
+
90
+ Optional --shard / --total set POLYRUN_SHARD_INDEX / POLYRUN_SHARD_TOTAL for the hook process.
91
+ POLYRUN_HOOKS_DISABLE=1 skips hooks during run-shards / ci-shard only; polyrun hook run still executes.
92
+ For CI matrix (POLYRUN_SHARD_TOTAL > 1), ci-shard-run skips before_suite / after_suite unless POLYRUN_HOOKS_SUITE_PER_MATRIX_JOB=1; run those phases here or in a dedicated CI job.
93
+ HELP
94
+ end
95
+ end
96
+ end
97
+ end
@@ -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?
@@ -0,0 +1,99 @@
1
+ module Polyrun
2
+ class CLI
3
+ # Spawns and waits on worker processes for +run-shards+ / +ci-shard-*+ fan-out.
4
+ module RunShardsParallelChildren
5
+ private
6
+
7
+ # @return [Array(Array, Integer, nil)] +[pids, spawn_error_code]+; +spawn_error_code+ is +nil+ when all spawns succeeded
8
+ # rubocop:disable Metrics/AbcSize -- shard loop: spawn + shard hooks + env
9
+ def run_shards_spawn_workers(ctx, hook_cfg)
10
+ workers = ctx[:workers]
11
+ cmd = ctx[:cmd]
12
+ cfg = ctx[:cfg]
13
+ plan = ctx[:plan]
14
+ parallel = ctx[:parallel]
15
+ mx = ctx[:matrix_shard_index]
16
+ mt = ctx[:matrix_shard_total]
17
+
18
+ pids = []
19
+ workers.times do |shard|
20
+ paths = plan.shard(shard)
21
+ if paths.empty?
22
+ Polyrun::Log.warn "polyrun run-shards: shard #{shard} skipped (no paths)" if @verbose || parallel
23
+ next
24
+ end
25
+
26
+ env_shard = ENV.to_h.merge(
27
+ "POLYRUN_HOOK_ORCHESTRATOR" => "1",
28
+ "POLYRUN_SHARD_INDEX" => shard.to_s,
29
+ "POLYRUN_SHARD_TOTAL" => workers.to_s
30
+ )
31
+ code = hook_cfg.run_phase_if_enabled(:before_shard, env_shard)
32
+ if code != 0
33
+ run_shards_terminate_children!(pids)
34
+ return [pids, code]
35
+ end
36
+
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
+ )
45
+ child_env = child_env.merge("POLYRUN_HOOK_ORCHESTRATOR" => "0")
46
+ child_env = hook_cfg.merge_worker_ruby_env(child_env)
47
+
48
+ Polyrun::Log.warn "polyrun run-shards: shard #{shard} → #{paths.size} file(s)" if @verbose
49
+ pid = run_shards_spawn_one_worker(child_env, cmd, paths, hook_cfg)
50
+ pids << {pid: pid, shard: shard}
51
+ Polyrun::Debug.log("[parent pid=#{$$}] run-shards: Process.spawn shard=#{shard} child_pid=#{pid} spec_files=#{paths.size}")
52
+ Polyrun::Log.warn "polyrun run-shards: started shard #{shard} pid=#{pid} (#{paths.size} file(s))" if parallel
53
+ end
54
+ [pids, nil]
55
+ end
56
+ # rubocop:enable Metrics/AbcSize
57
+
58
+ def run_shards_spawn_one_worker(child_env, cmd, paths, hook_cfg)
59
+ if hook_cfg.worker_hooks? && !Polyrun::Hooks.disabled?
60
+ Process.spawn(child_env, "sh", "-c", hook_cfg.build_worker_shell_script(cmd, paths))
61
+ else
62
+ Process.spawn(child_env, *cmd, *paths)
63
+ end
64
+ end
65
+
66
+ # @return [Array(Array, Integer)] +[shard_results, after_shard_hook_error_code]+ (0 when all +after_shard+ hooks passed)
67
+ def run_shards_wait_all_children(pids, hook_cfg, ctx)
68
+ workers = ctx[:workers]
69
+ shard_results = []
70
+ after_hook_err = 0
71
+ Polyrun::Debug.time("Process.wait (#{pids.size} worker process(es))") do
72
+ pids.each do |h|
73
+ Process.wait(h[:pid])
74
+ exitstatus = $?.exitstatus
75
+ ok = $?.success?
76
+ Polyrun::Debug.log("[parent pid=#{$$}] run-shards: Process.wait child_pid=#{h[:pid]} shard=#{h[:shard]} exit=#{exitstatus} success=#{ok}")
77
+ env_after = ENV.to_h.merge(
78
+ "POLYRUN_HOOK_ORCHESTRATOR" => "1",
79
+ "POLYRUN_SHARD_INDEX" => h[:shard].to_s,
80
+ "POLYRUN_SHARD_TOTAL" => workers.to_s,
81
+ "POLYRUN_WORKER_EXIT_STATUS" => exitstatus.to_s
82
+ )
83
+ rc = hook_cfg.run_phase_if_enabled(:after_shard, env_after)
84
+ after_hook_err = rc if rc != 0 && after_hook_err == 0
85
+ shard_results << {shard: h[:shard], exitstatus: exitstatus, success: ok}
86
+ end
87
+ rescue Interrupt
88
+ # Do not trap SIGINT: Process.wait raises Interrupt; a trap races and prints Interrupt + SystemExit traces.
89
+ run_shards_shutdown_on_signal!(pids, 130)
90
+ rescue SignalException => e
91
+ raise unless e.signm == "SIGTERM"
92
+
93
+ run_shards_shutdown_on_signal!(pids, 143)
94
+ end
95
+ [shard_results, after_hook_err]
96
+ end
97
+ end
98
+ end
99
+ end