polyrun 1.5.0 → 2.1.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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +2 -2
- data/docs/SETUP_PROFILE.md +2 -0
- data/lib/polyrun/cli/help.rb +7 -2
- data/lib/polyrun/cli/helpers.rb +16 -0
- data/lib/polyrun/cli/init_command.rb +8 -1
- data/lib/polyrun/cli/partition_diagnostics.rb +22 -0
- data/lib/polyrun/cli/plan_command.rb +47 -18
- data/lib/polyrun/cli/queue_command.rb +25 -2
- data/lib/polyrun/cli/run_queue_command.rb +145 -0
- data/lib/polyrun/cli/run_shards_command.rb +6 -1
- data/lib/polyrun/cli/run_shards_parallel_children.rb +2 -1
- data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +47 -2
- data/lib/polyrun/cli/run_shards_plan_options.rb +12 -2
- data/lib/polyrun/cli/run_shards_planning.rb +20 -12
- data/lib/polyrun/cli/run_shards_run.rb +21 -4
- data/lib/polyrun/cli/spec_quality_commands.rb +140 -0
- data/lib/polyrun/cli.rb +16 -2
- data/lib/polyrun/coverage/example_diff.rb +122 -0
- data/lib/polyrun/data/factory_counts.rb +14 -1
- data/lib/polyrun/database/clone_shards.rb +2 -0
- data/lib/polyrun/database/shard.rb +2 -1
- data/lib/polyrun/minitest.rb +9 -0
- data/lib/polyrun/partition/hrw.rb +40 -3
- data/lib/polyrun/partition/paths_build.rb +8 -3
- data/lib/polyrun/partition/plan.rb +88 -19
- data/lib/polyrun/partition/plan_lpt.rb +49 -7
- data/lib/polyrun/partition/plan_sharding.rb +8 -0
- data/lib/polyrun/partition/reports.rb +139 -0
- data/lib/polyrun/partition/timing_diagnostics.rb +139 -0
- data/lib/polyrun/partition/timing_keys.rb +2 -1
- data/lib/polyrun/queue/duration.rb +30 -0
- data/lib/polyrun/queue/file_store.rb +107 -3
- data/lib/polyrun/quick/example_runner.rb +2 -0
- data/lib/polyrun/quick/runner.rb +21 -0
- data/lib/polyrun/rspec.rb +8 -0
- data/lib/polyrun/spec_quality/config.rb +134 -0
- data/lib/polyrun/spec_quality/fragment.rb +39 -0
- data/lib/polyrun/spec_quality/merge.rb +78 -0
- data/lib/polyrun/spec_quality/minitest_hook.rb +42 -0
- data/lib/polyrun/spec_quality/plan_loader.rb +47 -0
- data/lib/polyrun/spec_quality/profile.rb +91 -0
- data/lib/polyrun/spec_quality/report.rb +261 -0
- data/lib/polyrun/spec_quality/rspec_hook.rb +55 -0
- data/lib/polyrun/spec_quality/sql_counter.rb +34 -0
- data/lib/polyrun/spec_quality.rb +205 -0
- data/lib/polyrun/templates/POLYRUN.md +6 -0
- data/lib/polyrun/templates/ci_matrix.polyrun.yml +4 -0
- data/lib/polyrun/templates/polyrun_hooks_spec_quality.rb +12 -0
- data/lib/polyrun/templates/polyrun_spec_quality.yml +20 -0
- data/lib/polyrun/templates/rails_prepare.polyrun.yml +5 -0
- data/lib/polyrun/timing/merge.rb +5 -5
- data/lib/polyrun/timing/stats.rb +76 -0
- data/lib/polyrun/timing/summary.rb +5 -2
- data/lib/polyrun/timing/variance_report.rb +51 -0
- data/lib/polyrun/version.rb +1 -1
- metadata +22 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# rubocop:disable Polyrun/FileLength, Metrics/ModuleLength -- report analysis + text formatting
|
|
2
|
+
module Polyrun
|
|
3
|
+
module SpecQuality
|
|
4
|
+
# Human-readable spec quality report from merged JSON.
|
|
5
|
+
module Report
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# rubocop:disable Metrics/AbcSize -- merged example analysis
|
|
9
|
+
def analyze(merged, cfg = {}, plan_shards: nil)
|
|
10
|
+
cfg = default_cfg(cfg)
|
|
11
|
+
examples = merged["examples"] || {}
|
|
12
|
+
hot_lines = merged["hot_lines"] || {}
|
|
13
|
+
shard_summary = merged["shard_summary"] || Merge.shard_summary(examples)
|
|
14
|
+
|
|
15
|
+
zero_hit = examples.select { |_loc, row| row["unique_lines"].to_i.zero? }
|
|
16
|
+
churn = examples.select { |_loc, row| row["line_churn"].to_i >= cfg["min_line_churn"] }
|
|
17
|
+
.sort_by { |_loc, row| -row["line_churn"].to_i }
|
|
18
|
+
hot = hot_lines.select { |_line, h| h["example_count"].to_i >= cfg["hot_line_example_overlap"] }
|
|
19
|
+
.sort_by { |_line, h| [-h["example_count"].to_i, -h["total_hits"].to_i] }
|
|
20
|
+
|
|
21
|
+
outliers = build_outliers(examples, cfg)
|
|
22
|
+
partition_hints = partition_hints_for(hot, examples, plan_shards) if plan_shards && !plan_shards.empty?
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
zero_hit: zero_hit,
|
|
26
|
+
line_churn: churn,
|
|
27
|
+
hot_lines: hot,
|
|
28
|
+
outliers: outliers,
|
|
29
|
+
shard_summary: shard_summary,
|
|
30
|
+
partition_hints: partition_hints,
|
|
31
|
+
config: cfg
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
# rubocop:enable Metrics/AbcSize
|
|
35
|
+
|
|
36
|
+
def format_report(merged, cfg: {}, top: 30, profile: nil, plan_shards: nil)
|
|
37
|
+
analysis = analyze(merged, cfg, plan_shards: plan_shards)
|
|
38
|
+
lines = ["Polyrun spec quality report", ""]
|
|
39
|
+
|
|
40
|
+
lines.concat(format_shard_summary_section(analysis[:shard_summary]))
|
|
41
|
+
lines << ""
|
|
42
|
+
lines.concat(format_zero_hit_section(analysis[:zero_hit], top))
|
|
43
|
+
lines << ""
|
|
44
|
+
lines.concat(format_hot_lines_section(analysis[:hot_lines], top))
|
|
45
|
+
lines << ""
|
|
46
|
+
lines.concat(format_churn_section(analysis[:line_churn], top))
|
|
47
|
+
hints_section = format_partition_hints_section(analysis[:partition_hints], top)
|
|
48
|
+
unless hints_section.empty?
|
|
49
|
+
lines << ""
|
|
50
|
+
lines.concat(hints_section)
|
|
51
|
+
end
|
|
52
|
+
lines << ""
|
|
53
|
+
lines.concat(format_outliers_section(analysis[:outliers], top, profile))
|
|
54
|
+
|
|
55
|
+
lines.join("\n") + "\n"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def gate_violations(merged, cfg = {})
|
|
59
|
+
cfg = default_cfg(cfg)
|
|
60
|
+
analysis = analyze(merged, cfg)
|
|
61
|
+
violations = []
|
|
62
|
+
|
|
63
|
+
max_zero = cfg["max_zero_hit_examples"]
|
|
64
|
+
if max_zero && analysis[:zero_hit].size > max_zero.to_i
|
|
65
|
+
violations << "zero_hit_examples=#{analysis[:zero_hit].size} max=#{max_zero}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
min_unique = cfg["minimum_unique_lines_per_example"]
|
|
69
|
+
if min_unique
|
|
70
|
+
bad = analysis[:zero_hit].keys
|
|
71
|
+
if bad.size.positive?
|
|
72
|
+
violations << "examples_below_minimum_unique_lines=#{bad.size} minimum=#{min_unique}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
max_hot = cfg["max_hot_line_overlap"]
|
|
77
|
+
if max_hot
|
|
78
|
+
over = analysis[:hot_lines].count { |_k, h| h["example_count"].to_i > max_hot.to_i }
|
|
79
|
+
violations << "hot_line_overlap_count=#{over} max=#{max_hot}" if over.positive?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
violations
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def emit_warnings!(merged, cfg = {})
|
|
86
|
+
analyze(merged, cfg).fetch(:line_churn, {}).each do |loc, row|
|
|
87
|
+
Polyrun::Log.warn "polyrun spec-quality line_churn: #{loc} churn=#{row["line_churn"]}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def default_cfg(cfg)
|
|
92
|
+
h = cfg.is_a?(Hash) ? cfg.transform_keys(&:to_s) : {}
|
|
93
|
+
SpecQuality::Config::DEFAULTS.merge(h)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def format_shard_summary_section(shard_summary)
|
|
97
|
+
lines = ["Shard attribution:"]
|
|
98
|
+
if shard_summary.nil? || shard_summary.empty?
|
|
99
|
+
lines << " (none)"
|
|
100
|
+
return lines
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
shard_summary.sort_by { |k, _| k.to_s }.each do |shard, stats|
|
|
104
|
+
lines << format(
|
|
105
|
+
" shard %s — examples=%d zero_hit=%d line_churn=%d",
|
|
106
|
+
shard,
|
|
107
|
+
stats["examples"],
|
|
108
|
+
stats["zero_hit"],
|
|
109
|
+
stats["line_churn"]
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
lines
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def format_partition_hints_section(hints, top)
|
|
116
|
+
return [] if hints.nil? || hints.empty?
|
|
117
|
+
|
|
118
|
+
lines = ["Partition hints (hot lines × shard):"]
|
|
119
|
+
hints.first(top).each do |h|
|
|
120
|
+
lines << format(" %s — shard %s (%d examples)", h[:line], h[:shard], h[:example_count])
|
|
121
|
+
end
|
|
122
|
+
lines << " …" if hints.size > top
|
|
123
|
+
lines
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def partition_hints_for(hot_lines, examples, plan_shards)
|
|
127
|
+
hot_lines.filter_map do |line, h|
|
|
128
|
+
example_locs = h["examples"] || []
|
|
129
|
+
shard_counts = Hash.new(0)
|
|
130
|
+
example_locs.each do |loc|
|
|
131
|
+
s = PlanLoader.shard_for_example(loc, plan_shards) || examples.dig(loc, "polyrun_shard_index")&.to_s
|
|
132
|
+
shard_counts[s] += 1 if s
|
|
133
|
+
end
|
|
134
|
+
next if shard_counts.empty?
|
|
135
|
+
|
|
136
|
+
shard, count = shard_counts.max_by { |_s, n| n }
|
|
137
|
+
{line: line, shard: shard, example_count: count, total_hits: h["total_hits"]}
|
|
138
|
+
end.sort_by { |h| [-h[:example_count], -h[:total_hits]] }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def format_zero_hit_section(zero_hit, top)
|
|
142
|
+
lines = ["Zero production lines (#{zero_hit.size} examples):"]
|
|
143
|
+
if zero_hit.empty?
|
|
144
|
+
lines << " (none)"
|
|
145
|
+
return lines
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
zero_hit.keys.sort.first(top).each { |loc| lines << " #{loc}" }
|
|
149
|
+
lines << " …" if zero_hit.size > top
|
|
150
|
+
lines
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def format_hot_lines_section(hot_lines, top)
|
|
154
|
+
lines = ["Hot lines (shared across examples):"]
|
|
155
|
+
if hot_lines.empty?
|
|
156
|
+
lines << " (none)"
|
|
157
|
+
return lines
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
hot_lines.first(top).each do |line, h|
|
|
161
|
+
lines << format(" %s — %d examples, %d cumulative hits", line, h["example_count"], h["total_hits"])
|
|
162
|
+
end
|
|
163
|
+
lines << " …" if hot_lines.size > top
|
|
164
|
+
lines
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def format_churn_section(churn_rows, top)
|
|
168
|
+
lines = ["Per-example line churn (top #{[top, churn_rows.size].min}):"]
|
|
169
|
+
if churn_rows.empty?
|
|
170
|
+
lines << " (none)"
|
|
171
|
+
return lines
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
churn_rows.first(top).each do |loc, row|
|
|
175
|
+
lines << format(" %s — churn=%d max_line=%d", loc, row["line_churn"], row["max_line_churn"])
|
|
176
|
+
end
|
|
177
|
+
lines
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# rubocop:disable Metrics/AbcSize -- outlier row filter
|
|
181
|
+
def build_outliers(examples, cfg)
|
|
182
|
+
examples.filter_map do |loc, row|
|
|
183
|
+
prof = row["profile"] || {}
|
|
184
|
+
unique = row["unique_lines"].to_i
|
|
185
|
+
wall = prof["wall"].to_f
|
|
186
|
+
alloc = prof["gc_allocated"].to_i
|
|
187
|
+
cpu = prof["cpu_user"].to_f + prof["cpu_system"].to_f
|
|
188
|
+
sql = row["sql_count"].to_i
|
|
189
|
+
factories = (row["factory_counts"] || {}).values.sum
|
|
190
|
+
|
|
191
|
+
score = 0
|
|
192
|
+
reasons = []
|
|
193
|
+
if unique.zero?
|
|
194
|
+
score += 10
|
|
195
|
+
reasons << "zero_lines"
|
|
196
|
+
end
|
|
197
|
+
if wall > 1.0 && unique < 3
|
|
198
|
+
score += 5
|
|
199
|
+
reasons << "slow_low_coverage"
|
|
200
|
+
end
|
|
201
|
+
if alloc > 50_000 && wall > 0.5
|
|
202
|
+
score += 3
|
|
203
|
+
reasons << "high_alloc"
|
|
204
|
+
end
|
|
205
|
+
if cpu > 0.5 && unique < 3
|
|
206
|
+
score += 3
|
|
207
|
+
reasons << "high_cpu_low_coverage"
|
|
208
|
+
end
|
|
209
|
+
if sql >= cfg["min_query_count"]
|
|
210
|
+
score += 4
|
|
211
|
+
reasons << "high_sql_count"
|
|
212
|
+
end
|
|
213
|
+
if factories >= 10
|
|
214
|
+
score += 2
|
|
215
|
+
reasons << "many_factories"
|
|
216
|
+
end
|
|
217
|
+
next if score.zero?
|
|
218
|
+
|
|
219
|
+
{location: loc, score: score, reasons: reasons, row: row}
|
|
220
|
+
end.sort_by { |h| -h[:score] }
|
|
221
|
+
end
|
|
222
|
+
# rubocop:enable Metrics/AbcSize
|
|
223
|
+
|
|
224
|
+
# rubocop:disable Metrics/AbcSize -- outlier text formatting
|
|
225
|
+
def format_outliers_section(outliers, top, profile)
|
|
226
|
+
lines = ["Correlated outliers (slow / empty / heavy):"]
|
|
227
|
+
if outliers.empty?
|
|
228
|
+
lines << " (none)"
|
|
229
|
+
return lines
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
dims = profile ? profile.to_s.split(",").map(&:strip) : nil
|
|
233
|
+
outliers.first(top).each do |o|
|
|
234
|
+
row = o[:row]
|
|
235
|
+
prof = row["profile"] || {}
|
|
236
|
+
detail = o[:reasons].join(", ")
|
|
237
|
+
if dims.nil? || dims.empty?
|
|
238
|
+
lines << format(" %s — score=%d (%s)", o[:location], o[:score], detail)
|
|
239
|
+
else
|
|
240
|
+
prof_bits = dims.filter_map do |d|
|
|
241
|
+
case d
|
|
242
|
+
when "wall" then "wall=#{format("%.2f", prof["wall"])}" if prof["wall"]
|
|
243
|
+
when "cpu" then "cpu=#{format("%.2f", prof["cpu_user"].to_f + prof["cpu_system"].to_f)}"
|
|
244
|
+
when "mem" then "alloc=#{prof["gc_allocated"]}" if prof["gc_allocated"]
|
|
245
|
+
when "io"
|
|
246
|
+
r = prof["io_read_bytes"]
|
|
247
|
+
w = prof["io_write_bytes"]
|
|
248
|
+
"io=#{r}/#{w}" if r || w
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
lines << format(" %s — score=%d (%s) %s", o[:location], o[:score], detail, prof_bits.join(" "))
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
lines << " …" if outliers.size > top
|
|
255
|
+
lines
|
|
256
|
+
end
|
|
257
|
+
# rubocop:enable Metrics/AbcSize
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
# rubocop:enable Polyrun/FileLength, Metrics/ModuleLength
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module SpecQuality
|
|
3
|
+
# RSpec hooks for per-example spec quality recording.
|
|
4
|
+
module RspecHook
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def install!(only_if: nil, root: nil, output_path: nil)
|
|
8
|
+
pred = only_if || -> { Polyrun::SpecQuality.enabled? }
|
|
9
|
+
return unless pred.call
|
|
10
|
+
|
|
11
|
+
require "rspec/core"
|
|
12
|
+
ensure_started!(root: root, output_path: output_path)
|
|
13
|
+
|
|
14
|
+
::RSpec.configure do |config|
|
|
15
|
+
config.before(:each) do |example|
|
|
16
|
+
next if example.pending?
|
|
17
|
+
|
|
18
|
+
Polyrun::SpecQuality.start_example!(
|
|
19
|
+
location: example.metadata[:location] || example.location
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
config.after(:each) do |example|
|
|
24
|
+
Polyrun::SpecQuality.finish_example!(
|
|
25
|
+
location: example.metadata[:location] || example.location,
|
|
26
|
+
pending: example.pending?
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ensure_started!(root: nil, output_path: nil)
|
|
33
|
+
return if Polyrun::SpecQuality.started?
|
|
34
|
+
|
|
35
|
+
Polyrun::SpecQuality.start!(
|
|
36
|
+
root: root || infer_root,
|
|
37
|
+
output_path: output_path
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def infer_root
|
|
42
|
+
if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
43
|
+
return ::Rails.root.to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
caller_locations.each do |loc|
|
|
47
|
+
inferred = Polyrun::Coverage::Rails.infer_root_from_path(loc.path)
|
|
48
|
+
return inferred if inferred
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Dir.pwd
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module SpecQuality
|
|
3
|
+
# Optional per-example SQL query counting via ActiveSupport::Notifications.
|
|
4
|
+
module SqlCounter
|
|
5
|
+
class << self
|
|
6
|
+
def install!
|
|
7
|
+
return false unless notifications_available?
|
|
8
|
+
return true if @installed
|
|
9
|
+
|
|
10
|
+
@installed = true
|
|
11
|
+
@subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
|
|
12
|
+
next unless Polyrun::SpecQuality.recording?
|
|
13
|
+
|
|
14
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
15
|
+
Polyrun::SpecQuality.record_sql!(event.payload[:sql].to_s)
|
|
16
|
+
end
|
|
17
|
+
true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def uninstall!
|
|
21
|
+
return unless @installed && @subscriber
|
|
22
|
+
|
|
23
|
+
ActiveSupport::Notifications.unsubscribe(@subscriber)
|
|
24
|
+
@subscriber = nil
|
|
25
|
+
@installed = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def notifications_available?
|
|
29
|
+
defined?(ActiveSupport::Notifications)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# rubocop:disable Polyrun/FileLength -- per-example recorder API
|
|
2
|
+
require "json"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
require_relative "coverage/example_diff"
|
|
6
|
+
require_relative "partition/timing_keys"
|
|
7
|
+
require_relative "spec_quality/config"
|
|
8
|
+
require_relative "spec_quality/profile"
|
|
9
|
+
require_relative "spec_quality/fragment"
|
|
10
|
+
require_relative "spec_quality/sql_counter"
|
|
11
|
+
require_relative "spec_quality/merge"
|
|
12
|
+
require_relative "spec_quality/plan_loader"
|
|
13
|
+
require_relative "spec_quality/report"
|
|
14
|
+
require_relative "spec_quality/rspec_hook"
|
|
15
|
+
require_relative "spec_quality/minitest_hook"
|
|
16
|
+
|
|
17
|
+
module Polyrun
|
|
18
|
+
# Per-example spec quality: coverage line deltas, resource use, optional SQL counts.
|
|
19
|
+
# Opt-in with +POLYRUN_SPEC_QUALITY=1+ or +Polyrun::SpecQuality.start!+.
|
|
20
|
+
module SpecQuality
|
|
21
|
+
class << self
|
|
22
|
+
def start!(root: Dir.pwd, config_path: nil, output_path: nil, **overrides)
|
|
23
|
+
return if Config.disabled?
|
|
24
|
+
|
|
25
|
+
@config = Config.load(root: root, config_path: config_path, **overrides)
|
|
26
|
+
@output_path = output_path || Fragment.default_fragment_path
|
|
27
|
+
@pause_depth = 0
|
|
28
|
+
@current = nil
|
|
29
|
+
@rng = Random.new(Process.pid ^ Time.now.to_i)
|
|
30
|
+
|
|
31
|
+
Fragment.truncate_fragment!(@output_path) unless fragment_append_mode?
|
|
32
|
+
SqlCounter.install! if @config["sql_counter"]
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def started?
|
|
37
|
+
instance_variable_defined?(:@config) && @config
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def enabled?
|
|
41
|
+
Config.enabled? && !Config.disabled?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :config
|
|
45
|
+
|
|
46
|
+
attr_reader :output_path
|
|
47
|
+
|
|
48
|
+
def recording?
|
|
49
|
+
started? && @current && @pause_depth.zero?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def start_example!(location:, wall_start: nil)
|
|
53
|
+
return unless started?
|
|
54
|
+
return if paused?
|
|
55
|
+
return if Config.ignored_example?(location, @config["ignore_examples"])
|
|
56
|
+
return unless sample_example?
|
|
57
|
+
|
|
58
|
+
@current = {
|
|
59
|
+
location: normalize_location(location),
|
|
60
|
+
wall_start: wall_start || Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
61
|
+
coverage_before: Coverage::ExampleDiff.peek_blob,
|
|
62
|
+
profile_before: Profile.snapshot,
|
|
63
|
+
sql_count: 0,
|
|
64
|
+
sql_fingerprints: Hash.new(0),
|
|
65
|
+
factory_counts: {}
|
|
66
|
+
}
|
|
67
|
+
Polyrun::Data::FactoryCounts.reset_example! if defined?(Polyrun::Data::FactoryCounts)
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def finish_example!(location: nil, pending: false)
|
|
72
|
+
return unless started?
|
|
73
|
+
cur = @current
|
|
74
|
+
@current = nil
|
|
75
|
+
return if cur.nil? || paused?
|
|
76
|
+
return if pending
|
|
77
|
+
|
|
78
|
+
loc = location || cur[:location]
|
|
79
|
+
return if loc.nil? || loc.to_s.empty?
|
|
80
|
+
|
|
81
|
+
after_cov = Coverage::ExampleDiff.peek_blob
|
|
82
|
+
delta = Coverage::ExampleDiff.diff(cur[:coverage_before], after_cov)
|
|
83
|
+
delta = Coverage::ExampleDiff.apply_track_under(
|
|
84
|
+
delta,
|
|
85
|
+
root: @config["root"],
|
|
86
|
+
track_under: @config["track_under"],
|
|
87
|
+
ignore_paths: @config["ignore_paths"]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
profile_after = Profile.snapshot
|
|
91
|
+
profile_delta = Profile.diff(cur[:profile_before], profile_after)
|
|
92
|
+
wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - cur[:wall_start]
|
|
93
|
+
profile_delta["wall"] = wall
|
|
94
|
+
|
|
95
|
+
factory_counts =
|
|
96
|
+
if defined?(Polyrun::Data::FactoryCounts)
|
|
97
|
+
Polyrun::Data::FactoryCounts.example_counts
|
|
98
|
+
else
|
|
99
|
+
{}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
row = build_row(cur, loc, delta, profile_delta, factory_counts)
|
|
103
|
+
Fragment.append_row!(@output_path, row)
|
|
104
|
+
row
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def pause
|
|
108
|
+
@pause_depth += 1
|
|
109
|
+
if block_given?
|
|
110
|
+
begin
|
|
111
|
+
yield
|
|
112
|
+
ensure
|
|
113
|
+
resume
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def resume
|
|
119
|
+
@pause_depth -= 1 if @pause_depth.positive?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def paused?
|
|
123
|
+
@pause_depth.positive?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def record_sql!(sql)
|
|
127
|
+
return unless recording?
|
|
128
|
+
|
|
129
|
+
@current[:sql_count] += 1
|
|
130
|
+
fp = normalize_sql(sql)
|
|
131
|
+
@current[:sql_fingerprints][fp] += 1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def spec_quality_requested_for_quick?(root = Dir.pwd)
|
|
135
|
+
return false if Config.disabled?
|
|
136
|
+
return true if %w[1 true yes].include?(ENV["POLYRUN_SPEC_QUALITY"]&.to_s&.downcase)
|
|
137
|
+
return true if %w[1 true yes].include?(ENV["POLYRUN_SPEC_QUALITY_FRAGMENTS"]&.to_s&.downcase)
|
|
138
|
+
|
|
139
|
+
path = File.join(File.expand_path(root), Config::DEFAULT_CONFIG_RELATIVE)
|
|
140
|
+
return false unless File.file?(path)
|
|
141
|
+
|
|
142
|
+
%w[1 true yes].include?(ENV["POLYRUN_QUICK_SPEC_QUALITY"]&.to_s&.downcase)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def fragment_append_mode?
|
|
148
|
+
truthy?(ENV["POLYRUN_SPEC_QUALITY_FRAGMENT_APPEND"])
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def sample_example?
|
|
152
|
+
rate = @config["sample"].to_f
|
|
153
|
+
return true if rate >= 1.0
|
|
154
|
+
return false if rate <= 0.0
|
|
155
|
+
|
|
156
|
+
@rng.rand < rate
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def normalize_location(location)
|
|
160
|
+
s = location.to_s.strip
|
|
161
|
+
return s if s.empty?
|
|
162
|
+
|
|
163
|
+
root = @config["root"]
|
|
164
|
+
if (m = s.match(/\A(.+):(\d+)\z/)) && m[2].match?(/\A\d+\z/)
|
|
165
|
+
fp = Polyrun::Partition::TimingKeys.canonical_file_path(File.expand_path(m[1], root))
|
|
166
|
+
return "#{fp}:#{m[2]}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
Polyrun::Partition::TimingKeys.canonical_file_path(File.expand_path(s, root))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def build_row(cur, location, delta, profile_delta, factory_counts)
|
|
173
|
+
profile = Profile.slice_profile(profile_delta, @config["profile"])
|
|
174
|
+
repeated_sql = cur[:sql_fingerprints].select { |_sql, n| n >= min_query_count }.transform_keys(&:to_s)
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
"example" => location.to_s,
|
|
178
|
+
"unique_lines" => delta[:unique_lines],
|
|
179
|
+
"line_churn" => delta[:line_churn],
|
|
180
|
+
"max_line_churn" => delta[:max_line_churn],
|
|
181
|
+
"lines" => delta[:lines],
|
|
182
|
+
"profile" => profile,
|
|
183
|
+
"sql_count" => cur[:sql_count],
|
|
184
|
+
"repeated_sql" => repeated_sql,
|
|
185
|
+
"factory_counts" => factory_counts.transform_keys(&:to_s),
|
|
186
|
+
"polyrun_shard_index" => ENV["POLYRUN_SHARD_INDEX"],
|
|
187
|
+
"polyrun_shard_total" => ENV["POLYRUN_SHARD_TOTAL"]
|
|
188
|
+
}.compact
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def min_query_count
|
|
192
|
+
@config["min_query_count"].to_i
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def normalize_sql(sql)
|
|
196
|
+
sql.to_s.gsub(/\s+/, " ").strip[0, 500]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def truthy?(value)
|
|
200
|
+
Config.send(:truthy?, value)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
# rubocop:enable Polyrun/FileLength
|
|
@@ -40,6 +40,12 @@ Do not combine Model A and Model B in one workflow without a documented reason (
|
|
|
40
40
|
- `spec/spec_helper.rb`: `require "polyrun"` and collector or Rails helper as appropriate.
|
|
41
41
|
- Fragments: `coverage/polyrun-fragment-<shard>.json` → `polyrun merge-coverage` → `polyrun report-coverage` / `report-junit` for CI.
|
|
42
42
|
|
|
43
|
+
## Spec quality (optional)
|
|
44
|
+
|
|
45
|
+
- `POLYRUN_SPEC_QUALITY=1` and `Polyrun::RSpec.install_spec_quality!` in `spec_helper` (requires coverage enabled for line deltas).
|
|
46
|
+
- `config/polyrun_spec_quality.yml` — thresholds and CI gates (`polyrun init --profile spec-quality`).
|
|
47
|
+
- Parallel: `run-shards --merge-spec-quality` → `coverage/polyrun-spec-quality.json` → `report-spec-quality`.
|
|
48
|
+
|
|
43
49
|
## Further reading
|
|
44
50
|
|
|
45
51
|
- Polyrun README: partition, prepare, databases, merge-coverage, `Polyrun::Env::Ci`
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Optional Polyrun hooks — copy to config/polyrun_hooks.rb and reference from polyrun.yml:
|
|
2
|
+
# hooks:
|
|
3
|
+
# ruby_file: config/polyrun_hooks.rb
|
|
4
|
+
#
|
|
5
|
+
# Worker-phase hooks run in each parallel test child (around bundle exec rspec).
|
|
6
|
+
|
|
7
|
+
before(:each) do |env|
|
|
8
|
+
next unless env["POLYRUN_HOOK_ORCHESTRATOR"] == "0"
|
|
9
|
+
next unless defined?(Polyrun::SpecQuality) && Polyrun::SpecQuality.enabled?
|
|
10
|
+
|
|
11
|
+
Polyrun::SpecQuality::RspecHook.ensure_started!
|
|
12
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Opt-in per-example spec quality (coverage deltas, resource use). See docs/SPEC_QUALITY.md.
|
|
2
|
+
track_under:
|
|
3
|
+
- lib
|
|
4
|
+
- app
|
|
5
|
+
min_line_churn: 50
|
|
6
|
+
min_query_count: 20
|
|
7
|
+
hot_line_example_overlap: 10
|
|
8
|
+
strict: false
|
|
9
|
+
sample: 1.0
|
|
10
|
+
ignore_examples: []
|
|
11
|
+
ignore_paths: []
|
|
12
|
+
ignore_query_patterns: []
|
|
13
|
+
profile:
|
|
14
|
+
- cpu
|
|
15
|
+
- mem
|
|
16
|
+
sql_counter: false
|
|
17
|
+
# CI gates (used with POLYRUN_SPEC_QUALITY_STRICT=1 or strict: true):
|
|
18
|
+
# minimum_unique_lines_per_example: 1
|
|
19
|
+
# max_zero_hit_examples: 0
|
|
20
|
+
# max_hot_line_overlap: 100
|
|
@@ -17,6 +17,11 @@ prepare:
|
|
|
17
17
|
rails_root: .
|
|
18
18
|
command: bundle exec ruby bin/test_prepare.rb
|
|
19
19
|
|
|
20
|
+
# Optional reporting (see docs/SPEC_QUALITY.md):
|
|
21
|
+
# reporting:
|
|
22
|
+
# merge_spec_quality: true
|
|
23
|
+
# merge_spec_quality_output: coverage/polyrun-spec-quality.json
|
|
24
|
+
|
|
20
25
|
# Uncomment and edit when using Postgres template + per-shard DB names from polyrun env:
|
|
21
26
|
# databases:
|
|
22
27
|
# template_db: my_app_template
|
data/lib/polyrun/timing/merge.rb
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
|
|
3
3
|
require_relative "../debug"
|
|
4
|
+
require_relative "stats"
|
|
5
|
+
require_relative "variance_report"
|
|
4
6
|
|
|
5
7
|
module Polyrun
|
|
6
8
|
module Timing
|
|
7
|
-
# Merges per-shard timing JSON files (spec2 §2.4): path => wall seconds (float), or (experimental)
|
|
8
|
-
# +absolute_path:line+ => seconds for per-example timing.
|
|
9
|
-
# Disjoint suites: values merged by taking the maximum per path when duplicates appear.
|
|
10
9
|
module Merge
|
|
11
10
|
module_function
|
|
12
11
|
|
|
@@ -18,8 +17,8 @@ module Polyrun
|
|
|
18
17
|
|
|
19
18
|
data.each do |file, sec|
|
|
20
19
|
f = file.to_s
|
|
21
|
-
|
|
22
|
-
merged[f] = merged.key?(f) ?
|
|
20
|
+
entry = sec
|
|
21
|
+
merged[f] = merged.key?(f) ? Stats.merge_entries(merged[f], entry) : Stats.normalize_entry(entry)
|
|
23
22
|
end
|
|
24
23
|
end
|
|
25
24
|
merged
|
|
@@ -29,6 +28,7 @@ module Polyrun
|
|
|
29
28
|
Polyrun::Debug.log_kv(merge_timing: "merge_and_write", input_count: paths.size, output_path: output_path)
|
|
30
29
|
merged = Polyrun::Debug.time("Timing::Merge.merge_files") { merge_files(paths) }
|
|
31
30
|
Polyrun::Debug.time("Timing::Merge.write JSON") { File.write(output_path, JSON.pretty_generate(merged)) }
|
|
31
|
+
Timing::VarianceReport.emit_warnings!(merged)
|
|
32
32
|
merged
|
|
33
33
|
end
|
|
34
34
|
end
|