pg_peek 0.1.0 → 0.2.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/pg_peek/application.css +37 -1
  3. data/app/controllers/pg_peek/databases_controller.rb +12 -9
  4. data/app/controllers/pg_peek/endpoints_controller.rb +15 -3
  5. data/app/controllers/pg_peek/jobs_controller.rb +1 -1
  6. data/app/controllers/pg_peek/pg_stat_statements_controller.rb +9 -3
  7. data/app/helpers/pg_peek/application_helper.rb +24 -9
  8. data/app/helpers/pg_peek/report_helper.rb +31 -7
  9. data/app/models/pg_peek/pg_stat_statements.rb +73 -7
  10. data/app/models/pg_peek/table.rb +4 -3
  11. data/app/queries/pg_peek/pg14/pg_stat_statements/outliers.sql +1 -1
  12. data/app/queries/pg_peek/pg14/pg_stat_statements/outliers_by_endpoint.sql +24 -0
  13. data/app/queries/pg_peek/pg14/pg_stat_statements/outliers_by_job.sql +1 -1
  14. data/app/queries/pg_peek/pg14/tables/dead_tuples.sql +7 -3
  15. data/app/queries/pg_peek/pg15/pg_stat_statements/outliers.sql +1 -1
  16. data/app/queries/pg_peek/pg15/pg_stat_statements/outliers_by_endpoint.sql +24 -0
  17. data/app/queries/pg_peek/pg15/pg_stat_statements/outliers_by_job.sql +1 -1
  18. data/app/queries/pg_peek/pg15/tables/dead_tuples.sql +7 -3
  19. data/app/queries/pg_peek/pg16/pg_stat_statements/outliers.sql +1 -1
  20. data/app/queries/pg_peek/pg16/pg_stat_statements/outliers_by_endpoint.sql +24 -0
  21. data/app/queries/pg_peek/pg16/pg_stat_statements/outliers_by_job.sql +1 -1
  22. data/app/queries/pg_peek/pg16/tables/dead_tuples.sql +7 -3
  23. data/app/queries/pg_peek/pg17/pg_stat_statements/outliers.sql +1 -1
  24. data/app/queries/pg_peek/pg17/pg_stat_statements/outliers_by_endpoint.sql +24 -0
  25. data/app/queries/pg_peek/pg17/pg_stat_statements/outliers_by_job.sql +1 -1
  26. data/app/queries/pg_peek/pg17/tables/dead_tuples.sql +7 -3
  27. data/app/queries/pg_peek/pg18/pg_stat_statements/outliers.sql +1 -1
  28. data/app/queries/pg_peek/pg18/pg_stat_statements/outliers_by_endpoint.sql +24 -0
  29. data/app/queries/pg_peek/pg18/pg_stat_statements/outliers_by_job.sql +1 -1
  30. data/app/queries/pg_peek/pg18/tables/dead_tuples.sql +7 -3
  31. data/app/reports/pg_peek/column.rb +6 -1
  32. data/app/reports/pg_peek/report.rb +1 -2
  33. data/app/reports/pg_peek/reports/endpoint_queries.rb +14 -0
  34. data/app/reports/pg_peek/reports/endpoints.rb +5 -8
  35. data/app/reports/pg_peek/reports/jobs.rb +7 -7
  36. data/app/reports/pg_peek/reports/queries.rb +2 -1
  37. data/app/views/layouts/pg_peek/application.html.erb +1 -0
  38. data/app/views/pg_peek/databases/show.html.erb +16 -4
  39. data/app/views/pg_peek/endpoints/index.html.erb +14 -1
  40. data/app/views/pg_peek/endpoints/show.html.erb +17 -0
  41. data/app/views/pg_peek/jobs/index.html.erb +1 -1
  42. data/app/views/pg_peek/jobs/show.html.erb +1 -1
  43. data/app/views/pg_peek/pg_stat_statements/_preload_instructions.html.erb +5 -1
  44. data/app/views/pg_peek/tables/show.html.erb +1 -1
  45. data/config/routes.rb +3 -0
  46. data/lib/generators/pg_peek/install/templates/initializer.rb +9 -0
  47. data/lib/pg_peek/configuration.rb +8 -1
  48. data/lib/pg_peek/version.rb +1 -1
  49. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: edfdc4a86128d1eec3f1c69e731780b9ef71afdd071d76cde2756eee57d55ff8
4
- data.tar.gz: 666c7f0204c05e295f6c6950e32362720e273d579ec3eec6c557fd2769ab14c0
3
+ metadata.gz: 52d233ab8cafc3dae1e3386c44b9244549344b3b4bf97bc9f053cfdb843bd223
4
+ data.tar.gz: 3abf5a72e79b3ffff19323bb6fc7812c479bfd23c8bbee8667aff5a52732cb1f
5
5
  SHA512:
6
- metadata.gz: 4ecde1526a25bd02fe30c47dbbe62ff2058ce2d8f6b1ff741ffd50b2eea26236e102646f98b67dabf4d9f6d566717a838c04c15be895d5d3b335a59bde342945
7
- data.tar.gz: 44fa0fbf8d8ccd408c9e967b78ec29503fc94f605beb961cdc8480137773e7b6145af740990c039ac4f09be1c7664d03c204290190c0f9cd94a1e674b9517d15
6
+ metadata.gz: 93640cabe15c0afecb89784c94623a77ccb118a1679a9fe821b58e4fb9f04f68de2e28b88222f475a5930f1daff722f577c65149159c498aece5b19e1fe20f9f
7
+ data.tar.gz: 37074ace65e4e13d9953f4d342a8b4011a0fa7903b7141e29b8961d3db44ea680273c78b4835157605275f4578878b23d04e28df1287a99ce3505703b33b11ab
@@ -149,6 +149,11 @@ pre {
149
149
 
150
150
  pre code { background: none; border: 0; padding: 0; }
151
151
 
152
+ /* Names from the data (a job class, a table) keep their case inside the
153
+ lowercase headings, and read as the heading rather than as a code box. */
154
+ .name { text-transform: none; }
155
+ code.name { background: none; border: 0; padding: 0; }
156
+
152
157
  /* Tables ------------------------------------------------------------------ */
153
158
 
154
159
  .scroll { overflow-x: auto; }
@@ -194,7 +199,6 @@ tbody th + td { text-align: right; }
194
199
  .nowrap { white-space: nowrap; }
195
200
  .dim { color: var(--dim); }
196
201
  .warn { color: var(--warn); }
197
- .intensity { white-space: nowrap; }
198
202
  .bar { letter-spacing: 0.08em; }
199
203
  .bar-on { color: var(--accent); }
200
204
  /* Brightness follows the value: a one-segment bar sits at 80%, five at full. */
@@ -204,6 +208,24 @@ tbody th + td { text-align: right; }
204
208
  .bar-4 .bar-on { opacity: 0.95; }
205
209
  .bar-5 .bar-on { opacity: 1; }
206
210
  .bar-off { color: var(--line); }
211
+ /* An endpoint or job name takes the width the figures leave over, so a long
212
+ one is cut instead of pushing them out of view (max-width: 0 drops the name
213
+ from the table's width calculation). The dimmed namespace shrinks first and
214
+ is cut from the left, since the end of a name tells rows apart: rtl puts
215
+ the ellipsis on the left, the inner span keeps the text itself ltr so "::"
216
+ and "/" stay in place. A leaf wider than the cell is cut on the right. */
217
+ .name-column { width: 100%; max-width: 0; min-width: 12ch; }
218
+ .name-column > a { display: block; }
219
+ .qualified { display: flex; overflow: hidden; white-space: nowrap; }
220
+ .qualified .namespace {
221
+ min-width: 0;
222
+ overflow: hidden;
223
+ text-overflow: ellipsis;
224
+ direction: rtl;
225
+ opacity: 0.6;
226
+ }
227
+ .qualified .namespace > span { direction: ltr; unicode-bidi: embed; }
228
+ .qualified .leaf { flex-shrink: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
207
229
  .sql { word-break: break-all; white-space: pre-wrap; background: none; border: 0; padding: 0; }
208
230
 
209
231
  /* What issued a query, from its SQLcommenter tags: a link line under the SQL,
@@ -280,6 +302,12 @@ button, .button {
280
302
  button:hover { border-color: var(--fg); }
281
303
  button.danger { color: var(--danger); border-color: var(--danger); }
282
304
  button.danger:hover { background: var(--danger); color: var(--bg); }
305
+ button:disabled, button:disabled:hover {
306
+ background: var(--panel);
307
+ color: var(--dim);
308
+ border-color: var(--line);
309
+ cursor: not-allowed;
310
+ }
283
311
 
284
312
  form.inline { display: inline; margin: 0; }
285
313
 
@@ -297,6 +325,13 @@ form.inline { display: inline; margin: 0; }
297
325
  margin: 0.75rem 0;
298
326
  }
299
327
 
328
+ .alert {
329
+ border: 1px solid var(--danger);
330
+ color: var(--danger);
331
+ padding: 0.4rem 0.75rem;
332
+ margin: 0.75rem 0;
333
+ }
334
+
300
335
  mark { background: var(--accent); color: var(--bg); }
301
336
 
302
337
  .grid {
@@ -340,6 +375,7 @@ footer.status button:hover { color: var(--fg); }
340
375
 
341
376
  .vitals { margin: 0 0 1rem; }
342
377
  .vitals span + span::before { content: " · "; color: var(--line); }
378
+ a.provisional { color: color-mix(in srgb, var(--warn) 70%, var(--dim)); }
343
379
 
344
380
  .panels {
345
381
  display: grid;
@@ -54,10 +54,18 @@ class PgPeek::DatabasesController < PgPeek::ApplicationController
54
54
  href: database_table_path(@database, table["relname"]) }
55
55
  end
56
56
 
57
- indexes = PgPeek::Reports::Indexes.new(database: @database)
58
- if indexes.unused.any?
59
- items << { text: "#{helpers.pluralize(indexes.unused.size, "unused index")} · #{helpers.number_to_human_size(indexes.unused_bytes)}",
60
- href: database_indexes_path(@database) }
57
+ # Scan counts restart at zero on a statistics reset, so right after one
58
+ # every index looks unused. Small ones cost too little to act on; the
59
+ # indexes page still lists them all.
60
+ reset_at = @database.stats_reset_at
61
+ if reset_at.nil? || reset_at < PgPeek.config.unused_index_min_stats_age.ago
62
+ unused = PgPeek::Reports::Indexes.new(database: @database).unused
63
+ .select { |row| row["size_bytes"].to_i >= PgPeek.config.unused_index_min_size }
64
+ if unused.any?
65
+ bytes = unused.sum { |row| row["size_bytes"].to_i }
66
+ items << { text: "#{helpers.pluralize(unused.size, "unused index")} · #{helpers.number_to_human_size(bytes)}",
67
+ href: database_indexes_path(@database) }
68
+ end
61
69
  end
62
70
 
63
71
  activity = database_activity_path(@database)
@@ -86,11 +94,6 @@ class PgPeek::DatabasesController < PgPeek::ApplicationController
86
94
  items << { text: "#{used} of #{max} connections in use", href: activity }
87
95
  end
88
96
 
89
- if @stats_reset_at && @stats_reset_at > 1.hour.ago
90
- items << { text: "statistics were reset #{helpers.time_ago_in_words(@stats_reset_at)} ago -- figures may not be representative yet",
91
- href: database_path(@database, anchor: "pg_stat_statements") }
92
- end
93
-
94
97
  items
95
98
  end
96
99
  end
@@ -1,10 +1,22 @@
1
1
  class PgPeek::EndpointsController < PgPeek::ApplicationController
2
- def index
3
- @database = PgPeek::Database.find(params[:database_id])
4
- @pg_stat_statements = PgPeek::PgStatStatements.new(database: @database)
2
+ before_action :set_database
5
3
 
4
+ def index
6
5
  return render "pg_peek/pg_stat_statements/not_preloaded" unless @pg_stat_statements.usable?
7
6
 
8
7
  @report = PgPeek::Reports::Endpoints.new(database: @database)
9
8
  end
9
+
10
+ def show
11
+ @endpoint = "#{params[:endpoint_controller]}##{params[:endpoint_action]}"
12
+ return render "pg_peek/pg_stat_statements/not_preloaded" unless @pg_stat_statements.usable?
13
+
14
+ @report = PgPeek::Reports::EndpointQueries.new(database: @database, endpoint: @endpoint)
15
+ end
16
+
17
+ private
18
+ def set_database
19
+ @database = PgPeek::Database.find(params[:database_id])
20
+ @pg_stat_statements = PgPeek::PgStatStatements.new(database: @database)
21
+ end
10
22
  end
@@ -13,7 +13,7 @@ class PgPeek::JobsController < PgPeek::ApplicationController
13
13
  end
14
14
 
15
15
  def show
16
- @job_class = CGI.unescape(params[:job_class])
16
+ @job_class = params[:job_class]
17
17
  return render "pg_peek/pg_stat_statements/not_preloaded" unless @pg_stat_statements.usable?
18
18
 
19
19
  @report = PgPeek::Reports::JobQueries.new(database: @database, job_class: @job_class)
@@ -3,9 +3,15 @@
3
3
  class PgPeek::PgStatStatementsController < PgPeek::ApplicationController
4
4
  def reset
5
5
  @database = PgPeek::Database.find(params[:database_id]) or raise ActiveRecord::RecordNotFound, "Database not found"
6
- PgPeek::PgStatStatements.new(database: @database).reset!
6
+ pg_stat_statements = PgPeek::PgStatStatements.new(database: @database)
7
7
 
8
- redirect_to database_path(@database, anchor: "pg_stat_statements"),
9
- notice: "pg_stat_statements has been reset."
8
+ if pg_stat_statements.resettable?
9
+ pg_stat_statements.reset!
10
+ flash[:notice] = "pg_stat_statements has been reset."
11
+ else
12
+ flash[:alert] = "This role may not reset pg_stat_statements; the statistics were left as they are."
13
+ end
14
+
15
+ redirect_to database_path(@database, anchor: "pg_stat_statements")
10
16
  end
11
17
  end
@@ -30,16 +30,23 @@ module PgPeek::ApplicationHelper
30
30
  sql.gsub(SQLCOMMENTER_PATTERN, "").strip
31
31
  end
32
32
 
33
- # Where a query's SQLcommenter tags point, if anywhere: a job's own page,
34
- # or the endpoints list for a controller/action (there is no per-endpoint
35
- # page to link into yet, so this lands on the list rather than one row of it).
33
+ # Where a query's SQLcommenter tags point, if anywhere: a job's own page, or
34
+ # a controller/action's own page. A controller without an action has no page,
35
+ # so it lands on the endpoints list.
36
36
  def sqlcommenter_href(database, tags)
37
37
  return database_job_path(database, tags["job"]) if tags["job"].present?
38
38
 
39
39
  controller = tags["namespaced_controller"] || tags["controller"]
40
- return database_endpoints_path(database) if controller.present?
40
+ return nil if controller.blank?
41
+ return database_endpoints_path(database) if tags["action"].blank?
41
42
 
42
- nil
43
+ endpoint_path(database, "#{controller}##{tags["action"]}")
44
+ end
45
+
46
+ # An endpoint's page, from its controller#action name.
47
+ def endpoint_path(database, endpoint)
48
+ controller, action = endpoint.split("#", 2)
49
+ database_endpoint_path(database, endpoint_controller: controller, endpoint_action: action)
43
50
  end
44
51
 
45
52
  # Format a rate with auto-scaled units based on magnitude
@@ -96,10 +103,15 @@ module PgPeek::ApplicationHelper
96
103
 
97
104
  # Every pg_stat_statements figure is cumulative since the last reset. Each
98
105
  # page says so in its header and links to the overview block that resets it.
106
+ # Within the first hour the figures cover too little to go by, and the link
107
+ # is marked provisional.
99
108
  def stats_since_link(database, reset_at)
109
+ provisional = reset_at && reset_at > 1.hour.ago
110
+
100
111
  link_to "stats since #{format_time_ago(reset_at)}",
101
112
  database_path(database, anchor: "pg_stat_statements"),
102
- title: "figures are cumulative since the last pg_stat_statements reset"
113
+ class: ("provisional" if provisional),
114
+ title: provisional ? "figures may not be representative yet" : "figures are cumulative since the last pg_stat_statements reset"
103
115
  end
104
116
 
105
117
  # Format cache hit ratio for inline display
@@ -134,12 +146,15 @@ module PgPeek::ApplicationHelper
134
146
  ((value.to_f / max_value) * INTENSITY_SEGMENTS).ceil.clamp(0, INTENSITY_SEGMENTS)
135
147
  end
136
148
 
137
- # Durations pick their unit by magnitude: 210ms, 1.20s, 2m 05s, 1h 12m.
138
- # Values stay narrow and read at a glance, and a header sits over data of
139
- # similar width. The exact figure belongs in a title, not the cell.
149
+ # Durations pick their unit by magnitude: <1ms, 210ms, 1.20s, 2m 05s,
150
+ # 1h 12m. Values stay narrow and read at a glance, and a header sits over
151
+ # data of similar width. The exact figure belongs in a title, not the cell.
152
+ # Anything above zero but under a millisecond is <1ms, so a fast query never
153
+ # reads as 0ms -- that is kept for a real zero.
140
154
  def format_duration_from_ms(total_ms)
141
155
  ms = total_ms.to_f
142
156
  return "0ms" if ms <= 0
157
+ return "<1ms" if ms < 1
143
158
  return format("%.1fms", ms) if ms < 10
144
159
  return "#{ms.round}ms" if ms < 1000
145
160
 
@@ -1,11 +1,18 @@
1
1
  # Turns a report cell into markup. The report is passed alongside the column
2
- # because some formats are relative -- an intensity bar means nothing without
3
- # the largest value in its column.
2
+ # because a bar is relative -- it means nothing without the largest value in
3
+ # its column.
4
4
  module PgPeek::ReportHelper
5
5
  def format_cell(report, column, row)
6
6
  value = row.value(column)
7
7
  return "" if value.nil? || value.to_s.empty?
8
8
 
9
+ content = format_value(report, column, value)
10
+ return content unless column.bar?
11
+
12
+ safe_join([ content, intensity_bar(value.to_f, report.max_for(column)) ], " ")
13
+ end
14
+
15
+ def format_value(report, column, value)
9
16
  case column.format
10
17
  when :number then format_number(value.to_i)
11
18
  when :duration_ms then duration_cell(value.to_f)
@@ -13,14 +20,30 @@ module PgPeek::ReportHelper
13
20
  when :percent then "#{value}%"
14
21
  when :ratio then "~#{value}"
15
22
  when :sql then sql_cell(report, value.to_s)
16
- when :intensity then intensity_bar(value.to_f, report.max_for(column))
23
+ when :name then name_cell(value.to_s)
17
24
  else value.to_s
18
25
  end
19
26
  end
20
27
 
21
28
  # The compact figure is what you scan; the exact one is a hover away.
22
29
  def duration_cell(ms)
23
- tag.span(format_duration_from_ms(ms), title: "#{number_with_delimiter(ms.round(1))} ms")
30
+ tag.span(format_duration_from_ms(ms), title: "#{number_with_delimiter(ms.round(3))} ms")
31
+ end
32
+
33
+ # An endpoint or job name in its native form, the namespace dimmed so the
34
+ # eye goes to the last part. When the column is too narrow, the namespace
35
+ # is what gets cut; the full name is on hover.
36
+ def name_cell(name)
37
+ namespace, leaf = name_parts(name)
38
+ tag.span(class: "qualified", title: name) do
39
+ safe_join([ (tag.span(tag.span(namespace), class: "namespace") if namespace), tag.span(leaf, class: "leaf") ].compact)
40
+ end
41
+ end
42
+
43
+ # Splits after the last "/" or "::": courier_company_app/dashboard/ and
44
+ # executions#show, or GDPR::OrderAnonymizationBatch:: and ProgressMonitorJob.
45
+ def name_parts(name)
46
+ name.match(%r{\A(.*(?:/|::))?(.*)\z}m).captures
24
47
  end
25
48
 
26
49
  # The SQL text, plus -- when SQLcommenter tagged it -- what issued it (a
@@ -50,9 +73,10 @@ module PgPeek::ReportHelper
50
73
  end
51
74
 
52
75
  def cell_class(column)
53
- case column.format
54
- when :intensity then "intensity"
55
- else column.numeric? ? "num" : nil
76
+ if column.format == :name
77
+ "name-column"
78
+ elsif column.numeric?
79
+ "num"
56
80
  end
57
81
  end
58
82
  end
@@ -24,16 +24,34 @@ class PgPeek::PgStatStatements
24
24
  # The extension can be created in the database while the module itself was
25
25
  # never loaded at server start. Querying the view then raises
26
26
  # PG::ObjectNotInPrerequisiteState, so every read has to check this first.
27
+ #
28
+ # Roles without pg_read_all_settings (dokku's postgres plugin, most managed
29
+ # hosts) cannot read shared_preload_libraries, so then the only way to tell
30
+ # is to ask the module itself.
27
31
  def preloaded?
28
- shared_preload_libraries.split(",").map(&:strip).include?(LIBRARY_NAME)
32
+ if shared_preload_libraries
33
+ shared_preload_libraries.split(",").map(&:strip).include?(LIBRARY_NAME)
34
+ else
35
+ installed? && module_loaded?
36
+ end
29
37
  end
30
38
 
31
39
  def usable?
32
40
  installed? && preloaded?
33
41
  end
34
42
 
43
+ # nil when this role may not examine the setting, as opposed to "" when
44
+ # nothing is preloaded.
35
45
  def shared_preload_libraries
36
- @shared_preload_libraries ||= connection.select_value(PgPeek::QueryLoader.mark("SHOW shared_preload_libraries")).to_s
46
+ return @shared_preload_libraries if defined?(@shared_preload_libraries)
47
+
48
+ @shared_preload_libraries = without_aborting_transaction do
49
+ connection.select_value(PgPeek::QueryLoader.mark("SHOW shared_preload_libraries")).to_s
50
+ end
51
+ rescue ActiveRecord::StatementInvalid => e
52
+ raise unless e.cause.is_a?(PG::InsufficientPrivilege)
53
+
54
+ @shared_preload_libraries = nil
37
55
  end
38
56
 
39
57
  def outdated?
@@ -42,12 +60,23 @@ class PgPeek::PgStatStatements
42
60
  @installed_version != @default_version
43
61
  end
44
62
 
63
+ # The extension revokes EXECUTE on the reset from PUBLIC, so only superusers
64
+ # and roles granted it may run one.
65
+ def resettable?
66
+ return @resettable if defined?(@resettable)
67
+
68
+ @resettable = usable? && connection.select_value(PgPeek::QueryLoader.mark(<<~SQL)) == true
69
+ SELECT bool_and(has_function_privilege(oid, 'EXECUTE'))
70
+ FROM pg_proc WHERE proname = 'pg_stat_statements_reset'
71
+ SQL
72
+ end
73
+
45
74
  # Server-wide on purpose. pg_stat_statements_info keeps a single stats_reset
46
75
  # for the whole server and only moves it when every entry is removed, so a
47
76
  # reset scoped to this database would leave "stats since" pointing at the
48
77
  # old time.
49
78
  def reset!
50
- return unless usable?
79
+ return unless resettable?
51
80
 
52
81
  connection.execute PgPeek::QueryLoader.mark(<<-SQL)
53
82
  SELECT pg_stat_statements_reset();
@@ -104,10 +133,10 @@ class PgPeek::PgStatStatements
104
133
  def outliers_by_job(job_class)
105
134
  return unless usable?
106
135
 
107
- # Build pattern to match job='JobClassName' in SQLcommenter
108
- # Need to escape for SQL LIKE and quote properly
109
- escaped_job = job_class.gsub("'", "''")
110
- pattern = "%job='#{escaped_job}'%"
136
+ # Rails writes the job tag url-encoded (job='Reports%3A%3ADigestJob'), so
137
+ # match it encoded, with the % of the encoding escaped for LIKE.
138
+ encoded_job = ActiveRecord::Base.sanitize_sql_like(ERB::Util.url_encode(job_class))
139
+ pattern = "%job='#{encoded_job}'%"
111
140
 
112
141
  query = PgPeek::QueryLoader.load("pg_stat_statements/outliers_by_job",
113
142
  pg_version: database.major_version,
@@ -117,8 +146,45 @@ class PgPeek::PgStatStatements
117
146
  result.to_a
118
147
  end
119
148
 
149
+ # endpoint is controller#action, as the endpoints report names it.
150
+ def outliers_by_endpoint(endpoint)
151
+ return unless usable?
152
+
153
+ # Rails writes the tags url-encoded (namespaced_controller='staff%2Fprocesses'),
154
+ # so compare them encoded.
155
+ controller, action = endpoint.split("#", 2).map { |part| ERB::Util.url_encode(part) }
156
+
157
+ query = PgPeek::QueryLoader.load("pg_stat_statements/outliers_by_endpoint",
158
+ pg_version: database.major_version,
159
+ controller: connection.quote(controller),
160
+ action: connection.quote(action),
161
+ limit: PgPeek.config.outliers_limit)
162
+ connection.execute(query).to_a
163
+ end
164
+
120
165
  private
121
166
 
167
+ # pg_stat_statements_info() raises PG::ObjectNotInPrerequisiteState unless
168
+ # the module was loaded at server start, and is readable by any role.
169
+ def module_loaded?
170
+ return @module_loaded if defined?(@module_loaded)
171
+
172
+ @module_loaded = without_aborting_transaction do
173
+ connection.select_value(PgPeek::QueryLoader.mark("SELECT 1 FROM pg_stat_statements_info"))
174
+ true
175
+ end
176
+ rescue ActiveRecord::StatementInvalid => e
177
+ raise unless e.cause.is_a?(PG::ObjectNotInPrerequisiteState)
178
+
179
+ @module_loaded = false
180
+ end
181
+
182
+ # A failed statement inside an open transaction (a host app's, or a
183
+ # transactional test) would poison it; a savepoint confines the failure.
184
+ def without_aborting_transaction(&)
185
+ connection.transaction(requires_new: true, &)
186
+ end
187
+
122
188
  def verify_installation
123
189
  result = connection.execute PgPeek::QueryLoader.mark(<<-SQL)
124
190
  SELECT default_version, installed_version FROM pg_available_extensions WHERE name = 'pg_stat_statements';
@@ -147,10 +147,11 @@ class PgPeek::Table
147
147
  (dead_tuples.to_f / total * 100).round(1)
148
148
  end
149
149
 
150
+ # The same rule as the overview's attention list: past autovacuum's trigger
151
+ # point as well as the percentage threshold.
150
152
  def dead_tuple_warning?
151
- ratio = dead_tuple_ratio
152
- return false if ratio.nil?
153
- ratio >= PgPeek.config.dead_tuple_warning_threshold
153
+ database.tables_with_dead_tuples(PgPeek.config.dead_tuple_warning_threshold)
154
+ .any? { |table| table["relname"] == name }
154
155
  end
155
156
 
156
157
  def row_estimate
@@ -2,7 +2,7 @@ SELECT
2
2
  interval '1 millisecond' * total_exec_time AS total_exec_time,
3
3
  COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
4
4
  to_char(calls, 'FM999G999G999G990') AS ncalls,
5
- ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
5
+ total_exec_time/NULLIF(calls, 0) AS avg_exec_ms,
6
6
  interval '1 millisecond' * (blk_read_time + blk_write_time) AS sync_io_time,
7
7
  query
8
8
  FROM pg_stat_statements
@@ -0,0 +1,24 @@
1
+ -- The query shapes one controller#action issued. The endpoint is read from the
2
+ -- tags exactly as endpoints.sql reads it, so this page lists the same shapes
3
+ -- that endpoints.sql counts for it. An equality, not a LIKE: with both tags on,
4
+ -- controller='posts' also appears in the queries of admin/posts.
5
+ SELECT
6
+ interval '1 millisecond' * total_exec_time AS total_exec_time,
7
+ COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
8
+ to_char(calls, 'FM999G999G999G990') AS ncalls,
9
+ ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
10
+ interval '1 millisecond' * (blk_read_time + blk_write_time) AS sync_io_time,
11
+ query
12
+ FROM pg_stat_statements
13
+ WHERE userid = (SELECT usesysid FROM pg_user WHERE usename = current_user LIMIT 1)
14
+ AND dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
15
+ AND query NOT LIKE '%/* pg_peek */%'
16
+ AND COALESCE(
17
+ substring(query from 'namespaced_controller=''([^'']+)'''),
18
+ substring(query from 'controller=''([^'']+)''')
19
+ ) = {{controller}}
20
+ AND substring(query from 'action=''([^'']+)''') = {{action}}
21
+ AND query !~* 'pg_catalog|information_schema|pg_index|pg_class|pg_attribute'
22
+ AND query !~* '^\s*SHOW '
23
+ ORDER BY total_exec_time DESC
24
+ LIMIT {{limit}}
@@ -2,7 +2,7 @@ SELECT
2
2
  interval '1 millisecond' * total_exec_time AS total_exec_time,
3
3
  COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
4
4
  to_char(calls, 'FM999G999G999G990') AS ncalls,
5
- ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
5
+ total_exec_time/NULLIF(calls, 0) AS avg_exec_ms,
6
6
  interval '1 millisecond' * (blk_read_time + blk_write_time) AS sync_io_time,
7
7
  query
8
8
  FROM pg_stat_statements
@@ -1,4 +1,7 @@
1
- -- Tables whose dead-tuple share is at or above the warning threshold.
1
+ -- Tables that autovacuum should have cleaned by now: more dead tuples than its
2
+ -- own trigger point (autovacuum_vacuum_threshold + scale_factor * live rows),
3
+ -- and a dead-tuple share at or above the warning threshold. Small tables never
4
+ -- reach the trigger point, so a high share alone is not a problem.
2
5
  SELECT
3
6
  relname,
4
7
  n_live_tup,
@@ -6,6 +9,7 @@ SELECT
6
9
  round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_ratio,
7
10
  last_autovacuum
8
11
  FROM pg_stat_user_tables
9
- WHERE n_live_tup + n_dead_tup > 0
10
- AND 100.0 * n_dead_tup / (n_live_tup + n_dead_tup) >= {{threshold}}
12
+ WHERE n_dead_tup > current_setting('autovacuum_vacuum_threshold')::int
13
+ + current_setting('autovacuum_vacuum_scale_factor')::float * n_live_tup
14
+ AND 100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0) >= {{threshold}}
11
15
  ORDER BY dead_ratio DESC
@@ -2,7 +2,7 @@ SELECT
2
2
  interval '1 millisecond' * total_exec_time AS total_exec_time,
3
3
  COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
4
4
  to_char(calls, 'FM999G999G999G990') AS ncalls,
5
- ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
5
+ total_exec_time/NULLIF(calls, 0) AS avg_exec_ms,
6
6
  interval '1 millisecond' * (blk_read_time + blk_write_time) AS sync_io_time,
7
7
  query
8
8
  FROM pg_stat_statements
@@ -0,0 +1,24 @@
1
+ -- The query shapes one controller#action issued. The endpoint is read from the
2
+ -- tags exactly as endpoints.sql reads it, so this page lists the same shapes
3
+ -- that endpoints.sql counts for it. An equality, not a LIKE: with both tags on,
4
+ -- controller='posts' also appears in the queries of admin/posts.
5
+ SELECT
6
+ interval '1 millisecond' * total_exec_time AS total_exec_time,
7
+ COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
8
+ to_char(calls, 'FM999G999G999G990') AS ncalls,
9
+ ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
10
+ interval '1 millisecond' * (blk_read_time + blk_write_time) AS sync_io_time,
11
+ query
12
+ FROM pg_stat_statements
13
+ WHERE userid = (SELECT usesysid FROM pg_user WHERE usename = current_user LIMIT 1)
14
+ AND dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
15
+ AND query NOT LIKE '%/* pg_peek */%'
16
+ AND COALESCE(
17
+ substring(query from 'namespaced_controller=''([^'']+)'''),
18
+ substring(query from 'controller=''([^'']+)''')
19
+ ) = {{controller}}
20
+ AND substring(query from 'action=''([^'']+)''') = {{action}}
21
+ AND query !~* 'pg_catalog|information_schema|pg_index|pg_class|pg_attribute'
22
+ AND query !~* '^\s*SHOW '
23
+ ORDER BY total_exec_time DESC
24
+ LIMIT {{limit}}
@@ -2,7 +2,7 @@ SELECT
2
2
  interval '1 millisecond' * total_exec_time AS total_exec_time,
3
3
  COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
4
4
  to_char(calls, 'FM999G999G999G990') AS ncalls,
5
- ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
5
+ total_exec_time/NULLIF(calls, 0) AS avg_exec_ms,
6
6
  interval '1 millisecond' * (blk_read_time + blk_write_time) AS sync_io_time,
7
7
  query
8
8
  FROM pg_stat_statements
@@ -1,4 +1,7 @@
1
- -- Tables whose dead-tuple share is at or above the warning threshold.
1
+ -- Tables that autovacuum should have cleaned by now: more dead tuples than its
2
+ -- own trigger point (autovacuum_vacuum_threshold + scale_factor * live rows),
3
+ -- and a dead-tuple share at or above the warning threshold. Small tables never
4
+ -- reach the trigger point, so a high share alone is not a problem.
2
5
  SELECT
3
6
  relname,
4
7
  n_live_tup,
@@ -6,6 +9,7 @@ SELECT
6
9
  round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_ratio,
7
10
  last_autovacuum
8
11
  FROM pg_stat_user_tables
9
- WHERE n_live_tup + n_dead_tup > 0
10
- AND 100.0 * n_dead_tup / (n_live_tup + n_dead_tup) >= {{threshold}}
12
+ WHERE n_dead_tup > current_setting('autovacuum_vacuum_threshold')::int
13
+ + current_setting('autovacuum_vacuum_scale_factor')::float * n_live_tup
14
+ AND 100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0) >= {{threshold}}
11
15
  ORDER BY dead_ratio DESC
@@ -2,7 +2,7 @@ SELECT
2
2
  interval '1 millisecond' * total_exec_time AS total_exec_time,
3
3
  COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
4
4
  to_char(calls, 'FM999G999G999G990') AS ncalls,
5
- ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
5
+ total_exec_time/NULLIF(calls, 0) AS avg_exec_ms,
6
6
  interval '1 millisecond' * (blk_read_time + blk_write_time) AS sync_io_time,
7
7
  query
8
8
  FROM pg_stat_statements
@@ -0,0 +1,24 @@
1
+ -- The query shapes one controller#action issued. The endpoint is read from the
2
+ -- tags exactly as endpoints.sql reads it, so this page lists the same shapes
3
+ -- that endpoints.sql counts for it. An equality, not a LIKE: with both tags on,
4
+ -- controller='posts' also appears in the queries of admin/posts.
5
+ SELECT
6
+ interval '1 millisecond' * total_exec_time AS total_exec_time,
7
+ COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
8
+ to_char(calls, 'FM999G999G999G990') AS ncalls,
9
+ ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
10
+ interval '1 millisecond' * (blk_read_time + blk_write_time) AS sync_io_time,
11
+ query
12
+ FROM pg_stat_statements
13
+ WHERE userid = (SELECT usesysid FROM pg_user WHERE usename = current_user LIMIT 1)
14
+ AND dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
15
+ AND query NOT LIKE '%/* pg_peek */%'
16
+ AND COALESCE(
17
+ substring(query from 'namespaced_controller=''([^'']+)'''),
18
+ substring(query from 'controller=''([^'']+)''')
19
+ ) = {{controller}}
20
+ AND substring(query from 'action=''([^'']+)''') = {{action}}
21
+ AND query !~* 'pg_catalog|information_schema|pg_index|pg_class|pg_attribute'
22
+ AND query !~* '^\s*SHOW '
23
+ ORDER BY total_exec_time DESC
24
+ LIMIT {{limit}}
@@ -2,7 +2,7 @@ SELECT
2
2
  interval '1 millisecond' * total_exec_time AS total_exec_time,
3
3
  COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
4
4
  to_char(calls, 'FM999G999G999G990') AS ncalls,
5
- ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
5
+ total_exec_time/NULLIF(calls, 0) AS avg_exec_ms,
6
6
  interval '1 millisecond' * (blk_read_time + blk_write_time) AS sync_io_time,
7
7
  query
8
8
  FROM pg_stat_statements
@@ -1,4 +1,7 @@
1
- -- Tables whose dead-tuple share is at or above the warning threshold.
1
+ -- Tables that autovacuum should have cleaned by now: more dead tuples than its
2
+ -- own trigger point (autovacuum_vacuum_threshold + scale_factor * live rows),
3
+ -- and a dead-tuple share at or above the warning threshold. Small tables never
4
+ -- reach the trigger point, so a high share alone is not a problem.
2
5
  SELECT
3
6
  relname,
4
7
  n_live_tup,
@@ -6,6 +9,7 @@ SELECT
6
9
  round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_ratio,
7
10
  last_autovacuum
8
11
  FROM pg_stat_user_tables
9
- WHERE n_live_tup + n_dead_tup > 0
10
- AND 100.0 * n_dead_tup / (n_live_tup + n_dead_tup) >= {{threshold}}
12
+ WHERE n_dead_tup > current_setting('autovacuum_vacuum_threshold')::int
13
+ + current_setting('autovacuum_vacuum_scale_factor')::float * n_live_tup
14
+ AND 100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0) >= {{threshold}}
11
15
  ORDER BY dead_ratio DESC
@@ -2,7 +2,7 @@ SELECT
2
2
  interval '1 millisecond' * total_exec_time AS total_exec_time,
3
3
  COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
4
4
  to_char(calls, 'FM999G999G999G990') AS ncalls,
5
- ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
5
+ total_exec_time/NULLIF(calls, 0) AS avg_exec_ms,
6
6
  interval '1 millisecond' * (shared_blk_read_time + shared_blk_write_time) AS sync_io_time,
7
7
  query
8
8
  FROM pg_stat_statements
@@ -0,0 +1,24 @@
1
+ -- The query shapes one controller#action issued. The endpoint is read from the
2
+ -- tags exactly as endpoints.sql reads it, so this page lists the same shapes
3
+ -- that endpoints.sql counts for it. An equality, not a LIKE: with both tags on,
4
+ -- controller='posts' also appears in the queries of admin/posts.
5
+ SELECT
6
+ interval '1 millisecond' * total_exec_time AS total_exec_time,
7
+ COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
8
+ to_char(calls, 'FM999G999G999G990') AS ncalls,
9
+ ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
10
+ interval '1 millisecond' * (shared_blk_read_time + shared_blk_write_time) AS sync_io_time,
11
+ query
12
+ FROM pg_stat_statements
13
+ WHERE userid = (SELECT usesysid FROM pg_user WHERE usename = current_user LIMIT 1)
14
+ AND dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
15
+ AND query NOT LIKE '%/* pg_peek */%'
16
+ AND COALESCE(
17
+ substring(query from 'namespaced_controller=''([^'']+)'''),
18
+ substring(query from 'controller=''([^'']+)''')
19
+ ) = {{controller}}
20
+ AND substring(query from 'action=''([^'']+)''') = {{action}}
21
+ AND query !~* 'pg_catalog|information_schema|pg_index|pg_class|pg_attribute'
22
+ AND query !~* '^\s*SHOW '
23
+ ORDER BY total_exec_time DESC
24
+ LIMIT {{limit}}
@@ -2,7 +2,7 @@ SELECT
2
2
  interval '1 millisecond' * total_exec_time AS total_exec_time,
3
3
  COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
4
4
  to_char(calls, 'FM999G999G999G990') AS ncalls,
5
- ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
5
+ total_exec_time/NULLIF(calls, 0) AS avg_exec_ms,
6
6
  interval '1 millisecond' * (shared_blk_read_time + shared_blk_write_time) AS sync_io_time,
7
7
  query
8
8
  FROM pg_stat_statements
@@ -1,4 +1,7 @@
1
- -- Tables whose dead-tuple share is at or above the warning threshold.
1
+ -- Tables that autovacuum should have cleaned by now: more dead tuples than its
2
+ -- own trigger point (autovacuum_vacuum_threshold + scale_factor * live rows),
3
+ -- and a dead-tuple share at or above the warning threshold. Small tables never
4
+ -- reach the trigger point, so a high share alone is not a problem.
2
5
  SELECT
3
6
  relname,
4
7
  n_live_tup,
@@ -6,6 +9,7 @@ SELECT
6
9
  round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_ratio,
7
10
  last_autovacuum
8
11
  FROM pg_stat_user_tables
9
- WHERE n_live_tup + n_dead_tup > 0
10
- AND 100.0 * n_dead_tup / (n_live_tup + n_dead_tup) >= {{threshold}}
12
+ WHERE n_dead_tup > current_setting('autovacuum_vacuum_threshold')::int
13
+ + current_setting('autovacuum_vacuum_scale_factor')::float * n_live_tup
14
+ AND 100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0) >= {{threshold}}
11
15
  ORDER BY dead_ratio DESC
@@ -2,7 +2,7 @@ SELECT
2
2
  interval '1 millisecond' * total_exec_time AS total_exec_time,
3
3
  COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
4
4
  to_char(calls, 'FM999G999G999G990') AS ncalls,
5
- ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
5
+ total_exec_time/NULLIF(calls, 0) AS avg_exec_ms,
6
6
  interval '1 millisecond' * (shared_blk_read_time + shared_blk_write_time) AS sync_io_time,
7
7
  query
8
8
  FROM pg_stat_statements
@@ -0,0 +1,24 @@
1
+ -- The query shapes one controller#action issued. The endpoint is read from the
2
+ -- tags exactly as endpoints.sql reads it, so this page lists the same shapes
3
+ -- that endpoints.sql counts for it. An equality, not a LIKE: with both tags on,
4
+ -- controller='posts' also appears in the queries of admin/posts.
5
+ SELECT
6
+ interval '1 millisecond' * total_exec_time AS total_exec_time,
7
+ COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
8
+ to_char(calls, 'FM999G999G999G990') AS ncalls,
9
+ ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
10
+ interval '1 millisecond' * (shared_blk_read_time + shared_blk_write_time) AS sync_io_time,
11
+ query
12
+ FROM pg_stat_statements
13
+ WHERE userid = (SELECT usesysid FROM pg_user WHERE usename = current_user LIMIT 1)
14
+ AND dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
15
+ AND query NOT LIKE '%/* pg_peek */%'
16
+ AND COALESCE(
17
+ substring(query from 'namespaced_controller=''([^'']+)'''),
18
+ substring(query from 'controller=''([^'']+)''')
19
+ ) = {{controller}}
20
+ AND substring(query from 'action=''([^'']+)''') = {{action}}
21
+ AND query !~* 'pg_catalog|information_schema|pg_index|pg_class|pg_attribute'
22
+ AND query !~* '^\s*SHOW '
23
+ ORDER BY total_exec_time DESC
24
+ LIMIT {{limit}}
@@ -2,7 +2,7 @@ SELECT
2
2
  interval '1 millisecond' * total_exec_time AS total_exec_time,
3
3
  COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
4
4
  to_char(calls, 'FM999G999G999G990') AS ncalls,
5
- ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
5
+ total_exec_time/NULLIF(calls, 0) AS avg_exec_ms,
6
6
  interval '1 millisecond' * (shared_blk_read_time + shared_blk_write_time) AS sync_io_time,
7
7
  query
8
8
  FROM pg_stat_statements
@@ -1,4 +1,7 @@
1
- -- Tables whose dead-tuple share is at or above the warning threshold.
1
+ -- Tables that autovacuum should have cleaned by now: more dead tuples than its
2
+ -- own trigger point (autovacuum_vacuum_threshold + scale_factor * live rows),
3
+ -- and a dead-tuple share at or above the warning threshold. Small tables never
4
+ -- reach the trigger point, so a high share alone is not a problem.
2
5
  SELECT
3
6
  relname,
4
7
  n_live_tup,
@@ -6,6 +9,7 @@ SELECT
6
9
  round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_ratio,
7
10
  last_autovacuum
8
11
  FROM pg_stat_user_tables
9
- WHERE n_live_tup + n_dead_tup > 0
10
- AND 100.0 * n_dead_tup / (n_live_tup + n_dead_tup) >= {{threshold}}
12
+ WHERE n_dead_tup > current_setting('autovacuum_vacuum_threshold')::int
13
+ + current_setting('autovacuum_vacuum_scale_factor')::float * n_live_tup
14
+ AND 100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0) >= {{threshold}}
11
15
  ORDER BY dead_ratio DESC
@@ -7,7 +7,9 @@ class PgPeek::Column
7
7
 
8
8
  attr_reader :key, :align, :format, :title
9
9
 
10
- def initialize(key, header: nil, align: :left, format: :text, title: nil)
10
+ # bar: draw the value with a bar next to it, scaled against the largest value
11
+ # in the column, so the biggest rows stand out without reading every figure.
12
+ def initialize(key, header: nil, align: :left, format: :text, title: nil, bar: false)
11
13
  raise ArgumentError, "unknown alignment #{align.inspect}" unless ALIGNMENTS.include?(align)
12
14
 
13
15
  @key = key
@@ -15,12 +17,15 @@ class PgPeek::Column
15
17
  @align = align
16
18
  @format = format
17
19
  @title = title
20
+ @bar = bar
18
21
  end
19
22
 
20
23
  def header
21
24
  @header || key.to_s.tr("_", " ")
22
25
  end
23
26
 
27
+ def bar? = @bar
28
+
24
29
  def numeric?
25
30
  align == :right
26
31
  end
@@ -43,8 +43,7 @@ class PgPeek::Report
43
43
 
44
44
  def title = self.class.title
45
45
 
46
- # Relative formats -- an intensity bar, a share of total -- need the extent of
47
- # the column, not just the cell.
46
+ # A bar is relative: it needs the extent of the column, not just the cell.
48
47
  def max_for(column)
49
48
  @maxima ||= {}
50
49
  @maxima[column.key] ||= rows.filter_map { |row| row.value(column)&.to_f }.max
@@ -0,0 +1,14 @@
1
+ # The query shapes one controller#action issues, most expensive first.
2
+ class PgPeek::Reports::EndpointQueries < PgPeek::Reports::Queries
3
+ attr_reader :endpoint
4
+
5
+ def initialize(database:, endpoint:)
6
+ super(database: database)
7
+ @endpoint = endpoint
8
+ end
9
+
10
+ private
11
+ def source_rows
12
+ pg_stat_statements.outliers_by_endpoint(endpoint)
13
+ end
14
+ end
@@ -6,9 +6,9 @@ class PgPeek::Reports::Endpoints < PgPeek::Report
6
6
  # the shape of an N+1 rather than a page that legitimately asks twice.
7
7
  N_PLUS_ONE_THRESHOLD = 5
8
8
 
9
- column :endpoint
10
- column :total_exec_time_ms, header: "db time", align: :right, format: :duration_ms
11
- column :share, align: :right, format: :intensity, title: "Share of database time across endpoints"
9
+ column :endpoint, format: :name
10
+ column :total_exec_time_ms, header: "db time", align: :right, format: :duration_ms, bar: true,
11
+ title: "Database time; the bar compares it with the other endpoints listed"
12
12
  column :request_count, header: "requests", align: :right, format: :number,
13
13
  title: "Estimated from the least-called query of this endpoint"
14
14
  column :total_calls, header: "queries", align: :right, format: :number
@@ -26,10 +26,7 @@ class PgPeek::Reports::Endpoints < PgPeek::Report
26
26
  rows = pg_stat_statements.endpoints
27
27
  return [] if rows.blank?
28
28
 
29
- rows.each do |row|
30
- # namespaced_controller arrives url-encoded: staff%2Fprocesses#show.
31
- row["endpoint"] = CGI.unescape(row["endpoint"].to_s)
32
- row["share"] = row["total_exec_time_ms"]
33
- end
29
+ # namespaced_controller arrives url-encoded: staff%2Fprocesses#show.
30
+ rows.each { |row| row["endpoint"] = CGI.unescape(row["endpoint"].to_s) }
34
31
  end
35
32
  end
@@ -4,18 +4,18 @@
4
4
  class PgPeek::Reports::Jobs < PgPeek::Report
5
5
  title "Jobs"
6
6
 
7
- column :job_class, header: "job"
8
- column :total_exec_time_ms, header: "db time", align: :right, format: :duration_ms
9
- column :share, align: :right, format: :intensity, title: "Share of database time across jobs"
7
+ column :job_class, header: "job", format: :name
8
+ column :total_exec_time_ms, header: "db time", align: :right, format: :duration_ms, bar: true,
9
+ title: "Database time; the bar compares it with the other jobs listed"
10
10
  column :total_calls, header: "queries", align: :right, format: :number
11
11
  column :query_count, header: "shapes", align: :right, format: :number,
12
12
  title: "Distinct query shapes issued by this job"
13
13
 
14
14
  private
15
15
  def fetch_rows
16
- rows = pg_stat_statements.jobs
17
- return [] if rows.blank?
18
-
19
- rows.each { |row| row["share"] = row["total_exec_time_ms"] }
16
+ pg_stat_statements.jobs.to_a.each do |row|
17
+ # The job tag arrives url-encoded: Reports%3A%3ADigestJob.
18
+ row["job_class"] = CGI.unescape(row["job_class"].to_s)
19
+ end
20
20
  end
21
21
  end
@@ -2,7 +2,8 @@
2
2
  class PgPeek::Reports::Queries < PgPeek::Report
3
3
  title "Queries"
4
4
 
5
- column :calls, align: :right, format: :intensity, title: "Call count relative to the other queries listed"
5
+ column :calls, align: :right, format: :number, bar: true,
6
+ title: "Call count; the bar compares it with the other queries listed"
6
7
  column :total_exec_time, header: "db time", align: :right, format: :duration
7
8
  column :prop_exec_time, header: "share", align: :right
8
9
  column :avg_exec_ms, header: "avg", align: :right, format: :duration_ms
@@ -15,6 +15,7 @@
15
15
  <body>
16
16
  <main>
17
17
  <% if flash[:notice] %><p class="notice"><%= flash[:notice] %></p><% end %>
18
+ <% if flash[:alert] %><p class="alert"><%= flash[:alert] %></p><% end %>
18
19
  <nav>
19
20
  <ul>
20
21
  <li><%= link_to "pg_peek", databases_path, class: "wordmark" %></li>
@@ -63,8 +63,10 @@ end</code></pre>
63
63
  <p class="dim">No endpoint has run a query yet.</p>
64
64
  <% else %>
65
65
  <%= render "pg_peek/reports/report", report: @endpoints,
66
- columns: %i[endpoint share total_exec_time_ms max_queries_per_request],
67
- limit: PgPeek::DatabasesController::OVERVIEW_ROWS %>
66
+ columns: %i[endpoint total_exec_time_ms max_queries_per_request],
67
+ limit: PgPeek::DatabasesController::OVERVIEW_ROWS,
68
+ link_column: :endpoint,
69
+ link: ->(row) { endpoint_path(@database, row["endpoint"]) } %>
68
70
  <% end %>
69
71
  </section>
70
72
 
@@ -74,8 +76,10 @@ end</code></pre>
74
76
  <p class="dim">No job has run a query yet.</p>
75
77
  <% else %>
76
78
  <%= render "pg_peek/reports/report", report: @jobs,
77
- columns: %i[job_class share total_exec_time_ms],
78
- limit: PgPeek::DatabasesController::OVERVIEW_ROWS %>
79
+ columns: %i[job_class total_exec_time_ms],
80
+ limit: PgPeek::DatabasesController::OVERVIEW_ROWS,
81
+ link_column: :job_class,
82
+ link: ->(row) { database_job_path(@database, row["job_class"]) } %>
79
83
  <% end %>
80
84
  </section>
81
85
 
@@ -142,8 +146,16 @@ end</code></pre>
142
146
  reset_database_pg_stat_statements_path(@database),
143
147
  method: :delete,
144
148
  class: "danger",
149
+ disabled: !@pg_stat_statements.resettable?,
145
150
  form_class: "inline",
146
151
  form: { data: { confirm: "Reset pg_stat_statements? This clears statement statistics for every database on this server." } } %>
152
+ <% unless @pg_stat_statements.resettable? %>
153
+ <p class="dim">
154
+ <%= @connection_summary["username"] %> may not reset the statistics: the extension
155
+ allows it only to superusers and roles granted it. To allow it:
156
+ </p>
157
+ <pre><code>GRANT EXECUTE ON FUNCTION pg_stat_statements_reset TO <%= @connection_summary["username"] %>;</code></pre>
158
+ <% end %>
147
159
  <% if @pg_stat_statements.outdated? %>
148
160
  <p class="dim">
149
161
  Version <%= @pg_stat_statements.default_version %> is available:
@@ -13,7 +13,20 @@
13
13
  </p>
14
14
  </article>
15
15
  <% else %>
16
- <%= render "pg_peek/reports/report", report: @report %>
16
+ <input type="search"
17
+ placeholder="filter endpoints… (press / to focus)"
18
+ data-filter-input
19
+ data-filter-noun="endpoint"
20
+ autofocus
21
+ autocomplete="off">
22
+ <p class="dim" data-filter-count></p>
23
+
24
+ <%= render "pg_peek/reports/report",
25
+ report: @report,
26
+ link_column: :endpoint,
27
+ link: ->(row) { endpoint_path(@database, row["endpoint"]) } %>
28
+
29
+ <p class="dim" data-filter-empty hidden>No endpoints match that filter.</p>
17
30
 
18
31
  <p class="dim">
19
32
  <strong>q/req</strong> is an estimate: the request count is inferred from the
@@ -0,0 +1,17 @@
1
+ <div class="toolbar">
2
+ <h1><code class="name"><%= @endpoint %></code></h1>
3
+ <%= link_to "← endpoints", database_endpoints_path(@database) %>
4
+ </div>
5
+
6
+ <% if @report.empty? %>
7
+ <article aria-label="No queries found">
8
+ <header>No queries found for this endpoint</header>
9
+ <p>This could mean:</p>
10
+ <ul>
11
+ <li>The endpoint hasn't executed any database queries since statistics were last reset</li>
12
+ <li>The endpoint's queries are not among the top outliers</li>
13
+ </ul>
14
+ </article>
15
+ <% else %>
16
+ <%= render "pg_peek/reports/report", report: @report %>
17
+ <% end %>
@@ -45,7 +45,7 @@
45
45
  <%= render "pg_peek/reports/report",
46
46
  report: @report,
47
47
  link_column: :job_class,
48
- link: ->(row) { database_job_path(@database, ERB::Util.url_encode(row["job_class"])) } %>
48
+ link: ->(row) { database_job_path(@database, row["job_class"]) } %>
49
49
 
50
50
  <p class="dim" data-filter-empty hidden>No jobs match that filter.</p>
51
51
  <% end %>
@@ -1,5 +1,5 @@
1
1
  <div class="toolbar">
2
- <h1><%= @job_class %></h1>
2
+ <h1><code class="name"><%= @job_class %></code></h1>
3
3
  <%= link_to "← jobs", database_jobs_path(@database) %>
4
4
  </div>
5
5
 
@@ -11,7 +11,11 @@
11
11
  <footer>
12
12
  <small>
13
13
  A server restart is required — this setting cannot be reloaded.
14
- Currently loaded: <code><%= pg_stat_statements.shared_preload_libraries.presence || "(none)" %></code>
14
+ <% if pg_stat_statements.shared_preload_libraries %>
15
+ Currently loaded: <code><%= pg_stat_statements.shared_preload_libraries.presence || "(none)" %></code>
16
+ <% else %>
17
+ This role may not read <code>shared_preload_libraries</code>; the module itself reported it is not loaded.
18
+ <% end %>
15
19
  </small>
16
20
  </footer>
17
21
  </article>
@@ -1,5 +1,5 @@
1
1
  <div class="toolbar">
2
- <h1><%= @table.name %></h1>
2
+ <h1><code class="name"><%= @table.name %></code></h1>
3
3
  <%= link_to "← tables", database_path(@database, anchor: "tables") %>
4
4
  </div>
5
5
 
data/config/routes.rb CHANGED
@@ -3,6 +3,9 @@ PgPeek::Engine.routes.draw do
3
3
  resources :queries, only: [ :index ]
4
4
  resources :tables, only: [ :show ], param: :name
5
5
  resources :endpoints, only: [ :index ]
6
+ # admin/posts#index lives at endpoints/admin/posts/index: the slashes of a
7
+ # namespace stay as they are, and no # needs encoding.
8
+ get "endpoints/*endpoint_controller/:endpoint_action", to: "endpoints#show", as: :endpoint, format: false
6
9
  resources :indexes, only: [ :index ]
7
10
  resource :activity, only: [ :show ], controller: "activity"
8
11
  resources :jobs, only: [ :index, :show ], param: :job_class
@@ -29,6 +29,15 @@ Rails.application.config.pg_peek.tap do |config|
29
29
  # Dead tuple ratio threshold (percentage) for warnings.
30
30
  # config.dead_tuple_warning_threshold = 10
31
31
 
32
+ # Index scan counts start from zero when statistics are reset, so right
33
+ # after a reset every index looks unused. The overview waits this long
34
+ # before calling unused indexes out.
35
+ # config.unused_index_min_stats_age = 1.day
36
+
37
+ # Unused indexes smaller than this (in bytes) are left out of the overview;
38
+ # the indexes page still lists them all.
39
+ # config.unused_index_min_size = 1.megabyte
40
+
32
41
  # A query running longer than this is called out on the overview.
33
42
  # config.long_query_warning_seconds = 5
34
43
 
@@ -1,3 +1,5 @@
1
+ require "active_support/core_ext/integer/time"
2
+
1
3
  module PgPeek
2
4
  class Configuration
3
5
  DEFAULT_EXCLUDED_TABLES = %w[schema_migrations ar_internal_metadata].freeze
@@ -6,10 +8,13 @@ module PgPeek
6
8
  DEFAULT_CACHE_HIT_WARNING_THRESHOLD = 99 # percentage
7
9
  DEFAULT_LONG_QUERY_WARNING_SECONDS = 5
8
10
  DEFAULT_IDLE_IN_TRANSACTION_WARNING_SECONDS = 60
11
+ DEFAULT_UNUSED_INDEX_MIN_STATS_AGE = 1.day
12
+ DEFAULT_UNUSED_INDEX_MIN_SIZE = 1.megabyte
9
13
 
10
14
  attr_accessor :connections, :excluded_tables, :dead_tuple_warning_threshold, :outliers_limit,
11
15
  :cache_hit_warning_threshold, :long_query_warning_seconds,
12
- :idle_in_transaction_warning_seconds, :public_dashboard
16
+ :idle_in_transaction_warning_seconds, :unused_index_min_stats_age,
17
+ :unused_index_min_size, :public_dashboard
13
18
  attr_writer :username, :password
14
19
 
15
20
  def initialize
@@ -20,6 +25,8 @@ module PgPeek
20
25
  @cache_hit_warning_threshold = DEFAULT_CACHE_HIT_WARNING_THRESHOLD
21
26
  @long_query_warning_seconds = DEFAULT_LONG_QUERY_WARNING_SECONDS
22
27
  @idle_in_transaction_warning_seconds = DEFAULT_IDLE_IN_TRANSACTION_WARNING_SECONDS
28
+ @unused_index_min_stats_age = DEFAULT_UNUSED_INDEX_MIN_STATS_AGE
29
+ @unused_index_min_size = DEFAULT_UNUSED_INDEX_MIN_SIZE
23
30
  @public_dashboard = false
24
31
  end
25
32
 
@@ -1,3 +1,3 @@
1
1
  module PgPeek
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_peek
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tomasz Mazur
@@ -78,6 +78,7 @@ files:
78
78
  - app/queries/pg_peek/pg14/pg_stat_statements/endpoints.sql
79
79
  - app/queries/pg_peek/pg14/pg_stat_statements/jobs.sql
80
80
  - app/queries/pg_peek/pg14/pg_stat_statements/outliers.sql
81
+ - app/queries/pg_peek/pg14/pg_stat_statements/outliers_by_endpoint.sql
81
82
  - app/queries/pg_peek/pg14/pg_stat_statements/outliers_by_job.sql
82
83
  - app/queries/pg_peek/pg14/tables/dead_tuples.sql
83
84
  - app/queries/pg_peek/pg14/tables/inline_stats.sql
@@ -90,6 +91,7 @@ files:
90
91
  - app/queries/pg_peek/pg15/pg_stat_statements/endpoints.sql
91
92
  - app/queries/pg_peek/pg15/pg_stat_statements/jobs.sql
92
93
  - app/queries/pg_peek/pg15/pg_stat_statements/outliers.sql
94
+ - app/queries/pg_peek/pg15/pg_stat_statements/outliers_by_endpoint.sql
93
95
  - app/queries/pg_peek/pg15/pg_stat_statements/outliers_by_job.sql
94
96
  - app/queries/pg_peek/pg15/tables/dead_tuples.sql
95
97
  - app/queries/pg_peek/pg15/tables/inline_stats.sql
@@ -102,6 +104,7 @@ files:
102
104
  - app/queries/pg_peek/pg16/pg_stat_statements/endpoints.sql
103
105
  - app/queries/pg_peek/pg16/pg_stat_statements/jobs.sql
104
106
  - app/queries/pg_peek/pg16/pg_stat_statements/outliers.sql
107
+ - app/queries/pg_peek/pg16/pg_stat_statements/outliers_by_endpoint.sql
105
108
  - app/queries/pg_peek/pg16/pg_stat_statements/outliers_by_job.sql
106
109
  - app/queries/pg_peek/pg16/tables/dead_tuples.sql
107
110
  - app/queries/pg_peek/pg16/tables/inline_stats.sql
@@ -114,6 +117,7 @@ files:
114
117
  - app/queries/pg_peek/pg17/pg_stat_statements/endpoints.sql
115
118
  - app/queries/pg_peek/pg17/pg_stat_statements/jobs.sql
116
119
  - app/queries/pg_peek/pg17/pg_stat_statements/outliers.sql
120
+ - app/queries/pg_peek/pg17/pg_stat_statements/outliers_by_endpoint.sql
117
121
  - app/queries/pg_peek/pg17/pg_stat_statements/outliers_by_job.sql
118
122
  - app/queries/pg_peek/pg17/tables/dead_tuples.sql
119
123
  - app/queries/pg_peek/pg17/tables/inline_stats.sql
@@ -126,6 +130,7 @@ files:
126
130
  - app/queries/pg_peek/pg18/pg_stat_statements/endpoints.sql
127
131
  - app/queries/pg_peek/pg18/pg_stat_statements/jobs.sql
128
132
  - app/queries/pg_peek/pg18/pg_stat_statements/outliers.sql
133
+ - app/queries/pg_peek/pg18/pg_stat_statements/outliers_by_endpoint.sql
129
134
  - app/queries/pg_peek/pg18/pg_stat_statements/outliers_by_job.sql
130
135
  - app/queries/pg_peek/pg18/tables/dead_tuples.sql
131
136
  - app/queries/pg_peek/pg18/tables/inline_stats.sql
@@ -134,6 +139,7 @@ files:
134
139
  - app/reports/pg_peek/report.rb
135
140
  - app/reports/pg_peek/reports/blocked.rb
136
141
  - app/reports/pg_peek/reports/databases.rb
142
+ - app/reports/pg_peek/reports/endpoint_queries.rb
137
143
  - app/reports/pg_peek/reports/endpoints.rb
138
144
  - app/reports/pg_peek/reports/indexes.rb
139
145
  - app/reports/pg_peek/reports/job_queries.rb
@@ -147,6 +153,7 @@ files:
147
153
  - app/views/pg_peek/databases/index.html.erb
148
154
  - app/views/pg_peek/databases/show.html.erb
149
155
  - app/views/pg_peek/endpoints/index.html.erb
156
+ - app/views/pg_peek/endpoints/show.html.erb
150
157
  - app/views/pg_peek/indexes/index.html.erb
151
158
  - app/views/pg_peek/jobs/index.html.erb
152
159
  - app/views/pg_peek/jobs/show.html.erb
@@ -171,6 +178,7 @@ licenses:
171
178
  metadata:
172
179
  homepage_uri: https://github.com/trix/pg_peek
173
180
  source_code_uri: https://github.com/trix/pg_peek
181
+ changelog_uri: https://github.com/trix/pg_peek/releases
174
182
  rubygems_mfa_required: 'true'
175
183
  rdoc_options: []
176
184
  require_paths: