rails_db_inspector 0.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +232 -0
- data/Rakefile +3 -0
- data/app/assets/stylesheets/rails_db_inspector/application.css +41 -0
- data/app/controllers/rails_db_inspector/application_controller.rb +15 -0
- data/app/controllers/rails_db_inspector/queries_controller.rb +42 -0
- data/app/controllers/rails_db_inspector/schema_controller.rb +13 -0
- data/app/helpers/rails_db_inspector/application_helper.rb +274 -0
- data/app/helpers/rails_db_inspector/plan_renderer.rb +887 -0
- data/app/jobs/rails_db_inspector/application_job.rb +4 -0
- data/app/mailers/rails_db_inspector/application_mailer.rb +6 -0
- data/app/models/rails_db_inspector/application_record.rb +5 -0
- data/app/views/layouts/rails_db_inspector/application.html.erb +55 -0
- data/app/views/rails_db_inspector/queries/explain.html.erb +128 -0
- data/app/views/rails_db_inspector/queries/index.html.erb +258 -0
- data/app/views/rails_db_inspector/queries/show.html.erb +103 -0
- data/app/views/rails_db_inspector/schema/index.html.erb +842 -0
- data/config/routes.rb +17 -0
- data/lib/rails_db_inspector/configuration.rb +17 -0
- data/lib/rails_db_inspector/dev_widget_middleware.rb +145 -0
- data/lib/rails_db_inspector/engine.rb +22 -0
- data/lib/rails_db_inspector/explain/my_sql.rb +28 -0
- data/lib/rails_db_inspector/explain/postgres.rb +32 -0
- data/lib/rails_db_inspector/explain.rb +27 -0
- data/lib/rails_db_inspector/query_store.rb +89 -0
- data/lib/rails_db_inspector/schema_inspector.rb +222 -0
- data/lib/rails_db_inspector/sql_subscriber.rb +42 -0
- data/lib/rails_db_inspector/version.rb +3 -0
- data/lib/rails_db_inspector.rb +25 -0
- data/lib/tasks/rails_db_inspector_tasks.rake +4 -0
- metadata +91 -0
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "erb"
|
|
5
|
+
|
|
6
|
+
module RailsDbInspector
|
|
7
|
+
module ApplicationHelper
|
|
8
|
+
class PostgresPlanRenderer
|
|
9
|
+
def initialize(plan_data)
|
|
10
|
+
@plan_data = plan_data
|
|
11
|
+
@plan = plan_data[:plan]
|
|
12
|
+
@analyze = plan_data[:analyze]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render_summary
|
|
16
|
+
return "" unless @plan && @plan.is_a?(Array) && @plan.first
|
|
17
|
+
|
|
18
|
+
root_plan = @plan.first
|
|
19
|
+
execution_time = root_plan["Execution Time"]
|
|
20
|
+
planning_time = root_plan["Planning Time"]
|
|
21
|
+
total_cost = root_plan["Plan"]["Total Cost"]
|
|
22
|
+
actual_rows = root_plan["Plan"]["Actual Rows"] if @analyze
|
|
23
|
+
|
|
24
|
+
hotspots = find_hotspots(root_plan["Plan"]) if @analyze
|
|
25
|
+
index_analysis = analyze_index_usage(root_plan["Plan"])
|
|
26
|
+
buffer_stats = collect_buffer_stats(root_plan["Plan"]) if @analyze
|
|
27
|
+
recommendations = generate_recommendations(root_plan, index_analysis, buffer_stats)
|
|
28
|
+
|
|
29
|
+
summary_html = <<~HTML
|
|
30
|
+
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 mb-6">
|
|
31
|
+
<h3 class="text-lg font-semibold text-gray-900 mb-4">Execution Summary</h3>
|
|
32
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
|
33
|
+
HTML
|
|
34
|
+
|
|
35
|
+
if execution_time
|
|
36
|
+
summary_html += <<~HTML
|
|
37
|
+
<div class="bg-white p-4 rounded-md border-l-4 border-blue-400">
|
|
38
|
+
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide">Execution Time</div>
|
|
39
|
+
<div class="text-lg font-semibold text-gray-900 font-mono">#{execution_time}ms</div>
|
|
40
|
+
<div class="text-xs text-gray-400 mt-1">Actual wall-clock time to execute the query.</div>
|
|
41
|
+
</div>
|
|
42
|
+
HTML
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if planning_time
|
|
46
|
+
summary_html += <<~HTML
|
|
47
|
+
<div class="bg-white p-4 rounded-md border-l-4 border-green-400">
|
|
48
|
+
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide">Planning Time</div>
|
|
49
|
+
<div class="text-lg font-semibold text-gray-900 font-mono">#{planning_time}ms</div>
|
|
50
|
+
<div class="text-xs text-gray-400 mt-1">Time spent choosing the best execution strategy.</div>
|
|
51
|
+
</div>
|
|
52
|
+
HTML
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if total_cost
|
|
56
|
+
summary_html += <<~HTML
|
|
57
|
+
<div class="bg-white p-4 rounded-md border-l-4 border-purple-400">
|
|
58
|
+
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide">Total Cost</div>
|
|
59
|
+
<div class="text-lg font-semibold text-gray-900 font-mono">#{total_cost}</div>
|
|
60
|
+
<div class="text-xs text-gray-400 mt-1">Arbitrary units representing estimated I/O and CPU work. Lower is better.</div>
|
|
61
|
+
</div>
|
|
62
|
+
HTML
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if actual_rows
|
|
66
|
+
summary_html += <<~HTML
|
|
67
|
+
<div class="bg-white p-4 rounded-md border-l-4 border-yellow-400">
|
|
68
|
+
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide">Rows Returned</div>
|
|
69
|
+
<div class="text-lg font-semibold text-gray-900 font-mono">#{number_with_delimiter(actual_rows)}</div>
|
|
70
|
+
<div class="text-xs text-gray-400 mt-1">Number of rows the query actually produced.</div>
|
|
71
|
+
</div>
|
|
72
|
+
HTML
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Add index usage summary
|
|
76
|
+
summary_html += <<~HTML
|
|
77
|
+
<div class="bg-white p-4 rounded-md border-l-4 border-indigo-400">
|
|
78
|
+
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide">Index Usage</div>
|
|
79
|
+
<div class="text-lg font-semibold text-gray-900 font-mono">#{index_analysis[:index_scans]} / #{index_analysis[:total_scans]} scans</div>
|
|
80
|
+
<div class="text-xs text-gray-400 mt-1">How many data lookups used an index vs scanning the whole table. Higher is better.</div>
|
|
81
|
+
</div>
|
|
82
|
+
HTML
|
|
83
|
+
|
|
84
|
+
# Add cache hit ratio if we have buffer stats
|
|
85
|
+
if buffer_stats && buffer_stats[:total_blocks] > 0
|
|
86
|
+
hit_ratio = ((buffer_stats[:hit_blocks].to_f / buffer_stats[:total_blocks]) * 100).round(1)
|
|
87
|
+
hit_color = hit_ratio >= 99 ? "border-green-400" : (hit_ratio >= 90 ? "border-yellow-400" : "border-red-400")
|
|
88
|
+
summary_html += <<~HTML
|
|
89
|
+
<div class="bg-white p-4 rounded-md border-l-4 #{hit_color}">
|
|
90
|
+
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide">Cache Hit Ratio</div>
|
|
91
|
+
<div class="text-lg font-semibold text-gray-900 font-mono">#{hit_ratio}%</div>
|
|
92
|
+
<div class="text-xs text-gray-400 mt-1">Percentage of data pages found in memory. Below 99% may indicate insufficient shared_buffers.</div>
|
|
93
|
+
</div>
|
|
94
|
+
HTML
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
summary_html += "</div>"
|
|
98
|
+
|
|
99
|
+
# Index analysis section
|
|
100
|
+
if index_analysis[:warnings].any? || index_analysis[:indexes_used].any?
|
|
101
|
+
summary_html += <<~HTML
|
|
102
|
+
<div class="mt-6">
|
|
103
|
+
<h4 class="text-md font-medium text-gray-900 mb-3">Index Analysis</h4>
|
|
104
|
+
HTML
|
|
105
|
+
|
|
106
|
+
if index_analysis[:indexes_used].any?
|
|
107
|
+
summary_html += <<~HTML
|
|
108
|
+
<div class="bg-green-50 border border-green-200 rounded-md p-3 mb-3">
|
|
109
|
+
<div class="flex">
|
|
110
|
+
<div class="flex-shrink-0">
|
|
111
|
+
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
|
112
|
+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
|
113
|
+
</svg>
|
|
114
|
+
</div>
|
|
115
|
+
<div class="ml-3">
|
|
116
|
+
<p class="text-sm font-medium text-green-800">
|
|
117
|
+
<strong>Indexes Used:</strong> #{ERB::Util.html_escape(index_analysis[:indexes_used].join(", "))}
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
HTML
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
if index_analysis[:warnings].any?
|
|
126
|
+
index_analysis[:warnings].each do |warning|
|
|
127
|
+
summary_html += <<~HTML
|
|
128
|
+
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-3 mb-2">
|
|
129
|
+
<div class="flex">
|
|
130
|
+
<div class="flex-shrink-0">
|
|
131
|
+
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
|
132
|
+
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
|
133
|
+
</svg>
|
|
134
|
+
</div>
|
|
135
|
+
<div class="ml-3">
|
|
136
|
+
<p class="text-sm text-yellow-700">#{ERB::Util.html_escape(warning)}</p>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
HTML
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
summary_html += "</div>"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
if hotspots && hotspots.any?
|
|
148
|
+
summary_html += <<~HTML
|
|
149
|
+
<div class="mt-6">
|
|
150
|
+
<h4 class="text-md font-medium text-gray-900 mb-3">Performance Hotspots</h4>
|
|
151
|
+
HTML
|
|
152
|
+
|
|
153
|
+
hotspots.first(3).each do |hotspot|
|
|
154
|
+
summary_html += <<~HTML
|
|
155
|
+
<div class="bg-red-50 border border-red-200 rounded-md p-3 mb-2">
|
|
156
|
+
<p class="text-sm font-mono text-red-700">#{ERB::Util.html_escape(hotspot)}</p>
|
|
157
|
+
</div>
|
|
158
|
+
HTML
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
summary_html += "</div>"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Recommendations section
|
|
165
|
+
if recommendations.any?
|
|
166
|
+
summary_html += <<~HTML
|
|
167
|
+
<div class="mt-6">
|
|
168
|
+
<h4 class="text-md font-medium text-gray-900 mb-3">💡 Recommendations</h4>
|
|
169
|
+
HTML
|
|
170
|
+
|
|
171
|
+
recommendations.each do |rec|
|
|
172
|
+
icon = case rec[:severity]
|
|
173
|
+
when :critical then "🔴"
|
|
174
|
+
when :warning then "🟡"
|
|
175
|
+
when :info then "🔵"
|
|
176
|
+
else "💡"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
border_class = case rec[:severity]
|
|
180
|
+
when :critical then "border-red-300 bg-red-50"
|
|
181
|
+
when :warning then "border-yellow-300 bg-yellow-50"
|
|
182
|
+
when :info then "border-blue-300 bg-blue-50"
|
|
183
|
+
else "border-gray-300 bg-gray-50"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
text_class = case rec[:severity]
|
|
187
|
+
when :critical then "text-red-800"
|
|
188
|
+
when :warning then "text-yellow-800"
|
|
189
|
+
when :info then "text-blue-800"
|
|
190
|
+
else "text-gray-800"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
summary_html += <<~HTML
|
|
194
|
+
<div class="border #{border_class} rounded-md p-4 mb-3">
|
|
195
|
+
<div class="flex items-start">
|
|
196
|
+
<span class="text-lg mr-3 flex-shrink-0">#{icon}</span>
|
|
197
|
+
<div class="flex-1">
|
|
198
|
+
<p class="text-sm font-semibold #{text_class}">#{ERB::Util.html_escape(rec[:title])}</p>
|
|
199
|
+
<p class="text-sm #{text_class} mt-1">#{ERB::Util.html_escape(rec[:description])}</p>
|
|
200
|
+
HTML
|
|
201
|
+
|
|
202
|
+
if rec[:action]
|
|
203
|
+
summary_html += <<~HTML
|
|
204
|
+
<div class="mt-2 bg-white bg-opacity-60 rounded p-2">
|
|
205
|
+
<p class="text-xs font-medium text-gray-600">Suggested action:</p>
|
|
206
|
+
<code class="text-xs font-mono text-gray-800 break-all">#{ERB::Util.html_escape(rec[:action])}</code>
|
|
207
|
+
</div>
|
|
208
|
+
HTML
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
summary_html += <<~HTML
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
HTML
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
summary_html += "</div>"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
summary_html += "</div>"
|
|
222
|
+
summary_html
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def render_tree
|
|
226
|
+
return "" unless @plan && @plan.is_a?(Array) && @plan.first
|
|
227
|
+
|
|
228
|
+
root_plan = @plan.first["Plan"]
|
|
229
|
+
tree_html = '<div class="font-mono text-sm space-y-2">'
|
|
230
|
+
tree_html += render_node(root_plan, 0)
|
|
231
|
+
tree_html += "</div>"
|
|
232
|
+
tree_html
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
def render_node(node, depth)
|
|
238
|
+
node_id = "node_#{SecureRandom.hex(6)}"
|
|
239
|
+
warnings = detect_warnings(node)
|
|
240
|
+
|
|
241
|
+
html = <<~HTML
|
|
242
|
+
<div class="border border-gray-200 rounded-lg bg-white shadow-sm">
|
|
243
|
+
<div class="p-3 cursor-pointer select-none relative bg-gray-50 border-b border-gray-200 rounded-t-lg hover:bg-gray-100" onclick="toggleNode('#{node_id}')">
|
|
244
|
+
HTML
|
|
245
|
+
|
|
246
|
+
if has_children?(node)
|
|
247
|
+
html += '<button class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 border-none bg-blue-600 text-white rounded-sm text-xs font-bold cursor-pointer hover:bg-blue-700">−</button>'
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
html += '<div class="'
|
|
251
|
+
html += has_children?(node) ? "ml-8" : "ml-3"
|
|
252
|
+
html += ' flex flex-wrap items-center gap-2">'
|
|
253
|
+
html += render_node_title(node, warnings)
|
|
254
|
+
html += "</div></div>"
|
|
255
|
+
|
|
256
|
+
html += '<div class="plan-node-body" id="' + node_id + '">'
|
|
257
|
+
html += render_node_details(node)
|
|
258
|
+
|
|
259
|
+
if has_children?(node)
|
|
260
|
+
html += '<div class="pl-6 space-y-2">'
|
|
261
|
+
children = node["Plans"] || []
|
|
262
|
+
children.each do |child|
|
|
263
|
+
html += render_node(child, depth + 1)
|
|
264
|
+
end
|
|
265
|
+
html += "</div>"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
html += "</div></div>"
|
|
269
|
+
html
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def render_node_title(node, warnings)
|
|
273
|
+
node_type = node["Node Type"]
|
|
274
|
+
title_html = "<span class=\"font-bold text-gray-700"
|
|
275
|
+
|
|
276
|
+
# Add special styling for index operations
|
|
277
|
+
if node_type.include?("Index") || node_type == "Bitmap Index Scan"
|
|
278
|
+
title_html += " text-green-700 bg-green-100 px-2 py-1 rounded"
|
|
279
|
+
elsif node_type == "Seq Scan"
|
|
280
|
+
title_html += " text-red-700 bg-red-100 px-2 py-1 rounded"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
title_html += "\">#{ERB::Util.html_escape(node_type)}</span>"
|
|
284
|
+
|
|
285
|
+
# Add relation name
|
|
286
|
+
if node["Relation Name"]
|
|
287
|
+
relation_name = node["Relation Name"]
|
|
288
|
+
relation_name += ".#{node["Schema"]}" if node["Schema"] && node["Schema"] != "public"
|
|
289
|
+
title_html += " <span class=\"text-blue-600 font-semibold\">#{ERB::Util.html_escape(relation_name)}</span>"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Add index name with emphasis
|
|
293
|
+
if node["Index Name"]
|
|
294
|
+
title_html += " <span class=\"text-green-700 font-bold bg-green-100 px-2 py-1 rounded inline-flex items-center\">📊 #{ERB::Util.html_escape(node["Index Name"])}</span>"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Add timing info for ANALYZE
|
|
298
|
+
if @analyze && node["Actual Total Time"]
|
|
299
|
+
time_class = node["Actual Total Time"] > 100 ? "text-red-600 bg-red-100" : "text-green-600 bg-green-100"
|
|
300
|
+
title_html += " <span class=\"#{time_class} px-2 py-1 rounded font-semibold\">#{node["Actual Total Time"]}ms</span>"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Add row count info with better ratio analysis
|
|
304
|
+
if @analyze && node["Actual Rows"] && node["Plan Rows"]
|
|
305
|
+
actual = node["Actual Rows"]
|
|
306
|
+
estimated = node["Plan Rows"]
|
|
307
|
+
abs_diff = (actual - estimated).abs
|
|
308
|
+
ratio = estimated > 0 ? (actual.to_f / estimated).round(2) : (actual == 0 ? 1.0 : "∞")
|
|
309
|
+
|
|
310
|
+
# Only flag as problematic when the absolute difference is large enough
|
|
311
|
+
# to actually affect plan choice. Small diffs (e.g. 0 vs 1) are harmless.
|
|
312
|
+
if abs_diff <= 100
|
|
313
|
+
# Small absolute difference — never a real problem
|
|
314
|
+
ratio_class = "text-blue-600 bg-blue-100"
|
|
315
|
+
elsif ratio.is_a?(Numeric)
|
|
316
|
+
if ratio < 0.1 || ratio > 10
|
|
317
|
+
# 10x+ mismatch with >100 row diff — likely a bad plan
|
|
318
|
+
ratio_class = "text-red-600 bg-red-100 font-bold"
|
|
319
|
+
elsif ratio < 0.5 || ratio > 2
|
|
320
|
+
# 2x–10x mismatch — planner estimate is noticeably off
|
|
321
|
+
ratio_class = "text-orange-600 bg-orange-100"
|
|
322
|
+
else
|
|
323
|
+
# Within 2x — estimate is fine
|
|
324
|
+
ratio_class = "text-blue-600 bg-blue-100"
|
|
325
|
+
end
|
|
326
|
+
else
|
|
327
|
+
ratio_class = "text-red-600 bg-red-100 font-bold"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
title_html += " <span class=\"#{ratio_class} px-2 py-1 rounded\">#{number_with_delimiter(actual)} rows"
|
|
331
|
+
if ratio_class.include?("red")
|
|
332
|
+
title_html += " ⚠️ (est: #{number_with_delimiter(estimated)})"
|
|
333
|
+
elsif ratio_class.include?("orange")
|
|
334
|
+
title_html += " ⚠ (est: #{number_with_delimiter(estimated)})"
|
|
335
|
+
else
|
|
336
|
+
title_html += " (est: #{number_with_delimiter(estimated)})"
|
|
337
|
+
end
|
|
338
|
+
title_html += "</span>"
|
|
339
|
+
elsif node["Plan Rows"]
|
|
340
|
+
title_html += " <span class=\"text-blue-600 bg-blue-100 px-2 py-1 rounded\">#{number_with_delimiter(node["Plan Rows"])} rows</span>"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Add warning badges
|
|
344
|
+
warnings.each do |warning|
|
|
345
|
+
case warning[:type]
|
|
346
|
+
when "seq-scan"
|
|
347
|
+
badge_class = "bg-red-100 text-red-800"
|
|
348
|
+
when "sort"
|
|
349
|
+
badge_class = "bg-yellow-100 text-yellow-800"
|
|
350
|
+
when "nested-loop"
|
|
351
|
+
badge_class = "bg-green-100 text-green-800"
|
|
352
|
+
when "fanout"
|
|
353
|
+
badge_class = "bg-blue-100 text-blue-800"
|
|
354
|
+
else
|
|
355
|
+
badge_class = "bg-gray-100 text-gray-800"
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
title_html += " <span class=\"inline-flex items-center px-2 py-1 rounded text-xs font-medium #{badge_class}\">#{ERB::Util.html_escape(warning[:text])}</span>"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
title_html
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def render_node_details(node)
|
|
365
|
+
details_html = '<div class="p-4 bg-gray-50 border-t border-gray-200"><div class="grid grid-cols-1 md:grid-cols-2 gap-4">'
|
|
366
|
+
|
|
367
|
+
# Show key plan details
|
|
368
|
+
details = []
|
|
369
|
+
|
|
370
|
+
if node["Startup Cost"] && node["Total Cost"]
|
|
371
|
+
details << [ "Cost", "#{node["Startup Cost"]}..#{node["Total Cost"]}", "Startup cost (before first row) to total cost (all rows). Arbitrary units — compare relative to other nodes." ]
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
if @analyze
|
|
375
|
+
if node["Actual Startup Time"] && node["Actual Total Time"]
|
|
376
|
+
details << [ "Actual Time", "#{node["Actual Startup Time"]}..#{node["Actual Total Time"]} ms", "Real time: from start until all rows returned for this node." ]
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
if node["Actual Loops"] && node["Actual Loops"] > 1
|
|
380
|
+
details << [ "Loops", node["Actual Loops"].to_s, "Number of times this operation was repeated (e.g. once per row from a parent join)." ]
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
if node["Shared Hit Blocks"]
|
|
384
|
+
buffers = []
|
|
385
|
+
buffers << "hit=#{node["Shared Hit Blocks"]}" if node["Shared Hit Blocks"] > 0
|
|
386
|
+
buffers << "read=#{node["Shared Read Blocks"]}" if node["Shared Read Blocks"] && node["Shared Read Blocks"] > 0
|
|
387
|
+
buffers << "written=#{node["Shared Written Blocks"]}" if node["Shared Written Blocks"] && node["Shared Written Blocks"] > 0
|
|
388
|
+
details << [ "Buffers", buffers.join(", "), "Shared memory pages accessed. 'hit' = cached in RAM, 'read' = fetched from disk." ] if buffers.any?
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Index-specific details
|
|
393
|
+
if node["Index Cond"]
|
|
394
|
+
details << [ "Index Condition", node["Index Cond"], "The WHERE clause condition evaluated using the index for fast lookup." ]
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
if node["Recheck Cond"]
|
|
398
|
+
details << [ "Recheck Condition", node["Recheck Cond"], "Condition re-verified against actual rows after a bitmap scan." ]
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
if node["Filter"]
|
|
402
|
+
details << [ "Filter", node["Filter"], "Rows matching the scan are then filtered by this condition. Rows that don't match are discarded." ]
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
if node["Rows Removed by Filter"] && @analyze
|
|
406
|
+
removed = node["Rows Removed by Filter"]
|
|
407
|
+
actual = node["Actual Rows"] || 0
|
|
408
|
+
total = actual + removed
|
|
409
|
+
if removed > 0
|
|
410
|
+
efficiency = total > 0 ? ((actual.to_f / total) * 100).round(1) : 0
|
|
411
|
+
details << [ "Filter Efficiency", "#{efficiency}% (#{number_with_delimiter(removed)} rows filtered out)", "Percentage of scanned rows that matched the filter. Low efficiency may indicate a missing or suboptimal index." ]
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
if node["Join Type"]
|
|
416
|
+
details << [ "Join Type", node["Join Type"], "How two result sets are combined (e.g. Hash, Nested Loop, Merge)." ]
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
if node["Hash Cond"]
|
|
420
|
+
details << [ "Hash Condition", node["Hash Cond"], "The equality condition used to match rows in a hash join." ]
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
if node["Sort Key"]
|
|
424
|
+
sort_keys = node["Sort Key"].is_a?(Array) ? node["Sort Key"].join(", ") : node["Sort Key"]
|
|
425
|
+
details << [ "Sort Key", sort_keys, "Column(s) used to order the result set." ]
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
if node["Sort Method"] && @analyze
|
|
429
|
+
sort_info = node["Sort Method"]
|
|
430
|
+
if node["Sort Space Used"]
|
|
431
|
+
sort_info += " (#{node["Sort Space Used"]}kB"
|
|
432
|
+
sort_info += node["Sort Space Type"] == "Disk" ? " on disk" : " in memory"
|
|
433
|
+
sort_info += ")"
|
|
434
|
+
end
|
|
435
|
+
details << [ "Sort Method", sort_info, "Algorithm used for sorting. In-memory is fast; disk-based sorting indicates insufficient work_mem." ]
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Split details into two columns
|
|
439
|
+
left_details = details.first((details.length + 1) / 2)
|
|
440
|
+
right_details = details.drop(left_details.length)
|
|
441
|
+
|
|
442
|
+
[ left_details, right_details ].each do |column_details|
|
|
443
|
+
details_html += '<div class="space-y-2">'
|
|
444
|
+
column_details.each do |label, value, explanation|
|
|
445
|
+
details_html += <<~HTML
|
|
446
|
+
<div class="text-xs">
|
|
447
|
+
<dt class="font-medium text-gray-600">#{ERB::Util.html_escape(label)}:</dt>
|
|
448
|
+
<dd class="mt-1 text-gray-900 font-mono break-words">#{ERB::Util.html_escape(value)}</dd>
|
|
449
|
+
HTML
|
|
450
|
+
if explanation
|
|
451
|
+
details_html += <<~HTML
|
|
452
|
+
<dd class="mt-0.5 text-gray-400 font-sans italic">#{ERB::Util.html_escape(explanation)}</dd>
|
|
453
|
+
HTML
|
|
454
|
+
end
|
|
455
|
+
details_html += "</div>"
|
|
456
|
+
end
|
|
457
|
+
details_html += "</div>"
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
details_html += "</div></div>"
|
|
461
|
+
details_html
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def detect_warnings(node)
|
|
465
|
+
warnings = []
|
|
466
|
+
|
|
467
|
+
# Enhanced Seq Scan warning with table name
|
|
468
|
+
if node["Node Type"] == "Seq Scan"
|
|
469
|
+
table_name = node["Relation Name"] || "table"
|
|
470
|
+
row_count = node["Plan Rows"] || 0
|
|
471
|
+
|
|
472
|
+
if row_count > 10000
|
|
473
|
+
warnings << { type: "seq-scan", text: "Large Seq Scan (#{number_with_delimiter(row_count)} rows on #{table_name})" }
|
|
474
|
+
elsif row_count > 1000
|
|
475
|
+
warnings << { type: "seq-scan", text: "Seq Scan (#{table_name})" }
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Bitmap Heap Scan warnings
|
|
480
|
+
if node["Node Type"] == "Bitmap Heap Scan" && node["Plan Rows"] && node["Plan Rows"] > 10000
|
|
481
|
+
warnings << { type: "sort", text: "Large Bitmap Scan" }
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Large sort warning
|
|
485
|
+
if node["Node Type"] == "Sort" && node["Plan Rows"] && node["Plan Rows"] > 10000
|
|
486
|
+
warnings << { type: "sort", text: "Large Sort (#{number_with_delimiter(node["Plan Rows"])} rows)" }
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Nested loop with large inner
|
|
490
|
+
if node["Node Type"] == "Nested Loop" && node["Plans"]
|
|
491
|
+
inner_rows = node["Plans"].map { |p| p["Plan Rows"] || 0 }.max
|
|
492
|
+
if inner_rows && inner_rows > 1000
|
|
493
|
+
warnings << { type: "nested-loop", text: "Large Nested Loop (#{number_with_delimiter(inner_rows)} inner rows)" }
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Row explosion (fanout)
|
|
498
|
+
if @analyze && node["Actual Rows"] && node["Plan Rows"]
|
|
499
|
+
actual = node["Actual Rows"]
|
|
500
|
+
estimated = node["Plan Rows"]
|
|
501
|
+
if estimated > 0 && actual > estimated * 10
|
|
502
|
+
ratio = (actual.to_f / estimated).round(1)
|
|
503
|
+
warnings << { type: "fanout", text: "Row Explosion (#{ratio}x estimate)" }
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Index usage analysis
|
|
508
|
+
if node["Node Type"].include?("Index") && node["Index Name"]
|
|
509
|
+
# This is good - using an index
|
|
510
|
+
# Could add positive feedback here in the future
|
|
511
|
+
elsif node["Node Type"] == "Seq Scan" && node["Filter"]
|
|
512
|
+
# Sequential scan with filter suggests missing index opportunity
|
|
513
|
+
warnings << { type: "seq-scan", text: "Filtered Seq Scan (missing index?)" }
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
warnings
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def has_children?(node)
|
|
520
|
+
node["Plans"] && node["Plans"].any?
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def find_hotspots(plan_node, hotspots = [])
|
|
524
|
+
return hotspots unless @analyze
|
|
525
|
+
|
|
526
|
+
# Add this node if it's slow
|
|
527
|
+
if plan_node["Actual Total Time"] && plan_node["Actual Total Time"] > 10
|
|
528
|
+
node_desc = "#{plan_node["Node Type"]}"
|
|
529
|
+
node_desc += " on #{plan_node["Relation Name"]}" if plan_node["Relation Name"]
|
|
530
|
+
node_desc += " (#{plan_node["Actual Total Time"]}ms)"
|
|
531
|
+
hotspots << node_desc
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# Recurse into children
|
|
535
|
+
if plan_node["Plans"]
|
|
536
|
+
plan_node["Plans"].each do |child|
|
|
537
|
+
find_hotspots(child, hotspots)
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Sort by time descending
|
|
542
|
+
hotspots.sort_by! do |desc|
|
|
543
|
+
match = desc.match(/\((\d+\.?\d*)ms\)/)
|
|
544
|
+
match ? -match[1].to_f : 0
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
hotspots
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def number_with_delimiter(number)
|
|
551
|
+
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def analyze_index_usage(plan_node, analysis = { index_scans: 0, total_scans: 0, indexes_used: [], warnings: [], seq_scans: [] })
|
|
555
|
+
node_type = plan_node["Node Type"]
|
|
556
|
+
|
|
557
|
+
# Count scan operations
|
|
558
|
+
if node_type.include?("Scan") || node_type.include?("Seek")
|
|
559
|
+
analysis[:total_scans] += 1
|
|
560
|
+
|
|
561
|
+
if node_type.include?("Index") || node_type == "Bitmap Index Scan"
|
|
562
|
+
analysis[:index_scans] += 1
|
|
563
|
+
|
|
564
|
+
# Track which indexes are being used
|
|
565
|
+
if plan_node["Index Name"]
|
|
566
|
+
index_name = plan_node["Index Name"]
|
|
567
|
+
relation = plan_node["Relation Name"]
|
|
568
|
+
full_name = relation ? "#{relation}.#{index_name}" : index_name
|
|
569
|
+
analysis[:indexes_used] << full_name unless analysis[:indexes_used].include?(full_name)
|
|
570
|
+
end
|
|
571
|
+
elsif node_type == "Seq Scan"
|
|
572
|
+
table_name = plan_node["Relation Name"] || "unknown table"
|
|
573
|
+
row_count = plan_node["Plan Rows"] || 0
|
|
574
|
+
|
|
575
|
+
# Collect columns from filter conditions for index suggestions
|
|
576
|
+
filter_cols = []
|
|
577
|
+
filter_cols += extract_columns_from_condition(plan_node["Filter"]) if plan_node["Filter"]
|
|
578
|
+
|
|
579
|
+
analysis[:seq_scans] << {
|
|
580
|
+
table: table_name,
|
|
581
|
+
rows: row_count,
|
|
582
|
+
filter: plan_node["Filter"],
|
|
583
|
+
columns: filter_cols
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if row_count > 10000
|
|
587
|
+
analysis[:warnings] << "Large sequential scan on #{table_name} (#{number_with_delimiter(row_count)} rows)"
|
|
588
|
+
elsif row_count > 1000 && plan_node["Filter"]
|
|
589
|
+
analysis[:warnings] << "Sequential scan with filter on #{table_name} - consider adding index"
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Recurse into child nodes
|
|
595
|
+
if plan_node["Plans"]
|
|
596
|
+
plan_node["Plans"].each do |child|
|
|
597
|
+
analyze_index_usage(child, analysis)
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
analysis
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def collect_buffer_stats(node, stats = { hit_blocks: 0, read_blocks: 0, written_blocks: 0, total_blocks: 0 })
|
|
605
|
+
if node["Shared Hit Blocks"]
|
|
606
|
+
stats[:hit_blocks] += node["Shared Hit Blocks"].to_i
|
|
607
|
+
stats[:total_blocks] += node["Shared Hit Blocks"].to_i
|
|
608
|
+
end
|
|
609
|
+
if node["Shared Read Blocks"]
|
|
610
|
+
stats[:read_blocks] += node["Shared Read Blocks"].to_i
|
|
611
|
+
stats[:total_blocks] += node["Shared Read Blocks"].to_i
|
|
612
|
+
end
|
|
613
|
+
if node["Shared Written Blocks"]
|
|
614
|
+
stats[:written_blocks] += node["Shared Written Blocks"].to_i
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
if node["Plans"]
|
|
618
|
+
node["Plans"].each { |child| collect_buffer_stats(child, stats) }
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
stats
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def generate_recommendations(root_plan, index_analysis, buffer_stats)
|
|
625
|
+
recs = []
|
|
626
|
+
plan = root_plan["Plan"]
|
|
627
|
+
execution_time = root_plan["Execution Time"]
|
|
628
|
+
planning_time = root_plan["Planning Time"]
|
|
629
|
+
|
|
630
|
+
# Walk the entire plan tree collecting issues
|
|
631
|
+
walk_plan_for_recommendations(plan, recs)
|
|
632
|
+
|
|
633
|
+
# Planning time vs execution time
|
|
634
|
+
if planning_time && execution_time && planning_time > 0 && execution_time > 0
|
|
635
|
+
if planning_time > execution_time * 2 && planning_time > 5
|
|
636
|
+
recs << {
|
|
637
|
+
severity: :info,
|
|
638
|
+
title: "Planning time exceeds execution time",
|
|
639
|
+
description: "The query planner spent #{planning_time}ms planning but only #{execution_time}ms executing. For frequently-run queries this overhead adds up.",
|
|
640
|
+
action: "Consider using prepared statements to skip repeated planning: connection.prepare('my_query', sql)"
|
|
641
|
+
}
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
# Cache hit ratio
|
|
646
|
+
if buffer_stats && buffer_stats[:total_blocks] > 0
|
|
647
|
+
hit_ratio = (buffer_stats[:hit_blocks].to_f / buffer_stats[:total_blocks]) * 100
|
|
648
|
+
if hit_ratio < 90 && buffer_stats[:read_blocks] > 10
|
|
649
|
+
recs << {
|
|
650
|
+
severity: :warning,
|
|
651
|
+
title: "Low cache hit ratio (#{hit_ratio.round(1)}%)",
|
|
652
|
+
description: "#{buffer_stats[:read_blocks]} pages were read from disk instead of cache. This slows queries significantly, especially under load.",
|
|
653
|
+
action: "Increase shared_buffers in postgresql.conf, or run the query again (it may now be cached)."
|
|
654
|
+
}
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Sequential scan warnings with specific index suggestions
|
|
659
|
+
if index_analysis[:total_scans] > 0 && index_analysis[:index_scans] == 0
|
|
660
|
+
seq_scans = index_analysis[:seq_scans] || []
|
|
661
|
+
index_suggestions = seq_scans.select { |s| s[:columns].any? }.map do |scan|
|
|
662
|
+
cols = scan[:columns].join(", ")
|
|
663
|
+
"CREATE INDEX idx_#{scan[:table]}_on_#{scan[:columns].first} ON #{scan[:table]} (#{cols});"
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
if index_suggestions.any?
|
|
667
|
+
recs << {
|
|
668
|
+
severity: :warning,
|
|
669
|
+
title: "No indexes used",
|
|
670
|
+
description: "All #{index_analysis[:total_scans]} scan(s) in this query are sequential scans. This means PostgreSQL is reading entire tables to find matching rows.",
|
|
671
|
+
action: index_suggestions.join("\n")
|
|
672
|
+
}
|
|
673
|
+
else
|
|
674
|
+
# Couldn't extract columns — list the tables at least
|
|
675
|
+
tables = seq_scans.map { |s| s[:table] }.uniq
|
|
676
|
+
recs << {
|
|
677
|
+
severity: :warning,
|
|
678
|
+
title: "No indexes used",
|
|
679
|
+
description: "All #{index_analysis[:total_scans]} scan(s) in this query are sequential scans on #{tables.join(', ')}. This means PostgreSQL is reading entire tables to find matching rows.",
|
|
680
|
+
action: "Add indexes on the columns used in WHERE, JOIN, and ORDER BY clauses for: #{tables.join(', ')}"
|
|
681
|
+
}
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# Slow query overall
|
|
686
|
+
if execution_time && execution_time > 1000
|
|
687
|
+
recs << {
|
|
688
|
+
severity: :critical,
|
|
689
|
+
title: "Slow query (#{execution_time}ms)",
|
|
690
|
+
description: "This query took over 1 second to execute. Users will notice this delay. Consider optimizing the query or adding caching.",
|
|
691
|
+
action: nil
|
|
692
|
+
}
|
|
693
|
+
elsif execution_time && execution_time > 100
|
|
694
|
+
recs << {
|
|
695
|
+
severity: :warning,
|
|
696
|
+
title: "Moderately slow query (#{execution_time}ms)",
|
|
697
|
+
description: "This query took over 100ms. It's acceptable for background jobs but may be too slow for web requests where < 50ms is ideal.",
|
|
698
|
+
action: nil
|
|
699
|
+
}
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
recs
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def walk_plan_for_recommendations(node, recs)
|
|
706
|
+
node_type = node["Node Type"]
|
|
707
|
+
|
|
708
|
+
# Seq Scan with filter on a table
|
|
709
|
+
if node_type == "Seq Scan" && node["Filter"]
|
|
710
|
+
table = node["Relation Name"] || "table"
|
|
711
|
+
rows = @analyze ? (node["Actual Rows"] || node["Plan Rows"] || 0) : (node["Plan Rows"] || 0)
|
|
712
|
+
removed = node["Rows Removed by Filter"] || 0
|
|
713
|
+
|
|
714
|
+
if rows + removed > 1000
|
|
715
|
+
filter_cols = extract_columns_from_condition(node["Filter"])
|
|
716
|
+
col_suggestion = filter_cols.any? ? filter_cols.join(", ") : "the filtered column(s)"
|
|
717
|
+
|
|
718
|
+
recs << {
|
|
719
|
+
severity: :critical,
|
|
720
|
+
title: "Sequential scan with filter on '#{table}'",
|
|
721
|
+
description: "PostgreSQL scanned #{number_with_delimiter(rows + removed)} rows but only kept #{number_with_delimiter(rows)} (#{removed > 0 ? ((rows.to_f / (rows + removed)) * 100).round(1) : 100}% selectivity). A targeted index would avoid scanning irrelevant rows.",
|
|
722
|
+
action: "CREATE INDEX idx_#{table}_on_#{filter_cols.first || 'column'} ON #{table} (#{col_suggestion});"
|
|
723
|
+
}
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
# Disk-based sort
|
|
728
|
+
if node_type == "Sort" && @analyze && node["Sort Space Type"] == "Disk"
|
|
729
|
+
space = node["Sort Space Used"] || 0
|
|
730
|
+
recs << {
|
|
731
|
+
severity: :warning,
|
|
732
|
+
title: "Sort spilled to disk (#{space}kB)",
|
|
733
|
+
description: "The sort couldn't fit in memory and used disk, which is much slower. This happens when work_mem is too small for the data being sorted.",
|
|
734
|
+
action: "SET work_mem = '#{[ (space * 2 / 1024.0).ceil, 4 ].max}MB'; -- or increase work_mem in postgresql.conf"
|
|
735
|
+
}
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# Large in-memory sort
|
|
739
|
+
if node_type == "Sort" && @analyze && node["Sort Space Type"] == "Memory" && (node["Sort Space Used"] || 0) > 10000
|
|
740
|
+
recs << {
|
|
741
|
+
severity: :info,
|
|
742
|
+
title: "Large in-memory sort (#{node["Sort Space Used"]}kB)",
|
|
743
|
+
description: "The sort fits in memory but uses significant space. If this query runs concurrently, total memory usage could be high.",
|
|
744
|
+
action: nil
|
|
745
|
+
}
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# Nested loop with many iterations
|
|
749
|
+
if node_type == "Nested Loop" && @analyze && node["Actual Loops"] && node["Actual Loops"] > 100
|
|
750
|
+
recs << {
|
|
751
|
+
severity: :warning,
|
|
752
|
+
title: "Nested loop with #{number_with_delimiter(node["Actual Loops"])} iterations",
|
|
753
|
+
description: "The inner side of this join is executed #{number_with_delimiter(node["Actual Loops"])} times. If the inner operation is not an index lookup, this can be extremely slow.",
|
|
754
|
+
action: "Consider restructuring the query to allow a hash join, or ensure the inner table has appropriate indexes."
|
|
755
|
+
}
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
# Large row estimate mismatch
|
|
759
|
+
if @analyze && node["Actual Rows"] && node["Plan Rows"]
|
|
760
|
+
actual = node["Actual Rows"]
|
|
761
|
+
estimated = node["Plan Rows"]
|
|
762
|
+
abs_diff = (actual - estimated).abs
|
|
763
|
+
ratio = estimated > 0 ? (actual.to_f / estimated) : 0
|
|
764
|
+
|
|
765
|
+
if abs_diff > 1000 && (ratio > 10 || ratio < 0.1)
|
|
766
|
+
table = node["Relation Name"] || "the involved table"
|
|
767
|
+
recs << {
|
|
768
|
+
severity: :warning,
|
|
769
|
+
title: "Row estimate off by #{ratio > 1 ? "#{ratio.round(0)}x" : "#{(1.0 / ratio).round(0)}x"} on #{node_type}",
|
|
770
|
+
description: "PostgreSQL estimated #{number_with_delimiter(estimated)} rows but got #{number_with_delimiter(actual)}. Bad estimates lead to suboptimal plan choices (wrong join type, wrong scan method).",
|
|
771
|
+
action: "ANALYZE #{table}; -- updates table statistics so the planner makes better estimates"
|
|
772
|
+
}
|
|
773
|
+
end
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
# Hash join with large buckets
|
|
777
|
+
if node_type == "Hash" && @analyze
|
|
778
|
+
if node["Peak Memory Usage"] && node["Peak Memory Usage"] > 100_000
|
|
779
|
+
recs << {
|
|
780
|
+
severity: :info,
|
|
781
|
+
title: "Large hash table (#{(node["Peak Memory Usage"] / 1024.0).round(1)}MB)",
|
|
782
|
+
description: "Building the hash table for this join used significant memory. Under concurrent load, this could cause memory pressure.",
|
|
783
|
+
action: "SET work_mem = '#{[ (node["Peak Memory Usage"] / 512.0).ceil, 4 ].max}MB'; -- ensure enough memory for the hash"
|
|
784
|
+
}
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
# Bitmap Heap Scan with many recheck rows
|
|
789
|
+
if node_type == "Bitmap Heap Scan" && @analyze && node["Rows Removed by Index Recheck"] && node["Rows Removed by Index Recheck"] > 1000
|
|
790
|
+
recs << {
|
|
791
|
+
severity: :info,
|
|
792
|
+
title: "Bitmap scan with heavy recheck",
|
|
793
|
+
description: "#{number_with_delimiter(node["Rows Removed by Index Recheck"])} rows were rechecked after the bitmap index scan. This happens when the bitmap becomes lossy (too many results).",
|
|
794
|
+
action: "Increase work_mem to keep more exact page references, or add a more selective index."
|
|
795
|
+
}
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
# Correlated subquery (SubPlan node)
|
|
799
|
+
if node_type == "SubPlan" || node_type.start_with?("SubPlan")
|
|
800
|
+
loops = node["Actual Loops"] || node["Plan Rows"] || 0
|
|
801
|
+
recs << {
|
|
802
|
+
severity: loops > 100 ? :critical : :warning,
|
|
803
|
+
title: "Correlated subquery detected",
|
|
804
|
+
description: "A subquery is being executed once per row from the outer query#{loops > 1 ? " (#{number_with_delimiter(loops)} times)" : ""}. This is one of the most common causes of slow queries.",
|
|
805
|
+
action: "Rewrite the correlated subquery as a JOIN or use a lateral join. Example: SELECT ... FROM outer_table LEFT JOIN (subquery) ON ... instead of SELECT ..., (SELECT ...) FROM outer_table"
|
|
806
|
+
}
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
# Also catch SubPlan via parent node's Plans having subplan-type entries
|
|
810
|
+
if node["Subplan Name"] || (node["Parent Relationship"] == "SubPlan")
|
|
811
|
+
loops = @analyze ? (node["Actual Loops"] || 0) : (node["Plan Rows"] || 0)
|
|
812
|
+
unless recs.any? { |r| r[:title] == "Correlated subquery detected" }
|
|
813
|
+
recs << {
|
|
814
|
+
severity: loops > 100 ? :critical : :warning,
|
|
815
|
+
title: "Correlated subquery detected",
|
|
816
|
+
description: "A subquery (#{node["Subplan Name"] || node["Node Type"]}) runs for each row of the outer query#{loops > 1 ? " (#{number_with_delimiter(loops)} executions)" : ""}. This pattern scales poorly with table size.",
|
|
817
|
+
action: "Rewrite as a JOIN: replace WHERE col IN (SELECT ...) with an INNER JOIN, or WHERE EXISTS (SELECT ...) with a semi-join."
|
|
818
|
+
}
|
|
819
|
+
end
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# CTE materialization warning
|
|
823
|
+
if node_type == "CTE Scan"
|
|
824
|
+
cte_name = node["CTE Name"] || "the CTE"
|
|
825
|
+
recs << {
|
|
826
|
+
severity: :info,
|
|
827
|
+
title: "Materialized CTE: #{cte_name}",
|
|
828
|
+
description: "The CTE '#{cte_name}' is materialized into a temporary result set before being scanned. If the CTE result is large or the outer query filters most rows, this can be wasteful.",
|
|
829
|
+
action: "WITH #{cte_name} AS NOT MATERIALIZED (SELECT ...) -- allows PostgreSQL to inline the CTE and apply outer filters (requires PostgreSQL 12+)"
|
|
830
|
+
}
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
# Index Only Scan with high heap fetches
|
|
834
|
+
if node_type == "Index Only Scan" && @analyze && node["Heap Fetches"]
|
|
835
|
+
heap_fetches = node["Heap Fetches"]
|
|
836
|
+
actual_rows = node["Actual Rows"] || 1
|
|
837
|
+
if heap_fetches > 0 && actual_rows > 0
|
|
838
|
+
fetch_ratio = (heap_fetches.to_f / actual_rows * 100).round(1)
|
|
839
|
+
if fetch_ratio > 50
|
|
840
|
+
table = node["Relation Name"] || "the table"
|
|
841
|
+
recs << {
|
|
842
|
+
severity: fetch_ratio > 90 ? :warning : :info,
|
|
843
|
+
title: "Index Only Scan falling back to heap (#{fetch_ratio}%)",
|
|
844
|
+
description: "#{number_with_delimiter(heap_fetches)} of #{number_with_delimiter(actual_rows)} rows required a heap fetch because the visibility map is out of date. This negates most of the benefit of an index-only scan.",
|
|
845
|
+
action: "VACUUM #{table}; -- refreshes the visibility map so future index-only scans can skip heap fetches"
|
|
846
|
+
}
|
|
847
|
+
end
|
|
848
|
+
end
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# Recurse
|
|
852
|
+
if node["Plans"]
|
|
853
|
+
node["Plans"].each { |child| walk_plan_for_recommendations(child, recs) }
|
|
854
|
+
end
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def extract_columns_from_condition(condition)
|
|
858
|
+
return [] unless condition.is_a?(String)
|
|
859
|
+
|
|
860
|
+
# PostgreSQL EXPLAIN outputs conditions like:
|
|
861
|
+
# (status = 'active'::text)
|
|
862
|
+
# ((scheduled_at >= '2026-01-01'::timestamp) AND (scheduled_at <= '2026-12-31'::timestamp))
|
|
863
|
+
# (users.email = 'test@example.com'::text)
|
|
864
|
+
# ((role)::text = 'admin'::text)
|
|
865
|
+
|
|
866
|
+
columns = []
|
|
867
|
+
|
|
868
|
+
# Match table.column references (e.g., users.email)
|
|
869
|
+
columns += condition.scan(/(\w+)\.(\w+)/).map { |_table, col| col }
|
|
870
|
+
|
|
871
|
+
# Match standalone column in parens before operator: (column_name = ...) or (column_name >= ...)
|
|
872
|
+
columns += condition.scan(/\((\w+)\s*[=<>!]/).map(&:first)
|
|
873
|
+
|
|
874
|
+
# Match ((column)::type ...) cast pattern
|
|
875
|
+
columns += condition.scan(/\(\((\w+)\)::/).map(&:first)
|
|
876
|
+
|
|
877
|
+
# Match column BETWEEN, column IN, column IS patterns
|
|
878
|
+
columns += condition.scan(/\((\w+)\s+(?:BETWEEN|IN|IS)\b/i).map(&:first)
|
|
879
|
+
|
|
880
|
+
# Filter out common noise
|
|
881
|
+
noise = %w[true false null text integer bigint timestamp date boolean numeric float double].map(&:downcase)
|
|
882
|
+
columns = columns.uniq.reject { |c| noise.include?(c.downcase) || c.length < 2 }
|
|
883
|
+
columns.first(5)
|
|
884
|
+
end
|
|
885
|
+
end
|
|
886
|
+
end
|
|
887
|
+
end
|