polyrun 1.4.2 → 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 +38 -0
- data/README.md +2 -2
- data/docs/SETUP_PROFILE.md +2 -0
- data/lib/polyrun/cli/ci_shard_hooks.rb +12 -4
- data/lib/polyrun/cli/ci_shard_run_command.rb +3 -1
- data/lib/polyrun/cli/help.rb +10 -2
- data/lib/polyrun/cli/helpers.rb +38 -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 +28 -35
- data/lib/polyrun/cli/run_shards_parallel_wait.rb +267 -0
- data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +81 -3
- data/lib/polyrun/cli/run_shards_plan_options.rb +17 -3
- data/lib/polyrun/cli/run_shards_planning.rb +20 -12
- data/lib/polyrun/cli/run_shards_run.rb +28 -37
- data/lib/polyrun/cli/run_shards_worker_interrupt.rb +75 -0
- 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/coverage/merge/formatters_html.rb +4 -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/hooks.rb +9 -1
- data/lib/polyrun/log.rb +16 -0
- data/lib/polyrun/minitest.rb +43 -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 +13 -0
- data/lib/polyrun/quick/runner.rb +21 -0
- data/lib/polyrun/rspec.rb +26 -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
- data/lib/polyrun/worker_ping.rb +74 -0
- data/sig/polyrun/minitest.rbs +2 -0
- data/sig/polyrun/rspec.rbs +4 -0
- data/sig/polyrun/worker_ping.rbs +10 -0
- metadata +26 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module SpecQuality
|
|
3
|
+
# Minitest hook: per-test spec quality (requires minitest loaded).
|
|
4
|
+
module MinitestHook
|
|
5
|
+
module SpecQualityTestHook
|
|
6
|
+
def setup
|
|
7
|
+
Polyrun::SpecQuality.start_example!(location: polyrun_minitest_location)
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def teardown
|
|
12
|
+
super
|
|
13
|
+
Polyrun::SpecQuality.finish_example!(location: polyrun_minitest_location)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def polyrun_minitest_location
|
|
19
|
+
file, line = method(name).source_location
|
|
20
|
+
(file && line) ? "#{file}:#{line}" : nil
|
|
21
|
+
rescue NameError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def install!(only_if: nil, root: nil, output_path: nil)
|
|
29
|
+
pred = only_if || -> { Polyrun::SpecQuality.enabled? }
|
|
30
|
+
return unless pred.call
|
|
31
|
+
|
|
32
|
+
unless defined?(::Minitest::Test)
|
|
33
|
+
Polyrun::Log.warn "polyrun minitest: install_spec_quality! skipped (load minitest first)"
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Polyrun::SpecQuality::RspecHook.ensure_started!(root: root, output_path: output_path)
|
|
38
|
+
::Minitest::Test.send(:prepend, SpecQualityTestHook)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Polyrun
|
|
4
|
+
module SpecQuality
|
|
5
|
+
# Loads partition plan JSON for spec-quality ↔ shard correlation.
|
|
6
|
+
module PlanLoader
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# @param paths [Array<String>] plan JSON files (+polyrun plan+ output per shard or a wrapper hash)
|
|
10
|
+
# @return [Hash{String=>Array<String>}] shard index string => spec file paths
|
|
11
|
+
def load_shards(paths)
|
|
12
|
+
out = {}
|
|
13
|
+
Array(paths).each do |path|
|
|
14
|
+
next unless File.file?(path)
|
|
15
|
+
|
|
16
|
+
data = JSON.parse(File.read(File.expand_path(path)))
|
|
17
|
+
merge_plan_data!(out, data)
|
|
18
|
+
end
|
|
19
|
+
out
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def merge_plan_data!(out, data)
|
|
23
|
+
if data.is_a?(Hash) && data["shards"].is_a?(Hash)
|
|
24
|
+
data["shards"].each { |k, v| out[k.to_s] = Array(v).map(&:to_s) }
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
return unless data.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
shard = data["shard_index"]
|
|
31
|
+
paths = data["paths"]
|
|
32
|
+
return if shard.nil? || !paths.is_a?(Array)
|
|
33
|
+
|
|
34
|
+
out[shard.to_s] = paths.map(&:to_s)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [String, nil] shard index for an example locator given plan shards
|
|
38
|
+
def shard_for_example(example_loc, plan_shards)
|
|
39
|
+
file = example_loc.to_s.sub(/:\d+\z/, "")
|
|
40
|
+
plan_shards.each do |shard, paths|
|
|
41
|
+
return shard if paths.any? { |p| file == p || file.end_with?("/#{File.basename(p)}") || file.include?(p) }
|
|
42
|
+
end
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module SpecQuality
|
|
3
|
+
# Stdlib per-example CPU / allocation / IO snapshots.
|
|
4
|
+
module Profile
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def snapshot
|
|
8
|
+
cpu = Process.times
|
|
9
|
+
gc = GC.stat
|
|
10
|
+
io = read_proc_io
|
|
11
|
+
{
|
|
12
|
+
"cpu_user" => cpu.utime,
|
|
13
|
+
"cpu_system" => cpu.stime,
|
|
14
|
+
"gc_allocated" => gc[:total_allocated_objects],
|
|
15
|
+
"gc_heap_live" => gc[:heap_live_slots],
|
|
16
|
+
"io_read_bytes" => io[:read_bytes],
|
|
17
|
+
"io_write_bytes" => io[:write_bytes]
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def diff(before, after)
|
|
22
|
+
before ||= {}
|
|
23
|
+
after ||= {}
|
|
24
|
+
out = {}
|
|
25
|
+
%w[cpu_user cpu_system gc_allocated gc_heap_live io_read_bytes io_write_bytes].each do |k|
|
|
26
|
+
b = before[k]
|
|
27
|
+
a = after[k]
|
|
28
|
+
next if b.nil? && a.nil?
|
|
29
|
+
|
|
30
|
+
delta = numeric(a) - numeric(b)
|
|
31
|
+
out[k] = delta if delta.positive? || k.start_with?("cpu")
|
|
32
|
+
out[k] = delta
|
|
33
|
+
end
|
|
34
|
+
out
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def enabled_dimensions(profile_list)
|
|
38
|
+
Array(profile_list).map(&:to_s).map(&:downcase)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# rubocop:disable Metrics/AbcSize -- profile dimension slice
|
|
42
|
+
def slice_profile(diff, dimensions)
|
|
43
|
+
dims = enabled_dimensions(dimensions)
|
|
44
|
+
return diff if dims.empty?
|
|
45
|
+
|
|
46
|
+
out = {}
|
|
47
|
+
out["wall"] = diff["wall"] if diff.key?("wall") && dims.include?("wall")
|
|
48
|
+
if dims.include?("cpu")
|
|
49
|
+
out["cpu_user"] = diff["cpu_user"] if diff.key?("cpu_user")
|
|
50
|
+
out["cpu_system"] = diff["cpu_system"] if diff.key?("cpu_system")
|
|
51
|
+
end
|
|
52
|
+
if dims.include?("mem")
|
|
53
|
+
out["gc_allocated"] = diff["gc_allocated"] if diff.key?("gc_allocated")
|
|
54
|
+
out["gc_heap_live"] = diff["gc_heap_live"] if diff.key?("gc_heap_live")
|
|
55
|
+
end
|
|
56
|
+
if dims.include?("io")
|
|
57
|
+
out["io_read_bytes"] = diff["io_read_bytes"] if diff.key?("io_read_bytes")
|
|
58
|
+
out["io_write_bytes"] = diff["io_write_bytes"] if diff.key?("io_write_bytes")
|
|
59
|
+
end
|
|
60
|
+
out
|
|
61
|
+
end
|
|
62
|
+
# rubocop:enable Metrics/AbcSize
|
|
63
|
+
|
|
64
|
+
def read_proc_io
|
|
65
|
+
path = "/proc/self/io"
|
|
66
|
+
return {read_bytes: nil, write_bytes: nil} unless File.readable?(path)
|
|
67
|
+
|
|
68
|
+
read_bytes = nil
|
|
69
|
+
write_bytes = nil
|
|
70
|
+
File.foreach(path) do |line|
|
|
71
|
+
case line
|
|
72
|
+
when /\Aread_bytes:\s+(\d+)/
|
|
73
|
+
read_bytes = Regexp.last_match(1).to_i
|
|
74
|
+
when /\Awrite_bytes:\s+(\d+)/
|
|
75
|
+
write_bytes = Regexp.last_match(1).to_i
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
{read_bytes: read_bytes, write_bytes: write_bytes}
|
|
79
|
+
rescue SystemCallError
|
|
80
|
+
{read_bytes: nil, write_bytes: nil}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def numeric(value)
|
|
84
|
+
return 0 if value.nil?
|
|
85
|
+
|
|
86
|
+
value.is_a?(Numeric) ? value : value.to_f
|
|
87
|
+
end
|
|
88
|
+
private_class_method :numeric
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -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
|