polyrun 1.1.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.
@@ -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
@@ -12,12 +12,10 @@ module Polyrun
12
12
 
13
13
  private
14
14
 
15
- # Default and upper bound for parallel OS processes (POLYRUN_WORKERS / --workers).
16
- DEFAULT_PARALLEL_WORKERS = 5
17
- MAX_PARALLEL_WORKERS = 10
15
+ # Default and upper bound for parallel OS processes (POLYRUN_WORKERS / --workers); see {Polyrun::Config}.
18
16
 
19
17
  # Spawns N OS processes (not Ruby threads) with POLYRUN_SHARD_INDEX / POLYRUN_SHARD_TOTAL so
20
- # {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.
21
19
  def cmd_run_shards(argv, config_path)
22
20
  run_shards_run!(argv, config_path)
23
21
  end
@@ -37,6 +35,54 @@ module Polyrun
37
35
  cmd_run_shards(combined, config_path)
38
36
  end
39
37
 
38
+ # Same as parallel-rspec but runs +bundle exec rails test+ or +bundle exec ruby -I test+ after +--+.
39
+ def cmd_parallel_minitest(argv, config_path)
40
+ cfg = Polyrun::Config.load(path: config_path || ENV["POLYRUN_CONFIG"])
41
+ code = start_bootstrap!(cfg, argv, config_path)
42
+ return code if code != 0
43
+
44
+ sep = argv.index("--")
45
+ combined =
46
+ if sep
47
+ head = argv[0...sep]
48
+ tail = argv[sep..]
49
+ head + ["--merge-coverage"] + tail
50
+ else
51
+ argv + ["--merge-coverage", "--"] + minitest_parallel_cmd
52
+ end
53
+ Polyrun::Debug.log_kv(parallel_minitest: "combined argv", argv: combined)
54
+ cmd_run_shards(combined, config_path)
55
+ end
56
+
57
+ # Same as parallel-rspec but runs +bundle exec polyrun quick+ after +--+ (one Quick process per shard).
58
+ # Run from the app root with +bundle exec+ so workers resolve the same gem as the parent (same concern as +bundle exec rspec+).
59
+ def cmd_parallel_quick(argv, config_path)
60
+ cfg = Polyrun::Config.load(path: config_path || ENV["POLYRUN_CONFIG"])
61
+ code = start_bootstrap!(cfg, argv, config_path)
62
+ return code if code != 0
63
+
64
+ sep = argv.index("--")
65
+ combined =
66
+ if sep
67
+ head = argv[0...sep]
68
+ tail = argv[sep..]
69
+ head + ["--merge-coverage"] + tail
70
+ else
71
+ argv + ["--merge-coverage", "--", "bundle", "exec", "polyrun", "quick"]
72
+ end
73
+ Polyrun::Debug.log_kv(parallel_quick: "combined argv", argv: combined)
74
+ cmd_run_shards(combined, config_path)
75
+ end
76
+
77
+ def minitest_parallel_cmd
78
+ rails_bin = File.expand_path("bin/rails", Dir.pwd)
79
+ if File.file?(rails_bin)
80
+ ["bundle", "exec", "rails", "test"]
81
+ else
82
+ ["bundle", "exec", "ruby", "-I", "test"]
83
+ end
84
+ end
85
+
40
86
  # Convenience alias: optional legacy script/build_spec_paths.rb (if present and partition.paths_build unset), then parallel-rspec.
41
87
  def cmd_start(argv, config_path)
42
88
  cfg = Polyrun::Config.load(path: config_path || ENV["POLYRUN_CONFIG"])
@@ -66,10 +112,21 @@ module Polyrun
66
112
  end
67
113
 
68
114
  # ENV for a worker process: POLYRUN_SHARD_* plus per-shard database URLs from polyrun.yml or DATABASE_URL.
69
- 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)
70
118
  child_env = ENV.to_h.merge(
71
119
  Polyrun::Database::Shard.env_map(shard_index: shard, shard_total: workers)
72
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
73
130
  dh = cfg.databases
74
131
  if dh.is_a?(Hash) && !dh.empty?
75
132
  child_env.merge!(Polyrun::Database::UrlBuilder.env_exports_for_databases(dh, shard_index: shard))
@@ -25,7 +25,7 @@ module Polyrun
25
25
  end
26
26
 
27
27
  def run_shards_plan_phase_b(o, cmd, cfg, pc, run_t0, config_path)
28
- items, paths_source, err = run_shards_resolve_items(o[:paths_file])
28
+ items, paths_source, err = run_shards_resolve_items(o[:paths_file], pc)
29
29
  return [err, nil] if err
30
30
 
31
31
  costs, strategy, err = run_shards_resolve_costs(o[:timing_path], o[:strategy], o[:timing_granularity])
@@ -15,7 +15,7 @@ module Polyrun
15
15
 
16
16
  def run_shards_plan_options_state(pc)
17
17
  {
18
- workers: env_int("POLYRUN_WORKERS", RunShardsCommand::DEFAULT_PARALLEL_WORKERS),
18
+ workers: env_int("POLYRUN_WORKERS", Polyrun::Config::DEFAULT_PARALLEL_WORKERS),
19
19
  paths_file: nil,
20
20
  strategy: (pc["strategy"] || pc[:strategy] || "round_robin").to_s,
21
21
  seed: pc["seed"] || pc[:seed],
@@ -38,9 +38,9 @@ module Polyrun
38
38
  Polyrun::Log.warn "polyrun run-shards: --workers must be >= 1"
39
39
  return 2
40
40
  end
41
- if w > RunShardsCommand::MAX_PARALLEL_WORKERS
42
- Polyrun::Log.warn "polyrun run-shards: capping --workers / POLYRUN_WORKERS from #{w} to #{RunShardsCommand::MAX_PARALLEL_WORKERS}"
43
- o[:workers] = RunShardsCommand::MAX_PARALLEL_WORKERS
41
+ if w > Polyrun::Config::MAX_PARALLEL_WORKERS
42
+ Polyrun::Log.warn "polyrun run-shards: capping --workers / POLYRUN_WORKERS from #{w} to #{Polyrun::Config::MAX_PARALLEL_WORKERS}"
43
+ o[:workers] = Polyrun::Config::MAX_PARALLEL_WORKERS
44
44
  end
45
45
  nil
46
46
  end
@@ -53,18 +53,18 @@ module Polyrun
53
53
  nil
54
54
  end
55
55
 
56
- def run_shards_resolve_items(paths_file)
57
- resolved = Polyrun::Partition::Paths.resolve_run_shard_items(paths_file: paths_file)
56
+ def run_shards_resolve_items(paths_file, partition)
57
+ resolved = Polyrun::Partition::Paths.resolve_run_shard_items(paths_file: paths_file, partition: partition)
58
58
  if resolved[:error]
59
59
  Polyrun::Log.warn "polyrun run-shards: #{resolved[:error]}"
60
60
  return [nil, nil, 2]
61
61
  end
62
62
  items = resolved[:items]
63
63
  paths_source = resolved[:source]
64
- Polyrun::Log.warn "polyrun run-shards: #{items.size} spec path(s) from #{paths_source}"
64
+ Polyrun::Log.warn "polyrun run-shards: #{items.size} path(s) from #{paths_source}"
65
65
 
66
66
  if items.empty?
67
- Polyrun::Log.warn "polyrun run-shards: no spec paths (spec/spec_paths.txt, partition.paths_file, or spec/**/*_spec.rb)"
67
+ Polyrun::Log.warn "polyrun run-shards: no paths (empty paths file or list)"
68
68
  return [nil, nil, 2]
69
69
  end
70
70
  [items, paths_source, nil]
@@ -119,7 +119,7 @@ module Polyrun
119
119
 
120
120
  def run_shards_warn_parallel_banner(item_count, workers, strategy)
121
121
  Polyrun::Log.warn <<~MSG
122
- polyrun run-shards: #{item_count} spec path(s) -> #{workers} parallel worker processes (not Ruby threads); strategy=#{strategy}
122
+ polyrun run-shards: #{item_count} path(s) -> #{workers} parallel worker processes (not Ruby threads); strategy=#{strategy}
123
123
  (plain `bundle exec rspec` is one process; this command fans out.)
124
124
  MSG
125
125
  end
@@ -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
@@ -4,10 +4,6 @@ module Polyrun
4
4
  module StartBootstrap
5
5
  private
6
6
 
7
- # Keep in sync with {RunShardsCommand} worker defaults.
8
- START_ARG_WORKERS_DEFAULT = 5
9
- START_ARG_WORKERS_MAX = 10
10
-
11
7
  def start_bootstrap!(cfg, argv, config_path)
12
8
  if start_run_prepare?(cfg) && !truthy_env?("POLYRUN_START_SKIP_PREPARE")
13
9
  recipe = cfg.prepare["recipe"] || cfg.prepare[:recipe] || "default"
@@ -76,7 +72,7 @@ module Polyrun
76
72
  def parse_workers_from_start_argv(argv)
77
73
  sep = argv.index("--")
78
74
  head = sep ? argv[0...sep] : argv
79
- workers = env_int("POLYRUN_WORKERS", START_ARG_WORKERS_DEFAULT)
75
+ workers = env_int("POLYRUN_WORKERS", Polyrun::Config::DEFAULT_PARALLEL_WORKERS)
80
76
  i = 0
81
77
  while i < head.size
82
78
  if head[i] == "--workers" && head[i + 1]
@@ -87,7 +83,7 @@ module Polyrun
87
83
  i += 1
88
84
  end
89
85
  end
90
- workers.clamp(1, START_ARG_WORKERS_MAX)
86
+ workers.clamp(1, Polyrun::Config::MAX_PARALLEL_WORKERS)
91
87
  end
92
88
 
93
89
  def truthy_env?(name)
data/lib/polyrun/cli.rb CHANGED
@@ -12,7 +12,11 @@ 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"
17
+ require_relative "cli/config_command"
18
+ require_relative "cli/default_run"
19
+ require_relative "cli/help"
16
20
 
17
21
  module Polyrun
18
22
  class CLI
@@ -21,6 +25,18 @@ module Polyrun
21
25
  "ci-shard-rspec" => :cmd_ci_shard_rspec
22
26
  }.freeze
23
27
 
28
+ # Keep in sync with +dispatch_cli_command_subcommands+ (+when+ branches). Used for implicit path routing.
29
+ DISPATCH_SUBCOMMAND_NAMES = %w[
30
+ plan prepare merge-coverage report-coverage report-junit report-timing
31
+ env config merge-timing db:setup-template db:setup-shard db:clone-shards
32
+ run-shards parallel-rspec start build-paths init queue quick
33
+ ].freeze
34
+
35
+ # First argv token that is a normal subcommand (not a path); if argv[0] is not here but looks like paths, run implicit parallel.
36
+ IMPLICIT_PATH_EXCLUSION_TOKENS = (
37
+ DISPATCH_SUBCOMMAND_NAMES + CI_SHARD_COMMANDS.keys + %w[help version]
38
+ ).freeze
39
+
24
40
  include Helpers
25
41
  include PlanCommand
26
42
  include PrepareCommand
@@ -33,7 +49,11 @@ module Polyrun
33
49
  include TimingCommand
34
50
  include InitCommand
35
51
  include QuickCommand
52
+ include CiShardRunParse
36
53
  include CiShardRunCommand
54
+ include ConfigCommand
55
+ include DefaultRun
56
+ include Help
37
57
 
38
58
  def self.run(argv = ARGV)
39
59
  new.run(argv)
@@ -44,12 +64,30 @@ module Polyrun
44
64
  config_path = parse_global_cli!(argv)
45
65
  return config_path if config_path.is_a?(Integer)
46
66
 
47
- command = argv.shift
48
- if command.nil?
49
- print_help
50
- return 0
67
+ if argv.empty?
68
+ Polyrun::Debug.log_kv(
69
+ command: "(default)",
70
+ cwd: Dir.pwd,
71
+ polyrun_config: config_path,
72
+ argv_rest: [],
73
+ verbose: @verbose
74
+ )
75
+ return dispatch_default_parallel!(config_path)
76
+ end
77
+
78
+ if implicit_parallel_run?(argv)
79
+ Polyrun::Debug.log_kv(
80
+ command: "(paths)",
81
+ cwd: Dir.pwd,
82
+ polyrun_config: config_path,
83
+ argv_rest: argv.dup,
84
+ verbose: @verbose
85
+ )
86
+ return dispatch_implicit_parallel_targets!(argv, config_path)
51
87
  end
52
88
 
89
+ command = argv.shift
90
+
53
91
  Polyrun::Debug.log_kv(
54
92
  command: command,
55
93
  cwd: Dir.pwd,
@@ -96,6 +134,7 @@ module Polyrun
96
134
  end
97
135
  end
98
136
 
137
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity -- explicit dispatch table
99
138
  def dispatch_cli_command_subcommands(command, argv, config_path)
100
139
  case command
101
140
  when "plan"
@@ -112,6 +151,8 @@ module Polyrun
112
151
  cmd_report_timing(argv)
113
152
  when "env"
114
153
  cmd_env(argv, config_path)
154
+ when "config"
155
+ cmd_config(argv, config_path)
115
156
  when "merge-timing"
116
157
  cmd_merge_timing(argv)
117
158
  when "db:setup-template"
@@ -141,52 +182,7 @@ module Polyrun
141
182
  2
142
183
  end
143
184
  end
144
-
145
- def print_help
146
- Polyrun::Log.puts <<~HELP
147
- usage: polyrun [global options] <command> [options]
148
-
149
- global:
150
- -c, --config PATH polyrun.yml path (or POLYRUN_CONFIG)
151
- -v, --verbose
152
- -h, --help
153
-
154
- Trace timing (stderr): DEBUG=1 or POLYRUN_DEBUG=1
155
- Branch coverage in JSON fragments: POLYRUN_COVERAGE_BRANCHES=1 (stdlib Coverage; merge-coverage merges branches)
156
- polyrun quick coverage: POLYRUN_COVERAGE=1 or (config/polyrun_coverage.yml + POLYRUN_QUICK_COVERAGE=1); POLYRUN_COVERAGE_DISABLE=1 skips
157
- Merge wall time (stderr): POLYRUN_PROFILE_MERGE=1 (or verbose / DEBUG)
158
- Post-merge formats (run-shards): POLYRUN_MERGE_FORMATS (default: json,lcov,cobertura,console,html)
159
- Skip optional script/build_spec_paths.rb before start: POLYRUN_SKIP_BUILD_SPEC_PATHS=1
160
- Skip start auto-prepare / auto DB provision: POLYRUN_START_SKIP_PREPARE=1, POLYRUN_START_SKIP_DATABASES=1
161
- Skip writing paths_file from partition.paths_build: POLYRUN_SKIP_PATHS_BUILD=1
162
- Warn if merge-coverage wall time exceeds N seconds (default 10): POLYRUN_MERGE_SLOW_WARN_SECONDS (0 disables)
163
- Parallel RSpec workers: POLYRUN_WORKERS default 5, max 10 (run-shards / parallel-rspec / start)
164
- Partition timing granularity (default file): POLYRUN_TIMING_GRANULARITY=file|example (experimental per-example; see partition.timing_granularity)
165
-
166
- commands:
167
- version print version
168
- plan emit partition manifest JSON
169
- prepare run prepare recipe: default | assets (optional prepare.command overrides bin/rails assets:precompile) | shell (prepare.command required)
170
- merge-coverage merge SimpleCov JSON fragments (json/lcov/cobertura/console)
171
- run-shards fan out N parallel OS processes (POLYRUN_SHARD_*; not Ruby threads); optional --merge-coverage
172
- parallel-rspec run-shards + merge-coverage (defaults to: bundle exec rspec after --)
173
- 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
174
- 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)
175
- ci-shard-rspec same as ci-shard-run -- bundle exec rspec; optional -- [rspec-only flags]
176
- build-paths write partition.paths_file from partition.paths_build (same as auto step before plan/run-shards)
177
- init write a starter polyrun.yml or POLYRUN.md from built-in templates (see docs/SETUP_PROFILE.md)
178
- queue file-backed batch queue (init / claim / ack / status)
179
- quick run Polyrun::Quick (describe/it, before/after, let, expect…to, assert_*; optional capybara!)
180
- report-coverage write all coverage formats from one JSON file
181
- report-junit RSpec JSON or Polyrun testcase JSON → JUnit XML (CI)
182
- report-timing print slow-file summary from merged timing JSON
183
- merge-timing merge polyrun_timing_*.json shards
184
- env print shard + database env (see polyrun.yml databases)
185
- db:setup-template migrate template DB (PostgreSQL)
186
- db:setup-shard CREATE DATABASE shard FROM template (one POLYRUN_SHARD_INDEX)
187
- db:clone-shards migrate templates + DROP/CREATE all shard DBs (replaces clone_shard shell scripts)
188
- HELP
189
- end
185
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
190
186
 
191
187
  def cmd_version
192
188
  Polyrun::Log.puts "polyrun #{Polyrun::VERSION}"
@@ -0,0 +1,21 @@
1
+ module Polyrun
2
+ class Config
3
+ # Read nested keys from loaded YAML (+String+ / +Symbol+ indifferent at each step).
4
+ module DottedPath
5
+ module_function
6
+
7
+ def dig(raw, dotted)
8
+ segments = dotted.split(".")
9
+ return nil if segments.empty?
10
+ return nil if segments.any?(&:empty?)
11
+
12
+ segments.reduce(raw) do |m, seg|
13
+ break nil if m.nil?
14
+ break nil unless m.is_a?(Hash)
15
+
16
+ m[seg] || m[seg.to_sym]
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,72 @@
1
+ require_relative "dotted_path"
2
+ require_relative "resolver"
3
+
4
+ module Polyrun
5
+ class Config
6
+ # Nested hash of values Polyrun uses: loaded YAML (string keys) with overlays for
7
+ # merged +prepare.env+, resolved +partition.shard_index+ / +shard_total+ / +shard_processes+ / +timing_granularity+,
8
+ # and top-level +workers+ (+POLYRUN_WORKERS+ default).
9
+ #
10
+ # +build+ memoizes the last (cfg, env) in-process so repeated +dig+ calls on the same load do not
11
+ # rebuild the tree (single-threaded CLI).
12
+ module Effective
13
+ class << self
14
+ # Per-thread cache avoids rebuilding the effective tree on repeated +dig+; no class ivars (RuboCop ThreadSafety).
15
+ def build(cfg, env: ENV)
16
+ key = cache_key(cfg, env)
17
+ per_thread = (Thread.current[:polyrun_effective_build] ||= {})
18
+ per_thread[key] ||= build_uncached(cfg, env: env)
19
+ end
20
+
21
+ def dig(cfg, dotted_path, env: ENV)
22
+ Polyrun::Config::DottedPath.dig(build(cfg, env: env), dotted_path)
23
+ end
24
+
25
+ private
26
+
27
+ def cache_key(cfg, env)
28
+ [cfg.path, cfg.object_id, env_fingerprint(env)]
29
+ end
30
+
31
+ def env_fingerprint(env)
32
+ env.to_h.keys.sort.map { |k| [k, env[k]] }.hash
33
+ end
34
+
35
+ def build_uncached(cfg, env:)
36
+ r = Polyrun::Config::Resolver
37
+ base = deep_stringify_keys(cfg.raw)
38
+
39
+ prep = cfg.prepare
40
+ base["prepare"] = deep_stringify_keys(prep)
41
+ base["prepare"]["env"] = r.merged_prepare_env(prep, env)
42
+
43
+ pc = cfg.partition
44
+ part = deep_stringify_keys(pc).merge(
45
+ "shard_index" => r.resolve_shard_index(pc, env),
46
+ "shard_total" => r.resolve_shard_total(pc, env),
47
+ "shard_processes" => r.resolve_shard_processes(pc, env),
48
+ "timing_granularity" => r.resolve_partition_timing_granularity(pc, nil, env).to_s
49
+ )
50
+ base["partition"] = part
51
+
52
+ base["workers"] = r.parallel_worker_count_default(env)
53
+
54
+ base
55
+ end
56
+
57
+ def deep_stringify_keys(obj)
58
+ case obj
59
+ when Hash
60
+ obj.each_with_object({}) do |(k, v), m|
61
+ m[k.to_s] = deep_stringify_keys(v)
62
+ end
63
+ when Array
64
+ obj.map { |e| deep_stringify_keys(e) }
65
+ else
66
+ obj
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,78 @@
1
+ require_relative "../env/ci"
2
+ require_relative "../partition/timing_keys"
3
+
4
+ module Polyrun
5
+ class Config
6
+ # Single source for values derived from +polyrun.yml+, +ENV+, and CI detection.
7
+ # Used by {Effective}, CLI helpers, and prepare.
8
+ module Resolver
9
+ module_function
10
+
11
+ def env_int(name, fallback, env = ENV)
12
+ s = env[name]
13
+ return fallback if s.nil? || s.empty?
14
+
15
+ Integer(s, exception: false) || fallback
16
+ end
17
+
18
+ def prepare_env_yaml_string_map(prep)
19
+ (prep["env"] || prep[:env] || {}).transform_keys(&:to_s).transform_values(&:to_s)
20
+ end
21
+
22
+ # Same merge order as +polyrun prepare+: YAML +prepare.env+ overrides process +ENV+ for overlapping keys.
23
+ def merged_prepare_env(prep, env = ENV)
24
+ prep_env = prepare_env_yaml_string_map(prep)
25
+ env.to_h.merge(prep_env)
26
+ end
27
+
28
+ def partition_int(pc, keys, default)
29
+ keys.each do |k|
30
+ v = pc[k] || pc[k.to_sym]
31
+ next if v.nil? || v.to_s.empty?
32
+
33
+ i = Integer(v, exception: false)
34
+ return i unless i.nil?
35
+ end
36
+ default
37
+ end
38
+
39
+ def resolve_shard_index(pc, env = ENV)
40
+ return Integer(env["POLYRUN_SHARD_INDEX"]) if env["POLYRUN_SHARD_INDEX"] && !env["POLYRUN_SHARD_INDEX"].empty?
41
+
42
+ ci = Polyrun::Env::Ci.detect_shard_index
43
+ return ci unless ci.nil?
44
+
45
+ partition_int(pc, %w[shard_index shard], 0)
46
+ end
47
+
48
+ def resolve_shard_total(pc, env = ENV)
49
+ return Integer(env["POLYRUN_SHARD_TOTAL"]) if env["POLYRUN_SHARD_TOTAL"] && !env["POLYRUN_SHARD_TOTAL"].empty?
50
+
51
+ ci = Polyrun::Env::Ci.detect_shard_total
52
+ return ci unless ci.nil?
53
+
54
+ partition_int(pc, %w[shard_total total], 1)
55
+ end
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
+
65
+ # +cli_val+ is an override (e.g. +run-shards --timing-granularity+); +nil+ uses YAML then +POLYRUN_TIMING_GRANULARITY+.
66
+ def resolve_partition_timing_granularity(pc, cli_val, env = ENV)
67
+ raw = cli_val
68
+ raw ||= pc && (pc["timing_granularity"] || pc[:timing_granularity])
69
+ raw ||= env["POLYRUN_TIMING_GRANULARITY"]
70
+ Polyrun::Partition::TimingKeys.normalize_granularity(raw || "file")
71
+ end
72
+
73
+ def parallel_worker_count_default(env = ENV)
74
+ env_int("POLYRUN_WORKERS", Polyrun::Config::DEFAULT_PARALLEL_WORKERS, env)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -5,6 +5,10 @@ module Polyrun
5
5
  class Config
6
6
  DEFAULT_FILENAMES = %w[polyrun.yml config/polyrun.yml].freeze
7
7
 
8
+ # Parallel worker defaults (+run-shards+, +POLYRUN_WORKERS+); single source with {Resolver} and {Effective}.
9
+ DEFAULT_PARALLEL_WORKERS = 5
10
+ MAX_PARALLEL_WORKERS = 10
11
+
8
12
  attr_reader :path, :raw
9
13
 
10
14
  def self.load(path: nil)
@@ -59,3 +63,6 @@ module Polyrun
59
63
  end
60
64
  end
61
65
  end
66
+
67
+ require_relative "config/resolver"
68
+ require_relative "config/effective"