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,29 +2,29 @@ module PgHero
2
2
  module Methods
3
3
  module SuggestedIndexes
4
4
  def suggested_indexes_enabled?
5
- defined?(PgQuery) && query_stats_enabled?
5
+ defined?(PgQuery) && Gem::Version.new(PgQuery::VERSION) >= Gem::Version.new("0.9.0") && query_stats_enabled?
6
6
  end
7
7
 
8
8
  # TODO clean this mess
9
- def suggested_indexes_by_query(options = {})
9
+ def suggested_indexes_by_query(queries: nil, query_stats: nil, indexes: nil)
10
10
  best_indexes = {}
11
11
 
12
12
  if suggested_indexes_enabled?
13
13
  # get most time-consuming queries
14
- queries = options[:queries] || (options[:query_stats] || query_stats(historical: true, start_at: 24.hours.ago)).map { |qs| qs["query"] }
14
+ queries ||= (query_stats || self.query_stats(historical: true, start_at: 24.hours.ago)).map { |qs| qs[:query] }
15
15
 
16
16
  # get best indexes for queries
17
17
  best_indexes = best_index_helper(queries)
18
18
 
19
19
  if best_indexes.any?
20
20
  existing_columns = Hash.new { |hash, key| hash[key] = Hash.new { |hash2, key2| hash2[key2] = [] } }
21
- indexes = self.indexes
22
- indexes.group_by { |g| g["using"] }.each do |group, inds|
21
+ indexes ||= self.indexes
22
+ indexes.group_by { |g| g[:using] }.each do |group, inds|
23
23
  inds.each do |i|
24
- existing_columns[group][i["table"]] << i["columns"]
24
+ existing_columns[group][i[:table]] << i[:columns]
25
25
  end
26
26
  end
27
- indexes_by_table = indexes.group_by { |i| i["table"] }
27
+ indexes_by_table = indexes.group_by { |i| i[:table] }
28
28
 
29
29
  best_indexes.each do |_query, best_index|
30
30
  if best_index[:found]
@@ -38,15 +38,17 @@ module PgHero
38
38
  end
39
39
  end
40
40
  end
41
+ else
42
+ raise NotEnabled, "Suggested indexes not enabled"
41
43
  end
42
44
 
43
45
  best_indexes
44
46
  end
45
47
 
46
- def suggested_indexes(options = {})
48
+ def suggested_indexes(suggested_indexes_by_query: nil, **options)
47
49
  indexes = []
48
50
 
49
- (options[:suggested_indexes_by_query] || suggested_indexes_by_query(options)).select { |_s, i| i[:found] && !i[:covering_index] }.group_by { |_s, i| i[:index] }.each do |index, group|
51
+ (suggested_indexes_by_query || self.suggested_indexes_by_query(options)).select { |_s, i| i[:found] && !i[:covering_index] }.group_by { |_s, i| i[:index] }.each do |index, group|
50
52
  details = {}
51
53
  group.map(&:second).each do |g|
52
54
  details = details.except(:index).deep_merge(g)
@@ -57,25 +59,16 @@ module PgHero
57
59
  indexes.sort_by { |i| [i[:table], i[:columns]] }
58
60
  end
59
61
 
60
- def autoindex(options = {})
62
+ def autoindex(create: false)
61
63
  suggested_indexes.each do |index|
62
64
  p index
63
- if options[:create]
65
+ if create
64
66
  connection.execute("CREATE INDEX CONCURRENTLY ON #{quote_table_name(index[:table])} (#{index[:columns].map { |c| quote_table_name(c) }.join(",")})")
65
67
  end
66
68
  end
67
69
  end
68
70
 
69
- def autoindex_all(options = {})
70
- config["databases"].keys.each do |database|
71
- with(database) do
72
- puts "Autoindexing #{database}..."
73
- autoindex(options)
74
- end
75
- end
76
- end
77
-
78
- def best_index(statement, _options = {})
71
+ def best_index(statement)
79
72
  best_index_helper([statement])[statement]
80
73
  end
81
74
 
@@ -95,8 +88,8 @@ module PgHero
95
88
  # TODO get schema from query structure, then try search path
96
89
  schema = connection_model.connection_config[:schema] || "public"
97
90
  if tables.any?
98
- row_stats = Hash[table_stats(table: tables, schema: schema).map { |i| [i["table"], i["reltuples"]] }]
99
- col_stats = column_stats(table: tables, schema: schema).group_by { |i| i["table"] }
91
+ row_stats = Hash[table_stats(table: tables, schema: schema).map { |i| [i[:table], i[:estimated_rows]] }]
92
+ col_stats = column_stats(table: tables, schema: schema).group_by { |i| i[:table] }
100
93
  end
101
94
 
102
95
  # find best index based on query structure and column stats
@@ -117,7 +110,7 @@ module PgHero
117
110
  total_rows = row_stats[table].to_i
118
111
  index[:rows] = total_rows
119
112
 
120
- ranks = Hash[col_stats[table].to_a.map { |r| [r["column"], r] }]
113
+ ranks = Hash[col_stats[table].to_a.map { |r| [r[:column], r] }]
121
114
  columns = (where + sort).map { |c| c[:column] }.uniq
122
115
 
123
116
  if columns.any?
@@ -188,9 +181,7 @@ module PgHero
188
181
  return {error: "Too large"} if statement.to_s.length > 10000
189
182
 
190
183
  begin
191
- parsed_statement = PgQuery.parse(statement)
192
- v2 = parsed_statement.respond_to?(:tree)
193
- tree = v2 ? parsed_statement.tree : parsed_statement.parsetree
184
+ tree = PgQuery.parse(statement).tree
194
185
  rescue PgQuery::ParseError
195
186
  return {error: "Parse error"}
196
187
  end
@@ -201,27 +192,23 @@ module PgHero
201
192
  unless table
202
193
  error =
203
194
  case tree.keys.first
204
- when "InsertStmt", "INSERT INTO"
195
+ when "InsertStmt"
205
196
  "INSERT statement"
206
- when "VariableSetStmt", "SET"
197
+ when "VariableSetStmt"
207
198
  "SET statement"
208
199
  when "SelectStmt"
209
200
  if (tree["SelectStmt"]["fromClause"].first["JoinExpr"] rescue false)
210
201
  "JOIN not supported yet"
211
202
  end
212
- when "SELECT"
213
- if (tree["SELECT"]["fromClause"].first["JOINEXPR"] rescue false)
214
- "JOIN not supported yet"
215
- end
216
203
  end
217
204
  return {error: error || "Unknown structure"}
218
205
  end
219
206
 
220
207
  select = tree.values.first
221
- where = (select["whereClause"] ? parse_where(select["whereClause"], v2) : []) rescue nil
208
+ where = (select["whereClause"] ? parse_where(select["whereClause"]) : []) rescue nil
222
209
  return {error: "Unknown structure"} unless where
223
210
 
224
- sort = (select["sortClause"] ? parse_sort(select["sortClause"], v2) : []) rescue []
211
+ sort = (select["sortClause"] ? parse_sort(select["sortClause"]) : []) rescue []
225
212
 
226
213
  {table: table, where: where, sort: sort}
227
214
  end
@@ -235,22 +222,22 @@ module PgHero
235
222
  def row_estimates(stats, total_rows, rows_left, op)
236
223
  case op
237
224
  when "null"
238
- rows_left * stats["null_frac"].to_f
225
+ rows_left * stats[:null_frac].to_f
239
226
  when "not_null"
240
- rows_left * (1 - stats["null_frac"].to_f)
227
+ rows_left * (1 - stats[:null_frac].to_f)
241
228
  else
242
- rows_left *= (1 - stats["null_frac"].to_f)
229
+ rows_left *= (1 - stats[:null_frac].to_f)
243
230
  ret =
244
- if stats["n_distinct"].to_f == 0
231
+ if stats[:n_distinct].to_f == 0
245
232
  0
246
- elsif stats["n_distinct"].to_f < 0
233
+ elsif stats[:n_distinct].to_f < 0
247
234
  if total_rows > 0
248
- (-1 / stats["n_distinct"].to_f) * (rows_left / total_rows.to_f)
235
+ (-1 / stats[:n_distinct].to_f) * (rows_left / total_rows.to_f)
249
236
  else
250
237
  0
251
238
  end
252
239
  else
253
- rows_left / stats["n_distinct"].to_f
240
+ rows_left / stats[:n_distinct].to_f
254
241
  end
255
242
 
256
243
  case op
@@ -272,85 +259,39 @@ module PgHero
272
259
  tree["DeleteStmt"]["relation"]["RangeVar"]["relname"]
273
260
  when "UpdateStmt"
274
261
  tree["UpdateStmt"]["relation"]["RangeVar"]["relname"]
275
- when "SELECT"
276
- tree["SELECT"]["fromClause"].first["RANGEVAR"]["relname"]
277
- when "DELETE FROM"
278
- tree["DELETE FROM"]["relation"]["RANGEVAR"]["relname"]
279
- when "UPDATE"
280
- tree["UPDATE"]["relation"]["RANGEVAR"]["relname"]
281
262
  end
282
263
  end
283
264
 
284
265
  # TODO capture values
285
- def parse_where(tree, v2 = false)
286
- if v2
287
- aexpr = tree["A_Expr"]
266
+ def parse_where(tree)
267
+ aexpr = tree["A_Expr"]
288
268
 
289
- if tree["BoolExpr"]
290
- if tree["BoolExpr"]["boolop"] == 0
291
- tree["BoolExpr"]["args"].flat_map { |v| parse_where(v, v2) }
292
- else
293
- raise "Not Implemented"
294
- end
295
- elsif aexpr && ["=", "<>", ">", ">=", "<", "<=", "~~", "~~*", "BETWEEN"].include?(aexpr["name"].first["String"]["str"])
296
- [{column: aexpr["lexpr"]["ColumnRef"]["fields"].last["String"]["str"], op: aexpr["name"].first["String"]["str"]}]
297
- elsif tree["NullTest"]
298
- op = tree["NullTest"]["nulltesttype"] == 1 ? "not_null" : "null"
299
- [{column: tree["NullTest"]["arg"]["ColumnRef"]["fields"].last["String"]["str"], op: op}]
269
+ if tree["BoolExpr"]
270
+ if tree["BoolExpr"]["boolop"] == 0
271
+ tree["BoolExpr"]["args"].flat_map { |v| parse_where(v) }
300
272
  else
301
273
  raise "Not Implemented"
302
274
  end
275
+ elsif aexpr && ["=", "<>", ">", ">=", "<", "<=", "~~", "~~*", "BETWEEN"].include?(aexpr["name"].first["String"]["str"])
276
+ [{column: aexpr["lexpr"]["ColumnRef"]["fields"].last["String"]["str"], op: aexpr["name"].first["String"]["str"]}]
277
+ elsif tree["NullTest"]
278
+ op = tree["NullTest"]["nulltesttype"] == 1 ? "not_null" : "null"
279
+ [{column: tree["NullTest"]["arg"]["ColumnRef"]["fields"].last["String"]["str"], op: op}]
303
280
  else
304
- aexpr = tree["AEXPR"] || tree[nil]
305
-
306
- if tree["BOOLEXPR"]
307
- if tree["BOOLEXPR"]["boolop"] == 0
308
- tree["BOOLEXPR"]["args"].flat_map { |v| parse_where(v) }
309
- else
310
- raise "Not Implemented"
311
- end
312
- elsif tree["AEXPR AND"]
313
- left = parse_where(tree["AEXPR AND"]["lexpr"])
314
- right = parse_where(tree["AEXPR AND"]["rexpr"])
315
- if left && right
316
- left + right
317
- else
318
- raise "Not Implemented"
319
- end
320
- elsif aexpr && ["=", "<>", ">", ">=", "<", "<=", "~~", "~~*", "BETWEEN"].include?(aexpr["name"].first)
321
- [{column: aexpr["lexpr"]["COLUMNREF"]["fields"].last, op: aexpr["name"].first}]
322
- elsif tree["AEXPR IN"] && ["=", "<>"].include?(tree["AEXPR IN"]["name"].first)
323
- [{column: tree["AEXPR IN"]["lexpr"]["COLUMNREF"]["fields"].last, op: tree["AEXPR IN"]["name"].first}]
324
- elsif tree["NULLTEST"]
325
- op = tree["NULLTEST"]["nulltesttype"] == 1 ? "not_null" : "null"
326
- [{column: tree["NULLTEST"]["arg"]["COLUMNREF"]["fields"].last, op: op}]
327
- else
328
- raise "Not Implemented"
329
- end
281
+ raise "Not Implemented"
330
282
  end
331
283
  end
332
284
 
333
- def parse_sort(sort_clause, v2)
334
- if v2
335
- sort_clause.map do |v|
336
- {
337
- column: v["SortBy"]["node"]["ColumnRef"]["fields"].last["String"]["str"],
338
- direction: v["SortBy"]["sortby_dir"] == 2 ? "desc" : "asc"
339
- }
340
- end
341
- else
342
- sort_clause.map do |v|
343
- {
344
- column: v["SORTBY"]["node"]["COLUMNREF"]["fields"].last,
345
- direction: v["SORTBY"]["sortby_dir"] == 2 ? "desc" : "asc"
346
- }
347
- end
285
+ def parse_sort(sort_clause)
286
+ sort_clause.map do |v|
287
+ {
288
+ column: v["SortBy"]["node"]["ColumnRef"]["fields"].last["String"]["str"],
289
+ direction: v["SortBy"]["sortby_dir"] == 2 ? "desc" : "asc"
290
+ }
348
291
  end
349
292
  end
350
293
 
351
- def column_stats(options = {})
352
- schema = options[:schema]
353
- tables = options[:table] ? Array(options[:table]) : nil
294
+ def column_stats(schema: nil, table: nil)
354
295
  select_all <<-SQL
355
296
  SELECT
356
297
  schemaname AS schema,
@@ -361,8 +302,8 @@ module PgHero
361
302
  FROM
362
303
  pg_stats
363
304
  WHERE
364
- #{tables ? "tablename IN (#{tables.map { |t| quote(t) }.join(", ")})" : "1 = 1"}
365
- AND schemaname = #{quote(schema)}
305
+ schemaname = #{quote(schema)}
306
+ #{table ? "AND tablename IN (#{Array(table).map { |t| quote(t) }.join(", ")})" : ""}
366
307
  ORDER BY
367
308
  1, 2, 3
368
309
  SQL
@@ -1,27 +1,31 @@
1
1
  module PgHero
2
2
  module Methods
3
3
  module System
4
- def cpu_usage(options = {})
4
+ def cpu_usage(**options)
5
5
  rds_stats("CPUUtilization", options)
6
6
  end
7
7
 
8
- def connection_stats(options = {})
8
+ def connection_stats(**options)
9
9
  rds_stats("DatabaseConnections", options)
10
10
  end
11
11
 
12
- def replication_lag_stats(options = {})
12
+ def replication_lag_stats(**options)
13
13
  rds_stats("ReplicaLag", options)
14
14
  end
15
15
 
16
- def read_iops_stats(options = {})
16
+ def read_iops_stats(**options)
17
17
  rds_stats("ReadIOPS", options)
18
18
  end
19
19
 
20
- def write_iops_stats(options = {})
20
+ def write_iops_stats(**options)
21
21
  rds_stats("WriteIOPS", options)
22
22
  end
23
23
 
24
- def rds_stats(metric_name, options = {})
24
+ def free_space_stats(**options)
25
+ rds_stats("FreeStorageSpace", options)
26
+ end
27
+
28
+ def rds_stats(metric_name, duration: nil, period: nil, offset: nil)
25
29
  if system_stats_enabled?
26
30
  aws_options = {region: region}
27
31
  if access_key_id
@@ -36,9 +40,9 @@ module PgHero
36
40
  AWS::CloudWatch.new(aws_options).client
37
41
  end
38
42
 
39
- duration = (options[:duration] || 1.hour).to_i
40
- period = (options[:period] || 1.minute).to_i
41
- offset = (options[:offset] || 0).to_i
43
+ duration = (duration || 1.hour).to_i
44
+ period = (period || 1.minute).to_i
45
+ offset = (offset || 0).to_i
42
46
 
43
47
  end_time = (Time.now - offset)
44
48
  # ceil period
@@ -59,7 +63,7 @@ module PgHero
59
63
  end
60
64
  data
61
65
  else
62
- {}
66
+ raise NotEnabled, "System stats not enabled"
63
67
  end
64
68
  end
65
69
 
@@ -2,18 +2,19 @@ module PgHero
2
2
  module Methods
3
3
  module Tables
4
4
  def table_hit_rate
5
- select_all(<<-SQL
5
+ select_one(<<-SQL
6
6
  SELECT
7
7
  sum(heap_blks_hit) / nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0) AS rate
8
8
  FROM
9
9
  pg_statio_user_tables
10
10
  SQL
11
- ).first["rate"].to_f
11
+ )
12
12
  end
13
13
 
14
14
  def table_caching
15
15
  select_all <<-SQL
16
16
  SELECT
17
+ schemaname AS schema,
17
18
  relname AS table,
18
19
  CASE WHEN heap_blks_hit + heap_blks_read = 0 THEN
19
20
  0
@@ -32,7 +33,7 @@ module PgHero
32
33
  SELECT
33
34
  schemaname AS schema,
34
35
  relname AS table,
35
- n_live_tup rows_in_table
36
+ n_live_tup AS estimated_rows
36
37
  FROM
37
38
  pg_stat_user_tables
38
39
  WHERE
@@ -43,14 +44,13 @@ module PgHero
43
44
  SQL
44
45
  end
45
46
 
46
- def table_stats(options = {})
47
- schema = options[:schema]
48
- tables = options[:table] ? Array(options[:table]) : nil
47
+ def table_stats(schema: nil, table: nil)
49
48
  select_all <<-SQL
50
49
  SELECT
51
50
  nspname AS schema,
52
51
  relname AS table,
53
- reltuples::bigint
52
+ reltuples::bigint AS estimated_rows,
53
+ pg_total_relation_size(pg_class.oid) AS size_bytes
54
54
  FROM
55
55
  pg_class
56
56
  INNER JOIN
@@ -58,7 +58,7 @@ module PgHero
58
58
  WHERE
59
59
  relkind = 'r'
60
60
  #{schema ? "AND nspname = #{quote(schema)}" : nil}
61
- #{tables ? "AND relname IN (#{tables.map { |t| quote(t) }.join(", ")})" : nil}
61
+ #{table ? "AND relname IN (#{Array(table).map { |t| quote(t) }.join(", ")})" : nil}
62
62
  ORDER BY
63
63
  1, 2
64
64
  SQL
@@ -1,10 +1,9 @@
1
1
  module PgHero
2
2
  module Methods
3
3
  module Users
4
- def create_user(user, options = {})
5
- password = options[:password] || random_password
6
- schema = options[:schema] || "public"
7
- database = options[:database] || connection_model.connection_config[:database]
4
+ def create_user(user, password: nil, schema: "public", database: nil, readonly: false, tables: nil)
5
+ password ||= random_password
6
+ database ||= connection_model.connection_config[:database]
8
7
 
9
8
  commands =
10
9
  [
@@ -12,16 +11,16 @@ module PgHero
12
11
  "GRANT CONNECT ON DATABASE #{database} TO #{user}",
13
12
  "GRANT USAGE ON SCHEMA #{schema} TO #{user}"
14
13
  ]
15
- if options[:readonly]
16
- if options[:tables]
17
- commands.concat table_grant_commands("SELECT", options[:tables], user)
14
+ if readonly
15
+ if tables
16
+ commands.concat table_grant_commands("SELECT", tables, user)
18
17
  else
19
18
  commands << "GRANT SELECT ON ALL TABLES IN SCHEMA #{schema} TO #{user}"
20
19
  commands << "ALTER DEFAULT PRIVILEGES IN SCHEMA #{schema} GRANT SELECT ON TABLES TO #{user}"
21
20
  end
22
21
  else
23
- if options[:tables]
24
- commands.concat table_grant_commands("ALL PRIVILEGES", options[:tables], user)
22
+ if tables
23
+ commands.concat table_grant_commands("ALL PRIVILEGES", tables, user)
25
24
  else
26
25
  commands << "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA #{schema} TO #{user}"
27
26
  commands << "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA #{schema} TO #{user}"
@@ -40,9 +39,8 @@ module PgHero
40
39
  {password: password}
41
40
  end
42
41
 
43
- def drop_user(user, options = {})
44
- schema = options[:schema] || "public"
45
- database = options[:database] || connection_model.connection_config[:database]
42
+ def drop_user(user, schema: "public", database: nil)
43
+ database ||= connection_model.connection_config[:database]
46
44
 
47
45
  # thanks shiftb
48
46
  commands =