pg_insights 0.3.2 → 0.4.1
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/app/assets/javascripts/pg_insights/application.js +91 -21
- data/app/assets/javascripts/pg_insights/plan_performance.js +53 -0
- data/app/assets/javascripts/pg_insights/query_comparison.js +1129 -0
- data/app/assets/javascripts/pg_insights/results/view_toggles.js +26 -5
- data/app/assets/javascripts/pg_insights/results.js +231 -1
- data/app/assets/stylesheets/pg_insights/analysis.css +2628 -0
- data/app/assets/stylesheets/pg_insights/application.css +51 -1
- data/app/assets/stylesheets/pg_insights/results.css +12 -1
- data/app/controllers/pg_insights/insights_controller.rb +486 -9
- data/app/helpers/pg_insights/application_helper.rb +339 -0
- data/app/helpers/pg_insights/insights_helper.rb +567 -0
- data/app/jobs/pg_insights/query_analysis_job.rb +142 -0
- data/app/models/pg_insights/query_execution.rb +198 -0
- data/app/services/pg_insights/query_analysis_service.rb +269 -0
- data/app/views/layouts/pg_insights/application.html.erb +2 -0
- data/app/views/pg_insights/insights/_compare_view.html.erb +264 -0
- data/app/views/pg_insights/insights/_empty_state.html.erb +9 -0
- data/app/views/pg_insights/insights/_execution_table_view.html.erb +86 -0
- data/app/views/pg_insights/insights/_history_bar.html.erb +33 -0
- data/app/views/pg_insights/insights/_perf_view.html.erb +244 -0
- data/app/views/pg_insights/insights/_plan_nodes.html.erb +12 -0
- data/app/views/pg_insights/insights/_plan_tree.html.erb +30 -0
- data/app/views/pg_insights/insights/_plan_tree_modern.html.erb +12 -0
- data/app/views/pg_insights/insights/_plan_view.html.erb +159 -0
- data/app/views/pg_insights/insights/_query_panel.html.erb +3 -2
- data/app/views/pg_insights/insights/_result.html.erb +19 -4
- data/app/views/pg_insights/insights/_results_info.html.erb +33 -9
- data/app/views/pg_insights/insights/_results_info_empty.html.erb +10 -0
- data/app/views/pg_insights/insights/_results_panel.html.erb +7 -9
- data/app/views/pg_insights/insights/_results_table.html.erb +0 -5
- data/app/views/pg_insights/insights/_visual_view.html.erb +212 -0
- data/app/views/pg_insights/insights/index.html.erb +4 -1
- data/app/views/pg_insights/timeline/compare.html.erb +3 -3
- data/config/routes.rb +6 -0
- data/lib/generators/pg_insights/install_generator.rb +20 -14
- data/lib/generators/pg_insights/templates/db/migrate/create_pg_insights_query_executions.rb +45 -0
- data/lib/pg_insights/engine.rb +8 -0
- data/lib/pg_insights/version.rb +1 -1
- data/lib/pg_insights.rb +30 -2
- metadata +20 -2
@@ -383,6 +383,7 @@
|
|
383
383
|
width: 100%;
|
384
384
|
height: 100%;
|
385
385
|
min-height: 200px;
|
386
|
+
max-height: 400px;
|
386
387
|
padding: 12px;
|
387
388
|
border: 2px solid #e5e7eb;
|
388
389
|
border-radius: 6px;
|
@@ -392,6 +393,7 @@
|
|
392
393
|
background: #fafbfc;
|
393
394
|
color: #1f2937;
|
394
395
|
resize: none;
|
396
|
+
overflow-y: auto;
|
395
397
|
transition: border-color 0.2s ease;
|
396
398
|
|
397
399
|
&:focus {
|
@@ -405,6 +407,24 @@
|
|
405
407
|
color: #9ca3af;
|
406
408
|
opacity: 0.8;
|
407
409
|
}
|
410
|
+
|
411
|
+
&::-webkit-scrollbar {
|
412
|
+
width: 8px;
|
413
|
+
}
|
414
|
+
|
415
|
+
&::-webkit-scrollbar-track {
|
416
|
+
background: #f1f5f9;
|
417
|
+
border-radius: 4px;
|
418
|
+
}
|
419
|
+
|
420
|
+
&::-webkit-scrollbar-thumb {
|
421
|
+
background: #cbd5e1;
|
422
|
+
border-radius: 4px;
|
423
|
+
|
424
|
+
&:hover {
|
425
|
+
background: #94a3b8;
|
426
|
+
}
|
427
|
+
}
|
408
428
|
}
|
409
429
|
|
410
430
|
.form-actions {
|
@@ -460,6 +480,31 @@
|
|
460
480
|
transform: translateY(-1px);
|
461
481
|
}
|
462
482
|
}
|
483
|
+
|
484
|
+
&.btn-info {
|
485
|
+
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
486
|
+
color: white;
|
487
|
+
|
488
|
+
&:hover {
|
489
|
+
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
490
|
+
transform: translateY(-1px);
|
491
|
+
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
492
|
+
}
|
493
|
+
|
494
|
+
&:disabled {
|
495
|
+
background: #9ca3af !important;
|
496
|
+
cursor: not-allowed !important;
|
497
|
+
transform: none !important;
|
498
|
+
box-shadow: none !important;
|
499
|
+
opacity: 0.6;
|
500
|
+
|
501
|
+
&:hover {
|
502
|
+
background: #9ca3af !important;
|
503
|
+
transform: none !important;
|
504
|
+
box-shadow: none !important;
|
505
|
+
}
|
506
|
+
}
|
507
|
+
}
|
463
508
|
}
|
464
509
|
|
465
510
|
/* Query Examples Section */
|
@@ -723,7 +768,8 @@
|
|
723
768
|
}
|
724
769
|
|
725
770
|
.sql-editor {
|
726
|
-
font-size: 14px;
|
771
|
+
font-size: 14px;
|
772
|
+
max-height: 400px;
|
727
773
|
}
|
728
774
|
|
729
775
|
.form-actions {
|
@@ -747,4 +793,8 @@
|
|
747
793
|
.results-panel {
|
748
794
|
height: 30%;
|
749
795
|
}
|
796
|
+
|
797
|
+
.sql-editor {
|
798
|
+
max-height: 400px;
|
799
|
+
}
|
750
800
|
}
|
@@ -50,11 +50,22 @@
|
|
50
50
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
51
51
|
}
|
52
52
|
|
53
|
-
.toggle-btn:hover:not(.active) {
|
53
|
+
.toggle-btn:hover:not(.active):not(.disabled) {
|
54
54
|
background: rgba(255,255,255,0.5);
|
55
55
|
color: #475569;
|
56
56
|
}
|
57
57
|
|
58
|
+
.toggle-btn.disabled {
|
59
|
+
opacity: 0.6;
|
60
|
+
cursor: help;
|
61
|
+
pointer-events: auto;
|
62
|
+
}
|
63
|
+
|
64
|
+
.toggle-btn.disabled:hover {
|
65
|
+
background: transparent;
|
66
|
+
color: #64748b;
|
67
|
+
}
|
68
|
+
|
58
69
|
.toggle-icon {
|
59
70
|
font-size: 14px;
|
60
71
|
}
|
@@ -6,7 +6,6 @@ module PgInsights
|
|
6
6
|
protect_from_forgery with: :exception
|
7
7
|
|
8
8
|
MAX_ROWS = 1_000
|
9
|
-
TIMEOUT = 5_000
|
10
9
|
|
11
10
|
# GET /pg_insights
|
12
11
|
# POST /pg_insights
|
@@ -27,6 +26,9 @@ module PgInsights
|
|
27
26
|
@insight_queries = built_in_queries + saved_queries
|
28
27
|
|
29
28
|
return unless request.post?
|
29
|
+
|
30
|
+
# Determine execution type from button clicked
|
31
|
+
execution_type = determine_execution_type
|
30
32
|
sql = params.require(:sql)
|
31
33
|
|
32
34
|
unless read_only?(sql)
|
@@ -34,19 +36,57 @@ module PgInsights
|
|
34
36
|
return render :index, status: :unprocessable_entity
|
35
37
|
end
|
36
38
|
|
37
|
-
|
39
|
+
# Handle different execution types
|
40
|
+
case execution_type
|
41
|
+
when "execute"
|
42
|
+
handle_execute_only(sql)
|
43
|
+
when "analyze"
|
44
|
+
handle_analyze_only(sql)
|
45
|
+
when "both"
|
46
|
+
handle_execute_and_analyze(sql)
|
47
|
+
else
|
48
|
+
# Fallback to original behavior for backward compatibility
|
49
|
+
handle_execute_only(sql)
|
50
|
+
end
|
51
|
+
|
52
|
+
render :index
|
53
|
+
end
|
54
|
+
|
55
|
+
# POST /pg_insights/analyze
|
56
|
+
def analyze
|
57
|
+
sql = params.require(:sql)
|
58
|
+
execution_type = params.fetch(:execution_type, "analyze")
|
59
|
+
async = params.fetch(:async, false)
|
60
|
+
|
61
|
+
unless read_only?(sql)
|
62
|
+
render json: { error: "Only single SELECT statements are allowed" }, status: :unprocessable_entity
|
63
|
+
return
|
64
|
+
end
|
38
65
|
|
39
66
|
begin
|
40
|
-
|
41
|
-
|
42
|
-
|
67
|
+
if async.to_s == "true"
|
68
|
+
execution = QueryAnalysisService.analyze_query_async(sql, execution_type: execution_type)
|
69
|
+
render json: {
|
70
|
+
execution_id: execution.id,
|
71
|
+
status: execution.status,
|
72
|
+
message: "Analysis started"
|
73
|
+
}
|
74
|
+
else
|
75
|
+
execution = QueryAnalysisService.execute_query(sql, execution_type: execution_type)
|
76
|
+
render json: format_execution_response(execution)
|
43
77
|
end
|
44
|
-
rescue
|
45
|
-
|
46
|
-
|
78
|
+
rescue => e
|
79
|
+
Rails.logger.error "Analysis failed: #{e.message}"
|
80
|
+
render json: { error: e.message }, status: :internal_server_error
|
47
81
|
end
|
82
|
+
end
|
48
83
|
|
49
|
-
|
84
|
+
# GET /pg_insights/execution/:id
|
85
|
+
def execution_status
|
86
|
+
execution = QueryExecution.find(params[:id])
|
87
|
+
render json: format_execution_response(execution)
|
88
|
+
rescue ActiveRecord::RecordNotFound
|
89
|
+
render json: { error: "Execution not found" }, status: :not_found
|
50
90
|
end
|
51
91
|
|
52
92
|
# GET /pg_insights/table_names
|
@@ -60,8 +100,171 @@ module PgInsights
|
|
60
100
|
render json: { tables: [] }
|
61
101
|
end
|
62
102
|
|
103
|
+
def query_history
|
104
|
+
@executions = QueryExecution.recent_history(10)
|
105
|
+
|
106
|
+
executions_data = @executions.map do |execution|
|
107
|
+
{
|
108
|
+
id: execution.id,
|
109
|
+
title: execution.display_title,
|
110
|
+
summary: execution.display_summary,
|
111
|
+
performance_class: execution.performance_class,
|
112
|
+
created_at: execution.created_at.strftime("%m/%d %H:%M"),
|
113
|
+
total_time_ms: execution.total_time_ms,
|
114
|
+
query_cost: execution.query_cost,
|
115
|
+
sql_text: execution.sql_text
|
116
|
+
}
|
117
|
+
end
|
118
|
+
|
119
|
+
respond_to do |format|
|
120
|
+
format.json { render json: executions_data }
|
121
|
+
format.html { redirect_to root_path }
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def compare
|
126
|
+
# Handle nested parameters from form submission
|
127
|
+
execution_ids = params[:execution_ids] || params.dig(:insight, :execution_ids)
|
128
|
+
|
129
|
+
if execution_ids.blank? || execution_ids.size != 2
|
130
|
+
error_response = { error: "Please select exactly 2 queries to compare" }
|
131
|
+
respond_to do |format|
|
132
|
+
format.json { render json: error_response, status: :bad_request }
|
133
|
+
format.html { redirect_to root_path, alert: error_response[:error] }
|
134
|
+
end
|
135
|
+
return
|
136
|
+
end
|
137
|
+
|
138
|
+
begin
|
139
|
+
@execution_a = QueryExecution.find(execution_ids[0])
|
140
|
+
@execution_b = QueryExecution.find(execution_ids[1])
|
141
|
+
|
142
|
+
# Performance logging
|
143
|
+
Rails.logger.info "PgInsights: Comparing query executions #{@execution_a.id} vs #{@execution_b.id}"
|
144
|
+
|
145
|
+
start_time = Time.current
|
146
|
+
@comparison_data = generate_comparison_data(@execution_a, @execution_b)
|
147
|
+
comparison_duration = ((Time.current - start_time) * 1000).round(2)
|
148
|
+
|
149
|
+
Rails.logger.info "PgInsights: Comparison completed in #{comparison_duration}ms"
|
150
|
+
|
151
|
+
respond_to do |format|
|
152
|
+
format.json { render json: @comparison_data }
|
153
|
+
format.html { redirect_to root_path, notice: "Comparison completed" }
|
154
|
+
end
|
155
|
+
rescue ActiveRecord::RecordNotFound => e
|
156
|
+
error_response = { error: "One or both query executions not found" }
|
157
|
+
respond_to do |format|
|
158
|
+
format.json { render json: error_response, status: :not_found }
|
159
|
+
format.html { redirect_to root_path, alert: error_response[:error] }
|
160
|
+
end
|
161
|
+
rescue => e
|
162
|
+
Rails.logger.error "Comparison failed: #{e.message}"
|
163
|
+
Rails.logger.error e.backtrace.join("\n")
|
164
|
+
|
165
|
+
error_response = { error: "Comparison failed: #{e.message}" }
|
166
|
+
respond_to do |format|
|
167
|
+
format.json { render json: error_response, status: :internal_server_error }
|
168
|
+
format.html { redirect_to root_path, alert: error_response[:error] }
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
63
173
|
private
|
64
174
|
|
175
|
+
def determine_execution_type
|
176
|
+
return "analyze" if params[:analyze_button].present?
|
177
|
+
return "both" if params[:both_button].present?
|
178
|
+
"execute" # Default
|
179
|
+
end
|
180
|
+
|
181
|
+
def handle_execute_only(sql)
|
182
|
+
sql = append_limit(sql, MAX_ROWS) unless sql.match?(/limit\s+\d+/i)
|
183
|
+
|
184
|
+
begin
|
185
|
+
ActiveRecord::Base.connection.transaction do
|
186
|
+
ActiveRecord::Base.connection.execute("SET LOCAL statement_timeout = #{PgInsights.query_execution_timeout_ms}")
|
187
|
+
@result = ActiveRecord::Base.connection.exec_query(sql)
|
188
|
+
end
|
189
|
+
@execution_type = "execute"
|
190
|
+
rescue ActiveRecord::StatementInvalid, PG::Error => e
|
191
|
+
flash.now[:alert] = "Query Error: #{e.message}"
|
192
|
+
nil
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
def handle_analyze_only(sql)
|
197
|
+
begin
|
198
|
+
@query_execution = QueryAnalysisService.execute_query(sql, execution_type: "analyze")
|
199
|
+
@execution_type = "analyze"
|
200
|
+
rescue => e
|
201
|
+
flash.now[:alert] = "Analysis Error: #{e.message}"
|
202
|
+
nil
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
def handle_execute_and_analyze(sql)
|
207
|
+
sql = append_limit(sql, MAX_ROWS) unless sql.match?(/limit\s+\d+/i)
|
208
|
+
|
209
|
+
begin
|
210
|
+
@query_execution = QueryAnalysisService.execute_query(sql, execution_type: "both")
|
211
|
+
@execution_type = "both"
|
212
|
+
|
213
|
+
# Extract regular result for backward compatibility
|
214
|
+
if @query_execution.success? && @query_execution.result_data
|
215
|
+
result_data = @query_execution.result_data
|
216
|
+
@result = ActiveRecord::Result.new(
|
217
|
+
result_data["columns"],
|
218
|
+
result_data["rows"],
|
219
|
+
result_data["column_types"] || {}
|
220
|
+
)
|
221
|
+
end
|
222
|
+
rescue => e
|
223
|
+
flash.now[:alert] = "Error: #{e.message}"
|
224
|
+
nil
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
def format_execution_response(execution)
|
229
|
+
response = {
|
230
|
+
id: execution.id,
|
231
|
+
status: execution.status,
|
232
|
+
execution_type: execution.execution_type
|
233
|
+
}
|
234
|
+
|
235
|
+
if execution.success?
|
236
|
+
# Include rich metrics for enhanced views
|
237
|
+
response[:metrics] = extract_metrics(execution)
|
238
|
+
|
239
|
+
if execution.has_result_data?
|
240
|
+
response[:result] = {
|
241
|
+
rows: execution.result_data["rows"],
|
242
|
+
columns: execution.result_data["columns"],
|
243
|
+
summary: execution.result_summary
|
244
|
+
}
|
245
|
+
end
|
246
|
+
|
247
|
+
if execution.has_plan_data?
|
248
|
+
response[:analysis] = {
|
249
|
+
execution_plan: execution.execution_plan,
|
250
|
+
plan_summary: execution.plan_summary,
|
251
|
+
performance_insights: execution.performance_insights,
|
252
|
+
timing: {
|
253
|
+
planning_time_ms: execution.planning_time_ms,
|
254
|
+
execution_time_ms: execution.execution_time_ms,
|
255
|
+
total_time_ms: execution.total_time_ms
|
256
|
+
},
|
257
|
+
cost: execution.query_cost,
|
258
|
+
stats: execution.execution_stats
|
259
|
+
}
|
260
|
+
end
|
261
|
+
elsif execution.failed?
|
262
|
+
response[:error] = execution.error_message
|
263
|
+
end
|
264
|
+
|
265
|
+
response
|
266
|
+
end
|
267
|
+
|
65
268
|
def read_only?(sql)
|
66
269
|
sql.strip!
|
67
270
|
# Check for a single SELECT statement
|
@@ -72,6 +275,280 @@ module PgInsights
|
|
72
275
|
|
73
276
|
def append_limit(sql, n)
|
74
277
|
"#{sql.strip} LIMIT #{n}"
|
278
|
+
end
|
279
|
+
|
280
|
+
def generate_comparison_data(exec_a, exec_b)
|
281
|
+
begin
|
282
|
+
{
|
283
|
+
executions: {
|
284
|
+
a: {
|
285
|
+
id: exec_a.id,
|
286
|
+
title: exec_a.respond_to?(:display_title) ? exec_a.display_title : "Query ##{exec_a.id}",
|
287
|
+
summary: exec_a.respond_to?(:display_summary) ? exec_a.display_summary : "",
|
288
|
+
sql_text: exec_a.sql_text,
|
289
|
+
metrics: extract_metrics(exec_a)
|
290
|
+
},
|
291
|
+
b: {
|
292
|
+
id: exec_b.id,
|
293
|
+
title: exec_b.respond_to?(:display_title) ? exec_b.display_title : "Query ##{exec_b.id}",
|
294
|
+
summary: exec_b.respond_to?(:display_summary) ? exec_b.display_summary : "",
|
295
|
+
sql_text: exec_b.sql_text,
|
296
|
+
metrics: extract_metrics(exec_b)
|
297
|
+
}
|
298
|
+
},
|
299
|
+
comparison: {
|
300
|
+
performance: calculate_performance_diff(exec_a, exec_b),
|
301
|
+
winner: determine_winner(exec_a, exec_b),
|
302
|
+
insights: generate_insights(exec_a, exec_b)
|
303
|
+
}
|
304
|
+
}
|
305
|
+
rescue => e
|
306
|
+
Rails.logger.error "Error in generate_comparison_data: #{e.message}"
|
307
|
+
Rails.logger.error "exec_a class: #{exec_a.class.name}, exec_b class: #{exec_b.class.name}"
|
308
|
+
Rails.logger.error e.backtrace.join("\n")
|
309
|
+
raise e
|
310
|
+
end
|
311
|
+
end
|
312
|
+
|
313
|
+
def extract_metrics(execution)
|
314
|
+
base_metrics = {
|
315
|
+
total_time_ms: execution.total_time_ms,
|
316
|
+
planning_time_ms: execution.planning_time_ms,
|
317
|
+
execution_time_ms: execution.execution_time_ms,
|
318
|
+
query_cost: execution.query_cost
|
319
|
+
}
|
320
|
+
|
321
|
+
# For queries with execution plans (EXPLAIN ANALYZE), extract from plan
|
322
|
+
if execution.execution_plan.present?
|
323
|
+
plan_metrics = extract_plan_metrics(execution)
|
324
|
+
base_metrics.merge(plan_metrics)
|
325
|
+
else
|
326
|
+
# For regular SELECT queries, use result data
|
327
|
+
base_metrics.merge(
|
328
|
+
rows_returned: execution.result_rows_count,
|
329
|
+
rows_scanned: nil
|
330
|
+
)
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
def calculate_performance_diff(exec_a, exec_b)
|
335
|
+
return {} unless exec_a.total_time_ms && exec_b.total_time_ms
|
336
|
+
|
337
|
+
time_diff_pct = ((exec_a.total_time_ms - exec_b.total_time_ms) / exec_a.total_time_ms * 100).round(1)
|
338
|
+
cost_diff_pct = if exec_a.query_cost && exec_b.query_cost
|
339
|
+
((exec_a.query_cost - exec_b.query_cost) / exec_a.query_cost * 100).round(1)
|
340
|
+
else
|
341
|
+
nil
|
342
|
+
end
|
343
|
+
|
344
|
+
{
|
345
|
+
time_difference_pct: time_diff_pct,
|
346
|
+
cost_difference_pct: cost_diff_pct,
|
347
|
+
time_faster: time_diff_pct > 0 ? "b" : "a",
|
348
|
+
cost_cheaper: cost_diff_pct && cost_diff_pct > 0 ? "b" : "a"
|
349
|
+
}
|
350
|
+
end
|
351
|
+
|
352
|
+
def determine_winner(exec_a, exec_b)
|
353
|
+
return "unknown" unless exec_a.total_time_ms && exec_b.total_time_ms
|
354
|
+
exec_a.total_time_ms < exec_b.total_time_ms ? "a" : "b"
|
355
|
+
end
|
356
|
+
|
357
|
+
def generate_insights(exec_a, exec_b)
|
358
|
+
insights = []
|
359
|
+
|
360
|
+
if exec_a.total_time_ms && exec_b.total_time_ms
|
361
|
+
time_diff_pct = ((exec_a.total_time_ms - exec_b.total_time_ms).abs / [ exec_a.total_time_ms, exec_b.total_time_ms ].max * 100).round(1)
|
362
|
+
|
363
|
+
if time_diff_pct > 20
|
364
|
+
faster_query = exec_a.total_time_ms < exec_b.total_time_ms ? "Query A" : "Query B"
|
365
|
+
insights << "#{faster_query} is #{time_diff_pct}% faster in execution time"
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
# Add plan structure insights
|
370
|
+
if has_sequential_scan?(exec_a) && !has_sequential_scan?(exec_b)
|
371
|
+
insights << "Query B uses index scans while Query A uses sequential scans"
|
372
|
+
elsif has_sequential_scan?(exec_b) && !has_sequential_scan?(exec_a)
|
373
|
+
insights << "Query A uses index scans while Query B uses sequential scans"
|
374
|
+
end
|
375
|
+
|
376
|
+
insights
|
377
|
+
end
|
378
|
+
|
379
|
+
def extract_rows_scanned(execution)
|
380
|
+
return nil unless execution.execution_plan.present?
|
381
|
+
|
382
|
+
plan = execution.execution_plan.is_a?(Array) ? execution.execution_plan[0] : execution.execution_plan
|
383
|
+
extract_total_rows_from_plan(plan&.dig("Plan"))
|
384
|
+
end
|
385
|
+
|
386
|
+
def extract_total_rows_from_plan(node)
|
387
|
+
return 0 unless node
|
388
|
+
|
389
|
+
current_rows = node["Actual Rows"] || 0
|
390
|
+
child_rows = 0
|
391
|
+
|
392
|
+
if node["Plans"]&.any?
|
393
|
+
child_rows = node["Plans"].sum { |child| extract_total_rows_from_plan(child) }
|
394
|
+
end
|
395
|
+
|
396
|
+
current_rows + child_rows
|
397
|
+
end
|
398
|
+
|
399
|
+
def has_sequential_scan?(execution)
|
400
|
+
return false unless execution.execution_plan.present?
|
401
|
+
|
402
|
+
plan = execution.execution_plan.is_a?(Array) ? execution.execution_plan[0] : execution.execution_plan
|
403
|
+
check_for_seq_scan(plan&.dig("Plan"))
|
404
|
+
end
|
405
|
+
|
406
|
+
def extract_plan_metrics(execution)
|
407
|
+
plan_data = execution.execution_plan.is_a?(Array) ? execution.execution_plan[0] : execution.execution_plan
|
408
|
+
return {} unless plan_data && plan_data["Plan"]
|
409
|
+
|
410
|
+
root_plan = plan_data["Plan"]
|
411
|
+
|
412
|
+
{
|
413
|
+
rows_returned: root_plan["Actual Rows"],
|
414
|
+
rows_scanned: extract_total_rows_scanned(root_plan),
|
415
|
+
workers_planned: extract_workers_info(root_plan)[:planned],
|
416
|
+
workers_launched: extract_workers_info(root_plan)[:launched],
|
417
|
+
memory_usage_kb: extract_peak_memory_usage(root_plan),
|
418
|
+
sort_methods: extract_sort_methods(root_plan),
|
419
|
+
index_usage: extract_index_usage(root_plan),
|
420
|
+
node_count: count_plan_nodes(root_plan),
|
421
|
+
join_types: extract_join_types(root_plan),
|
422
|
+
scan_types: extract_scan_types(root_plan)
|
423
|
+
}
|
424
|
+
end
|
425
|
+
|
426
|
+
def extract_workers_info(node, workers_info = { planned: 0, launched: 0 })
|
427
|
+
return workers_info unless node
|
428
|
+
|
429
|
+
if node["Workers Planned"]
|
430
|
+
workers_info[:planned] = [ workers_info[:planned], node["Workers Planned"] ].max
|
431
|
+
end
|
432
|
+
|
433
|
+
if node["Workers Launched"]
|
434
|
+
workers_info[:launched] = [ workers_info[:launched], node["Workers Launched"] ].max
|
435
|
+
end
|
436
|
+
|
437
|
+
if node["Plans"]&.any?
|
438
|
+
node["Plans"].each { |child| extract_workers_info(child, workers_info) }
|
439
|
+
end
|
440
|
+
|
441
|
+
workers_info
|
442
|
+
end
|
443
|
+
|
444
|
+
def extract_peak_memory_usage(node, max_memory = 0)
|
445
|
+
return max_memory unless node
|
446
|
+
|
447
|
+
if node["Peak Memory Usage"]
|
448
|
+
max_memory = [ max_memory, node["Peak Memory Usage"] ].max
|
449
|
+
end
|
450
|
+
|
451
|
+
if node["Plans"]&.any?
|
452
|
+
node["Plans"].each { |child| max_memory = extract_peak_memory_usage(child, max_memory) }
|
453
|
+
end
|
454
|
+
|
455
|
+
max_memory
|
456
|
+
end
|
457
|
+
|
458
|
+
def extract_sort_methods(node, methods = Set.new)
|
459
|
+
return methods.to_a unless node
|
460
|
+
|
461
|
+
if node["Sort Method"]
|
462
|
+
methods.add(node["Sort Method"])
|
463
|
+
end
|
464
|
+
|
465
|
+
if node["Plans"]&.any?
|
466
|
+
node["Plans"].each { |child| extract_sort_methods(child, methods) }
|
467
|
+
end
|
468
|
+
|
469
|
+
methods.to_a
|
470
|
+
end
|
471
|
+
|
472
|
+
def extract_index_usage(node, indexes = Set.new)
|
473
|
+
return indexes.to_a unless node
|
474
|
+
|
475
|
+
if node["Index Name"]
|
476
|
+
indexes.add(node["Index Name"])
|
477
|
+
end
|
478
|
+
|
479
|
+
if node["Plans"]&.any?
|
480
|
+
node["Plans"].each { |child| extract_index_usage(child, indexes) }
|
481
|
+
end
|
482
|
+
|
483
|
+
indexes.to_a
|
484
|
+
end
|
485
|
+
|
486
|
+
def count_plan_nodes(node)
|
487
|
+
return 0 unless node
|
488
|
+
|
489
|
+
count = 1
|
490
|
+
if node["Plans"]&.any?
|
491
|
+
count += node["Plans"].sum { |child| count_plan_nodes(child) }
|
492
|
+
end
|
493
|
+
count
|
494
|
+
end
|
495
|
+
|
496
|
+
def extract_join_types(node, types = Set.new)
|
497
|
+
return types.to_a unless node
|
498
|
+
|
499
|
+
if node["Node Type"]&.include?("Join")
|
500
|
+
join_type = node["Join Type"] ? "#{node['Join Type']} #{node['Node Type']}" : node["Node Type"]
|
501
|
+
types.add(join_type)
|
502
|
+
end
|
503
|
+
|
504
|
+
if node["Plans"]&.any?
|
505
|
+
node["Plans"].each { |child| extract_join_types(child, types) }
|
506
|
+
end
|
507
|
+
|
508
|
+
types.to_a
|
509
|
+
end
|
510
|
+
|
511
|
+
def extract_scan_types(node, types = Set.new)
|
512
|
+
return types.to_a unless node
|
513
|
+
|
514
|
+
if node["Node Type"]&.include?("Scan")
|
515
|
+
types.add(node["Node Type"])
|
516
|
+
end
|
517
|
+
|
518
|
+
if node["Plans"]&.any?
|
519
|
+
node["Plans"].each { |child| extract_scan_types(child, types) }
|
520
|
+
end
|
521
|
+
|
522
|
+
types.to_a
|
523
|
+
end
|
524
|
+
|
525
|
+
def extract_total_rows_scanned(node)
|
526
|
+
return 0 unless node
|
527
|
+
|
528
|
+
scanned_rows = 0
|
529
|
+
|
530
|
+
# Count rows from scan operations
|
531
|
+
if node["Node Type"]&.include?("Scan") && node["Actual Rows"]
|
532
|
+
scanned_rows += node["Actual Rows"] || 0
|
533
|
+
end
|
534
|
+
|
535
|
+
if node["Plans"]&.any?
|
536
|
+
scanned_rows += node["Plans"].sum { |child| extract_total_rows_scanned(child) }
|
537
|
+
end
|
538
|
+
|
539
|
+
scanned_rows
|
540
|
+
end
|
541
|
+
|
542
|
+
def check_for_seq_scan(node)
|
543
|
+
return false unless node
|
544
|
+
|
545
|
+
return true if node["Node Type"]&.include?("Seq Scan")
|
546
|
+
|
547
|
+
if node["Plans"]&.any?
|
548
|
+
return node["Plans"].any? { |child| check_for_seq_scan(child) }
|
549
|
+
end
|
550
|
+
|
551
|
+
false
|
75
552
|
end
|
76
553
|
end
|
77
554
|
end
|