polyrun 1.2.0 → 1.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5dd8b178e07e0cf6648284d59d5b8f58a5feceeb2c7570edfd381aafaed207cb
4
- data.tar.gz: cd2846dc77b56bccf0ac411fcc4f6a2a7aaf2e106609172e84299c2a1d253f64
3
+ metadata.gz: e3162ed760c231d4fa78ff396f55f708af13bda62808ba340f3c1494cf2dd97e
4
+ data.tar.gz: f401bd075462bafa14905c08a996fa46370025be9872f6a4d7aefbfdde251d2d
5
5
  SHA512:
6
- metadata.gz: c9a71f317d28ce3dcdf25d9c8e08221e71eadb2cf044217170ca0ba08cdc22cb702e6adaf02383d4e6089fa2fdddc94198cc420f031f53186715b67c14c92567
7
- data.tar.gz: 81469ea975e78befbf6c66e3482e5c6006c11bb8b1806a079545a00ac057f250d49323aeab6685e8eaec22ff65e5aa72fb28aaca688365048483650d3c78de00
6
+ metadata.gz: 3d0bf0e8ac88d3d0a007f53ce340c324bcd65c6397b4b67905d428d104ebff3078bc41220873b0b7d741e6cc65be06d5d103f8b8abd64e18ce0dc2d8f3e55bc1
7
+ data.tar.gz: 42906169eeeae14b3531d870359ce8fe9bd59261770553a6e7e6872e48e47b7b1013709eedea5c176a94ccee5c1e498551e8d6285ffc68b37c51d05169b184b1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 1.3.0 (2026-04-15)
4
+
5
+ - Add safe parsing for `ci-shard-run` / `ci-shard-rspec` `--shard-processes` and `--workers` (warn + exit 2 on missing or non-integer values).
6
+ - Fix `shard_child_env` when `matrix_total > 1` and `matrix_index` is nil: omit `POLYRUN_SHARD_MATRIX_*` and warn (avoid `Integer(nil)`).
7
+ - Document in `polyrun help` that `POLYRUN_SHARD_PROCESSES` and ci-shard `--workers` / `--shard-processes` are local processes per matrix job, distinct from `POLYRUN_WORKERS` / `run-shards`.
8
+ - BREAKING: Multi-worker shard runs may emit coverage JSON fragments whose basenames include `shard*` and `worker*` segments; `merge-coverage` still matches `polyrun-fragment-*.json`.
9
+
3
10
  ## 1.2.0 (2026-04-15)
4
11
 
5
12
  - Add `polyrun config <dotted.path>` to print values from `Polyrun::Config::Effective` (same effective tree as runtime: arbitrary YAML paths, merged `prepare.env.<KEY>` as for `polyrun prepare`, resolved `partition.shard_index`, `partition.shard_total`, `partition.timing_granularity`, and `workers`).
@@ -6,6 +6,11 @@ module Polyrun
6
6
  # workers on a single host. Runs +build-paths+, +plan+ for that shard, then +exec+ of a user command
7
7
  # with that shard's paths appended (same argv pattern as +run-shards+ after +--+).
8
8
  #
9
+ # With +--shard-processes M+ (or +partition.shard_processes+ / +POLYRUN_SHARD_PROCESSES+), fans out
10
+ # +M+ OS processes on this host, each running a subset of this shard's paths (NxM: +N+ matrix jobs × +M+
11
+ # processes). Child processes get local +POLYRUN_SHARD_INDEX+ / +POLYRUN_SHARD_TOTAL+ (+0..M-1+, +M+);
12
+ # when +N+ > 1, also +POLYRUN_SHARD_MATRIX_INDEX+ / +POLYRUN_SHARD_MATRIX_TOTAL+ for unique coverage fragments.
13
+ #
9
14
  # After +--+, prefer **multiple argv tokens** (+bundle+, +exec+, +rspec+, …). A single token that
10
15
  # contains spaces is split with +Shellwords+ (not a full shell); exotic quoting differs from +sh -c+.
11
16
  module CiShardRunCommand
@@ -25,6 +30,60 @@ module Polyrun
25
30
  [paths, 0]
26
31
  end
27
32
 
33
+ def ci_shard_local_plan!(paths, workers)
34
+ Polyrun::Partition::Plan.new(
35
+ items: paths,
36
+ total_shards: workers,
37
+ strategy: "round_robin",
38
+ root: Dir.pwd
39
+ )
40
+ end
41
+
42
+ # When +N+ > 1 and +M+ > 1, pass matrix index/total for coverage fragment names; else nil (see +shard_child_env+).
43
+ def ci_shard_matrix_context(pc, shard_processes)
44
+ n = resolve_shard_total(pc)
45
+ return [nil, nil] if n <= 1 || shard_processes <= 1
46
+
47
+ [resolve_shard_index(pc), n]
48
+ end
49
+
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
+ def ci_shard_fanout_context(cfg:, pc:, paths:, shard_processes:, cmd:, config_path:)
69
+ plan = ci_shard_local_plan!(paths, shard_processes)
70
+ mx, mt = ci_shard_matrix_context(pc, shard_processes)
71
+ {
72
+ workers: shard_processes,
73
+ cmd: cmd,
74
+ cfg: cfg,
75
+ plan: plan,
76
+ run_t0: Process.clock_gettime(Process::CLOCK_MONOTONIC),
77
+ parallel: true,
78
+ merge_coverage: false,
79
+ merge_output: nil,
80
+ merge_format: nil,
81
+ config_path: config_path,
82
+ matrix_shard_index: mx,
83
+ matrix_shard_total: mt
84
+ }
85
+ end
86
+
28
87
  # Runner-agnostic matrix shard: +polyrun ci-shard-run [plan options] -- <command> [args...]+
29
88
  # Paths for this shard are appended after the command (like +run-shards+).
30
89
  def cmd_ci_shard_run(argv, config_path)
@@ -42,10 +101,27 @@ module Polyrun
42
101
  end
43
102
  cmd = Shellwords.split(cmd.first) if cmd.size == 1 && cmd.first.include?(" ")
44
103
 
104
+ cfg = Polyrun::Config.load(path: config_path || ENV["POLYRUN_CONFIG"])
105
+ pc = cfg.partition
106
+ shard_processes, perr = ci_shard_parse_shard_processes!(plan_argv, pc)
107
+ return perr if perr
108
+
109
+ shard_processes, err = ci_shard_normalize_shard_processes(shard_processes)
110
+ return err if err
111
+
45
112
  paths, code = ci_shard_planned_paths!(plan_argv, config_path, command_label: "ci-shard-run")
46
113
  return code if code != 0
47
114
 
48
- exec(*cmd, *paths)
115
+ if shard_processes <= 1
116
+ exec(*cmd, *paths)
117
+ return 0
118
+ end
119
+
120
+ ctx = ci_shard_fanout_context(
121
+ cfg: cfg, pc: pc, paths: paths, shard_processes: shard_processes, cmd: cmd, config_path: config_path
122
+ )
123
+ Polyrun::Log.warn "polyrun ci-shard-run: #{paths.size} path(s) → #{shard_processes} process(es) on this host (NxM: matrix jobs × local processes)"
124
+ ci_shard_run_fanout!(ctx)
49
125
  end
50
126
 
51
127
  # Same as +ci-shard-run -- bundle exec rspec+ with an optional second segment for RSpec-only flags:
@@ -55,10 +131,29 @@ module Polyrun
55
131
  plan_argv = sep ? argv[0...sep] : argv
56
132
  rspec_argv = sep ? argv[(sep + 1)..] : []
57
133
 
134
+ cfg = Polyrun::Config.load(path: config_path || ENV["POLYRUN_CONFIG"])
135
+ pc = cfg.partition
136
+ shard_processes, perr = ci_shard_parse_shard_processes!(plan_argv, pc)
137
+ return perr if perr
138
+
139
+ shard_processes, err = ci_shard_normalize_shard_processes(shard_processes)
140
+ return err if err
141
+
58
142
  paths, code = ci_shard_planned_paths!(plan_argv, config_path, command_label: "ci-shard-rspec")
59
143
  return code if code != 0
60
144
 
61
- exec("bundle", "exec", "rspec", *rspec_argv, *paths)
145
+ cmd = ["bundle", "exec", "rspec", *rspec_argv]
146
+
147
+ if shard_processes <= 1
148
+ exec(*cmd, *paths)
149
+ return 0
150
+ end
151
+
152
+ ctx = ci_shard_fanout_context(
153
+ cfg: cfg, pc: pc, paths: paths, shard_processes: shard_processes, cmd: cmd, config_path: config_path
154
+ )
155
+ Polyrun::Log.warn "polyrun ci-shard-rspec: #{paths.size} path(s) → #{shard_processes} process(es) on this host (NxM: matrix jobs × local processes)"
156
+ ci_shard_run_fanout!(ctx)
62
157
  end
63
158
  end
64
159
  end
@@ -0,0 +1,68 @@
1
+ module Polyrun
2
+ class CLI
3
+ # Parsing for +ci-shard-run+ / +ci-shard-rspec+ plan argv (+--shard-processes+, +--workers+).
4
+ module CiShardRunParse
5
+ private
6
+
7
+ # Strips +--shard-processes+ / +--workers+ from +plan_argv+ and returns +[count, exit_code]+.
8
+ # +exit_code+ is +nil+ on success, +2+ on invalid or missing integer (no exception).
9
+ # Does not use +OptionParser+ so +plan+ flags (+--shard+, +--total+, …) pass through unchanged.
10
+ # Note: +--workers+ here means processes for this matrix job (+POLYRUN_SHARD_PROCESSES+), not +run-shards+ +POLYRUN_WORKERS+.
11
+ def ci_shard_parse_shard_processes!(plan_argv, pc)
12
+ workers = Polyrun::Config::Resolver.resolve_shard_processes(pc)
13
+ rest = []
14
+ i = 0
15
+ while i < plan_argv.size
16
+ case plan_argv[i]
17
+ when "--shard-processes"
18
+ n, err = ci_shard_parse_positive_int_flag!(plan_argv, i, "--shard-processes")
19
+ return [nil, err] if err
20
+
21
+ workers = n
22
+ i += 2
23
+ when "--workers"
24
+ n, err = ci_shard_parse_positive_int_flag!(plan_argv, i, "--workers")
25
+ return [nil, err] if err
26
+
27
+ workers = n
28
+ i += 2
29
+ else
30
+ rest << plan_argv[i]
31
+ i += 1
32
+ end
33
+ end
34
+ plan_argv.replace(rest)
35
+ [workers, nil]
36
+ end
37
+
38
+ # @return [Array(Integer or nil, Integer or nil)] +[value, exit_code]+ — +exit_code+ is +nil+ on success, +2+ on error
39
+ def ci_shard_parse_positive_int_flag!(argv, i, flag_name)
40
+ arg = argv[i + 1]
41
+ if arg.nil?
42
+ Polyrun::Log.warn "polyrun ci-shard: missing value for #{flag_name}"
43
+ return [nil, 2]
44
+ end
45
+ n = Integer(arg, exception: false)
46
+ if n.nil?
47
+ Polyrun::Log.warn "polyrun ci-shard: #{flag_name} must be an integer (got #{arg.inspect})"
48
+ return [nil, 2]
49
+ end
50
+ [n, nil]
51
+ end
52
+
53
+ # @return [Array(Integer, Integer, nil)] +[capped_workers, exit_code]+ — +exit_code+ is +nil+ when OK
54
+ def ci_shard_normalize_shard_processes(workers)
55
+ if workers < 1
56
+ Polyrun::Log.warn "polyrun ci-shard: --shard-processes / --workers must be >= 1"
57
+ return [workers, 2]
58
+ end
59
+ w = workers
60
+ if w > Polyrun::Config::MAX_PARALLEL_WORKERS
61
+ Polyrun::Log.warn "polyrun ci-shard: capping --shard-processes / --workers from #{w} to #{Polyrun::Config::MAX_PARALLEL_WORKERS}"
62
+ w = Polyrun::Config::MAX_PARALLEL_WORKERS
63
+ end
64
+ [w, nil]
65
+ end
66
+ end
67
+ end
68
+ end
@@ -21,7 +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
- Parallel RSpec workers: POLYRUN_WORKERS default 5, max 10 (run-shards / parallel-rspec / start)
24
+ 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
25
  Partition timing granularity (default file): POLYRUN_TIMING_GRANULARITY=file|example (experimental per-example; see partition.timing_granularity)
26
26
 
27
27
  commands:
@@ -32,11 +32,11 @@ module Polyrun
32
32
  run-shards fan out N parallel OS processes (POLYRUN_SHARD_*; not Ruby threads); optional --merge-coverage
33
33
  parallel-rspec run-shards + merge-coverage (defaults to: bundle exec rspec after --)
34
34
  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
- 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 -- (like run-shards; not multi-worker)
36
- ci-shard-rspec same as ci-shard-run -- bundle exec rspec; optional -- [rspec-only flags]
35
+ 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
36
+ ci-shard-rspec same as ci-shard-run -- bundle exec rspec; optional --shard-processes / --workers / -- [rspec-only flags]
37
37
  build-paths write partition.paths_file from partition.paths_build (same as auto step before plan/run-shards)
38
38
  init write a starter polyrun.yml or POLYRUN.md from built-in templates (see docs/SETUP_PROFILE.md)
39
- queue file-backed batch queue (init / claim / ack / status)
39
+ 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
40
  quick run Polyrun::Quick (describe/it, before/after, let, expect…to, assert_*; optional capybara!)
41
41
  report-coverage write all coverage formats from one JSON file
42
42
  report-junit RSpec JSON or Polyrun testcase JSON → JUnit XML (CI)
@@ -76,21 +76,31 @@ module Polyrun
76
76
  }
77
77
  end
78
78
 
79
+ # Partition flags shared by +polyrun plan+ and +queue init+ (excluding +--paths-file+, which each command registers once).
80
+ def plan_command_register_partition_options!(opts, ctx)
81
+ opts.on("--shard INDEX", Integer) { |v| ctx[:shard] = v }
82
+ opts.on("--total N", Integer) { |v| ctx[:total] = v }
83
+ opts.on("--strategy NAME", String) { |v| ctx[:strategy] = v }
84
+ opts.on("--seed VAL") { |v| ctx[:seed] = v }
85
+ opts.on("--constraints PATH", "YAML: pin / serial_glob (see spec_queue.md)") { |v| ctx[:constraints_path] = v }
86
+ opts.on("--timing PATH", "path => seconds JSON; implies cost_binpack unless strategy is cost-based or hrw") do |v|
87
+ ctx[:timing_path] = v
88
+ end
89
+ opts.on("--timing-granularity VAL", "file (default) or example (experimental: path:line items)") do |v|
90
+ ctx[:timing_granularity] = v
91
+ end
92
+ end
93
+
94
+ # Shared by +polyrun plan+ and +queue init+ so partition flags match +Partition::Plan+ / +plan+ JSON.
95
+ def plan_command_register_options!(opts, ctx)
96
+ opts.on("--paths-file PATH", String) { |v| ctx[:paths_file] = v }
97
+ plan_command_register_partition_options!(opts, ctx)
98
+ end
99
+
79
100
  def plan_command_parse_argv!(argv, ctx)
80
101
  OptionParser.new do |opts|
81
102
  opts.banner = "usage: polyrun plan [options] [--] [paths...]"
82
- opts.on("--shard INDEX", Integer) { |v| ctx[:shard] = v }
83
- opts.on("--total N", Integer) { |v| ctx[:total] = v }
84
- opts.on("--strategy NAME", String) { |v| ctx[:strategy] = v }
85
- opts.on("--seed VAL") { |v| ctx[:seed] = v }
86
- opts.on("--paths-file PATH", String) { |v| ctx[:paths_file] = v }
87
- opts.on("--constraints PATH", "YAML: pin / serial_glob (see spec_queue.md)") { |v| ctx[:constraints_path] = v }
88
- opts.on("--timing PATH", "path => seconds JSON; implies cost_binpack unless strategy is cost-based or hrw") do |v|
89
- ctx[:timing_path] = v
90
- end
91
- opts.on("--timing-granularity VAL", "file (default) or example (experimental: path:line items)") do |v|
92
- ctx[:timing_granularity] = v
93
- end
103
+ plan_command_register_options!(opts, ctx)
94
104
  end.parse!(argv)
95
105
  end
96
106
 
@@ -7,11 +7,18 @@ module Polyrun
7
7
  private
8
8
 
9
9
  # File-backed queue (spec_queue.md): init → claim batches → ack (ledger append-only).
10
+ #
11
+ # *N×M and load balancing*: +queue init+ uses the same +Partition::Plan+ slice as +polyrun plan+
12
+ # when +--shard+ / +--total+ (or +partition.shard_index+ / +shard_total+ / CI env) define a matrix.
13
+ # Each CI job runs +init+ once for its shard; the queue holds **only** that shard's paths (no duplicate
14
+ # work across matrix jobs). **M** local workers use +queue claim+ on a **shared** queue directory
15
+ # (NFS or the runner disk): claims are mutually exclusive — **dynamic** balance (fast workers pull more
16
+ # batches), **not** the same static per-worker wall time as +cost_binpack+ on the full suite.
17
+ # +--timing+ on init sorts dequeue order (heavy items first when weights are known); it does **not**
18
+ # replace binpack across workers — for that, use static +plan+ / +run-shards+ with +cost_binpack+.
10
19
  def cmd_queue(argv)
11
20
  dir = ".polyrun-queue"
12
21
  paths_file = nil
13
- timing_path = nil
14
- timing_granularity = nil
15
22
  worker = ENV["USER"] || "worker"
16
23
  batch = 5
17
24
  lease_id = nil
@@ -20,7 +27,7 @@ module Polyrun
20
27
  Polyrun::Debug.log("queue: subcommand=#{sub.inspect}")
21
28
  case sub
22
29
  when "init"
23
- queue_cmd_init(argv, dir, paths_file, timing_path, timing_granularity)
30
+ queue_cmd_init(argv, dir)
24
31
  when "claim"
25
32
  queue_cmd_claim(argv, dir, worker, batch)
26
33
  when "ack"
@@ -33,34 +40,54 @@ module Polyrun
33
40
  end
34
41
  end
35
42
 
36
- def queue_cmd_init(argv, dir, paths_file, timing_path, timing_granularity)
43
+ def queue_cmd_init(argv, dir)
44
+ cfg = Polyrun::Config.load(path: ENV["POLYRUN_CONFIG"])
45
+ pc = cfg.partition
46
+ ctx = plan_command_initial_context(pc)
47
+ paths_file = nil
37
48
  OptionParser.new do |opts|
38
- opts.banner = "usage: polyrun queue init --paths-file P [--timing PATH] [--timing-granularity VAL] [--dir DIR]"
49
+ opts.banner = "usage: polyrun queue init --paths-file P [--dir DIR] [same partition options as polyrun plan]"
39
50
  opts.on("--dir PATH") { |v| dir = v }
40
- opts.on("--paths-file PATH") { |v| paths_file = v }
41
- opts.on("--timing PATH") { |v| timing_path = v }
42
- opts.on("--timing-granularity VAL") { |v| timing_granularity = v }
51
+ opts.on("--paths-file PATH", String) { |v| paths_file = v }
52
+ plan_command_register_partition_options!(opts, ctx)
43
53
  end.parse!(argv)
44
54
  unless paths_file
45
55
  Polyrun::Log.warn "queue init: need --paths-file"
46
56
  return 2
47
57
  end
48
- cfg = Polyrun::Config.load(path: ENV["POLYRUN_CONFIG"])
49
- g = resolve_partition_timing_granularity(cfg.partition, timing_granularity)
50
- items = Polyrun::Partition::Paths.read_lines(paths_file)
51
- costs =
52
- if timing_path
53
- Polyrun::Partition::Plan.load_timing_costs(
54
- File.expand_path(timing_path, Dir.pwd),
55
- granularity: g
56
- )
57
- end
58
- ordered = queue_init_ordered_items(items, costs, g)
58
+ code = Polyrun::Partition::PathsBuild.apply!(partition: pc, cwd: Dir.pwd)
59
+ return code if code != 0
60
+
61
+ ordered, code = queue_partition_manifest_and_ordered_paths(cfg, pc, ctx, paths_file)
62
+ return code if code != 0
63
+
59
64
  Polyrun::Queue::FileStore.new(dir).init!(ordered)
60
65
  Polyrun::Log.puts JSON.generate({"dir" => File.expand_path(dir), "count" => ordered.size})
61
66
  0
62
67
  end
63
68
 
69
+ def queue_partition_manifest_and_ordered_paths(cfg, pc, ctx, paths_file)
70
+ Polyrun::Log.warn "polyrun queue init: using #{cfg.path}" if @verbose && cfg.path
71
+
72
+ manifest, code = plan_command_manifest_from_paths(cfg, pc, [], ctx, paths_file)
73
+ return [nil, code] if code != 0
74
+
75
+ paths = manifest["paths"] || []
76
+ g = resolve_partition_timing_granularity(pc, ctx[:timing_granularity])
77
+ timing_for_sort = plan_resolve_timing_path(pc, ctx[:timing_path], ctx[:strategy])
78
+ costs = queue_init_timing_costs(timing_for_sort, g)
79
+ [queue_init_ordered_items(paths, costs, g), 0]
80
+ end
81
+
82
+ def queue_init_timing_costs(timing_for_sort, g)
83
+ return nil unless timing_for_sort
84
+
85
+ Polyrun::Partition::Plan.load_timing_costs(
86
+ File.expand_path(timing_for_sort.to_s, Dir.pwd),
87
+ granularity: g
88
+ )
89
+ end
90
+
64
91
  def queue_init_ordered_items(items, costs, granularity = :file)
65
92
  if costs && !costs.empty?
66
93
  dw = costs.values.sum / costs.size.to_f
@@ -15,7 +15,7 @@ module Polyrun
15
15
  # Default and upper bound for parallel OS processes (POLYRUN_WORKERS / --workers); see {Polyrun::Config}.
16
16
 
17
17
  # Spawns N OS processes (not Ruby threads) with POLYRUN_SHARD_INDEX / POLYRUN_SHARD_TOTAL so
18
- # {Coverage::Collector} writes coverage/polyrun-fragment-<shard>.json. Merge with merge-coverage.
18
+ # {Coverage::Collector} writes coverage/polyrun-fragment-worker<N>.json (or shard<S>-worker<W>.json in N×M CI). Merge with merge-coverage.
19
19
  def cmd_run_shards(argv, config_path)
20
20
  run_shards_run!(argv, config_path)
21
21
  end
@@ -112,10 +112,21 @@ module Polyrun
112
112
  end
113
113
 
114
114
  # ENV for a worker process: POLYRUN_SHARD_* plus per-shard database URLs from polyrun.yml or DATABASE_URL.
115
- def shard_child_env(cfg:, workers:, shard:)
115
+ # When +matrix_total+ > 1 with multiple local workers, sets +POLYRUN_SHARD_MATRIX_INDEX+ / +POLYRUN_SHARD_MATRIX_TOTAL+
116
+ # 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)
116
118
  child_env = ENV.to_h.merge(
117
119
  Polyrun::Database::Shard.env_map(shard_index: shard, shard_total: workers)
118
120
  )
121
+ mt = matrix_total.nil? ? 0 : Integer(matrix_total)
122
+ if mt > 1
123
+ if matrix_index.nil?
124
+ Polyrun::Log.warn "polyrun run-shards: matrix_total=#{mt} but matrix_index is nil; omit POLYRUN_SHARD_MATRIX_*"
125
+ else
126
+ child_env["POLYRUN_SHARD_MATRIX_INDEX"] = Integer(matrix_index).to_s
127
+ child_env["POLYRUN_SHARD_MATRIX_TOTAL"] = mt.to_s
128
+ end
129
+ end
119
130
  dh = cfg.databases
120
131
  if dh.is_a?(Hash) && !dh.empty?
121
132
  child_env.merge!(Polyrun::Database::UrlBuilder.env_exports_for_databases(dh, shard_index: shard))
@@ -50,6 +50,8 @@ module Polyrun
50
50
  cfg = ctx[:cfg]
51
51
  plan = ctx[:plan]
52
52
  parallel = ctx[:parallel]
53
+ mx = ctx[:matrix_shard_index]
54
+ mt = ctx[:matrix_shard_total]
53
55
 
54
56
  pids = []
55
57
  workers.times do |shard|
@@ -59,7 +61,7 @@ module Polyrun
59
61
  next
60
62
  end
61
63
 
62
- child_env = shard_child_env(cfg: cfg, workers: workers, shard: shard)
64
+ child_env = shard_child_env(cfg: cfg, workers: workers, shard: shard, matrix_index: mx, matrix_total: mt)
63
65
 
64
66
  Polyrun::Log.warn "polyrun run-shards: shard #{shard} → #{paths.size} file(s)" if @verbose
65
67
  pid = Process.spawn(child_env, *cmd, *paths)
@@ -140,7 +142,7 @@ module Polyrun
140
142
 
141
143
  if ctx[:parallel]
142
144
  Polyrun::Log.warn <<~MSG
143
- polyrun run-shards: coverage — each worker writes coverage/polyrun-fragment-<shard>.json when Polyrun coverage is enabled (POLYRUN_SHARD_INDEX per process).
145
+ polyrun run-shards: coverage — each worker writes coverage/polyrun-fragment-worker<N>.json when Polyrun coverage is enabled (POLYRUN_SHARD_INDEX per process).
144
146
  polyrun run-shards: next step — merge with: polyrun merge-coverage -i 'coverage/polyrun-fragment-*.json' -o coverage/merged.json --format json,cobertura,console
145
147
  MSG
146
148
  end
data/lib/polyrun/cli.rb CHANGED
@@ -12,6 +12,7 @@ require_relative "cli/queue_command"
12
12
  require_relative "cli/timing_command"
13
13
  require_relative "cli/init_command"
14
14
  require_relative "cli/quick_command"
15
+ require_relative "cli/ci_shard_run_parse"
15
16
  require_relative "cli/ci_shard_run_command"
16
17
  require_relative "cli/config_command"
17
18
  require_relative "cli/default_run"
@@ -48,6 +49,7 @@ module Polyrun
48
49
  include TimingCommand
49
50
  include InitCommand
50
51
  include QuickCommand
52
+ include CiShardRunParse
51
53
  include CiShardRunCommand
52
54
  include ConfigCommand
53
55
  include DefaultRun
@@ -4,7 +4,7 @@ require_relative "resolver"
4
4
  module Polyrun
5
5
  class Config
6
6
  # Nested hash of values Polyrun uses: loaded YAML (string keys) with overlays for
7
- # merged +prepare.env+, resolved +partition.shard_index+ / +shard_total+ / +timing_granularity+,
7
+ # merged +prepare.env+, resolved +partition.shard_index+ / +shard_total+ / +shard_processes+ / +timing_granularity+,
8
8
  # and top-level +workers+ (+POLYRUN_WORKERS+ default).
9
9
  #
10
10
  # +build+ memoizes the last (cfg, env) in-process so repeated +dig+ calls on the same load do not
@@ -44,6 +44,7 @@ module Polyrun
44
44
  part = deep_stringify_keys(pc).merge(
45
45
  "shard_index" => r.resolve_shard_index(pc, env),
46
46
  "shard_total" => r.resolve_shard_total(pc, env),
47
+ "shard_processes" => r.resolve_shard_processes(pc, env),
47
48
  "timing_granularity" => r.resolve_partition_timing_granularity(pc, nil, env).to_s
48
49
  )
49
50
  base["partition"] = part
@@ -54,6 +54,14 @@ module Polyrun
54
54
  partition_int(pc, %w[shard_total total], 1)
55
55
  end
56
56
 
57
+ # Processes per CI matrix job for +ci-shard-run+ / +ci-shard-rspec+ (NxM: N jobs × M processes).
58
+ # +POLYRUN_SHARD_PROCESSES+ or +partition.shard_processes+; CLI +--shard-processes+ / +--workers+ overrides.
59
+ def resolve_shard_processes(pc, env = ENV)
60
+ return Integer(env["POLYRUN_SHARD_PROCESSES"]) if env["POLYRUN_SHARD_PROCESSES"] && !env["POLYRUN_SHARD_PROCESSES"].empty?
61
+
62
+ partition_int(pc, %w[shard_processes shard_workers workers_per_shard], 1)
63
+ end
64
+
57
65
  # +cli_val+ is an override (e.g. +run-shards --timing-granularity+); +nil+ uses YAML then +POLYRUN_TIMING_GRANULARITY+.
58
66
  def resolve_partition_timing_granularity(pc, cli_val, env = ENV)
59
67
  raw = cli_val
@@ -8,6 +8,7 @@ require_relative "formatter"
8
8
  require_relative "merge"
9
9
  require_relative "result"
10
10
  require_relative "track_files"
11
+ require_relative "collector_fragment_meta"
11
12
  require_relative "../debug"
12
13
 
13
14
  module Polyrun
@@ -24,7 +25,7 @@ module Polyrun
24
25
 
25
26
  # @param root [String] project root (absolute or relative)
26
27
  # @param reject_patterns [Array<String>] path substrings to drop (like SimpleCov add_filter)
27
- # @param output_path [String, nil] default coverage/polyrun-fragment-<shard>.json
28
+ # @param output_path [String, nil] default see {.fragment_default_basename_from_env}
28
29
  # @param minimum_line_percent [Float, nil] exit 1 if below (when strict)
29
30
  # @param strict [Boolean] whether to exit non-zero on threshold failure (default true when minimum set)
30
31
  # @param track_under [Array<String>] when +track_files+ is nil, only keep coverage keys under these dirs relative to +root+. Default +["lib"]+.
@@ -34,18 +35,25 @@ module Polyrun
34
35
  # @param formatter [Object, nil] Object responding to +format(result, output_dir:, basename:)+ like SimpleCov formatters (e.g. {Formatter.multi} or {Formatter::MultiFormatter})
35
36
  # @param report_output_dir [String, nil] directory for +formatter+ outputs (default +coverage/+ under +root+)
36
37
  # @param report_basename [String] file prefix for formatter outputs (default +polyrun-coverage+)
38
+ # See {CollectorFragmentMeta.fragment_default_basename_from_env}.
39
+ def self.fragment_default_basename_from_env(env = ENV)
40
+ CollectorFragmentMeta.fragment_default_basename_from_env(env)
41
+ end
42
+
37
43
  def start!(root:, reject_patterns: [], track_under: ["lib"], track_files: nil, groups: nil, output_path: nil, minimum_line_percent: nil, strict: nil, meta: {}, formatter: nil, report_output_dir: nil, report_basename: "polyrun-coverage")
38
44
  return if disabled?
39
45
 
40
46
  root = File.expand_path(root)
41
- shard = ENV.fetch("POLYRUN_SHARD_INDEX", "0")
42
- output_path ||= File.join(root, "coverage", "polyrun-fragment-#{shard}.json")
47
+ basename = fragment_default_basename_from_env
48
+ output_path ||= File.join(root, "coverage", "polyrun-fragment-#{basename}.json")
43
49
  strict = if minimum_line_percent.nil?
44
50
  false
45
51
  else
46
52
  strict.nil? || strict
47
53
  end
48
54
 
55
+ fragment_meta = CollectorFragmentMeta.fragment_meta_from_env(basename)
56
+
49
57
  @config = {
50
58
  root: root,
51
59
  track_under: Array(track_under).map(&:to_s),
@@ -59,7 +67,8 @@ module Polyrun
59
67
  formatter: formatter,
60
68
  report_output_dir: report_output_dir,
61
69
  report_basename: report_basename,
62
- shard_total_at_start: ENV["POLYRUN_SHARD_TOTAL"].to_i
70
+ shard_total_at_start: ENV["POLYRUN_SHARD_TOTAL"].to_i,
71
+ fragment_meta: fragment_meta
63
72
  }
64
73
 
65
74
  unless ::Coverage.running?
@@ -110,11 +119,7 @@ module Polyrun
110
119
  end
111
120
 
112
121
  def self.finish_debug_time_label
113
- if ENV["POLYRUN_SHARD_TOTAL"].to_i > 1
114
- "worker pid=#{$$} shard=#{ENV.fetch("POLYRUN_SHARD_INDEX", "?")} Coverage::Collector.finish (write fragment)"
115
- else
116
- "Coverage::Collector.finish (write fragment)"
117
- end
122
+ CollectorFragmentMeta.finish_debug_time_label
118
123
  end
119
124
 
120
125
  def build_meta(cfg)
@@ -123,6 +128,7 @@ module Polyrun
123
128
  m["timestamp"] ||= Time.now.to_i
124
129
  m["command_name"] ||= "rspec"
125
130
  m["polyrun_coverage_root"] = cfg[:root].to_s
131
+ CollectorFragmentMeta.merge_fragment_meta!(m, cfg[:fragment_meta])
126
132
  if cfg[:groups]
127
133
  m["polyrun_coverage_groups"] = cfg[:groups].transform_keys(&:to_s).transform_values(&:to_s)
128
134
  end
@@ -12,6 +12,8 @@ module Polyrun
12
12
  collector_finish: "start",
13
13
  polyrun_shard_index: ENV["POLYRUN_SHARD_INDEX"],
14
14
  polyrun_shard_total: ENV["POLYRUN_SHARD_TOTAL"],
15
+ polyrun_shard_matrix_index: ENV["POLYRUN_SHARD_MATRIX_INDEX"],
16
+ polyrun_shard_matrix_total: ENV["POLYRUN_SHARD_MATRIX_TOTAL"],
15
17
  output_path: cfg[:output_path]
16
18
  )
17
19
  Polyrun::Debug.time(Collector.finish_debug_time_label) do
@@ -0,0 +1,57 @@
1
+ module Polyrun
2
+ module Coverage
3
+ # Shard / worker naming for coverage JSON fragments (N×M CI vs run-shards).
4
+ module CollectorFragmentMeta
5
+ module_function
6
+
7
+ # Default fragment basename (no extension) for +coverage/polyrun-fragment-<basename>.json+.
8
+ def fragment_default_basename_from_env(env = ENV)
9
+ local = env.fetch("POLYRUN_SHARD_INDEX", "0")
10
+ mt = env["POLYRUN_SHARD_MATRIX_TOTAL"].to_i
11
+ if mt > 1
12
+ mi = env.fetch("POLYRUN_SHARD_MATRIX_INDEX", "0")
13
+ "shard#{mi}-worker#{local}"
14
+ elsif env["POLYRUN_SHARD_TOTAL"].to_i > 1
15
+ "worker#{local}"
16
+ else
17
+ local
18
+ end
19
+ end
20
+
21
+ def finish_debug_time_label
22
+ mt = ENV["POLYRUN_SHARD_MATRIX_TOTAL"].to_i
23
+ if mt > 1
24
+ "worker pid=#{$$} shard(matrix)=#{ENV.fetch("POLYRUN_SHARD_MATRIX_INDEX", "?")} worker(local)=#{ENV.fetch("POLYRUN_SHARD_INDEX", "?")} Coverage::Collector.finish (write fragment)"
25
+ elsif ENV["POLYRUN_SHARD_TOTAL"].to_i > 1
26
+ "worker pid=#{$$} worker=#{ENV.fetch("POLYRUN_SHARD_INDEX", "?")} Coverage::Collector.finish (write fragment)"
27
+ else
28
+ "Coverage::Collector.finish (write fragment)"
29
+ end
30
+ end
31
+
32
+ def fragment_meta_from_env(basename)
33
+ mt = ENV["POLYRUN_SHARD_MATRIX_TOTAL"].to_i
34
+ {
35
+ basename: basename,
36
+ worker_index: ENV.fetch("POLYRUN_SHARD_INDEX", "0"),
37
+ shard_matrix_index: shard_matrix_index_value(mt)
38
+ }
39
+ end
40
+
41
+ def shard_matrix_index_value(matrix_total)
42
+ return nil if matrix_total <= 1
43
+
44
+ ENV.fetch("POLYRUN_SHARD_MATRIX_INDEX", "0")
45
+ end
46
+
47
+ def merge_fragment_meta!(m, fm)
48
+ return m if fm.nil?
49
+
50
+ m["polyrun_fragment_basename"] = fm[:basename].to_s if fm[:basename]
51
+ m["polyrun_worker_index"] = fm[:worker_index].to_s if fm[:worker_index]
52
+ m["polyrun_shard_matrix_index"] = fm[:shard_matrix_index].to_s if fm[:shard_matrix_index]
53
+ m
54
+ end
55
+ end
56
+ end
57
+ 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.3.0"
3
3
  end
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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -167,6 +167,7 @@ files:
167
167
  - lib/polyrun.rb
168
168
  - lib/polyrun/cli.rb
169
169
  - lib/polyrun/cli/ci_shard_run_command.rb
170
+ - lib/polyrun/cli/ci_shard_run_parse.rb
170
171
  - lib/polyrun/cli/config_command.rb
171
172
  - lib/polyrun/cli/coverage_commands.rb
172
173
  - lib/polyrun/cli/coverage_merge_io.rb
@@ -196,6 +197,7 @@ files:
196
197
  - lib/polyrun/coverage/cobertura_zero_lines.rb
197
198
  - lib/polyrun/coverage/collector.rb
198
199
  - lib/polyrun/coverage/collector_finish.rb
200
+ - lib/polyrun/coverage/collector_fragment_meta.rb
199
201
  - lib/polyrun/coverage/filter.rb
200
202
  - lib/polyrun/coverage/formatter.rb
201
203
  - lib/polyrun/coverage/merge.rb