polyrun 1.0.0 → 1.2.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +23 -3
  4. data/docs/SETUP_PROFILE.md +1 -1
  5. data/lib/polyrun/cli/ci_shard_run_command.rb +65 -0
  6. data/lib/polyrun/cli/config_command.rb +42 -0
  7. data/lib/polyrun/cli/default_run.rb +115 -0
  8. data/lib/polyrun/cli/help.rb +54 -0
  9. data/lib/polyrun/cli/helpers.rb +19 -30
  10. data/lib/polyrun/cli/plan_command.rb +47 -17
  11. data/lib/polyrun/cli/prepare_command.rb +2 -3
  12. data/lib/polyrun/cli/prepare_recipe.rb +12 -7
  13. data/lib/polyrun/cli/queue_command.rb +17 -7
  14. data/lib/polyrun/cli/run_shards_command.rb +49 -3
  15. data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +3 -3
  16. data/lib/polyrun/cli/run_shards_plan_options.rb +18 -11
  17. data/lib/polyrun/cli/run_shards_planning.rb +16 -12
  18. data/lib/polyrun/cli/start_bootstrap.rb +2 -6
  19. data/lib/polyrun/cli.rb +53 -47
  20. data/lib/polyrun/config/dotted_path.rb +21 -0
  21. data/lib/polyrun/config/effective.rb +71 -0
  22. data/lib/polyrun/config/resolver.rb +70 -0
  23. data/lib/polyrun/config.rb +7 -0
  24. data/lib/polyrun/database/provision.rb +12 -7
  25. data/lib/polyrun/partition/constraints.rb +15 -4
  26. data/lib/polyrun/partition/paths.rb +83 -2
  27. data/lib/polyrun/partition/plan.rb +38 -28
  28. data/lib/polyrun/partition/timing_keys.rb +85 -0
  29. data/lib/polyrun/prepare/assets.rb +12 -5
  30. data/lib/polyrun/process_stdio.rb +91 -0
  31. data/lib/polyrun/quick/runner.rb +26 -17
  32. data/lib/polyrun/rspec.rb +19 -0
  33. data/lib/polyrun/templates/POLYRUN.md +1 -1
  34. data/lib/polyrun/templates/ci_matrix.polyrun.yml +4 -1
  35. data/lib/polyrun/timing/merge.rb +2 -1
  36. data/lib/polyrun/timing/rspec_example_formatter.rb +53 -0
  37. data/lib/polyrun/version.rb +1 -1
  38. data/polyrun.gemspec +1 -1
  39. data/sig/polyrun/rspec.rbs +2 -0
  40. metadata +12 -1
@@ -1,4 +1,4 @@
1
- require "open3"
1
+ require_relative "../process_stdio"
2
2
 
3
3
  module Polyrun
4
4
  class CLI
@@ -22,8 +22,7 @@ module Polyrun
22
22
  return [manifest, nil]
23
23
  end
24
24
  if custom && !custom.to_s.strip.empty?
25
- _out, err, st = Open3.capture3(*([child_env].compact + ["sh", "-c", custom.to_s]), chdir: rails_root)
26
- prepare_log_stderr(err)
25
+ st = prepare_run_shell_inherit_stdio(child_env, custom.to_s, rails_root, silent: !@verbose)
27
26
  unless st.success?
28
27
  Polyrun::Log.warn "polyrun prepare: assets custom command failed (exit #{st.exitstatus})"
29
28
  return [manifest, 1]
@@ -59,8 +58,7 @@ module Polyrun
59
58
  return [manifest, nil]
60
59
  end
61
60
  lines.each_with_index do |line, i|
62
- _out, err, st = Open3.capture3(*([child_env].compact + ["sh", "-c", line]), chdir: rails_root)
63
- prepare_log_stderr(err)
61
+ st = prepare_run_shell_inherit_stdio(child_env, line, rails_root, silent: !@verbose)
64
62
  unless st.success?
65
63
  Polyrun::Log.warn "polyrun prepare: shell step #{i + 1} failed (exit #{st.exitstatus})"
66
64
  return [manifest, 1]
@@ -69,8 +67,15 @@ module Polyrun
69
67
  [manifest, nil]
70
68
  end
71
69
 
72
- def prepare_log_stderr(err)
73
- Polyrun::Log.warn err unless err.to_s.empty?
70
+ def prepare_run_shell_inherit_stdio(child_env, script, rails_root, silent: false)
71
+ Polyrun::ProcessStdio.inherit_stdio_spawn_wait(
72
+ child_env,
73
+ "sh",
74
+ "-c",
75
+ script.to_s,
76
+ chdir: rails_root,
77
+ silent: silent
78
+ )
74
79
  end
75
80
  end
76
81
  end
@@ -11,6 +11,7 @@ module Polyrun
11
11
  dir = ".polyrun-queue"
12
12
  paths_file = nil
13
13
  timing_path = nil
14
+ timing_granularity = nil
14
15
  worker = ENV["USER"] || "worker"
15
16
  batch = 5
16
17
  lease_id = nil
@@ -19,7 +20,7 @@ module Polyrun
19
20
  Polyrun::Debug.log("queue: subcommand=#{sub.inspect}")
20
21
  case sub
21
22
  when "init"
22
- queue_cmd_init(argv, dir, paths_file, timing_path)
23
+ queue_cmd_init(argv, dir, paths_file, timing_path, timing_granularity)
23
24
  when "claim"
24
25
  queue_cmd_claim(argv, dir, worker, batch)
25
26
  when "ack"
@@ -32,29 +33,38 @@ module Polyrun
32
33
  end
33
34
  end
34
35
 
35
- def queue_cmd_init(argv, dir, paths_file, timing_path)
36
+ def queue_cmd_init(argv, dir, paths_file, timing_path, timing_granularity)
36
37
  OptionParser.new do |opts|
37
- opts.banner = "usage: polyrun queue init --paths-file P [--timing PATH] [--dir DIR]"
38
+ opts.banner = "usage: polyrun queue init --paths-file P [--timing PATH] [--timing-granularity VAL] [--dir DIR]"
38
39
  opts.on("--dir PATH") { |v| dir = v }
39
40
  opts.on("--paths-file PATH") { |v| paths_file = v }
40
41
  opts.on("--timing PATH") { |v| timing_path = v }
42
+ opts.on("--timing-granularity VAL") { |v| timing_granularity = v }
41
43
  end.parse!(argv)
42
44
  unless paths_file
43
45
  Polyrun::Log.warn "queue init: need --paths-file"
44
46
  return 2
45
47
  end
48
+ cfg = Polyrun::Config.load(path: ENV["POLYRUN_CONFIG"])
49
+ g = resolve_partition_timing_granularity(cfg.partition, timing_granularity)
46
50
  items = Polyrun::Partition::Paths.read_lines(paths_file)
47
- costs = timing_path ? Polyrun::Partition::Plan.load_timing_costs(File.expand_path(timing_path, Dir.pwd)) : nil
48
- ordered = queue_init_ordered_items(items, costs)
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)
49
59
  Polyrun::Queue::FileStore.new(dir).init!(ordered)
50
60
  Polyrun::Log.puts JSON.generate({"dir" => File.expand_path(dir), "count" => ordered.size})
51
61
  0
52
62
  end
53
63
 
54
- def queue_init_ordered_items(items, costs)
64
+ def queue_init_ordered_items(items, costs, granularity = :file)
55
65
  if costs && !costs.empty?
56
66
  dw = costs.values.sum / costs.size.to_f
57
- items.sort_by { |p| [-queue_weight_for(p, costs, dw), p] }
67
+ items.sort_by { |p| [-queue_weight_for(p, costs, dw, granularity: granularity), p] }
58
68
  else
59
69
  items.sort
60
70
  end
@@ -12,9 +12,7 @@ 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
18
  # {Coverage::Collector} writes coverage/polyrun-fragment-<shard>.json. Merge with merge-coverage.
@@ -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"])
@@ -25,16 +25,16 @@ 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
- costs, strategy, err = run_shards_resolve_costs(o[:timing_path], o[:strategy])
31
+ costs, strategy, err = run_shards_resolve_costs(o[:timing_path], o[:strategy], o[:timing_granularity])
32
32
  return [err, nil] if err
33
33
 
34
34
  run_shards_plan_ready_log(o, strategy, cmd, paths_source, items.size)
35
35
 
36
36
  constraints = load_partition_constraints(pc, o[:constraints_path])
37
- plan = run_shards_make_plan(items, o[:workers], strategy, o[:seed], costs, constraints)
37
+ plan = run_shards_make_plan(items, o[:workers], strategy, o[:seed], costs, constraints, o[:timing_granularity])
38
38
 
39
39
  run_shards_debug_shard_sizes(plan, o[:workers])
40
40
  Polyrun::Log.warn "polyrun run-shards: #{items.size} paths → #{o[:workers]} workers (#{strategy})" if @verbose
@@ -9,17 +9,19 @@ module Polyrun
9
9
  st = run_shards_plan_options_state(pc)
10
10
  run_shards_plan_options_parse!(head, st)
11
11
  st[:paths_file] ||= pc["paths_file"] || pc[:paths_file]
12
+ st[:timing_granularity] = resolve_partition_timing_granularity(pc, st[:timing_granularity])
12
13
  st
13
14
  end
14
15
 
15
16
  def run_shards_plan_options_state(pc)
16
17
  {
17
- workers: env_int("POLYRUN_WORKERS", RunShardsCommand::DEFAULT_PARALLEL_WORKERS),
18
+ workers: env_int("POLYRUN_WORKERS", Polyrun::Config::DEFAULT_PARALLEL_WORKERS),
18
19
  paths_file: nil,
19
20
  strategy: (pc["strategy"] || pc[:strategy] || "round_robin").to_s,
20
21
  seed: pc["seed"] || pc[:seed],
21
22
  timing_path: nil,
22
23
  constraints_path: nil,
24
+ timing_granularity: nil,
23
25
  merge_coverage: false,
24
26
  merge_output: nil,
25
27
  merge_format: nil
@@ -28,18 +30,23 @@ module Polyrun
28
30
 
29
31
  def run_shards_plan_options_parse!(head, st)
30
32
  OptionParser.new do |opts|
31
- opts.banner = "usage: polyrun run-shards [--workers N] [--strategy NAME] [--paths-file P] [--timing P] [--constraints P] [--seed S] [--merge-coverage] [--merge-output P] [--merge-format LIST] [--] <command> [args...]"
32
- opts.on("--workers N", Integer) { |v| st[:workers] = v }
33
- opts.on("--strategy NAME", String) { |v| st[:strategy] = v }
34
- opts.on("--seed VAL") { |v| st[:seed] = v }
35
- opts.on("--paths-file PATH", String) { |v| st[:paths_file] = v }
36
- opts.on("--constraints PATH", String) { |v| st[:constraints_path] = v }
37
- opts.on("--timing PATH", "merged polyrun_timing.json; implies cost_binpack unless hrw/cost") { |v| st[:timing_path] = v }
38
- opts.on("--merge-coverage", "After success, merge coverage/polyrun-fragment-*.json (Polyrun coverage must be enabled)") { st[:merge_coverage] = true }
39
- opts.on("--merge-output PATH", String) { |v| st[:merge_output] = v }
40
- opts.on("--merge-format LIST", String) { |v| st[:merge_format] = v }
33
+ run_shards_plan_options_register!(opts, st)
41
34
  end.parse!(head)
42
35
  end
36
+
37
+ def run_shards_plan_options_register!(opts, st)
38
+ opts.banner = "usage: polyrun run-shards [--workers N] [--strategy NAME] [--paths-file P] [--timing P] [--timing-granularity VAL] [--constraints P] [--seed S] [--merge-coverage] [--merge-output P] [--merge-format LIST] [--] <command> [args...]"
39
+ opts.on("--workers N", Integer) { |v| st[:workers] = v }
40
+ opts.on("--strategy NAME", String) { |v| st[:strategy] = v }
41
+ opts.on("--seed VAL") { |v| st[:seed] = v }
42
+ opts.on("--paths-file PATH", String) { |v| st[:paths_file] = v }
43
+ opts.on("--constraints PATH", String) { |v| st[:constraints_path] = v }
44
+ opts.on("--timing PATH", "merged polyrun_timing.json; implies cost_binpack unless hrw/cost") { |v| st[:timing_path] = v }
45
+ opts.on("--timing-granularity VAL", "file (default) or example (experimental)") { |v| st[:timing_granularity] = v }
46
+ opts.on("--merge-coverage", "After success, merge coverage/polyrun-fragment-*.json (Polyrun coverage must be enabled)") { st[:merge_coverage] = true }
47
+ opts.on("--merge-output PATH", String) { |v| st[:merge_output] = v }
48
+ opts.on("--merge-format LIST", String) { |v| st[:merge_format] = v }
49
+ end
43
50
  end
44
51
  end
45
52
  end
@@ -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,26 +53,29 @@ 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]
71
71
  end
72
72
 
73
- def run_shards_resolve_costs(timing_path, strategy)
73
+ def run_shards_resolve_costs(timing_path, strategy, timing_granularity)
74
74
  if timing_path
75
- costs = Polyrun::Partition::Plan.load_timing_costs(File.expand_path(timing_path.to_s, Dir.pwd))
75
+ costs = Polyrun::Partition::Plan.load_timing_costs(
76
+ File.expand_path(timing_path.to_s, Dir.pwd),
77
+ granularity: timing_granularity
78
+ )
76
79
  if costs.empty?
77
80
  Polyrun::Log.warn "polyrun run-shards: timing file missing or empty: #{timing_path}"
78
81
  return [nil, nil, 2]
@@ -90,7 +93,7 @@ module Polyrun
90
93
  end
91
94
  end
92
95
 
93
- def run_shards_make_plan(items, workers, strategy, seed, costs, constraints)
96
+ def run_shards_make_plan(items, workers, strategy, seed, costs, constraints, timing_granularity)
94
97
  Polyrun::Debug.time("Partition::Plan.new (partition #{items.size} paths → #{workers} shards)") do
95
98
  Polyrun::Partition::Plan.new(
96
99
  items: items,
@@ -99,7 +102,8 @@ module Polyrun
99
102
  seed: seed,
100
103
  costs: costs,
101
104
  constraints: constraints,
102
- root: Dir.pwd
105
+ root: Dir.pwd,
106
+ timing_granularity: timing_granularity
103
107
  )
104
108
  end
105
109
  end
@@ -115,7 +119,7 @@ module Polyrun
115
119
 
116
120
  def run_shards_warn_parallel_banner(item_count, workers, strategy)
117
121
  Polyrun::Log.warn <<~MSG
118
- 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}
119
123
  (plain `bundle exec rspec` is one process; this command fans out.)
120
124
  MSG
121
125
  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,9 +12,30 @@ 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_command"
16
+ require_relative "cli/config_command"
17
+ require_relative "cli/default_run"
18
+ require_relative "cli/help"
15
19
 
16
20
  module Polyrun
17
21
  class CLI
22
+ CI_SHARD_COMMANDS = {
23
+ "ci-shard-run" => :cmd_ci_shard_run,
24
+ "ci-shard-rspec" => :cmd_ci_shard_rspec
25
+ }.freeze
26
+
27
+ # Keep in sync with +dispatch_cli_command_subcommands+ (+when+ branches). Used for implicit path routing.
28
+ DISPATCH_SUBCOMMAND_NAMES = %w[
29
+ plan prepare merge-coverage report-coverage report-junit report-timing
30
+ env config merge-timing db:setup-template db:setup-shard db:clone-shards
31
+ run-shards parallel-rspec start build-paths init queue quick
32
+ ].freeze
33
+
34
+ # First argv token that is a normal subcommand (not a path); if argv[0] is not here but looks like paths, run implicit parallel.
35
+ IMPLICIT_PATH_EXCLUSION_TOKENS = (
36
+ DISPATCH_SUBCOMMAND_NAMES + CI_SHARD_COMMANDS.keys + %w[help version]
37
+ ).freeze
38
+
18
39
  include Helpers
19
40
  include PlanCommand
20
41
  include PrepareCommand
@@ -27,6 +48,10 @@ module Polyrun
27
48
  include TimingCommand
28
49
  include InitCommand
29
50
  include QuickCommand
51
+ include CiShardRunCommand
52
+ include ConfigCommand
53
+ include DefaultRun
54
+ include Help
30
55
 
31
56
  def self.run(argv = ARGV)
32
57
  new.run(argv)
@@ -37,12 +62,30 @@ module Polyrun
37
62
  config_path = parse_global_cli!(argv)
38
63
  return config_path if config_path.is_a?(Integer)
39
64
 
40
- command = argv.shift
41
- if command.nil?
42
- print_help
43
- return 0
65
+ if argv.empty?
66
+ Polyrun::Debug.log_kv(
67
+ command: "(default)",
68
+ cwd: Dir.pwd,
69
+ polyrun_config: config_path,
70
+ argv_rest: [],
71
+ verbose: @verbose
72
+ )
73
+ return dispatch_default_parallel!(config_path)
74
+ end
75
+
76
+ if implicit_parallel_run?(argv)
77
+ Polyrun::Debug.log_kv(
78
+ command: "(paths)",
79
+ cwd: Dir.pwd,
80
+ polyrun_config: config_path,
81
+ argv_rest: argv.dup,
82
+ verbose: @verbose
83
+ )
84
+ return dispatch_implicit_parallel_targets!(argv, config_path)
44
85
  end
45
86
 
87
+ command = argv.shift
88
+
46
89
  Polyrun::Debug.log_kv(
47
90
  command: command,
48
91
  cwd: Dir.pwd,
@@ -89,6 +132,7 @@ module Polyrun
89
132
  end
90
133
  end
91
134
 
135
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity -- explicit dispatch table
92
136
  def dispatch_cli_command_subcommands(command, argv, config_path)
93
137
  case command
94
138
  when "plan"
@@ -105,6 +149,8 @@ module Polyrun
105
149
  cmd_report_timing(argv)
106
150
  when "env"
107
151
  cmd_env(argv, config_path)
152
+ when "config"
153
+ cmd_config(argv, config_path)
108
154
  when "merge-timing"
109
155
  cmd_merge_timing(argv)
110
156
  when "db:setup-template"
@@ -121,6 +167,8 @@ module Polyrun
121
167
  cmd_start(argv, config_path)
122
168
  when "build-paths"
123
169
  cmd_build_paths(config_path)
170
+ when *CI_SHARD_COMMANDS.keys
171
+ send(CI_SHARD_COMMANDS.fetch(command), argv, config_path)
124
172
  when "init"
125
173
  cmd_init(argv, config_path)
126
174
  when "queue"
@@ -132,49 +180,7 @@ module Polyrun
132
180
  2
133
181
  end
134
182
  end
135
-
136
- def print_help
137
- Polyrun::Log.puts <<~HELP
138
- usage: polyrun [global options] <command> [options]
139
-
140
- global:
141
- -c, --config PATH polyrun.yml path (or POLYRUN_CONFIG)
142
- -v, --verbose
143
- -h, --help
144
-
145
- Trace timing (stderr): DEBUG=1 or POLYRUN_DEBUG=1
146
- Branch coverage in JSON fragments: POLYRUN_COVERAGE_BRANCHES=1 (stdlib Coverage; merge-coverage merges branches)
147
- polyrun quick coverage: POLYRUN_COVERAGE=1 or (config/polyrun_coverage.yml + POLYRUN_QUICK_COVERAGE=1); POLYRUN_COVERAGE_DISABLE=1 skips
148
- Merge wall time (stderr): POLYRUN_PROFILE_MERGE=1 (or verbose / DEBUG)
149
- Post-merge formats (run-shards): POLYRUN_MERGE_FORMATS (default: json,lcov,cobertura,console,html)
150
- Skip optional script/build_spec_paths.rb before start: POLYRUN_SKIP_BUILD_SPEC_PATHS=1
151
- Skip start auto-prepare / auto DB provision: POLYRUN_START_SKIP_PREPARE=1, POLYRUN_START_SKIP_DATABASES=1
152
- Skip writing paths_file from partition.paths_build: POLYRUN_SKIP_PATHS_BUILD=1
153
- Warn if merge-coverage wall time exceeds N seconds (default 10): POLYRUN_MERGE_SLOW_WARN_SECONDS (0 disables)
154
- Parallel RSpec workers: POLYRUN_WORKERS default 5, max 10 (run-shards / parallel-rspec / start)
155
-
156
- commands:
157
- version print version
158
- plan emit partition manifest JSON
159
- prepare run prepare recipe: default | assets (optional prepare.command overrides bin/rails assets:precompile) | shell (prepare.command required)
160
- merge-coverage merge SimpleCov JSON fragments (json/lcov/cobertura/console)
161
- run-shards fan out N parallel OS processes (POLYRUN_SHARD_*; not Ruby threads); optional --merge-coverage
162
- parallel-rspec run-shards + merge-coverage (defaults to: bundle exec rspec after --)
163
- 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
164
- build-paths write partition.paths_file from partition.paths_build (same as auto step before plan/run-shards)
165
- init write a starter polyrun.yml or POLYRUN.md from built-in templates (see docs/SETUP_PROFILE.md)
166
- queue file-backed batch queue (init / claim / ack / status)
167
- quick run Polyrun::Quick (describe/it, before/after, let, expect…to, assert_*; optional capybara!)
168
- report-coverage write all coverage formats from one JSON file
169
- report-junit RSpec JSON or Polyrun testcase JSON → JUnit XML (CI)
170
- report-timing print slow-file summary from merged timing JSON
171
- merge-timing merge polyrun_timing_*.json shards
172
- env print shard + database env (see polyrun.yml databases)
173
- db:setup-template migrate template DB (PostgreSQL)
174
- db:setup-shard CREATE DATABASE shard FROM template (one POLYRUN_SHARD_INDEX)
175
- db:clone-shards migrate templates + DROP/CREATE all shard DBs (replaces clone_shard shell scripts)
176
- HELP
177
- end
183
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
178
184
 
179
185
  def cmd_version
180
186
  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,71 @@
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+ / +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
+ "timing_granularity" => r.resolve_partition_timing_granularity(pc, nil, env).to_s
48
+ )
49
+ base["partition"] = part
50
+
51
+ base["workers"] = r.parallel_worker_count_default(env)
52
+
53
+ base
54
+ end
55
+
56
+ def deep_stringify_keys(obj)
57
+ case obj
58
+ when Hash
59
+ obj.each_with_object({}) do |(k, v), m|
60
+ m[k.to_s] = deep_stringify_keys(v)
61
+ end
62
+ when Array
63
+ obj.map { |e| deep_stringify_keys(e) }
64
+ else
65
+ obj
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,70 @@
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
+ # +cli_val+ is an override (e.g. +run-shards --timing-granularity+); +nil+ uses YAML then +POLYRUN_TIMING_GRANULARITY+.
58
+ def resolve_partition_timing_granularity(pc, cli_val, env = ENV)
59
+ raw = cli_val
60
+ raw ||= pc && (pc["timing_granularity"] || pc[:timing_granularity])
61
+ raw ||= env["POLYRUN_TIMING_GRANULARITY"]
62
+ Polyrun::Partition::TimingKeys.normalize_granularity(raw || "file")
63
+ end
64
+
65
+ def parallel_worker_count_default(env = ENV)
66
+ env_int("POLYRUN_WORKERS", Polyrun::Config::DEFAULT_PARALLEL_WORKERS, env)
67
+ end
68
+ end
69
+ end
70
+ end