polyrun 1.5.0 → 2.1.2

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +2 -2
  4. data/docs/SETUP_PROFILE.md +2 -0
  5. data/lib/polyrun/cli/coverage_commands.rb +1 -1
  6. data/lib/polyrun/cli/failure_commands.rb +1 -1
  7. data/lib/polyrun/cli/help.rb +20 -17
  8. data/lib/polyrun/cli/helpers.rb +16 -0
  9. data/lib/polyrun/cli/init_command.rb +8 -1
  10. data/lib/polyrun/cli/partition_diagnostics.rb +22 -0
  11. data/lib/polyrun/cli/plan_command.rb +47 -18
  12. data/lib/polyrun/cli/queue_command.rb +25 -2
  13. data/lib/polyrun/cli/run_queue_command.rb +145 -0
  14. data/lib/polyrun/cli/run_shards_command.rb +6 -1
  15. data/lib/polyrun/cli/run_shards_parallel_children.rb +2 -1
  16. data/lib/polyrun/cli/run_shards_parallel_wait.rb +5 -1
  17. data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +47 -2
  18. data/lib/polyrun/cli/run_shards_plan_options.rb +14 -4
  19. data/lib/polyrun/cli/run_shards_planning.rb +20 -12
  20. data/lib/polyrun/cli/run_shards_run.rb +22 -5
  21. data/lib/polyrun/cli/spec_quality_commands.rb +140 -0
  22. data/lib/polyrun/cli.rb +16 -2
  23. data/lib/polyrun/coverage/example_diff.rb +122 -0
  24. data/lib/polyrun/coverage/merge/formatters_html.rb +5 -5
  25. data/lib/polyrun/data/factory_counts.rb +14 -1
  26. data/lib/polyrun/database/clone_shards.rb +2 -0
  27. data/lib/polyrun/database/shard.rb +2 -1
  28. data/lib/polyrun/minitest.rb +9 -0
  29. data/lib/polyrun/partition/hrw.rb +40 -3
  30. data/lib/polyrun/partition/paths_build.rb +8 -3
  31. data/lib/polyrun/partition/plan.rb +88 -19
  32. data/lib/polyrun/partition/plan_lpt.rb +49 -7
  33. data/lib/polyrun/partition/plan_sharding.rb +8 -0
  34. data/lib/polyrun/partition/reports.rb +139 -0
  35. data/lib/polyrun/partition/timing_diagnostics.rb +139 -0
  36. data/lib/polyrun/partition/timing_keys.rb +2 -1
  37. data/lib/polyrun/queue/duration.rb +30 -0
  38. data/lib/polyrun/queue/file_store.rb +114 -3
  39. data/lib/polyrun/quick/example_runner.rb +2 -0
  40. data/lib/polyrun/quick/runner.rb +21 -0
  41. data/lib/polyrun/rspec.rb +10 -0
  42. data/lib/polyrun/spec_quality/config.rb +134 -0
  43. data/lib/polyrun/spec_quality/fragment.rb +39 -0
  44. data/lib/polyrun/spec_quality/merge.rb +78 -0
  45. data/lib/polyrun/spec_quality/minitest_hook.rb +42 -0
  46. data/lib/polyrun/spec_quality/plan_loader.rb +47 -0
  47. data/lib/polyrun/spec_quality/profile.rb +91 -0
  48. data/lib/polyrun/spec_quality/report.rb +261 -0
  49. data/lib/polyrun/spec_quality/rspec_hook.rb +55 -0
  50. data/lib/polyrun/spec_quality/sql_counter.rb +34 -0
  51. data/lib/polyrun/spec_quality.rb +205 -0
  52. data/lib/polyrun/templates/POLYRUN.md +6 -0
  53. data/lib/polyrun/templates/ci_matrix.polyrun.yml +4 -0
  54. data/lib/polyrun/templates/polyrun_hooks_spec_quality.rb +12 -0
  55. data/lib/polyrun/templates/polyrun_spec_quality.yml +20 -0
  56. data/lib/polyrun/templates/rails_prepare.polyrun.yml +5 -0
  57. data/lib/polyrun/timing/merge.rb +5 -5
  58. data/lib/polyrun/timing/rspec_example_formatter.rb +14 -7
  59. data/lib/polyrun/timing/stats.rb +76 -0
  60. data/lib/polyrun/timing/summary.rb +5 -2
  61. data/lib/polyrun/timing/variance_report.rb +51 -0
  62. data/lib/polyrun/version.rb +1 -1
  63. metadata +22 -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