polyrun 2.2.5 → 2.2.6

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +1 -0
  4. data/docs/SETUP_PROFILE.md +1 -0
  5. data/lib/polyrun/cli/database_commands.rb +21 -7
  6. data/lib/polyrun/cli/default_run.rb +13 -3
  7. data/lib/polyrun/cli/helpers.rb +41 -6
  8. data/lib/polyrun/cli/plan_command.rb +10 -29
  9. data/lib/polyrun/cli/run_queue_command.rb +10 -1
  10. data/lib/polyrun/cli/run_shards_parallel_children.rb +52 -39
  11. data/lib/polyrun/cli/run_shards_parallel_wait.rb +2 -2
  12. data/lib/polyrun/cli/run_shards_planning.rb +7 -29
  13. data/lib/polyrun/cli/run_shards_run.rb +1 -1
  14. data/lib/polyrun/cli/run_shards_worker_interrupt.rb +16 -14
  15. data/lib/polyrun/cli/start_bootstrap.rb +51 -22
  16. data/lib/polyrun/cli.rb +3 -0
  17. data/lib/polyrun/config.rb +9 -4
  18. data/lib/polyrun/coverage/example_diff.rb +6 -7
  19. data/lib/polyrun/coverage/merge/formatters_html.rb +20 -2
  20. data/lib/polyrun/coverage/merge.rb +9 -3
  21. data/lib/polyrun/coverage/merge_fragment_meta.rb +6 -1
  22. data/lib/polyrun/coverage/rails.rb +1 -1
  23. data/lib/polyrun/database/clone_shards.rb +28 -5
  24. data/lib/polyrun/database/provision.rb +15 -9
  25. data/lib/polyrun/database/url_builder/connection/infer.rb +4 -0
  26. data/lib/polyrun/database/url_builder.rb +31 -3
  27. data/lib/polyrun/partition/plan.rb +6 -24
  28. data/lib/polyrun/partition/timing_diagnostics.rb +2 -3
  29. data/lib/polyrun/queue/file_store.rb +133 -30
  30. data/lib/polyrun/queue/file_store_intent.rb +188 -0
  31. data/lib/polyrun/queue/worker_loop.rb +41 -7
  32. data/lib/polyrun/spec_quality/config.rb +1 -1
  33. data/lib/polyrun/spec_quality/profile.rb +0 -1
  34. data/lib/polyrun/spec_quality/report.rb +16 -7
  35. data/lib/polyrun/templates/rails_prepare.polyrun.yml +3 -1
  36. data/lib/polyrun/version.rb +1 -1
  37. data/lib/polyrun/worker_output.rb +30 -13
  38. metadata +2 -1
data/lib/polyrun/cli.rb CHANGED
@@ -108,6 +108,9 @@ module Polyrun
108
108
  )
109
109
 
110
110
  dispatch_cli_command(command, argv, config_path)
111
+ rescue Polyrun::Error => e
112
+ Polyrun::Log.warn "polyrun: #{e.message}"
113
+ 2
111
114
  end
112
115
 
113
116
  private
@@ -12,14 +12,19 @@ module Polyrun
12
12
  attr_reader :path, :raw
13
13
 
14
14
  def self.load(path: nil)
15
- path = resolve_path(path)
15
+ explicit = path && !path.to_s.strip.empty?
16
+ resolved = resolve_path(path)
17
+ if explicit && !(resolved && File.file?(resolved))
18
+ raise Polyrun::Error, "polyrun config not found: #{path}"
19
+ end
20
+
16
21
  raw =
17
- if path && File.file?(path)
18
- YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true) || {}
22
+ if resolved && File.file?(resolved)
23
+ YAML.safe_load_file(resolved, permitted_classes: [Symbol], aliases: true) || {}
19
24
  else
20
25
  {}
21
26
  end
22
- new(path: path, raw: raw)
27
+ new(path: resolved, raw: raw)
23
28
  end
24
29
 
25
30
  def self.resolve_path(explicit)
@@ -131,14 +131,13 @@ module Polyrun
131
131
  end
132
132
 
133
133
  def collect_diff_paths(before_blob, after_blob, filter: nil)
134
- keys = before_blob.keys.dup
135
- after_blob.each_key do |path|
136
- string_path = path.to_s
137
- keys << string_path unless keys.include?(string_path)
138
- end
139
- return keys unless filter
134
+ keys = {}
135
+ before_blob.each_key { |path| keys[path.to_s] = true }
136
+ after_blob.each_key { |path| keys[path.to_s] = true }
137
+ list = keys.keys
138
+ return list unless filter
140
139
 
141
- keys.select { |path| filter.include_path?(path) }
140
+ list.select { |path| filter.include_path?(path) }
142
141
  end
143
142
 
144
143
  def accumulate_line_delta(before_entry, after_lines)
@@ -23,7 +23,7 @@ module Polyrun
23
23
  )
24
24
  file_list_html = render_html_partial("file_list", file_rows_html: files.map { |file| html_file_list_row(file) }.join("\n"))
25
25
  file_sections_html = files.map { |file| render_html_partial("file_section", file: file) }.join("\n")
26
- ERB.new(File.read(html_template_path, encoding: Encoding::UTF_8), trim_mode: "-").result_with_hash(
26
+ html_root_template_erb.result_with_hash(
27
27
  title: CGI.escapeHTML(title.to_s),
28
28
  generated_label: html_generated_label(generated_at),
29
29
  overview_html: overview_html,
@@ -56,9 +56,27 @@ module Polyrun
56
56
  end
57
57
 
58
58
  def render_html_partial(name, locals = {})
59
- ERB.new(File.read(html_partial_path(name), encoding: Encoding::UTF_8), trim_mode: "-").result_with_hash(locals)
59
+ html_partial_erb(name).result_with_hash(locals)
60
60
  end
61
61
 
62
+ # rubocop:disable ThreadSafety/ClassInstanceVariable -- template cache keyed by partial name; process-local
63
+ def html_partial_erb(name)
64
+ @html_partial_erbs ||= {}
65
+ @html_partial_erbs[name] ||= ERB.new(
66
+ File.read(html_partial_path(name), encoding: Encoding::UTF_8),
67
+ trim_mode: "-"
68
+ )
69
+ end
70
+
71
+ def html_root_template_erb
72
+ @html_root_template_erb ||= ERB.new(
73
+ File.read(html_template_path, encoding: Encoding::UTF_8),
74
+ trim_mode: "-"
75
+ )
76
+ end
77
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
78
+ private_class_method :html_partial_erb, :html_root_template_erb
79
+
62
80
  def html_file_payload(path, file, root)
63
81
  line_arr = line_array_from_file_entry(file) || []
64
82
  source_lines = html_source_lines(path, line_arr.length)
@@ -26,10 +26,16 @@ module Polyrun
26
26
  def merge_fragments(paths)
27
27
  return {blob: {}, meta: {}, groups: nil} if paths.empty?
28
28
 
29
- docs = paths.map { |p| JSON.parse(File.read(p)) }
30
- blobs = docs.map { |d| extract_coverage_blob(d) }
29
+ # Parse one fragment at a time: keep only meta + blob so peak RSS drops after each fold step.
30
+ metas = []
31
+ blobs = []
32
+ paths.each do |path|
33
+ doc = JSON.parse(File.read(path))
34
+ metas << extract_doc_meta(doc)
35
+ blobs << extract_coverage_blob(doc)
36
+ end
31
37
  merged_blob = merge_blob_tree(blobs)
32
- merged_meta = merge_fragment_metas(docs)
38
+ merged_meta = merge_fragment_metas_from_metas(metas)
33
39
  merged_blob = apply_track_files_once_after_merge(merged_blob, merged_meta)
34
40
  groups_payload = recompute_groups_from_meta(merged_blob, merged_meta)
35
41
  {blob: merged_blob, meta: merged_meta, groups: groups_payload}
@@ -4,7 +4,12 @@ module Polyrun
4
4
  module_function
5
5
 
6
6
  def merge_fragment_metas(docs)
7
- metas = docs.map { |d| extract_doc_meta(d) }
7
+ merge_fragment_metas_from_metas(docs.map { |d| extract_doc_meta(d) })
8
+ end
9
+
10
+ def merge_fragment_metas_from_metas(metas)
11
+ return {} if metas.empty?
12
+
8
13
  base = metas.first.dup
9
14
  roots = metas.map { |m| m["polyrun_coverage_root"] }.compact
10
15
  grs = metas.map { |m| m["polyrun_coverage_groups"] }.compact
@@ -67,7 +67,7 @@ module Polyrun
67
67
  path = File.expand_path(path)
68
68
  return {} unless File.file?(path)
69
69
 
70
- data = YAML.load_file(path)
70
+ data = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true)
71
71
  data.is_a?(Hash) ? stringify_keys(data) : {}
72
72
  end
73
73
 
@@ -9,6 +9,7 @@ module Polyrun
9
9
  # See +provision!+ on the singleton class for options.
10
10
  def provision!(databases_hash, workers:, rails_root:, migrate: true, replace: true, force_drop: false, dry_run: false, silent: true)
11
11
  dh = databases_hash.is_a?(Hash) ? databases_hash : {}
12
+ ensure_postgresql!(dh)
12
13
  workers = Integer(workers)
13
14
  raise Polyrun::Error, "workers must be >= 1" if workers < 1
14
15
 
@@ -19,6 +20,16 @@ module Polyrun
19
20
  true
20
21
  end
21
22
 
23
+ def ensure_postgresql!(dh)
24
+ return if UrlBuilder::ConnectionInfer.postgresql?(dh)
25
+
26
+ adapter = UrlBuilder::ConnectionInfer.infer_adapter_name(dh)
27
+ raise Polyrun::Error,
28
+ "CloneShards is PostgreSQL-only (databases adapter is #{adapter}). " \
29
+ "Use polyrun env for URLs and provision shards yourself."
30
+ end
31
+ private_class_method :ensure_postgresql!
32
+
22
33
  def migrate_canonical_databases!(dh, rails_root, dry_run, silent)
23
34
  pt = (dh["template_db"] || dh[:template_db]).to_s
24
35
  if pt.empty?
@@ -37,6 +48,7 @@ module Polyrun
37
48
  private_class_method :migrate_canonical_databases!
38
49
 
39
50
  def create_shards_from_plan!(dh, workers, replace, force_drop, dry_run)
51
+ conn = UrlBuilder::Connection.resolve_connection(dh)
40
52
  if dry_run
41
53
  workers.times do |shard_index|
42
54
  plan = UrlBuilder.shard_database_plan(dh, shard_index: shard_index)
@@ -44,7 +56,7 @@ module Polyrun
44
56
  raise Polyrun::Error, "CloneShards: empty shard plan for shard_index=#{shard_index}"
45
57
  end
46
58
 
47
- plan.each { |row| create_one_shard!(row, replace, force_drop, dry_run) }
59
+ plan.each { |row| create_one_shard!(row, replace, force_drop, dry_run, conn) }
48
60
  end
49
61
  return
50
62
  end
@@ -56,7 +68,7 @@ module Polyrun
56
68
  raise Polyrun::Error, "CloneShards: empty shard plan for shard_index=#{shard_index}"
57
69
  end
58
70
 
59
- plan.each { |row| create_one_shard!(row, replace, force_drop, dry_run) }
71
+ plan.each { |row| create_one_shard!(row, replace, force_drop, dry_run, conn) }
60
72
  rescue => e
61
73
  raise Polyrun::Error, "CloneShards shard_index=#{shard_index}: #{e.message}"
62
74
  end
@@ -65,7 +77,7 @@ module Polyrun
65
77
  end
66
78
  private_class_method :create_shards_from_plan!
67
79
 
68
- def create_one_shard!(row, replace, force_drop, dry_run)
80
+ def create_one_shard!(row, replace, force_drop, dry_run, conn)
69
81
  new_db = row[:new_db].to_s
70
82
  tmpl = row[:template_db].to_s
71
83
  if dry_run
@@ -74,10 +86,21 @@ module Polyrun
74
86
  return
75
87
  end
76
88
 
77
- Provision.drop_database_if_exists!(database: new_db, force: force_drop) if replace
78
- Provision.create_database_from_template!(new_db: new_db, template_db: tmpl)
89
+ opts = provision_connection_options(conn)
90
+ Provision.drop_database_if_exists!(database: new_db, force: force_drop, **opts) if replace
91
+ Provision.create_database_from_template!(new_db: new_db, template_db: tmpl, **opts)
79
92
  end
80
93
  private_class_method :create_one_shard!
94
+
95
+ def provision_connection_options(conn)
96
+ {
97
+ host: conn[:host],
98
+ port: conn[:port],
99
+ username: conn[:user],
100
+ password: conn[:password]
101
+ }
102
+ end
103
+ private_class_method :provision_connection_options
81
104
  end
82
105
  end
83
106
  end
@@ -14,7 +14,7 @@ module Polyrun
14
14
  end
15
15
 
16
16
  # +DROP DATABASE IF EXISTS name;+ — maintenance DB +postgres+ (or +maintenance_db+).
17
- def drop_database_if_exists!(database:, host: nil, port: nil, username: nil, maintenance_db: "postgres", force: false)
17
+ def drop_database_if_exists!(database:, host: nil, port: nil, username: nil, password: nil, maintenance_db: "postgres", force: false)
18
18
  host ||= ENV["PGHOST"] || "localhost"
19
19
  port ||= ENV["PGPORT"] || "5432"
20
20
  username ||= ENV["PGUSER"] || "postgres"
@@ -25,26 +25,32 @@ module Polyrun
25
25
  else
26
26
  "DROP DATABASE IF EXISTS #{quote_ident(database)};"
27
27
  end
28
- cmd = ["psql", "-U", username, "-h", host, "-p", port.to_s, "-d", maintenance_db, "-v", "ON_ERROR_STOP=1", "-c", sql]
29
- _out, err, st = Open3.capture3(*cmd)
30
- raise Polyrun::Error, "drop database failed: #{err}" unless st.success?
31
-
32
- true
28
+ run_psql!(sql, host: host, port: port, username: username, password: password, maintenance_db: maintenance_db, failure_prefix: "drop database failed")
33
29
  end
34
30
 
35
31
  # CREATE DATABASE new_db TEMPLATE template_db — connects to maintenance DB +postgres+.
36
- def create_database_from_template!(new_db:, template_db:, host: nil, port: nil, username: nil, maintenance_db: "postgres")
32
+ def create_database_from_template!(new_db:, template_db:, host: nil, port: nil, username: nil, password: nil, maintenance_db: "postgres")
37
33
  host ||= ENV["PGHOST"] || "localhost"
38
34
  port ||= ENV["PGPORT"] || "5432"
39
35
  username ||= ENV["PGUSER"] || "postgres"
40
36
 
41
37
  sql = "CREATE DATABASE #{quote_ident(new_db)} TEMPLATE #{quote_ident(template_db)};"
38
+ run_psql!(sql, host: host, port: port, username: username, password: password, maintenance_db: maintenance_db, failure_prefix: "create database failed")
39
+ end
40
+
41
+ def run_psql!(sql, host:, port:, username:, password:, maintenance_db:, failure_prefix:)
42
42
  cmd = ["psql", "-U", username, "-h", host, "-p", port.to_s, "-d", maintenance_db, "-v", "ON_ERROR_STOP=1", "-c", sql]
43
- _out, err, st = Open3.capture3(*cmd)
44
- raise Polyrun::Error, "create database failed: #{err}" unless st.success?
43
+ _out, err, st =
44
+ if password.nil? || password.to_s.empty?
45
+ Open3.capture3(*cmd)
46
+ else
47
+ Open3.capture3(ENV.to_h.merge("PGPASSWORD" => password.to_s), *cmd)
48
+ end
49
+ raise Polyrun::Error, "#{failure_prefix}: #{err}" unless st.success?
45
50
 
46
51
  true
47
52
  end
53
+ private_class_method :run_psql!
48
54
 
49
55
  # Runs +bin/rails db:prepare+ with merged ENV (+DATABASE_URL+ for primary, +CACHE_DATABASE_URL+, etc.).
50
56
  # Multi-DB Rails apps must pass all template URLs in one invocation so each DB uses its own +migrations_paths+.
@@ -29,6 +29,10 @@ module Polyrun
29
29
  "postgresql"
30
30
  end
31
31
 
32
+ def postgresql?(databases_hash)
33
+ infer_adapter_name(databases_hash.is_a?(Hash) ? databases_hash : {}) == "postgresql"
34
+ end
35
+
32
36
  def normalize_adapter_alias(name)
33
37
  case name
34
38
  when "postgres", "pg" then "postgresql"
@@ -48,7 +48,9 @@ module Polyrun
48
48
  end
49
49
 
50
50
  def template_prepare_env_shell_log(databases_hash)
51
- template_prepare_env(databases_hash).sort.map { |k, v| "#{k}=#{Shellwords.escape(v.to_s)}" }.join(" ")
51
+ template_prepare_env(databases_hash).sort.map do |k, v|
52
+ "#{k}=#{Shellwords.escape(redact_url_userinfo_password(v.to_s))}"
53
+ end.join(" ")
52
54
  end
53
55
 
54
56
  def unique_template_migrate_urls(databases_hash)
@@ -111,6 +113,19 @@ module Polyrun
111
113
  def url_for_shard(databases_hash, shard_index:, connection: nil)
112
114
  dh = databases_hash.is_a?(Hash) ? databases_hash : {}
113
115
  conn = Connection.resolve_connection(dh)
116
+ pattern = shard_pattern(dh, connection)
117
+ validate_shard_db_pattern!(pattern)
118
+
119
+ dbname = pattern.to_s.gsub("%{shard}", Integer(shard_index).to_s).gsub("%<shard>d", format("%d", Integer(shard_index)))
120
+ template_name = template_database_name_for(dh, connection: connection)
121
+ if !template_name.to_s.empty? && dbname == template_name.to_s
122
+ raise Polyrun::Error,
123
+ "databases.shard_db_pattern resolves to template_db #{template_name.inspect} for shard #{shard_index}"
124
+ end
125
+ Connection.build_database_url(dbname, conn)
126
+ end
127
+
128
+ def shard_pattern(dh, connection)
114
129
  pattern =
115
130
  if connection
116
131
  conns = dh["connections"] || dh[:connections] || []
@@ -120,10 +135,23 @@ module Polyrun
120
135
  dh["shard_db_pattern"] || dh[:shard_db_pattern]
121
136
  end
122
137
  pattern ||= "app_test_%{shard}"
138
+ pattern
139
+ end
140
+ private_class_method :shard_pattern
123
141
 
124
- dbname = pattern.to_s.gsub("%{shard}", Integer(shard_index).to_s).gsub("%<shard>d", format("%d", Integer(shard_index)))
125
- Connection.build_database_url(dbname, conn)
142
+ def validate_shard_db_pattern!(pattern)
143
+ p = pattern.to_s
144
+ return if p.include?("%{shard}") || p.include?("%<shard>d")
145
+
146
+ raise Polyrun::Error, "databases.shard_db_pattern must include %{shard} or %<shard>d (got #{p.inspect})"
147
+ end
148
+ private_class_method :validate_shard_db_pattern!
149
+
150
+ def redact_url_userinfo_password(url)
151
+ s = url.to_s
152
+ s.sub(%r{\A([a-z][a-z0-9+.-]*://[^:/?#]+:)[^@/?#]+(@)}i, '\1***\2')
126
153
  end
154
+ private_class_method :redact_url_userinfo_password
127
155
 
128
156
  def env_exports_for_databases(databases_hash, shard_index:)
129
157
  dh = databases_hash.is_a?(Hash) ? databases_hash : {}
@@ -110,11 +110,9 @@ module Polyrun
110
110
  end
111
111
 
112
112
  def default_weight
113
- vals = @costs&.values || []
114
- if vals.empty?
115
- 1.0
116
- else
117
- vals.sum / vals.size
113
+ @default_weight ||= begin
114
+ vals = @costs&.values || []
115
+ vals.empty? ? 1.0 : vals.sum / vals.size
118
116
  end
119
117
  end
120
118
 
@@ -193,13 +191,7 @@ module Polyrun
193
191
 
194
192
  out = {}
195
193
  map.each do |k, v|
196
- key =
197
- if @timing_granularity == :example
198
- TimingKeys.normalize_locator(k.to_s, @root, :example)
199
- else
200
- File.expand_path(k.to_s, @root)
201
- end
202
- out[key] = Integer(v)
194
+ out[TimingKeys.normalize_locator(k.to_s, @root, @timing_granularity)] = Integer(v)
203
195
  end
204
196
  out
205
197
  end
@@ -209,13 +201,7 @@ module Polyrun
209
201
 
210
202
  c = {}
211
203
  costs.each do |k, v|
212
- key =
213
- if @timing_granularity == :example
214
- TimingKeys.normalize_locator(k.to_s, @root, :example)
215
- else
216
- File.expand_path(k.to_s, @root)
217
- end
218
- c[key] = v.to_f
204
+ c[TimingKeys.normalize_locator(k.to_s, @root, @timing_granularity)] = v.to_f
219
205
  end
220
206
  c
221
207
  end
@@ -243,11 +229,7 @@ module Polyrun
243
229
  end
244
230
 
245
231
  def cost_lookup_key(path)
246
- if @timing_granularity == :example
247
- TimingKeys.normalize_locator(path, @root, :example)
248
- else
249
- File.expand_path(path, @root)
250
- end
232
+ TimingKeys.normalize_locator(path, @root, @timing_granularity)
251
233
  end
252
234
 
253
235
  def cost_shards
@@ -16,8 +16,9 @@ module Polyrun
16
16
  item_keys = items.map { |p| lookup_key(p, root, g) }
17
17
  cost_keys = costs&.keys || []
18
18
 
19
+ item_keys_set = item_keys.to_set
20
+ stale = cost_keys.reject { |k| item_keys_set.include?(k) }
19
21
  if g == :example
20
- item_keys_set = item_keys.to_set
21
22
  cost_keys_set = cost_keys.to_set
22
23
  cost_file_keys_set = cost_keys.map { |k| file_from_locator(k) }.uniq.to_set
23
24
  known = item_keys.count do |ik|
@@ -26,11 +27,9 @@ module Polyrun
26
27
  missing = item_keys.reject do |ik|
27
28
  cost_keys_set.include?(ik) || cost_file_keys_set.include?(file_from_locator(ik))
28
29
  end
29
- stale = cost_keys.reject { |k| item_keys_set.include?(k) }
30
30
  else
31
31
  known = item_keys.count { |k| costs&.key?(k) }
32
32
  missing = item_keys.reject { |k| costs&.key?(k) }
33
- stale = cost_keys.reject { |k| item_keys.include?(k) }
34
33
  end
35
34
  total = item_keys.size
36
35