pghero 1.7.0 → 2.0.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.

Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +2 -0
  3. data/CHANGELOG.md +31 -0
  4. data/README.md +2 -2
  5. data/app/assets/javascripts/pghero/Chart.bundle.js +7512 -5661
  6. data/app/assets/javascripts/pghero/application.js +9 -0
  7. data/app/assets/javascripts/pghero/highlight.pack.js +2 -0
  8. data/app/assets/stylesheets/pghero/application.css +54 -2
  9. data/app/assets/stylesheets/pghero/arduino-light.css +86 -0
  10. data/app/controllers/pg_hero/home_controller.rb +148 -52
  11. data/app/helpers/pg_hero/base_helper.rb +15 -0
  12. data/app/views/layouts/pg_hero/application.html.erb +1 -1
  13. data/app/views/pg_hero/home/_connections_table.html.erb +2 -2
  14. data/app/views/pg_hero/home/_live_queries_table.html.erb +11 -7
  15. data/app/views/pg_hero/home/_queries_table.html.erb +21 -10
  16. data/app/views/pg_hero/home/_suggested_index.html.erb +1 -1
  17. data/app/views/pg_hero/home/connections.html.erb +2 -14
  18. data/app/views/pg_hero/home/explain.html.erb +1 -1
  19. data/app/views/pg_hero/home/index.html.erb +58 -22
  20. data/app/views/pg_hero/home/index_bloat.html.erb +69 -0
  21. data/app/views/pg_hero/home/maintenance.html.erb +7 -7
  22. data/app/views/pg_hero/home/queries.html.erb +10 -0
  23. data/app/views/pg_hero/home/relation_space.html.erb +9 -0
  24. data/app/views/pg_hero/home/show_query.html.erb +107 -0
  25. data/app/views/pg_hero/home/space.html.erb +64 -10
  26. data/config/routes.rb +4 -2
  27. data/guides/Rails.md +28 -1
  28. data/guides/Suggested-Indexes.md +1 -1
  29. data/lib/pghero.rb +25 -36
  30. data/lib/pghero/database.rb +5 -1
  31. data/lib/pghero/methods/basic.rb +78 -13
  32. data/lib/pghero/methods/connections.rb +16 -56
  33. data/lib/pghero/methods/explain.rb +2 -6
  34. data/lib/pghero/methods/indexes.rb +173 -18
  35. data/lib/pghero/methods/kill.rb +2 -2
  36. data/lib/pghero/methods/maintenance.rb +23 -26
  37. data/lib/pghero/methods/queries.rb +1 -23
  38. data/lib/pghero/methods/query_stats.rb +95 -96
  39. data/lib/pghero/methods/{replica.rb → replication.rb} +17 -4
  40. data/lib/pghero/methods/sequences.rb +4 -5
  41. data/lib/pghero/methods/space.rb +101 -8
  42. data/lib/pghero/methods/suggested_indexes.rb +49 -108
  43. data/lib/pghero/methods/system.rb +14 -10
  44. data/lib/pghero/methods/tables.rb +8 -8
  45. data/lib/pghero/methods/users.rb +10 -12
  46. data/lib/pghero/version.rb +1 -1
  47. data/lib/tasks/pghero.rake +1 -1
  48. data/test/basic_test.rb +38 -0
  49. data/test/best_index_test.rb +3 -3
  50. data/test/suggested_indexes_test.rb +0 -2
  51. data/test/test_helper.rb +38 -40
  52. metadata +11 -6
  53. data/app/views/pg_hero/home/index_usage.html.erb +0 -27
  54. data/test/explain_test.rb +0 -18
@@ -2,64 +2,24 @@ module PgHero
2
2
  module Methods
3
3
  module Connections
4
4
  def total_connections
5
- select_all("SELECT COUNT(*) FROM pg_stat_activity WHERE pid <> pg_backend_pid()").first["count"].to_i
5
+ select_one("SELECT COUNT(*) FROM pg_stat_activity")
6
6
  end
7
7
 
8
- def connection_sources(options = {})
9
- if options[:by_database_and_user]
10
- select_all <<-SQL
11
- SELECT
12
- datname AS database,
13
- usename AS user,
14
- application_name AS source,
15
- client_addr AS ip,
16
- COUNT(*) AS total_connections
17
- FROM
18
- pg_stat_activity
19
- WHERE
20
- pid <> pg_backend_pid()
21
- GROUP BY
22
- 1, 2, 3, 4
23
- ORDER BY
24
- 5 DESC, 1, 2, 3, 4
25
- SQL
26
- elsif options[:by_database]
27
- select_all <<-SQL
28
- SELECT
29
- application_name AS source,
30
- client_addr AS ip,
31
- datname AS database,
32
- COUNT(*) AS total_connections
33
- FROM
34
- pg_stat_activity
35
- WHERE
36
- pid <> pg_backend_pid()
37
- GROUP BY
38
- 1, 2, 3
39
- ORDER BY
40
- COUNT(*) DESC,
41
- application_name ASC,
42
- client_addr ASC
43
- SQL
44
- else
45
- select_all <<-SQL
46
- SELECT
47
- application_name AS source,
48
- client_addr AS ip,
49
- COUNT(*) AS total_connections
50
- FROM
51
- pg_stat_activity
52
- WHERE
53
- pid <> pg_backend_pid()
54
- GROUP BY
55
- application_name,
56
- ip
57
- ORDER BY
58
- COUNT(*) DESC,
59
- application_name ASC,
60
- client_addr ASC
61
- SQL
62
- end
8
+ def connection_sources
9
+ select_all <<-SQL
10
+ SELECT
11
+ datname AS database,
12
+ usename AS user,
13
+ application_name AS source,
14
+ client_addr AS ip,
15
+ COUNT(*) AS total_connections
16
+ FROM
17
+ pg_stat_activity
18
+ GROUP BY
19
+ 1, 2, 3, 4
20
+ ORDER BY
21
+ 5 DESC, 1, 2, 3, 4
22
+ SQL
63
23
  end
64
24
  end
65
25
  end
@@ -6,15 +6,11 @@ module PgHero
6
6
  explanation = nil
7
7
 
8
8
  # use transaction for safety
9
- connection_model.transaction do
10
- # protect the DB with a 10 second timeout
11
- # this could potentially increase the timeout, but 10 seconds should be okay
12
- select_all("SET LOCAL statement_timeout = 10000")
9
+ with_transaction(statement_timeout: 10000, rollback: true) do
13
10
  if (sql.sub(/;\z/, "").include?(";") || sql.upcase.include?("COMMIT")) && !explain_safe?
14
11
  raise ActiveRecord::StatementInvalid, "Unsafe statement"
15
12
  end
16
- explanation = select_all("EXPLAIN #{sql}").map { |v| v["QUERY PLAN"] }.join("\n")
17
- raise ActiveRecord::Rollback
13
+ explanation = select_all("EXPLAIN #{sql}").map { |v| v[:"QUERY PLAN"] }.join("\n")
18
14
  end
19
15
 
20
16
  explanation
@@ -2,20 +2,20 @@ module PgHero
2
2
  module Methods
3
3
  module Indexes
4
4
  def index_hit_rate
5
- select_all(<<-SQL
5
+ select_one <<-SQL
6
6
  SELECT
7
7
  (sum(idx_blks_hit)) / nullif(sum(idx_blks_hit + idx_blks_read), 0) AS rate
8
8
  FROM
9
9
  pg_statio_user_indexes
10
10
  SQL
11
- ).first["rate"].to_f
12
11
  end
13
12
 
14
13
  def index_caching
15
14
  select_all <<-SQL
16
15
  SELECT
17
- indexrelname AS index,
16
+ schemaname AS schema,
18
17
  relname AS table,
18
+ indexrelname AS index,
19
19
  CASE WHEN idx_blks_hit + idx_blks_read = 0 THEN
20
20
  0
21
21
  ELSE
@@ -37,7 +37,7 @@ module PgHero
37
37
  WHEN 0 THEN 'Insufficient data'
38
38
  ELSE (100 * idx_scan / (seq_scan + idx_scan))::text
39
39
  END percent_of_times_index_used,
40
- n_live_tup rows_in_table
40
+ n_live_tup AS estimated_rows
41
41
  FROM
42
42
  pg_stat_user_tables
43
43
  ORDER BY
@@ -55,7 +55,7 @@ module PgHero
55
55
  WHEN 0 THEN 'Insufficient data'
56
56
  ELSE (100 * idx_scan / (seq_scan + idx_scan))::text
57
57
  END percent_of_times_index_used,
58
- n_live_tup rows_in_table
58
+ n_live_tup AS estimated_rows
59
59
  FROM
60
60
  pg_stat_user_tables
61
61
  WHERE
@@ -68,13 +68,13 @@ module PgHero
68
68
  SQL
69
69
  end
70
70
 
71
- def unused_indexes
72
- select_all <<-SQL
71
+ def unused_indexes(max_scans: 50)
72
+ select_all_size <<-SQL
73
73
  SELECT
74
74
  schemaname AS schema,
75
75
  relname AS table,
76
76
  indexrelname AS index,
77
- pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size,
77
+ pg_relation_size(i.indexrelid) AS size_bytes,
78
78
  idx_scan as index_scans
79
79
  FROM
80
80
  pg_stat_user_indexes ui
@@ -82,13 +82,29 @@ module PgHero
82
82
  pg_index i ON ui.indexrelid = i.indexrelid
83
83
  WHERE
84
84
  NOT indisunique
85
- AND idx_scan < 50
85
+ AND idx_scan <= #{max_scans.to_i}
86
86
  ORDER BY
87
87
  pg_relation_size(i.indexrelid) DESC,
88
88
  relname ASC
89
89
  SQL
90
90
  end
91
91
 
92
+ def reset_stats
93
+ execute("SELECT pg_stat_reset()")
94
+ true
95
+ end
96
+
97
+ def last_stats_reset_time
98
+ select_one <<-SQL
99
+ SELECT
100
+ pg_stat_get_db_stat_reset_time(oid) AS reset_time
101
+ FROM
102
+ pg_database
103
+ WHERE
104
+ datname = current_database()
105
+ SQL
106
+ end
107
+
92
108
  def invalid_indexes
93
109
  select_all <<-SQL
94
110
  SELECT
@@ -133,24 +149,163 @@ module PgHero
133
149
  pg_class ix ON ix.oid = i.indexrelid
134
150
  LEFT JOIN
135
151
  pg_stat_user_indexes ui ON ui.indexrelid = i.indexrelid
152
+ WHERE
153
+ schemaname IS NOT NULL
136
154
  ORDER BY
137
155
  1, 2
138
156
  SQL
139
- ).map { |v| v["columns"] = v["columns"].sub(") WHERE (", " WHERE ").split(", ").map { |c| unquote(c) }; v }
157
+ ).map { |v| v[:columns] = v[:columns].sub(") WHERE (", " WHERE ").split(", ").map { |c| unquote(c) }; v }
140
158
  end
141
159
 
142
- def duplicate_indexes
143
- indexes = []
160
+ def duplicate_indexes(indexes: nil)
161
+ dup_indexes = []
144
162
 
145
- indexes_by_table = self.indexes.group_by { |i| i["table"] }
146
- indexes_by_table.values.flatten.select { |i| PgHero.falsey?(i["primary"]) && PgHero.falsey?(i["unique"]) && !i["indexprs"] && !i["indpred"] && PgHero.truthy?(i["valid"]) }.each do |index|
147
- covering_index = indexes_by_table[index["table"]].find { |i| index_covers?(i["columns"], index["columns"]) && i["using"] == index["using"] && i["name"] != index["name"] && i["schema"] == index["schema"] && !i["indexprs"] && !i["indpred"] && PgHero.truthy?(i["valid"]) }
148
- if covering_index && (covering_index["columns"] != index["columns"] || index["name"] > covering_index["name"])
149
- indexes << {"unneeded_index" => index, "covering_index" => covering_index}
163
+ indexes_by_table = (indexes || self.indexes).group_by { |i| i[:table] }
164
+ indexes_by_table.values.flatten.select { |i| !i[:primary] && !i[:unique] && !i[:indexprs] && !i[:indpred] && i[:valid] }.each do |index|
165
+ covering_index = indexes_by_table[index[:table]].find { |i| index_covers?(i[:columns], index[:columns]) && i[:using] == index[:using] && i[:name] != index[:name] && i[:schema] == index[:schema] && !i[:indexprs] && !i[:indpred] && i[:valid] }
166
+ if covering_index && (covering_index[:columns] != index[:columns] || index[:name] > covering_index[:name])
167
+ dup_indexes << {unneeded_index: index, covering_index: covering_index}
150
168
  end
151
169
  end
152
170
 
153
- indexes.sort_by { |i| ui = i["unneeded_index"]; [ui["table"], ui["columns"]] }
171
+ dup_indexes.sort_by { |i| ui = i[:unneeded_index]; [ui[:table], ui[:columns]] }
172
+ end
173
+
174
+ # https://gist.github.com/mbanck/9976015/71888a24e464e2f772182a7eb54f15a125edf398
175
+ # thanks @jberkus and @mbanck
176
+ def index_bloat(min_size: nil)
177
+ min_size ||= index_bloat_bytes
178
+ select_all <<-SQL
179
+ WITH btree_index_atts AS (
180
+ SELECT
181
+ nspname, relname, reltuples, relpages, indrelid, relam,
182
+ regexp_split_to_table(indkey::text, ' ')::smallint AS attnum,
183
+ indexrelid as index_oid
184
+ FROM
185
+ pg_index
186
+ JOIN
187
+ pg_class ON pg_class.oid=pg_index.indexrelid
188
+ JOIN
189
+ pg_namespace ON pg_namespace.oid = pg_class.relnamespace
190
+ JOIN
191
+ pg_am ON pg_class.relam = pg_am.oid
192
+ WHERE
193
+ pg_am.amname = 'btree'
194
+ ),
195
+ index_item_sizes AS (
196
+ SELECT
197
+ i.nspname,
198
+ i.relname,
199
+ i.reltuples,
200
+ i.relpages,
201
+ i.relam,
202
+ (quote_ident(s.schemaname) || '.' || quote_ident(s.tablename))::regclass AS starelid,
203
+ a.attrelid AS table_oid, index_oid,
204
+ current_setting('block_size')::numeric AS bs,
205
+ /* MAXALIGN: 4 on 32bits, 8 on 64bits (and mingw32 ?) */
206
+ CASE
207
+ WHEN version() ~ 'mingw32' OR version() ~ '64-bit' THEN 8
208
+ ELSE 4
209
+ END AS maxalign,
210
+ 24 AS pagehdr,
211
+ /* per tuple header: add index_attribute_bm if some cols are null-able */
212
+ CASE WHEN max(coalesce(s.null_frac,0)) = 0
213
+ THEN 2
214
+ ELSE 6
215
+ END AS index_tuple_hdr,
216
+ /* data len: we remove null values save space using it fractionnal part from stats */
217
+ sum( (1-coalesce(s.null_frac, 0)) * coalesce(s.avg_width, 2048) ) AS nulldatawidth
218
+ FROM
219
+ pg_attribute AS a
220
+ JOIN
221
+ pg_stats AS s ON (quote_ident(s.schemaname) || '.' || quote_ident(s.tablename))::regclass=a.attrelid AND s.attname = a.attname
222
+ JOIN
223
+ btree_index_atts AS i ON i.indrelid = a.attrelid AND a.attnum = i.attnum
224
+ WHERE
225
+ a.attnum > 0
226
+ GROUP BY
227
+ 1, 2, 3, 4, 5, 6, 7, 8, 9
228
+ ),
229
+ index_aligned AS (
230
+ SELECT
231
+ maxalign,
232
+ bs,
233
+ nspname,
234
+ relname AS index_name,
235
+ reltuples,
236
+ relpages,
237
+ relam,
238
+ table_oid,
239
+ index_oid,
240
+ ( 2 +
241
+ maxalign - CASE /* Add padding to the index tuple header to align on MAXALIGN */
242
+ WHEN index_tuple_hdr%maxalign = 0 THEN maxalign
243
+ ELSE index_tuple_hdr%maxalign
244
+ END
245
+ + nulldatawidth + maxalign - CASE /* Add padding to the data to align on MAXALIGN */
246
+ WHEN nulldatawidth::integer%maxalign = 0 THEN maxalign
247
+ ELSE nulldatawidth::integer%maxalign
248
+ END
249
+ )::numeric AS nulldatahdrwidth, pagehdr
250
+ FROM
251
+ index_item_sizes AS s1
252
+ ),
253
+ otta_calc AS (
254
+ SELECT
255
+ bs,
256
+ nspname,
257
+ table_oid,
258
+ index_oid,
259
+ index_name,
260
+ relpages,
261
+ coalesce(
262
+ ceil((reltuples*(4+nulldatahdrwidth))/(bs-pagehdr::float)) +
263
+ CASE WHEN am.amname IN ('hash','btree') THEN 1 ELSE 0 END , 0 /* btree and hash have a metadata reserved block */
264
+ ) AS otta
265
+ FROM
266
+ index_aligned AS s2
267
+ LEFT JOIN
268
+ pg_am am ON s2.relam = am.oid
269
+ ),
270
+ raw_bloat AS (
271
+ SELECT
272
+ nspname,
273
+ c.relname AS table_name,
274
+ index_name,
275
+ bs*(sub.relpages)::bigint AS totalbytes,
276
+ CASE
277
+ WHEN sub.relpages <= otta THEN 0
278
+ ELSE bs*(sub.relpages-otta)::bigint END
279
+ AS wastedbytes,
280
+ CASE
281
+ WHEN sub.relpages <= otta
282
+ THEN 0 ELSE bs*(sub.relpages-otta)::bigint * 100 / (bs*(sub.relpages)::bigint) END
283
+ AS realbloat,
284
+ pg_relation_size(sub.table_oid) as table_bytes,
285
+ stat.idx_scan as index_scans,
286
+ stat.indexrelid
287
+ FROM
288
+ otta_calc AS sub
289
+ JOIN
290
+ pg_class AS c ON c.oid=sub.table_oid
291
+ JOIN
292
+ pg_stat_user_indexes AS stat ON sub.index_oid = stat.indexrelid
293
+ )
294
+ SELECT
295
+ nspname AS schema,
296
+ table_name AS table,
297
+ index_name AS index,
298
+ wastedbytes AS bloat_bytes,
299
+ totalbytes AS index_bytes,
300
+ pg_get_indexdef(indexrelid) AS definition
301
+ FROM
302
+ raw_bloat
303
+ WHERE
304
+ wastedbytes >= #{min_size.to_i}
305
+ ORDER BY
306
+ wastedbytes DESC,
307
+ index_name
308
+ SQL
154
309
  end
155
310
  end
156
311
  end
@@ -2,11 +2,11 @@ module PgHero
2
2
  module Methods
3
3
  module Kill
4
4
  def kill(pid)
5
- PgHero.truthy? execute("SELECT pg_terminate_backend(#{pid.to_i})").first["pg_terminate_backend"]
5
+ select_one("SELECT pg_terminate_backend(#{pid.to_i})")
6
6
  end
7
7
 
8
8
  def kill_long_running_queries
9
- long_running_queries.each { |query| kill(query["pid"]) }
9
+ long_running_queries.each { |query| kill(query[:pid]) }
10
10
  true
11
11
  end
12
12
 
@@ -5,40 +5,32 @@ module PgHero
5
5
  # "the system will shut down and refuse to start any new transactions
6
6
  # once there are fewer than 1 million transactions left until wraparound"
7
7
  # warn when 10,000,000 transactions left
8
- def transaction_id_danger(options = {})
9
- threshold = options[:threshold] || 10000000
8
+ def transaction_id_danger(threshold: 10000000, max_value: 2146483648)
9
+ max_value = max_value.to_i
10
+ threshold = threshold.to_i
11
+
10
12
  select_all <<-SQL
11
13
  SELECT
12
- c.oid::regclass::text AS table,
13
- 2146483648 - GREATEST(AGE(c.relfrozenxid), AGE(t.relfrozenxid)) AS transactions_before_shutdown
14
+ n.nspname AS schema,
15
+ c.relname AS table,
16
+ #{quote(max_value)} - GREATEST(AGE(c.relfrozenxid), AGE(t.relfrozenxid)) AS transactions_left
14
17
  FROM
15
18
  pg_class c
19
+ INNER JOIN
20
+ pg_catalog.pg_namespace n ON n.oid = c.relnamespace
16
21
  LEFT JOIN
17
22
  pg_class t ON c.reltoastrelid = t.oid
18
23
  WHERE
19
24
  c.relkind = 'r'
20
- AND (2146483648 - GREATEST(AGE(c.relfrozenxid), AGE(t.relfrozenxid))) < #{threshold}
25
+ AND (#{quote(max_value)} - GREATEST(AGE(c.relfrozenxid), AGE(t.relfrozenxid))) < #{quote(threshold)}
21
26
  ORDER BY
22
27
  2, 1
23
28
  SQL
24
29
  end
25
30
 
26
31
  def autovacuum_danger
27
- select_all <<-SQL
28
- SELECT
29
- c.oid::regclass::text as table,
30
- (SELECT setting FROM pg_settings WHERE name = 'autovacuum_freeze_max_age')::int -
31
- GREATEST(AGE(c.relfrozenxid), AGE(t.relfrozenxid)) AS transactions_before_autovacuum
32
- FROM
33
- pg_class c
34
- LEFT JOIN
35
- pg_class t ON c.reltoastrelid = t.oid
36
- WHERE
37
- c.relkind = 'r'
38
- AND (SELECT setting FROM pg_settings WHERE name = 'autovacuum_freeze_max_age')::int - GREATEST(AGE(c.relfrozenxid), AGE(t.relfrozenxid)) < 2000000
39
- ORDER BY
40
- transactions_before_autovacuum
41
- SQL
32
+ max_value = select_one("SHOW autovacuum_freeze_max_age").to_i
33
+ transaction_id_danger(threshold: 2000000, max_value: max_value)
42
34
  end
43
35
 
44
36
  def maintenance_info
@@ -57,20 +49,25 @@ module PgHero
57
49
  SQL
58
50
  end
59
51
 
60
- def analyze(table)
61
- execute "ANALYZE #{quote_table_name(table)}"
52
+ def analyze(table, verbose: false)
53
+ execute "ANALYZE #{verbose ? "VERBOSE " : ""}#{quote_table_name(table)}"
62
54
  true
63
55
  end
64
56
 
65
- def analyze_tables
66
- table_stats.reject { |s| %w(information_schema pg_catalog).include?(s["schema"]) }.map { |s| s.slice("schema", "table") }.each do |stats|
57
+ def analyze_tables(verbose: false, min_size: nil, tables: nil)
58
+ tables = table_stats(table: tables).reject { |s| %w(information_schema pg_catalog).include?(s[:schema]) }
59
+ tables = tables.select { |s| s[:size_bytes] > min_size } if min_size
60
+ tables.map { |s| s.slice(:schema, :table) }.each do |stats|
67
61
  begin
68
- with_lock_timeout(5000) do
69
- analyze "#{stats["schema"]}.#{stats["table"]}"
62
+ with_transaction(lock_timeout: 5000, statement_timeout: 120000) do
63
+ analyze "#{stats[:schema]}.#{stats[:table]}", verbose: verbose
70
64
  end
65
+ success = true
71
66
  rescue ActiveRecord::StatementInvalid => e
72
67
  $stderr.puts e.message
68
+ success = false
73
69
  end
70
+ stats[:success] = success
74
71
  end
75
72
  end
76
73
  end