pghero 2.4.1 → 2.7.0
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of pghero might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +19 -8
- data/app/assets/javascripts/pghero/application.js +1 -1
- data/app/controllers/pg_hero/home_controller.rb +79 -19
- data/app/helpers/pg_hero/home_helper.rb +11 -0
- data/app/views/pg_hero/home/_live_queries_table.html.erb +3 -1
- data/app/views/pg_hero/home/connections.html.erb +9 -0
- data/app/views/pg_hero/home/index.html.erb +2 -2
- data/app/views/pg_hero/home/maintenance.html.erb +16 -2
- data/app/views/pg_hero/home/space.html.erb +1 -1
- data/app/views/pg_hero/home/tune.html.erb +2 -1
- data/lib/generators/pghero/templates/config.yml.tt +11 -4
- data/lib/pghero.rb +29 -13
- data/lib/pghero/database.rb +81 -21
- data/lib/pghero/methods/basic.rb +32 -7
- data/lib/pghero/methods/connections.rb +35 -0
- data/lib/pghero/methods/explain.rb +1 -1
- data/lib/pghero/methods/maintenance.rb +3 -1
- data/lib/pghero/methods/queries.rb +6 -2
- data/lib/pghero/methods/query_stats.rb +93 -25
- data/lib/pghero/methods/space.rb +4 -0
- data/lib/pghero/methods/suggested_indexes.rb +1 -1
- data/lib/pghero/methods/system.rb +227 -15
- data/lib/pghero/methods/users.rb +4 -0
- data/lib/pghero/version.rb +1 -1
- metadata +3 -3
data/lib/pghero/database.rb
CHANGED
@@ -23,6 +23,11 @@ module PgHero
|
|
23
23
|
def initialize(id, config)
|
24
24
|
@id = id
|
25
25
|
@config = config || {}
|
26
|
+
|
27
|
+
# preload model to ensure only one connection pool
|
28
|
+
# this doesn't actually start any connections
|
29
|
+
@adapter_checked = false
|
30
|
+
@connection_model = build_connection_model
|
26
31
|
end
|
27
32
|
|
28
33
|
def name
|
@@ -50,15 +55,16 @@ module PgHero
|
|
50
55
|
end
|
51
56
|
|
52
57
|
def explain_timeout_sec
|
53
|
-
(config["explain_timeout_sec"] || PgHero.config["explain_timeout_sec"] || PgHero.explain_timeout_sec).
|
58
|
+
(config["explain_timeout_sec"] || PgHero.config["explain_timeout_sec"] || PgHero.explain_timeout_sec).to_f
|
54
59
|
end
|
55
60
|
|
56
61
|
def long_running_query_sec
|
57
62
|
(config["long_running_query_sec"] || PgHero.config["long_running_query_sec"] || PgHero.long_running_query_sec).to_i
|
58
63
|
end
|
59
64
|
|
65
|
+
# defaults to 100 megabytes
|
60
66
|
def index_bloat_bytes
|
61
|
-
(config["index_bloat_bytes"] || PgHero.config["index_bloat_bytes"] ||
|
67
|
+
(config["index_bloat_bytes"] || PgHero.config["index_bloat_bytes"] || 104857600).to_i
|
62
68
|
end
|
63
69
|
|
64
70
|
def aws_access_key_id
|
@@ -73,8 +79,43 @@ module PgHero
|
|
73
79
|
config["aws_region"] || PgHero.config["aws_region"] || ENV["PGHERO_REGION"] || ENV["AWS_REGION"] || (defined?(Aws) && Aws.config[:region]) || "us-east-1"
|
74
80
|
end
|
75
81
|
|
82
|
+
# environment variable is only used if no config file
|
76
83
|
def aws_db_instance_identifier
|
77
|
-
@
|
84
|
+
@aws_db_instance_identifier ||= config["aws_db_instance_identifier"] || config["db_instance_identifier"]
|
85
|
+
end
|
86
|
+
|
87
|
+
# environment variable is only used if no config file
|
88
|
+
def gcp_database_id
|
89
|
+
@gcp_database_id ||= config["gcp_database_id"]
|
90
|
+
end
|
91
|
+
|
92
|
+
# environment variable is only used if no config file
|
93
|
+
def azure_resource_id
|
94
|
+
@azure_resource_id ||= config["azure_resource_id"]
|
95
|
+
end
|
96
|
+
|
97
|
+
# must check keys for booleans
|
98
|
+
def filter_data
|
99
|
+
unless defined?(@filter_data)
|
100
|
+
@filter_data =
|
101
|
+
if config.key?("filter_data")
|
102
|
+
config["filter_data"]
|
103
|
+
elsif PgHero.config.key?("filter_data")
|
104
|
+
PgHero.config.key?("filter_data")
|
105
|
+
else
|
106
|
+
PgHero.filter_data
|
107
|
+
end
|
108
|
+
|
109
|
+
if @filter_data
|
110
|
+
begin
|
111
|
+
require "pg_query"
|
112
|
+
rescue LoadError
|
113
|
+
raise Error, "pg_query required for filter_data"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
@filter_data
|
78
119
|
end
|
79
120
|
|
80
121
|
# TODO remove in next major version
|
@@ -85,27 +126,46 @@ module PgHero
|
|
85
126
|
|
86
127
|
private
|
87
128
|
|
129
|
+
# check adapter lazily
|
88
130
|
def connection_model
|
89
|
-
@
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
raise Error, "
|
95
|
-
url = resolved.config
|
131
|
+
unless @adapter_checked
|
132
|
+
# rough check for Postgres adapter
|
133
|
+
# keep this message generic so it's useful
|
134
|
+
# when empty url set in Docker image pghero.yml
|
135
|
+
unless @connection_model.connection.adapter_name =~ /postg/i
|
136
|
+
raise Error, "Invalid connection URL"
|
96
137
|
end
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
138
|
+
@adapter_checked = true
|
139
|
+
end
|
140
|
+
|
141
|
+
@connection_model
|
142
|
+
end
|
143
|
+
|
144
|
+
# just return the model
|
145
|
+
# do not start a connection
|
146
|
+
def build_connection_model
|
147
|
+
url = config["url"]
|
148
|
+
|
149
|
+
# resolve spec
|
150
|
+
if !url && config["spec"]
|
151
|
+
raise Error, "Spec requires Rails 6+" unless PgHero.spec_supported?
|
152
|
+
resolved = ActiveRecord::Base.configurations.configs_for(env_name: PgHero.env, spec_name: config["spec"], include_replicas: true)
|
153
|
+
raise Error, "Spec not found: #{config["spec"]}" unless resolved
|
154
|
+
url = resolved.config
|
155
|
+
end
|
156
|
+
|
157
|
+
Class.new(PgHero::Connection) do
|
158
|
+
def self.name
|
159
|
+
"PgHero::Connection::Database#{object_id}"
|
160
|
+
end
|
161
|
+
|
162
|
+
case url
|
163
|
+
when String
|
164
|
+
url = "#{url}#{url.include?("?") ? "&" : "?"}connect_timeout=5" unless url.include?("connect_timeout=")
|
165
|
+
when Hash
|
166
|
+
url[:connect_timeout] ||= 5
|
108
167
|
end
|
168
|
+
establish_connection url if url
|
109
169
|
end
|
110
170
|
end
|
111
171
|
end
|
data/lib/pghero/methods/basic.rb
CHANGED
@@ -18,6 +18,10 @@ module PgHero
|
|
18
18
|
select_one("SELECT current_database()")
|
19
19
|
end
|
20
20
|
|
21
|
+
def current_user
|
22
|
+
select_one("SELECT current_user")
|
23
|
+
end
|
24
|
+
|
21
25
|
def server_version
|
22
26
|
@server_version ||= select_one("SHOW server_version")
|
23
27
|
end
|
@@ -32,13 +36,34 @@ module PgHero
|
|
32
36
|
|
33
37
|
private
|
34
38
|
|
35
|
-
def select_all(sql, conn
|
39
|
+
def select_all(sql, conn: nil, query_columns: [])
|
36
40
|
conn ||= connection
|
37
41
|
# squish for logs
|
38
42
|
retries = 0
|
39
43
|
begin
|
40
44
|
result = conn.select_all(add_source(squish(sql)))
|
41
|
-
result.map { |row| Hash[row.map { |col, val| [col.to_sym, result.column_types[col].send(:cast_value, val)] }] }
|
45
|
+
result = result.map { |row| Hash[row.map { |col, val| [col.to_sym, result.column_types[col].send(:cast_value, val)] }] }
|
46
|
+
if filter_data
|
47
|
+
query_columns.each do |column|
|
48
|
+
result.each do |row|
|
49
|
+
begin
|
50
|
+
row[column] = PgQuery.normalize(row[column])
|
51
|
+
rescue PgQuery::ParseError
|
52
|
+
# try replacing "interval $1" with "$1::interval"
|
53
|
+
# see https://github.com/lfittl/pg_query/issues/169 for more info
|
54
|
+
# this is not ideal since it changes the query slightly
|
55
|
+
# we could skip normalization
|
56
|
+
# but this has a very small chance of data leakage
|
57
|
+
begin
|
58
|
+
row[column] = PgQuery.normalize(row[column].gsub(/\binterval\s+(\$\d+)\b/i, "\\1::interval"))
|
59
|
+
rescue PgQuery::ParseError
|
60
|
+
row[column] = "<unable to filter data>"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
result
|
42
67
|
rescue ActiveRecord::StatementInvalid => e
|
43
68
|
# fix for random internal errors
|
44
69
|
if e.message.include?("PG::InternalError") && retries < 2
|
@@ -51,8 +76,8 @@ module PgHero
|
|
51
76
|
end
|
52
77
|
end
|
53
78
|
|
54
|
-
def select_all_stats(sql)
|
55
|
-
select_all(sql, stats_connection)
|
79
|
+
def select_all_stats(sql, **options)
|
80
|
+
select_all(sql, **options, conn: stats_connection)
|
56
81
|
end
|
57
82
|
|
58
83
|
def select_all_size(sql)
|
@@ -63,12 +88,12 @@ module PgHero
|
|
63
88
|
result
|
64
89
|
end
|
65
90
|
|
66
|
-
def select_one(sql, conn
|
67
|
-
select_all(sql, conn).first.values.first
|
91
|
+
def select_one(sql, conn: nil)
|
92
|
+
select_all(sql, conn: conn).first.values.first
|
68
93
|
end
|
69
94
|
|
70
95
|
def select_one_stats(sql)
|
71
|
-
select_one(sql, stats_connection)
|
96
|
+
select_one(sql, conn: stats_connection)
|
72
97
|
end
|
73
98
|
|
74
99
|
def execute(sql)
|
@@ -1,6 +1,41 @@
|
|
1
1
|
module PgHero
|
2
2
|
module Methods
|
3
3
|
module Connections
|
4
|
+
def connections
|
5
|
+
if server_version_num >= 90500
|
6
|
+
select_all <<-SQL
|
7
|
+
SELECT
|
8
|
+
pg_stat_activity.pid,
|
9
|
+
datname AS database,
|
10
|
+
usename AS user,
|
11
|
+
application_name AS source,
|
12
|
+
client_addr AS ip,
|
13
|
+
state,
|
14
|
+
ssl
|
15
|
+
FROM
|
16
|
+
pg_stat_activity
|
17
|
+
LEFT JOIN
|
18
|
+
pg_stat_ssl ON pg_stat_activity.pid = pg_stat_ssl.pid
|
19
|
+
ORDER BY
|
20
|
+
pg_stat_activity.pid
|
21
|
+
SQL
|
22
|
+
else
|
23
|
+
select_all <<-SQL
|
24
|
+
SELECT
|
25
|
+
pid,
|
26
|
+
datname AS database,
|
27
|
+
usename AS user,
|
28
|
+
application_name AS source,
|
29
|
+
client_addr AS ip,
|
30
|
+
state
|
31
|
+
FROM
|
32
|
+
pg_stat_activity
|
33
|
+
ORDER BY
|
34
|
+
pid
|
35
|
+
SQL
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
4
39
|
def total_connections
|
5
40
|
select_one("SELECT COUNT(*) FROM pg_stat_activity")
|
6
41
|
end
|
@@ -6,7 +6,7 @@ module PgHero
|
|
6
6
|
explanation = nil
|
7
7
|
|
8
8
|
# use transaction for safety
|
9
|
-
with_transaction(statement_timeout: (explain_timeout_sec * 1000), rollback: true) do
|
9
|
+
with_transaction(statement_timeout: (explain_timeout_sec * 1000).round, rollback: true) do
|
10
10
|
if (sql.sub(/;\z/, "").include?(";") || sql.upcase.include?("COMMIT")) && !explain_safe?
|
11
11
|
raise ActiveRecord::StatementInvalid, "Unsafe statement"
|
12
12
|
end
|
@@ -2,7 +2,7 @@ module PgHero
|
|
2
2
|
module Methods
|
3
3
|
module Queries
|
4
4
|
def running_queries(min_duration: nil, all: false)
|
5
|
-
|
5
|
+
query = <<-SQL
|
6
6
|
SELECT
|
7
7
|
pid,
|
8
8
|
state,
|
@@ -24,6 +24,8 @@ module PgHero
|
|
24
24
|
ORDER BY
|
25
25
|
COALESCE(query_start, xact_start) DESC
|
26
26
|
SQL
|
27
|
+
|
28
|
+
select_all(query, query_columns: [:query])
|
27
29
|
end
|
28
30
|
|
29
31
|
def long_running_queries
|
@@ -33,7 +35,7 @@ module PgHero
|
|
33
35
|
# from https://wiki.postgresql.org/wiki/Lock_Monitoring
|
34
36
|
# and https://big-elephants.com/2013-09/exploring-query-locks-in-postgres/
|
35
37
|
def blocked_queries
|
36
|
-
|
38
|
+
query = <<-SQL
|
37
39
|
SELECT
|
38
40
|
COALESCE(blockingl.relation::regclass::text,blockingl.locktype) as locked_item,
|
39
41
|
blockeda.pid AS blocked_pid,
|
@@ -65,6 +67,8 @@ module PgHero
|
|
65
67
|
ORDER BY
|
66
68
|
blocked_duration DESC
|
67
69
|
SQL
|
70
|
+
|
71
|
+
select_all(query, query_columns: [:blocked_query, :current_or_recent_query_in_blocking_process])
|
68
72
|
end
|
69
73
|
end
|
70
74
|
end
|
@@ -2,7 +2,7 @@ module PgHero
|
|
2
2
|
module Methods
|
3
3
|
module QueryStats
|
4
4
|
def query_stats(historical: false, start_at: nil, end_at: nil, min_average_time: nil, min_calls: nil, **options)
|
5
|
-
current_query_stats = historical && end_at && end_at < Time.now ? [] : current_query_stats(options)
|
5
|
+
current_query_stats = historical && end_at && end_at < Time.now ? [] : current_query_stats(**options)
|
6
6
|
historical_query_stats = historical && historical_query_stats_enabled? ? historical_query_stats(start_at: start_at, end_at: end_at, **options) : []
|
7
7
|
|
8
8
|
query_stats = combine_query_stats((current_query_stats + historical_query_stats).group_by { |q| [q[:query_hash], q[:user]] })
|
@@ -56,8 +56,46 @@ module PgHero
|
|
56
56
|
true
|
57
57
|
end
|
58
58
|
|
59
|
-
|
60
|
-
|
59
|
+
# TODO scope by database in PgHero 3.0
|
60
|
+
# (add database: database_name to options)
|
61
|
+
def reset_query_stats(**options)
|
62
|
+
reset_instance_query_stats(**options)
|
63
|
+
end
|
64
|
+
|
65
|
+
# resets query stats for the entire instance
|
66
|
+
# it's possible to reset stats for a specific
|
67
|
+
# database, user or query hash in Postgres 12+
|
68
|
+
def reset_instance_query_stats(database: nil, user: nil, query_hash: nil, raise_errors: false)
|
69
|
+
if database || user || query_hash
|
70
|
+
raise PgHero::Error, "Requires PostgreSQL 12+" if server_version_num < 120000
|
71
|
+
|
72
|
+
if database
|
73
|
+
database_id = execute("SELECT oid FROM pg_database WHERE datname = #{quote(database)}").first.try(:[], "oid")
|
74
|
+
raise PgHero::Error, "Database not found: #{database}" unless database_id
|
75
|
+
else
|
76
|
+
database_id = 0
|
77
|
+
end
|
78
|
+
|
79
|
+
if user
|
80
|
+
user_id = execute("SELECT usesysid FROM pg_user WHERE usename = #{quote(user)}").first.try(:[], "usesysid")
|
81
|
+
raise PgHero::Error, "User not found: #{user}" unless user_id
|
82
|
+
else
|
83
|
+
user_id = 0
|
84
|
+
end
|
85
|
+
|
86
|
+
if query_hash
|
87
|
+
query_id = query_hash.to_i
|
88
|
+
# may not be needed
|
89
|
+
# but not intuitive that all query hashes are reset with 0
|
90
|
+
raise PgHero::Error, "Invalid query hash: #{query_hash}" if query_id == 0
|
91
|
+
else
|
92
|
+
query_id = 0
|
93
|
+
end
|
94
|
+
|
95
|
+
execute("SELECT pg_stat_statements_reset(#{quote(user_id.to_i)}, #{quote(database_id.to_i)}, #{quote(query_id.to_i)})")
|
96
|
+
else
|
97
|
+
execute("SELECT pg_stat_statements_reset()")
|
98
|
+
end
|
61
99
|
true
|
62
100
|
rescue ActiveRecord::StatementInvalid => e
|
63
101
|
raise e if raise_errors
|
@@ -104,31 +142,32 @@ module PgHero
|
|
104
142
|
query_stats[database_id] = query_stats(limit: 1000000, database: database_name)
|
105
143
|
end
|
106
144
|
|
107
|
-
|
145
|
+
query_stats = query_stats.select { |_, v| v.any? }
|
146
|
+
|
147
|
+
# nothing to do
|
148
|
+
return if query_stats.empty?
|
108
149
|
|
109
|
-
|
150
|
+
# use mapping, not query stats here
|
151
|
+
# TODO add option for this, and make default in PgHero 3.0
|
152
|
+
if false # mapping.size == 1 && server_version_num >= 120000
|
110
153
|
query_stats.each do |db_id, db_query_stats|
|
111
|
-
if
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
now,
|
120
|
-
supports_query_hash ? qs[:query_hash] : nil,
|
121
|
-
qs[:user]
|
122
|
-
]
|
123
|
-
end
|
124
|
-
|
125
|
-
columns = %w[database query total_time calls captured_at query_hash user]
|
126
|
-
insert_stats("pghero_query_stats", columns, values)
|
154
|
+
if reset_query_stats(database: mapping[db_id], raise_errors: raise_errors)
|
155
|
+
insert_query_stats(db_id, db_query_stats, now)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
else
|
159
|
+
if reset_query_stats(raise_errors: raise_errors)
|
160
|
+
query_stats.each do |db_id, db_query_stats|
|
161
|
+
insert_query_stats(db_id, db_query_stats, now)
|
127
162
|
end
|
128
163
|
end
|
129
164
|
end
|
130
165
|
end
|
131
166
|
|
167
|
+
def clean_query_stats
|
168
|
+
PgHero::QueryStats.where(database: id).where("captured_at < ?", 14.days.ago).delete_all
|
169
|
+
end
|
170
|
+
|
132
171
|
def slow_queries(query_stats: nil, **options)
|
133
172
|
query_stats ||= self.query_stats(options)
|
134
173
|
query_stats.select { |q| q[:calls].to_i >= slow_query_calls.to_i && q[:average_time].to_f >= slow_query_ms.to_f }
|
@@ -166,14 +205,15 @@ module PgHero
|
|
166
205
|
if query_stats_enabled?
|
167
206
|
limit ||= 100
|
168
207
|
sort ||= "total_minutes"
|
169
|
-
|
208
|
+
total_time = server_version_num >= 130000 ? "(total_plan_time + total_exec_time)" : "total_time"
|
209
|
+
query = <<-SQL
|
170
210
|
WITH query_stats AS (
|
171
211
|
SELECT
|
172
212
|
LEFT(query, 10000) AS query,
|
173
213
|
#{supports_query_hash? ? "queryid" : "md5(query)"} AS query_hash,
|
174
214
|
rolname AS user,
|
175
|
-
(total_time / 1000 / 60) AS total_minutes,
|
176
|
-
(total_time / calls) AS average_time,
|
215
|
+
(#{total_time} / 1000 / 60) AS total_minutes,
|
216
|
+
(#{total_time} / calls) AS average_time,
|
177
217
|
calls
|
178
218
|
FROM
|
179
219
|
pg_stat_statements
|
@@ -182,6 +222,7 @@ module PgHero
|
|
182
222
|
INNER JOIN
|
183
223
|
pg_roles ON pg_roles.oid = pg_stat_statements.userid
|
184
224
|
WHERE
|
225
|
+
calls > 0 AND
|
185
226
|
pg_database.datname = #{database ? quote(database) : "current_database()"}
|
186
227
|
#{query_hash ? "AND queryid = #{quote(query_hash)}" : nil}
|
187
228
|
)
|
@@ -200,6 +241,11 @@ module PgHero
|
|
200
241
|
#{quote_table_name(sort)} DESC
|
201
242
|
LIMIT #{limit.to_i}
|
202
243
|
SQL
|
244
|
+
|
245
|
+
# we may be able to skip query_columns
|
246
|
+
# in more recent versions of Postgres
|
247
|
+
# as pg_stat_statements should be already normalized
|
248
|
+
select_all(query, query_columns: [:query])
|
203
249
|
else
|
204
250
|
raise NotEnabled, "Query stats not enabled"
|
205
251
|
end
|
@@ -208,7 +254,7 @@ module PgHero
|
|
208
254
|
def historical_query_stats(sort: nil, start_at: nil, end_at: nil, query_hash: nil)
|
209
255
|
if historical_query_stats_enabled?
|
210
256
|
sort ||= "total_minutes"
|
211
|
-
|
257
|
+
query = <<-SQL
|
212
258
|
WITH query_stats AS (
|
213
259
|
SELECT
|
214
260
|
#{supports_query_hash? ? "query_hash" : "md5(query)"} AS query_hash,
|
@@ -244,6 +290,10 @@ module PgHero
|
|
244
290
|
#{quote_table_name(sort)} DESC
|
245
291
|
LIMIT 100
|
246
292
|
SQL
|
293
|
+
|
294
|
+
# we can skip query_columns if all stored data is normalized
|
295
|
+
# for now, assume it's not
|
296
|
+
select_all_stats(query, query_columns: [:query, :explainable_query])
|
247
297
|
else
|
248
298
|
raise NotEnabled, "Historical query stats not enabled"
|
249
299
|
end
|
@@ -276,6 +326,24 @@ module PgHero
|
|
276
326
|
def normalize_query(query)
|
277
327
|
squish(query.to_s.gsub(/\?(, ?\?)+/, "?").gsub(/\/\*.+?\*\//, ""))
|
278
328
|
end
|
329
|
+
|
330
|
+
def insert_query_stats(db_id, db_query_stats, now)
|
331
|
+
values =
|
332
|
+
db_query_stats.map do |qs|
|
333
|
+
[
|
334
|
+
db_id,
|
335
|
+
qs[:query],
|
336
|
+
qs[:total_minutes] * 60 * 1000,
|
337
|
+
qs[:calls],
|
338
|
+
now,
|
339
|
+
supports_query_hash? ? qs[:query_hash] : nil,
|
340
|
+
qs[:user]
|
341
|
+
]
|
342
|
+
end
|
343
|
+
|
344
|
+
columns = %w[database query total_time calls captured_at query_hash user]
|
345
|
+
insert_stats("pghero_query_stats", columns, values)
|
346
|
+
end
|
279
347
|
end
|
280
348
|
end
|
281
349
|
end
|