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.

@@ -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).to_i
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"] || 100.megabytes).to_i
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
- @db_instance_identifier ||= config["aws_db_instance_identifier"] || config["db_instance_identifier"]
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
- @connection_model ||= begin
90
- url = config["url"]
91
- if !url && config["spec"]
92
- raise Error, "Spec requires Rails 6+" unless PgHero.spec_supported?
93
- resolved = ActiveRecord::Base.configurations.configs_for(env_name: PgHero.env, spec_name: config["spec"], include_replicas: true)
94
- raise Error, "Spec not found: #{config["spec"]}" unless resolved
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
- Class.new(PgHero::Connection) do
98
- def self.name
99
- "PgHero::Connection::Database#{object_id}"
100
- end
101
- case url
102
- when String
103
- url = "#{url}#{url.include?("?") ? "&" : "?"}connect_timeout=5" unless url.include?("connect_timeout=")
104
- when Hash
105
- url[:connect_timeout] ||= 5
106
- end
107
- establish_connection url if url
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
@@ -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 = nil)
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 = nil)
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
@@ -57,7 +57,9 @@ module PgHero
57
57
  last_vacuum,
58
58
  last_autovacuum,
59
59
  last_analyze,
60
- last_autoanalyze
60
+ last_autoanalyze,
61
+ n_dead_tup AS dead_rows,
62
+ n_live_tup AS live_rows
61
63
  FROM
62
64
  pg_stat_user_tables
63
65
  ORDER BY
@@ -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
- select_all <<-SQL
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
- select_all <<-SQL
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
- def reset_query_stats(raise_errors: false)
60
- execute("SELECT pg_stat_statements_reset()")
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
- supports_query_hash = supports_query_hash?
145
+ query_stats = query_stats.select { |_, v| v.any? }
146
+
147
+ # nothing to do
148
+ return if query_stats.empty?
108
149
 
109
- if query_stats.any? { |_, v| v.any? } && reset_query_stats(raise_errors: raise_errors)
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 db_query_stats.any?
112
- values =
113
- db_query_stats.map do |qs|
114
- [
115
- db_id,
116
- qs[:query],
117
- qs[:total_minutes] * 60 * 1000,
118
- qs[:calls],
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
- select_all <<-SQL
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
- select_all_stats <<-SQL
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