pg_insights 0.3.2 → 0.4.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/pg_insights/application.js +91 -21
  3. data/app/assets/javascripts/pg_insights/plan_performance.js +53 -0
  4. data/app/assets/javascripts/pg_insights/query_comparison.js +1129 -0
  5. data/app/assets/javascripts/pg_insights/results/view_toggles.js +26 -5
  6. data/app/assets/javascripts/pg_insights/results.js +231 -1
  7. data/app/assets/stylesheets/pg_insights/analysis.css +2628 -0
  8. data/app/assets/stylesheets/pg_insights/application.css +51 -1
  9. data/app/assets/stylesheets/pg_insights/results.css +12 -1
  10. data/app/controllers/pg_insights/insights_controller.rb +486 -9
  11. data/app/helpers/pg_insights/application_helper.rb +339 -0
  12. data/app/helpers/pg_insights/insights_helper.rb +567 -0
  13. data/app/jobs/pg_insights/query_analysis_job.rb +142 -0
  14. data/app/models/pg_insights/query_execution.rb +198 -0
  15. data/app/services/pg_insights/query_analysis_service.rb +269 -0
  16. data/app/views/layouts/pg_insights/application.html.erb +2 -0
  17. data/app/views/pg_insights/insights/_compare_view.html.erb +264 -0
  18. data/app/views/pg_insights/insights/_empty_state.html.erb +9 -0
  19. data/app/views/pg_insights/insights/_execution_table_view.html.erb +86 -0
  20. data/app/views/pg_insights/insights/_history_bar.html.erb +33 -0
  21. data/app/views/pg_insights/insights/_perf_view.html.erb +244 -0
  22. data/app/views/pg_insights/insights/_plan_nodes.html.erb +12 -0
  23. data/app/views/pg_insights/insights/_plan_tree.html.erb +30 -0
  24. data/app/views/pg_insights/insights/_plan_tree_modern.html.erb +12 -0
  25. data/app/views/pg_insights/insights/_plan_view.html.erb +159 -0
  26. data/app/views/pg_insights/insights/_query_panel.html.erb +3 -2
  27. data/app/views/pg_insights/insights/_result.html.erb +19 -4
  28. data/app/views/pg_insights/insights/_results_info.html.erb +33 -9
  29. data/app/views/pg_insights/insights/_results_info_empty.html.erb +10 -0
  30. data/app/views/pg_insights/insights/_results_panel.html.erb +7 -9
  31. data/app/views/pg_insights/insights/_results_table.html.erb +0 -5
  32. data/app/views/pg_insights/insights/_visual_view.html.erb +212 -0
  33. data/app/views/pg_insights/insights/index.html.erb +4 -1
  34. data/app/views/pg_insights/timeline/compare.html.erb +3 -3
  35. data/config/routes.rb +6 -0
  36. data/lib/generators/pg_insights/install_generator.rb +20 -14
  37. data/lib/generators/pg_insights/templates/db/migrate/create_pg_insights_query_executions.rb +45 -0
  38. data/lib/pg_insights/version.rb +1 -1
  39. data/lib/pg_insights.rb +30 -2
  40. metadata +20 -2
@@ -111,8 +111,19 @@ module PgInsights
111
111
  end
112
112
  end
113
113
 
114
+ def query_info_text
115
+ execute_timeout = format_timeout(PgInsights.query_execution_timeout)
116
+ analyze_timeout = format_timeout(PgInsights.query_analysis_timeout)
117
+ "SELECT only • #{execute_timeout} exec • #{analyze_timeout} analyze • 1k row limit"
118
+ end
119
+
114
120
  private
115
121
 
122
+ def format_timeout(timeout)
123
+ seconds = timeout.to_f
124
+ seconds >= 1 ? "#{seconds.to_i}s" : "#{(seconds * 1000).to_i}ms"
125
+ end
126
+
116
127
  def prepare_chart_data(result)
117
128
  return { labels: [], chartData: [] } unless result&.rows&.any?
118
129
 
@@ -186,5 +197,561 @@ module PgInsights
186
197
 
187
198
  nil
188
199
  end
200
+
201
+ def extract_rich_plan_metrics(execution)
202
+ return {} unless execution.execution_plan.present?
203
+
204
+ plan_data = execution.execution_plan.is_a?(Array) ? execution.execution_plan[0] : execution.execution_plan
205
+ return {} unless plan_data && plan_data["Plan"]
206
+
207
+ root_plan = plan_data["Plan"]
208
+
209
+ {
210
+ rows_returned: root_plan["Actual Rows"],
211
+ rows_scanned: extract_total_rows_scanned_helper(root_plan),
212
+ workers_planned: extract_workers_info_helper(root_plan)[:planned],
213
+ workers_launched: extract_workers_info_helper(root_plan)[:launched],
214
+ memory_usage_kb: extract_peak_memory_usage_helper(root_plan),
215
+ sort_methods: extract_sort_methods_helper(root_plan),
216
+ index_usage: extract_index_usage_helper(root_plan),
217
+ node_count: count_plan_nodes_helper(root_plan),
218
+ join_types: extract_join_types_helper(root_plan),
219
+ scan_types: extract_scan_types_helper(root_plan)
220
+ }
221
+ end
222
+
223
+ private
224
+
225
+ def extract_workers_info_helper(node, workers_info = { planned: 0, launched: 0 })
226
+ return workers_info unless node
227
+
228
+ if node["Workers Planned"]
229
+ workers_info[:planned] = [ workers_info[:planned], node["Workers Planned"] ].max
230
+ end
231
+
232
+ if node["Workers Launched"]
233
+ workers_info[:launched] = [ workers_info[:launched], node["Workers Launched"] ].max
234
+ end
235
+
236
+ if node["Plans"]&.any?
237
+ node["Plans"].each { |child| extract_workers_info_helper(child, workers_info) }
238
+ end
239
+
240
+ workers_info
241
+ end
242
+
243
+ def extract_peak_memory_usage_helper(node, max_memory = 0)
244
+ return max_memory unless node
245
+
246
+ if node["Peak Memory Usage"]
247
+ max_memory = [ max_memory, node["Peak Memory Usage"] ].max
248
+ end
249
+
250
+ if node["Plans"]&.any?
251
+ node["Plans"].each { |child| max_memory = extract_peak_memory_usage_helper(child, max_memory) }
252
+ end
253
+
254
+ max_memory
255
+ end
256
+
257
+ def extract_sort_methods_helper(node, methods = Set.new)
258
+ return methods.to_a unless node
259
+
260
+ if node["Sort Method"]
261
+ methods.add(node["Sort Method"])
262
+ end
263
+
264
+ if node["Plans"]&.any?
265
+ node["Plans"].each { |child| extract_sort_methods_helper(child, methods) }
266
+ end
267
+
268
+ methods.to_a
269
+ end
270
+
271
+ def extract_index_usage_helper(node, indexes = Set.new)
272
+ return indexes.to_a unless node
273
+
274
+ if node["Index Name"]
275
+ indexes.add(node["Index Name"])
276
+ end
277
+
278
+ if node["Plans"]&.any?
279
+ node["Plans"].each { |child| extract_index_usage_helper(child, indexes) }
280
+ end
281
+
282
+ indexes.to_a
283
+ end
284
+
285
+ def count_plan_nodes_helper(node)
286
+ return 0 unless node
287
+
288
+ count = 1
289
+ if node["Plans"]&.any?
290
+ count += node["Plans"].sum { |child| count_plan_nodes_helper(child) }
291
+ end
292
+ count
293
+ end
294
+
295
+ def extract_join_types_helper(node, types = Set.new)
296
+ return types.to_a unless node
297
+
298
+ if node["Node Type"]&.include?("Join")
299
+ join_type = node["Join Type"] ? "#{node['Join Type']} #{node['Node Type']}" : node["Node Type"]
300
+ types.add(join_type)
301
+ end
302
+
303
+ if node["Plans"]&.any?
304
+ node["Plans"].each { |child| extract_join_types_helper(child, types) }
305
+ end
306
+
307
+ types.to_a
308
+ end
309
+
310
+ def extract_scan_types_helper(node, types = Set.new)
311
+ return types.to_a unless node
312
+
313
+ if node["Node Type"]&.include?("Scan")
314
+ types.add(node["Node Type"])
315
+ end
316
+
317
+ if node["Plans"]&.any?
318
+ node["Plans"].each { |child| extract_scan_types_helper(child, types) }
319
+ end
320
+
321
+ types.to_a
322
+ end
323
+
324
+ def extract_total_rows_scanned_helper(node)
325
+ return 0 unless node
326
+
327
+ scanned_rows = 0
328
+
329
+ # Count rows from scan operations
330
+ if node["Node Type"]&.include?("Scan") && node["Actual Rows"]
331
+ scanned_rows += node["Actual Rows"] || 0
332
+ end
333
+
334
+ if node["Plans"]&.any?
335
+ scanned_rows += node["Plans"].sum { |child| extract_total_rows_scanned_helper(child) }
336
+ end
337
+
338
+ scanned_rows
339
+ end
340
+
341
+ def calculate_plan_efficiency_score(plan_metrics)
342
+ return "N/A" unless plan_metrics[:rows_returned] && plan_metrics[:rows_scanned]
343
+
344
+ score = 100
345
+
346
+ # Penalize based on I/O efficiency
347
+ if plan_metrics[:rows_scanned] > 0
348
+ io_efficiency = (plan_metrics[:rows_returned].to_f / plan_metrics[:rows_scanned]) * 100
349
+ if io_efficiency < 20
350
+ score -= 40
351
+ elsif io_efficiency < 50
352
+ score -= 20
353
+ end
354
+ end
355
+
356
+ # Bonus for index usage
357
+ score += 10 if plan_metrics[:index_usage]&.any?
358
+
359
+ # Penalty for sequential scans on large datasets
360
+ if plan_metrics[:scan_types]&.include?("Seq Scan") && plan_metrics[:rows_scanned] > 10000
361
+ score -= 20
362
+ end
363
+
364
+ # Penalty for external sorting
365
+ score -= 15 if plan_metrics[:sort_methods]&.include?("external merge")
366
+
367
+ # Bonus for parallel execution
368
+ score += 5 if plan_metrics[:workers_launched] && plan_metrics[:workers_launched] > 0
369
+
370
+ score = [ 0, [ 100, score ].min ].max
371
+
372
+ case score
373
+ when 80..100 then "Excellent"
374
+ when 60..79 then "Good"
375
+ when 40..59 then "Fair"
376
+ else "Poor"
377
+ end
378
+ end
379
+
380
+ def render_plan_performance_issues(plan_metrics, execution)
381
+ issues = []
382
+
383
+ # Check for performance issues
384
+ if execution.total_time_ms && execution.total_time_ms > 5000
385
+ issues << "<li>Very slow execution time (>5 seconds)</li>"
386
+ elsif execution.total_time_ms && execution.total_time_ms > 1000
387
+ issues << "<li>Slow execution time (>1 second)</li>"
388
+ end
389
+
390
+ if plan_metrics[:memory_usage_kb] && plan_metrics[:memory_usage_kb] > 100000
391
+ issues << "<li>High memory usage (>100MB)</li>"
392
+ end
393
+
394
+ if plan_metrics[:sort_methods]&.include?("external merge")
395
+ issues << "<li>Sorting spilled to disk</li>"
396
+ end
397
+
398
+ if plan_metrics[:scan_types]&.include?("Seq Scan") && plan_metrics[:rows_scanned] && plan_metrics[:rows_scanned] > 100000
399
+ issues << "<li>Large sequential scan detected</li>"
400
+ end
401
+
402
+ if plan_metrics[:workers_planned] && plan_metrics[:workers_launched] &&
403
+ plan_metrics[:workers_planned] > plan_metrics[:workers_launched]
404
+ issues << "<li>Worker shortage detected</li>"
405
+ end
406
+
407
+ if issues.empty?
408
+ "<li>No major performance issues detected</li>"
409
+ else
410
+ issues.join.html_safe
411
+ end
412
+ end
413
+
414
+ def render_plan_optimizations(plan_metrics, execution)
415
+ optimizations = []
416
+
417
+ if plan_metrics[:scan_types]&.include?("Seq Scan")
418
+ optimizations << "<li>Consider adding indexes to eliminate sequential scans</li>"
419
+ end
420
+
421
+ if plan_metrics[:sort_methods]&.include?("external merge")
422
+ optimizations << "<li>Increase work_mem to avoid disk-based sorting</li>"
423
+ end
424
+
425
+ if (!plan_metrics[:workers_launched] || plan_metrics[:workers_launched] == 0) &&
426
+ execution.total_time_ms && execution.total_time_ms > 1000
427
+ optimizations << "<li>Query could benefit from parallel execution</li>"
428
+ end
429
+
430
+ if (!plan_metrics[:index_usage] || plan_metrics[:index_usage].empty?) &&
431
+ plan_metrics[:rows_scanned] && plan_metrics[:rows_scanned] > 10000
432
+ optimizations << "<li>Add indexes to improve query performance</li>"
433
+ end
434
+
435
+ if plan_metrics[:join_types]&.any? && execution.total_time_ms && execution.total_time_ms > 500
436
+ optimizations << "<li>Review join strategy and column statistics</li>"
437
+ end
438
+
439
+ if optimizations.empty?
440
+ "<li>Query is well-optimized</li>"
441
+ else
442
+ optimizations.join.html_safe
443
+ end
444
+ end
445
+
446
+ def calculate_advanced_metrics(plan_metrics)
447
+ {
448
+ io_efficiency: calculate_io_efficiency(plan_metrics),
449
+ memory_efficiency: calculate_memory_efficiency(plan_metrics),
450
+ parallelization: calculate_parallelization_score(plan_metrics),
451
+ index_utilization: calculate_index_utilization(plan_metrics)
452
+ }
453
+ end
454
+
455
+ def calculate_io_efficiency(plan_metrics)
456
+ return "N/A" unless plan_metrics[:rows_returned] && plan_metrics[:rows_scanned] &&
457
+ plan_metrics[:rows_scanned] > 0
458
+
459
+ efficiency = (plan_metrics[:rows_returned].to_f / plan_metrics[:rows_scanned]) * 100
460
+ case efficiency
461
+ when 80..Float::INFINITY then "Excellent"
462
+ when 50..79 then "Good"
463
+ when 20..49 then "Fair"
464
+ else "Poor"
465
+ end
466
+ end
467
+
468
+ def calculate_memory_efficiency(plan_metrics)
469
+ return "N/A" unless plan_metrics[:memory_usage_kb] && plan_metrics[:rows_returned] &&
470
+ plan_metrics[:rows_returned] > 0
471
+
472
+ memory_per_row = plan_metrics[:memory_usage_kb].to_f / plan_metrics[:rows_returned]
473
+ case memory_per_row
474
+ when 0..1 then "Excellent"
475
+ when 1..5 then "Good"
476
+ when 5..20 then "Fair"
477
+ else "Poor"
478
+ end
479
+ end
480
+
481
+ def calculate_parallelization_score(plan_metrics)
482
+ return "N/A" unless plan_metrics[:workers_planned] && plan_metrics[:workers_planned] > 0
483
+
484
+ if plan_metrics[:workers_launched].nil? || plan_metrics[:workers_launched] == 0
485
+ return "None"
486
+ end
487
+
488
+ utilization = (plan_metrics[:workers_launched].to_f / plan_metrics[:workers_planned]) * 100
489
+ case utilization
490
+ when 90..Float::INFINITY then "Excellent"
491
+ when 70..89 then "Good"
492
+ when 50..69 then "Fair"
493
+ else "Poor"
494
+ end
495
+ end
496
+
497
+ def calculate_index_utilization(plan_metrics)
498
+ return "N/A" unless plan_metrics[:scan_types]&.any?
499
+
500
+ has_seq_scan = plan_metrics[:scan_types].include?("Seq Scan")
501
+ index_scans = plan_metrics[:scan_types].count { |type| type.include?("Index") }
502
+
503
+ if !has_seq_scan && index_scans > 0
504
+ "Excellent"
505
+ elsif index_scans > 0
506
+ "Good"
507
+ elsif has_seq_scan
508
+ "Poor"
509
+ else
510
+ "N/A"
511
+ end
512
+ end
513
+
514
+ def score_css_class(score)
515
+ case score.to_s.downcase
516
+ when "excellent"
517
+ "score-excellent"
518
+ when "good"
519
+ "score-good"
520
+ when "fair"
521
+ "score-fair"
522
+ when "poor"
523
+ "score-poor"
524
+ when "none"
525
+ "score-none"
526
+ else
527
+ ""
528
+ end
529
+ end
530
+
531
+ # Enhanced performance analysis helper methods
532
+ def calculate_overall_performance_score(execution)
533
+ return 75 unless execution.total_time_ms && execution.query_cost
534
+
535
+ score = 100
536
+
537
+ # Time penalty
538
+ if execution.total_time_ms > 10000
539
+ score -= 40
540
+ elsif execution.total_time_ms > 5000
541
+ score -= 25
542
+ elsif execution.total_time_ms > 1000
543
+ score -= 15
544
+ elsif execution.total_time_ms > 500
545
+ score -= 8
546
+ end
547
+
548
+ # Cost penalty
549
+ if execution.query_cost > 1000000
550
+ score -= 20
551
+ elsif execution.query_cost > 100000
552
+ score -= 10
553
+ end
554
+
555
+ # Plan metrics bonus/penalty
556
+ if execution.execution_plan.present?
557
+ plan_metrics = extract_rich_plan_metrics(execution)
558
+ score += 5 if plan_metrics[:index_usage]&.any?
559
+ score += 5 if plan_metrics[:workers_launched] && plan_metrics[:workers_launched] > 0
560
+ score -= 10 if plan_metrics[:sort_methods]&.include?("external merge")
561
+ end
562
+
563
+ [ 0, [ 100, score ].min ].max
564
+ end
565
+
566
+ def planning_vs_execution_insight(planning_ms, execution_ms)
567
+ return "" unless planning_ms && execution_ms && execution_ms > 0
568
+
569
+ ratio = planning_ms / execution_ms.to_f
570
+
571
+ if ratio > 0.5
572
+ "High planning overhead"
573
+ elsif ratio > 0.2
574
+ "Moderate planning cost"
575
+ elsif ratio < 0.05
576
+ "Efficient planning"
577
+ else
578
+ "Balanced timing"
579
+ end
580
+ end
581
+
582
+ def performance_benchmark_text(total_time_ms)
583
+ return "" unless total_time_ms
584
+
585
+ case total_time_ms
586
+ when 0..100
587
+ "Excellent"
588
+ when 101..500
589
+ "Good"
590
+ when 501..2000
591
+ "Acceptable"
592
+ when 2001..10000
593
+ "Slow"
594
+ else
595
+ "Very Slow"
596
+ end
597
+ end
598
+
599
+ def cost_threshold_indicator(cost)
600
+ return "" unless cost
601
+
602
+ case cost
603
+ when 0..1000
604
+ "\u2713 Low"
605
+ when 1001..10000
606
+ "\u26A0 Medium"
607
+ when 10001..100000
608
+ "\u26A0 High"
609
+ else
610
+ "\u26A0 Very High"
611
+ end
612
+ end
613
+
614
+ def memory_threshold_indicator(memory_kb)
615
+ return "" unless memory_kb
616
+
617
+ case memory_kb
618
+ when 0..10000
619
+ "\u2713 Low"
620
+ when 10001..50000
621
+ "\u26A0 Medium"
622
+ when 50001..200000
623
+ "\u26A0 High"
624
+ else
625
+ "\u26A0 Very High"
626
+ end
627
+ end
628
+
629
+ def format_memory_size(kb)
630
+ return "N/A" unless kb
631
+
632
+ if kb >= 1024 * 1024
633
+ "#{(kb / (1024.0 * 1024)).round(1)} GB"
634
+ elsif kb >= 1024
635
+ "#{(kb / 1024.0).round(1)} MB"
636
+ else
637
+ "#{number_with_delimiter(kb)} KB"
638
+ end
639
+ end
640
+
641
+ def io_efficiency_impact(plan_metrics)
642
+ return "N/A" unless plan_metrics[:rows_returned] && plan_metrics[:rows_scanned]
643
+
644
+ ratio = plan_metrics[:rows_returned].to_f / plan_metrics[:rows_scanned]
645
+
646
+ case ratio
647
+ when 0.8..1.0
648
+ "Excellent selectivity"
649
+ when 0.5..0.79
650
+ "Good filtering"
651
+ when 0.1..0.49
652
+ "Moderate waste"
653
+ else
654
+ "Poor selectivity"
655
+ end
656
+ end
657
+
658
+ def memory_efficiency_impact(plan_metrics)
659
+ return "N/A" unless plan_metrics[:memory_usage_kb] && plan_metrics[:rows_returned]
660
+
661
+ memory_per_row = plan_metrics[:memory_usage_kb].to_f / plan_metrics[:rows_returned]
662
+
663
+ case memory_per_row
664
+ when 0..1
665
+ "Very efficient"
666
+ when 1..10
667
+ "Reasonable usage"
668
+ when 10..100
669
+ "High consumption"
670
+ else
671
+ "Memory intensive"
672
+ end
673
+ end
674
+
675
+ def parallelization_impact(plan_metrics)
676
+ return "N/A" unless plan_metrics[:workers_planned]
677
+
678
+ if plan_metrics[:workers_launched] == 0
679
+ "No parallelization"
680
+ elsif plan_metrics[:workers_launched] == plan_metrics[:workers_planned]
681
+ "Full utilization"
682
+ else
683
+ "Partial utilization"
684
+ end
685
+ end
686
+
687
+ def index_usage_impact(plan_metrics)
688
+ return "N/A" unless plan_metrics[:scan_types]&.any?
689
+
690
+ has_seq_scan = plan_metrics[:scan_types].include?("Seq Scan")
691
+ has_index_scan = plan_metrics[:scan_types].any? { |type| type.include?("Index") }
692
+
693
+ if has_index_scan && !has_seq_scan
694
+ "Optimal access"
695
+ elsif has_index_scan && has_seq_scan
696
+ "Mixed access pattern"
697
+ elsif has_seq_scan
698
+ "Sequential scans"
699
+ else
700
+ "Unknown pattern"
701
+ end
702
+ end
703
+
704
+ def recommendation_priority_class(suggestion, index)
705
+ priority = recommendation_priority_level(suggestion, index)
706
+ "priority-#{priority.downcase}"
707
+ end
708
+
709
+ def recommendation_priority_text(suggestion, index)
710
+ recommendation_priority_level(suggestion, index)
711
+ end
712
+
713
+ def recommendation_priority_level(suggestion, index)
714
+ text = suggestion.downcase
715
+
716
+ if text.include?("index") && (text.include?("avoid") || text.include?("eliminate"))
717
+ "HIGH"
718
+ elsif text.include?("work_mem") || text.include?("memory")
719
+ "MEDIUM"
720
+ elsif index == 0
721
+ "HIGH"
722
+ elsif index <= 2
723
+ "MEDIUM"
724
+ else
725
+ "LOW"
726
+ end
727
+ end
728
+
729
+ def recommendation_impact_estimate(suggestion)
730
+ text = suggestion.downcase
731
+
732
+ if text.include?("index") && text.include?("avoid")
733
+ "~50-80% faster"
734
+ elsif text.include?("work_mem")
735
+ "~20-40% improvement"
736
+ elsif text.include?("parallel")
737
+ "~30-60% faster"
738
+ else
739
+ "Performance gain"
740
+ end
741
+ end
742
+
743
+ def recommendation_hint(suggestion)
744
+ text = suggestion.downcase
745
+
746
+ if text.include?("index") && text.include?("order_items")
747
+ "CREATE INDEX ON order_items(...)"
748
+ elsif text.include?("work_mem")
749
+ "SET work_mem = '256MB'"
750
+ elsif text.include?("parallel")
751
+ "Adjust max_parallel_workers"
752
+ else
753
+ "See PostgreSQL docs"
754
+ end
755
+ end
189
756
  end
190
757
  end