pghero 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d94ff090a5f14bb71bfa373b96d1e384968e2981fdb2bd897aceeaea9d7256fe
4
- data.tar.gz: d2b03444934fe241cfd75506e850818acedbe4dc64083cfd1d29a7c1f1264c80
3
+ metadata.gz: 3acbc4629f0e8c8b468669dbcbeeb01ef5e8b3827f891772412a19d92d1c3b76
4
+ data.tar.gz: d9f2f710437de7b76e0d6757be81c36ca68f2469ee9c44ec751972a55d00e20f
5
5
  SHA512:
6
- metadata.gz: b97f3b6f3ff66d8c5a628a23590940377dd6dcce53993997394ede41c49df7fa49a422aff8722d06d65b8e36203f5dfb3a81a6e415360e32cddc0cccf14db6b3
7
- data.tar.gz: d8c42ca3cc1fd0e2b59d08af48992aa88e335f6ab95d03188fc59454d1f495bac3883a995c3cb0d5681e2a7c3a0698ab199ff6089a05a4d0e8038d1ca3a58fef
6
+ metadata.gz: eecc0a4dce22f7203e7f18928ea9537cb35e4f9b7814ed68cf216b669795bfaa85ec33bd31b94e86c523564d1965058d19fed919471e3882cccd6762be6c7a15
7
+ data.tar.gz: 64d275ba685349df7be06a829927f5a711a36c33afffc4af43bc3bb916b819b3978e0f51fa01c7085d57406dc9fff7aeadd8796c2d772c2c38790259879a87b1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
+ ## 4.1.0 (2026-10-20)
2
+
3
+ - Restricted killing queries to current database and certain backend types - [more info](https://github.com/ankane/pghero/security/advisories/GHSA-5287-hmjh-qmqq)
4
+ - Added grouping for parallel queries
5
+ - Fixed error when index is dropped during indexes query
6
+ - Fixed `unable to filter data` messages
7
+ - Removed replication backend types from live queries page
8
+
9
+ ## 4.0.1 (2026-08-15)
10
+
11
+ - Added sequences from identity columns
12
+
1
13
  ## 4.0.0 (2026-08-05)
2
14
 
15
+ See the upgrade notes for [Docker](guides/Docker.md#upgrading) or [Rails](guides/Rails.md#upgrading)
16
+
3
17
  - Reduced space for historical query stats
4
18
  - Added `disable_kill` option to disable killing queries and connections
5
19
  - Added `unused_index_megabytes` option to set minimum size for unused indexes
@@ -36,8 +36,7 @@ module PgHero
36
36
  @inactive_replication_slots = @database.replication_slots.select { |r| !r[:active] }
37
37
  end
38
38
 
39
- @walsender_queries, long_running_queries = @database.long_running_queries.partition { |q| q[:backend_type] == "walsender" }
40
- @autovacuum_queries, @long_running_queries = long_running_queries.partition { |q| q[:query].starts_with?("autovacuum:") }
39
+ @autovacuum_queries, @long_running_queries = @database.long_running_queries.partition { |q| q[:backend_type] == "autovacuum worker" }
41
40
 
42
41
  connection_states = @database.connection_states
43
42
  @total_connections = connection_states.values.sum
@@ -416,21 +415,27 @@ module PgHero
416
415
  end
417
416
 
418
417
  def kill
419
- if @database.kill(params[:pid])
418
+ if @database.kill(params[:pid], key: params[:key].to_i)
420
419
  redirect_backward notice: "Query killed"
421
420
  else
422
421
  redirect_backward notice: "Query no longer running"
423
422
  end
423
+ rescue ActiveRecord::StatementInvalid
424
+ redirect_backward alert: "The database user does not have permission to kill this query"
424
425
  end
425
426
 
426
427
  def kill_long_running_queries
427
428
  @database.kill_long_running_queries
428
429
  redirect_backward notice: "Queries killed"
430
+ rescue ActiveRecord::StatementInvalid
431
+ redirect_backward alert: "The database user does not have permission to kill some queries"
429
432
  end
430
433
 
431
434
  def kill_all
432
435
  @database.kill_all
433
436
  redirect_backward notice: "Connections killed"
437
+ rescue ActiveRecord::StatementInvalid
438
+ redirect_backward alert: "The database user does not have permission to kill some connections"
434
439
  end
435
440
 
436
441
  def enable_query_stats
@@ -528,7 +533,7 @@ module PgHero
528
533
  end
529
534
 
530
535
  def group_connections_by_key(connections, key)
531
- group_connections(connections, [key]).map { |v| [v[key], v[:total_connections]] }.to_h
536
+ group_connections(connections, [key]).to_h { |v| [v[key], v[:total_connections]] }
532
537
  end
533
538
 
534
539
  def check_api
@@ -10,7 +10,7 @@
10
10
  <tbody>
11
11
  <% queries.reverse.each do |query| %>
12
12
  <tr>
13
- <td><%= query[:pid] %></td>
13
+ <td><%= ([query[:pid]] + query[:worker_pids]).join(", ") %></td>
14
14
  <td>
15
15
  <% if query[:duration_ms] %>
16
16
  <% sec = query[:duration_ms] / 1000.0 %>
@@ -32,11 +32,11 @@
32
32
  <% end %>
33
33
  </td>
34
34
  <td class="text-right">
35
- <% unless @database.filter_data %>
35
+ <% if @explain_enabled && (!@database.filter_data || @database.server_version_num >= 160000) %>
36
36
  <%= button_to "Explain", explain_path, params: {query: query[:query]}, form: {target: "_blank"}, class: "btn btn-info" %>
37
37
  <% end %>
38
- <% if @kill_enabled %>
39
- <%= button_to "Kill", kill_path(pid: query[:pid]), class: "btn btn-danger" %>
38
+ <% if @kill_enabled && ["client backend", "autovacuum worker"].include?(query[:backend_type]) %>
39
+ <%= button_to "Kill", kill_path(pid: query[:pid], key: query[:started_at].usec), class: "btn btn-danger" %>
40
40
  <% end %>
41
41
  </td>
42
42
  </tr>
@@ -46,7 +46,7 @@
46
46
  <span class="user">
47
47
  <% if query[:user] %>
48
48
  <%= @link_user ? link_to(query[:user], {user: query[:user]}.merge(@link_options)) : query[:user] %>
49
- <% if @show_details %>
49
+ <% if @show_details && query[:query_hash] %>
50
50
  &middot;
51
51
  <% end %>
52
52
  <% end %>
@@ -61,7 +61,7 @@
61
61
  <td colspan="3" class="query-row">
62
62
  <pre><code class="query-code language-pgsql"><%= query[:query] %></code></pre>
63
63
  <% if query[:query] == "<insufficient privilege>" %>
64
- <p class="text-muted">For security reasons, only superusers can see queries executed by other users.</p>
64
+ <p class="text-muted">Add the <code>pg_read_all_stats</code> role to see queries executed by other users.</p>
65
65
  <% end %>
66
66
  <% if local_assigns[:suggested_indexes] != false && (details = @suggested_indexes_by_query[query[:query]]) && (details[:index] || @debug) %>
67
67
  <%= render partial: "suggested_index", locals: {index: details[:index], details: details} %>
@@ -26,9 +26,6 @@
26
26
  <% if @autovacuum_queries.any? %>
27
27
  <span class="tiny"><%= @autovacuum_queries.size %> autovacuum</span>
28
28
  <% end %>
29
- <% if @walsender_queries.any? %>
30
- <span class="tiny"><%= @walsender_queries.size %> walsender</span>
31
- <% end %>
32
29
  </div>
33
30
  <% if @extended %>
34
31
  <div class="alert alert-<%= @good_cache_rate ? "success" : "warning" %>">
@@ -68,7 +65,7 @@
68
65
  <% end %>
69
66
  </div>
70
67
  <div class="alert alert-<%= @invalid_indexes.empty? && @invalid_constraints.empty? ? "success" : "warning" %>">
71
- <% if @invalid_indexes.empty? && @invalid_constraints.empty? %>
68
+ <% if @invalid_indexes.empty? && @invalid_constraints.empty? %>
72
69
  No invalid indexes or constraints
73
70
  <% else %>
74
71
  <% if @invalid_indexes.any? %>
data/config/routes.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  PgHero::Engine.routes.draw do
2
- scope "(:database)", constraints: proc { |req| (PgHero.config["databases"].keys + [nil]).include?(req.params[:database]) } do
2
+ scope "(:database)", constraints: PgHero::DatabaseConstraint.new do
3
3
  get "space", to: "home#space"
4
4
  get "space/:relation", to: "home#relation_space", as: :relation_space
5
5
  get "index_bloat", to: "home#index_bloat"
@@ -98,7 +98,7 @@ module PgHero
98
98
  if config.key?("filter_data")
99
99
  config["filter_data"]
100
100
  elsif PgHero.config.key?("filter_data")
101
- PgHero.config.key?("filter_data")
101
+ PgHero.config["filter_data"]
102
102
  else
103
103
  PgHero.filter_data
104
104
  end
data/lib/pghero/engine.rb CHANGED
@@ -14,4 +14,11 @@ module PgHero
14
14
  PgHero.time_zone = file_config["time_zone"] if file_config["time_zone"]
15
15
  end
16
16
  end
17
+
18
+ class DatabaseConstraint
19
+ def matches?(request)
20
+ database = request.params[:database]
21
+ database.nil? || PgHero.config["databases"].key?(database)
22
+ end
23
+ end
17
24
  end
@@ -66,8 +66,20 @@ module PgHero
66
66
  if filter_data
67
67
  query_columns.each do |column|
68
68
  result.each do |row|
69
+ value = row[column]
70
+ if value.nil? || value == "<insufficient privilege>"
71
+ next
72
+ end
73
+
74
+ if value.start_with?("autovacuum:")
75
+ prefix = "autovacuum:"
76
+ value = value.delete_prefix("autovacuum:")
77
+ else
78
+ prefix = ""
79
+ end
80
+
69
81
  begin
70
- row[column] = PgQuery.normalize(row[column]) unless row[column].nil?
82
+ row[column] = prefix + PgQuery.normalize(value)
71
83
  rescue PgQuery::ParseError
72
84
  # try replacing "interval $1" with "$1::interval"
73
85
  # see https://github.com/lfittl/pg_query/issues/169 for more info
@@ -75,7 +87,7 @@ module PgHero
75
87
  # we could skip normalization
76
88
  # but this has a very small chance of data leakage
77
89
  begin
78
- row[column] = PgQuery.normalize(row[column].gsub(/\binterval\s+(\$\d+)\b/i, "\\1::interval"))
90
+ row[column] = prefix + PgQuery.normalize(value.gsub(/\binterval\s+(\$\d+)\b/i, "\\1::interval"))
79
91
  rescue PgQuery::ParseError
80
92
  row[column] = "<unable to filter data>"
81
93
  end
@@ -154,7 +154,7 @@ module PgHero
154
154
  ORDER BY
155
155
  1, 2
156
156
  SQL
157
- ).map { |v| v[:columns] = v[:columns].sub(") WHERE (", " WHERE ").split(", ").map { |c| unquote(c) }; v }
157
+ ).select { |v| v[:definition] }.map { |v| v[:columns] = v[:columns].sub(") WHERE (", " WHERE ").split(", ").map { |c| unquote(c) }; v }
158
158
 
159
159
  # determine if any invalid indexes being created
160
160
  # hacky, but works for simple cases
@@ -322,7 +322,7 @@ module PgHero
322
322
  wastedbytes DESC,
323
323
  index_name
324
324
  SQL
325
- select_all(sql, {min_size: min_size.to_i})
325
+ select_all(sql, {min_size: min_size.to_i}).select { |v| v[:definition] }
326
326
  end
327
327
 
328
328
  protected
@@ -2,29 +2,51 @@ module PgHero
2
2
  module Methods
3
3
  module Kill
4
4
  # note: this method is not affected by disable_kill option
5
- def kill(pid)
6
- select_one("SELECT pg_terminate_backend(:pid)", {pid: pid.to_i})
5
+ def kill(pid, key: nil)
6
+ kill_backends(pid: pid, key: key, backend_type: ["client backend", "autovacuum worker"]).any? { |v| v[:pg_terminate_backend] }
7
7
  end
8
8
 
9
9
  # note: this method is not affected by disable_kill option
10
10
  def kill_long_running_queries(min_duration: nil)
11
- running_queries(min_duration: min_duration || long_running_query_sec).each { |query| kill(query[:pid]) }
11
+ kill_backends(min_duration: min_duration || long_running_query_sec)
12
12
  true
13
13
  end
14
14
 
15
15
  # note: this method is not affected by disable_kill option
16
16
  def kill_all
17
- select_all <<~SQL
17
+ kill_backends
18
+ true
19
+ end
20
+
21
+ private
22
+
23
+ def kill_backends(pid: nil, key: nil, min_duration: nil, backend_type: ["client backend"])
24
+ select_expr = "pg_terminate_backend(pid)"
25
+ # key is used to reduce chance of killing different query
26
+ # check *after* row matches other conditions
27
+ if key
28
+ select_expr = "CASE WHEN EXTRACT(MICROSECONDS FROM COALESCE(query_start, xact_start)) % 1000000 = :key THEN #{select_expr} ELSE NULL END AS pg_terminate_backend"
29
+ end
30
+ query = <<~SQL
18
31
  SELECT
19
- pg_terminate_backend(pid)
32
+ #{select_expr}
20
33
  FROM
21
34
  pg_stat_activity
22
35
  WHERE
23
36
  pid <> pg_backend_pid()
24
37
  AND query <> '<insufficient privilege>'
25
38
  AND datname = current_database()
39
+ AND backend_type IN (:backend_type)
40
+ #{"AND pid = :pid" if pid}
41
+ #{"AND state <> 'idle' AND NOW() - COALESCE(query_start, xact_start) > interval :min_duration" if min_duration}
26
42
  SQL
27
- true
43
+
44
+ binds = {}
45
+ binds[:backend_type] = Array(backend_type)
46
+ binds[:pid] = pid.to_i if pid
47
+ binds[:key] = key.to_i if key
48
+ binds[:min_duration] = "#{min_duration.to_i} seconds" if min_duration
49
+ select_all(query, binds)
28
50
  end
29
51
  end
30
52
  end
@@ -5,6 +5,7 @@ module PgHero
5
5
  query = <<~SQL
6
6
  SELECT
7
7
  pid,
8
+ leader_pid,
8
9
  state,
9
10
  application_name AS source,
10
11
  age(NOW(), COALESCE(query_start, xact_start)) AS duration,
@@ -20,6 +21,7 @@ module PgHero
20
21
  state <> 'idle'
21
22
  AND pid <> pg_backend_pid()
22
23
  AND datname = current_database()
24
+ AND backend_type IN ('client backend', 'parallel worker', 'autovacuum worker')
23
25
  #{"AND NOW() - COALESCE(query_start, xact_start) > interval :min_duration" if min_duration}
24
26
  #{"AND query <> '<insufficient privilege>'" if !all}
25
27
  ORDER BY
@@ -28,7 +30,14 @@ module PgHero
28
30
 
29
31
  binds = {}
30
32
  binds[:min_duration] = "#{min_duration.to_i} seconds" if min_duration
31
- select_all(query, binds, query_columns: [:query])
33
+ queries = select_all(query, binds, query_columns: [:query])
34
+ groups = queries.group_by { |v| v[:leader_pid] || v[:pid] }
35
+ groups.map do |pid, queries|
36
+ leader = queries.find { |v| v[:leader_pid].nil? } || queries.first
37
+ leader.delete(:leader_pid)
38
+ leader[:worker_pids] = queries.filter_map { |v| v[:pid] if v.key?(:leader_pid) }
39
+ leader
40
+ end
32
41
  end
33
42
 
34
43
  def long_running_queries
@@ -67,10 +76,10 @@ module PgHero
67
76
  NOT blockedl.granted
68
77
  AND blockeda.query <> '<insufficient privilege>'
69
78
  AND blockeda.datname = current_database()
79
+ AND blockeda.backend_type IN ('client backend', 'parallel worker', 'autovacuum worker')
70
80
  ORDER BY
71
81
  blocked_duration DESC
72
82
  SQL
73
-
74
83
  select_all(query, query_columns: [:blocked_query, :current_or_recent_query_in_blocking_process])
75
84
  end
76
85
  end
@@ -2,13 +2,16 @@ module PgHero
2
2
  module Methods
3
3
  module Sequences
4
4
  def sequences
5
- # get columns with default values
6
- # use pg_get_expr to get correct default value
5
+ # get columns with default values and identity columns
6
+ # use pg_depend to get owned sequences (like pg_get_serial_sequence)
7
+ # use pg_get_expr to get correct default value for rest
7
8
  # it's what information_schema.columns uses
8
9
  # also, exclude temporary tables to prevent error
9
10
  # when accessing across sessions
10
11
  sequences = select_all <<~SQL
11
12
  SELECT
13
+ sn.nspname AS schema,
14
+ s.relname AS sequence,
12
15
  n.nspname AS table_schema,
13
16
  c.relname AS table,
14
17
  attname AS column,
@@ -20,32 +23,51 @@ module PgHero
20
23
  pg_catalog.pg_class c ON c.oid = a.attrelid
21
24
  INNER JOIN
22
25
  pg_catalog.pg_namespace n ON n.oid = c.relnamespace
23
- INNER JOIN
24
- pg_catalog.pg_attrdef d ON (a.attrelid, a.attnum) = (d.adrelid, d.adnum)
26
+ LEFT JOIN
27
+ pg_catalog.pg_depend dep ON dep.refclassid = 'pg_catalog.pg_class'::regclass
28
+ AND dep.refobjid = a.attrelid
29
+ AND dep.refobjsubid = a.attnum
30
+ AND dep.classid = 'pg_catalog.pg_class'::regclass
31
+ AND dep.objsubid = 0
32
+ AND dep.deptype IN ('i', 'a')
33
+ AND dep.objid IN (SELECT oid FROM pg_class WHERE relkind = 'S')
34
+ LEFT JOIN
35
+ pg_catalog.pg_class s ON s.oid = dep.objid
36
+ LEFT JOIN
37
+ pg_catalog.pg_namespace sn ON sn.oid = s.relnamespace
38
+ LEFT JOIN
39
+ pg_catalog.pg_attrdef d ON a.attrelid = d.adrelid
40
+ AND a.attnum = d.adnum
41
+ AND s.relkind IS NULL
25
42
  WHERE
26
43
  NOT a.attisdropped
27
44
  AND a.attnum > 0
28
- AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval%'
29
- AND n.nspname NOT LIKE 'pg\\_temp\\_%'
45
+ AND (pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval%' OR s.relname IS NOT NULL)
46
+ AND c.relpersistence <> 't'
30
47
  SQL
31
48
 
32
- # parse out sequence
33
49
  sequences.each do |column|
34
- column[:max_value] = column[:column_type] == 'integer' ? 2147483647 : 9223372036854775807
50
+ column[:max_value] =
51
+ case column[:column_type]
52
+ when "smallint"
53
+ 32767
54
+ when "integer"
55
+ 2147483647
56
+ else
57
+ 9223372036854775807
58
+ end
35
59
 
36
- column[:schema], column[:sequence] = parse_default_value(column[:default_value])
60
+ unless column[:sequence]
61
+ column[:schema], column[:sequence] = parse_default_value(column[:default_value])
62
+ end
37
63
  column.delete(:default_value) if column[:sequence]
38
64
  end
39
65
 
40
66
  add_sequence_attributes(sequences)
41
67
 
42
68
  last_value = {}
43
- sequences.select { |s| s[:readable] }.map { |s| [s[:schema], s[:sequence]] }.uniq.each_slice(1024) do |slice|
44
- sql = slice.map { |s| "SELECT last_value FROM #{quote_ident(s[0])}.#{quote_ident(s[1])}" }.join(" UNION ALL ")
45
-
46
- select_all(sql).zip(slice) do |row, seq|
47
- last_value[seq] = row[:last_value]
48
- end
69
+ select_all("SELECT schemaname AS schema, sequencename AS sequence, COALESCE(last_value, start_value) AS last_value FROM pg_sequences").each do |row|
70
+ last_value[[row[:schema], row[:sequence]]] = row[:last_value]
49
71
  end
50
72
 
51
73
  sequences.select { |s| s[:readable] }.each do |seq|
@@ -67,8 +89,8 @@ module PgHero
67
89
  # nextval('id_seq'::regclass)
68
90
  # nextval(('id_seq'::text)::regclass)
69
91
  def parse_default_value(default_value)
70
- m = /^nextval\('(.+)'\:\:regclass\)$/.match(default_value)
71
- m = /^nextval\(\('(.+)'\:\:text\)\:\:regclass\)$/.match(default_value) unless m
92
+ m = /\Anextval\('(.+)'\:\:regclass\)\z/.match(default_value)
93
+ m = /\Anextval\(\('(.+)'\:\:text\)\:\:regclass\)\z/.match(default_value) unless m
72
94
  if m
73
95
  unquote_ident(m[1])
74
96
  else
@@ -93,7 +93,7 @@ module PgHero
93
93
  def relation_space_stats(relation, schema: "public")
94
94
  if space_stats_enabled?
95
95
  relation_sizes ||= self.relation_sizes
96
- sizes = relation_sizes.map { |r| [[r[:schema], r[:relation]], r[:size_bytes]] }.to_h
96
+ sizes = relation_sizes.to_h { |r| [[r[:schema], r[:relation]], r[:size_bytes]] }
97
97
  start_at = 30.days.ago
98
98
 
99
99
  sql = <<~SQL
@@ -198,6 +198,7 @@ module PgHero
198
198
  def best_index_structure(statement)
199
199
  return {error: "Empty statement"} if statement.to_s.empty?
200
200
  return {error: "Too large"} if statement.to_s.length > 10000
201
+ return {error: "Insufficient privilege"} if statement == "<insufficient privilege>"
201
202
 
202
203
  begin
203
204
  tree = PgQuery.parse(statement).tree
@@ -1,3 +1,3 @@
1
1
  module PgHero
2
- VERSION = "4.0.0"
2
+ VERSION = "4.1.0"
3
3
  end
data/lib/pghero.rb CHANGED
@@ -83,7 +83,7 @@ module PgHero
83
83
 
84
84
  # private
85
85
  def explain_mode
86
- @config["explain"]
86
+ config["explain"]
87
87
  end
88
88
 
89
89
  def visualize_url
@@ -163,7 +163,7 @@ module PgHero
163
163
  # return if another process initialized while we were waiting
164
164
  return @databases if defined?(@databases)
165
165
 
166
- @databases = config["databases"].map { |id, c| [id.to_sym, Database.new(id, c)] }.to_h
166
+ @databases = config["databases"].to_h { |id, c| [id.to_sym, Database.new(id, c)] }
167
167
  end
168
168
  end
169
169
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pghero
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
@@ -121,7 +121,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
121
121
  - !ruby/object:Gem::Version
122
122
  version: '0'
123
123
  requirements: []
124
- rubygems_version: 4.0.16
124
+ rubygems_version: 4.0.20
125
125
  specification_version: 4
126
126
  summary: A performance dashboard for Postgres
127
127
  test_files: []