pghero 1.1.4 → 1.2.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.

@@ -4,6 +4,9 @@
4
4
  <h1>CPU</h1>
5
5
  <div style="margin-bottom: 20px;"><%= line_chart cpu_usage_path, max: 100, colors: ["#5bc0de"], library: {pointSize: 0, lineWidth: 5} %></div>
6
6
 
7
+ <h1>Load</h1>
8
+ <div style="margin-bottom: 20px;"><%= line_chart load_stats_path, colors: ["#5bc0de", "#d9534f"], library: {pointSize: 0, lineWidth: 5} %></div>
9
+
7
10
  <h1>Connections</h1>
8
11
  <div style="margin-bottom: 20px;"><%= line_chart connection_stats_path, colors: ["#5bc0de"], library: {pointSize: 0, lineWidth: 5} %></div>
9
12
 
@@ -8,9 +8,11 @@ PgHero::Engine.routes.draw do
8
8
  get "cpu_usage", to: "home#cpu_usage"
9
9
  get "connection_stats", to: "home#connection_stats"
10
10
  get "replication_lag_stats", to: "home#replication_lag_stats"
11
+ get "load_stats", to: "home#load_stats"
11
12
  get "explain", to: "home#explain"
12
13
  get "tune", to: "home#tune"
13
14
  get "connections", to: "home#connections"
15
+ get "maintenance", to: "home#maintenance"
14
16
  post "kill", to: "home#kill"
15
17
  post "kill_long_running_queries", to: "home#kill_long_running_queries"
16
18
  post "kill_all", to: "home#kill_all"
@@ -18,6 +18,16 @@ mount PgHero::Engine, at: "pghero"
18
18
 
19
19
  Be sure to [secure the dashboard](#security) in production.
20
20
 
21
+ ### Suggested Indexes
22
+
23
+ PgHero can suggest indexes to add. To enable, add to your Gemfile:
24
+
25
+ ```ruby
26
+ gem 'pg_query'
27
+ ```
28
+
29
+ and make sure [query stats](#query-stats) are enabled. Read about how it works [here](Suggested-Indexes.md).
30
+
21
31
  ## Insights
22
32
 
23
33
  ```ruby
@@ -55,6 +65,13 @@ PgHero.query_stats
55
65
  PgHero.slow_queries
56
66
  ```
57
67
 
68
+ Suggested indexes
69
+
70
+ ```ruby
71
+ PgHero.suggested_indexes
72
+ PgHero.best_index(query)
73
+ ```
74
+
58
75
  Security
59
76
 
60
77
  ```ruby
@@ -0,0 +1,19 @@
1
+ # How PgHero Suggests Indexes
2
+
3
+ 1. Get the most time-consuming queries from [pg_stat_statements](http://www.postgresql.org/docs/9.3/static/pgstatstatements.html).
4
+
5
+ 2. Parse queries with [pg_query](https://github.com/lfittl/pg_query). Look for a single table with a `WHERE` clause that consists of only `=`, `IN`, `IS NULL` or `IS NOT NULL` and/or an `ORDER BY` clause.
6
+
7
+ 3. Use the [pg_stats](http://www.postgresql.org/docs/current/static/view-pg-stats.html) view to get estimates about distinct rows and percent of `NULL` values for each column.
8
+
9
+ 4. For each column in the `WHERE` clause, sort by the highest [cardinality](https://en.wikipedia.org/wiki/Cardinality_(SQL_statements)) (most unique values). This allows the database to narrow its search the fastest. Perform [row estimation](http://www.postgresql.org/docs/current/static/row-estimation-examples.html) to get the expected number of rows as we add columns to the index.
10
+
11
+ 5. Continue this process with columns in the `ORDER BY` clause.
12
+
13
+ 6. To make sure we don’t add useless columns, stop once we narrow it down to 50 rows in steps 5 or 6. Also, recheck the last columns to make sure they add value.
14
+
15
+ 7. Profit :moneybag:
16
+
17
+ ## TODO
18
+
19
+ - examples
@@ -17,15 +17,25 @@ module PgHero
17
17
  end
18
18
 
19
19
  class << self
20
- attr_accessor :long_running_query_sec, :slow_query_ms, :slow_query_calls, :total_connections_threshold, :env
20
+ attr_accessor :long_running_query_sec, :slow_query_ms, :slow_query_calls, :total_connections_threshold, :cache_hit_rate_threshold, :env, :show_migrations
21
21
  end
22
22
  self.long_running_query_sec = (ENV["PGHERO_LONG_RUNNING_QUERY_SEC"] || 60).to_i
23
23
  self.slow_query_ms = (ENV["PGHERO_SLOW_QUERY_MS"] || 20).to_i
24
24
  self.slow_query_calls = (ENV["PGHERO_SLOW_QUERY_CALLS"] || 100).to_i
25
25
  self.total_connections_threshold = (ENV["PGHERO_TOTAL_CONNECTIONS_THRESHOLD"] || 100).to_i
26
+ self.cache_hit_rate_threshold = 99
26
27
  self.env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
28
+ self.show_migrations = true
27
29
 
28
30
  class << self
31
+ def time_zone=(time_zone)
32
+ @time_zone = time_zone.is_a?(ActiveSupport::TimeZone) ? time_zone : ActiveSupport::TimeZone[time_zone.to_s]
33
+ end
34
+
35
+ def time_zone
36
+ @time_zone || Time.zone
37
+ end
38
+
29
39
  def config
30
40
  Thread.current[:pghero_config] ||= begin
31
41
  path = "config/pghero.yml"
@@ -167,6 +177,39 @@ module PgHero
167
177
  ).first["rate"].to_f
168
178
  end
169
179
 
180
+ def table_caching
181
+ select_all <<-SQL
182
+ SELECT
183
+ relname AS table,
184
+ CASE WHEN heap_blks_hit + heap_blks_read = 0 THEN
185
+ 0
186
+ ELSE
187
+ ROUND(1.0 * heap_blks_hit / (heap_blks_hit + heap_blks_read), 2)
188
+ END AS hit_rate
189
+ FROM
190
+ pg_statio_user_tables
191
+ ORDER BY
192
+ 2 DESC, 1
193
+ SQL
194
+ end
195
+
196
+ def index_caching
197
+ select_all <<-SQL
198
+ SELECT
199
+ indexrelname AS index,
200
+ relname AS table,
201
+ CASE WHEN idx_blks_hit + idx_blks_read = 0 THEN
202
+ 0
203
+ ELSE
204
+ ROUND(1.0 * idx_blks_hit / (idx_blks_hit + idx_blks_read), 2)
205
+ END AS hit_rate
206
+ FROM
207
+ pg_statio_user_indexes
208
+ ORDER BY
209
+ 3 DESC, 1
210
+ SQL
211
+ end
212
+
170
213
  def index_usage
171
214
  select_all <<-SQL
172
215
  SELECT
@@ -288,24 +331,44 @@ module PgHero
288
331
  select_all("SELECT COUNT(*) FROM pg_stat_activity WHERE pid <> pg_backend_pid()").first["count"].to_i
289
332
  end
290
333
 
291
- def connection_sources
292
- select_all <<-SQL
293
- SELECT
294
- application_name AS source,
295
- client_addr AS ip,
296
- COUNT(*) AS total_connections
297
- FROM
298
- pg_stat_activity
299
- WHERE
300
- pid <> pg_backend_pid()
301
- GROUP BY
302
- application_name,
303
- ip
304
- ORDER BY
305
- COUNT(*) DESC,
306
- application_name ASC,
307
- client_addr ASC
308
- SQL
334
+ def connection_sources(options = {})
335
+ if options[:by_database]
336
+ select_all <<-SQL
337
+ SELECT
338
+ application_name AS source,
339
+ client_addr AS ip,
340
+ datname AS database,
341
+ COUNT(*) AS total_connections
342
+ FROM
343
+ pg_stat_activity
344
+ WHERE
345
+ pid <> pg_backend_pid()
346
+ GROUP BY
347
+ 1, 2, 3
348
+ ORDER BY
349
+ COUNT(*) DESC,
350
+ application_name ASC,
351
+ client_addr ASC
352
+ SQL
353
+ else
354
+ select_all <<-SQL
355
+ SELECT
356
+ application_name AS source,
357
+ client_addr AS ip,
358
+ COUNT(*) AS total_connections
359
+ FROM
360
+ pg_stat_activity
361
+ WHERE
362
+ pid <> pg_backend_pid()
363
+ GROUP BY
364
+ application_name,
365
+ ip
366
+ ORDER BY
367
+ COUNT(*) DESC,
368
+ application_name ASC,
369
+ client_addr ASC
370
+ SQL
371
+ end
309
372
  end
310
373
 
311
374
  # http://www.postgresql.org/docs/9.1/static/routine-vacuuming.html#VACUUM-FOR-WRAPAROUND
@@ -332,7 +395,7 @@ module PgHero
332
395
  def autovacuum_danger
333
396
  select_all <<-SQL
334
397
  SELECT
335
- c.oid::regclass as table,
398
+ c.oid::regclass::text as table,
336
399
  (SELECT setting FROM pg_settings WHERE name = 'autovacuum_freeze_max_age')::int -
337
400
  GREATEST(AGE(c.relfrozenxid), AGE(t.relfrozenxid)) AS transactions_before_autovacuum
338
401
  FROM
@@ -347,6 +410,21 @@ module PgHero
347
410
  SQL
348
411
  end
349
412
 
413
+ def maintenance_info
414
+ select_all <<-SQL
415
+ SELECT
416
+ relname AS table,
417
+ last_vacuum,
418
+ last_autovacuum,
419
+ last_analyze,
420
+ last_autoanalyze
421
+ FROM
422
+ pg_stat_user_tables
423
+ ORDER BY
424
+ relname ASC
425
+ SQL
426
+ end
427
+
350
428
  def kill(pid)
351
429
  execute("SELECT pg_terminate_backend(#{pid.to_i})").first["pg_terminate_backend"] == "t"
352
430
  end
@@ -387,11 +465,19 @@ module PgHero
387
465
  query_stats << value
388
466
  end
389
467
  sort = options[:sort] || "total_minutes"
390
- query_stats.sort_by { |q| -q[sort] }.first(100)
468
+ query_stats = query_stats.sort_by { |q| -q[sort] }.first(100)
469
+ if options[:min_average_time]
470
+ query_stats.reject! { |q| q["average_time"].to_f < options[:min_average_time] }
471
+ end
472
+ if options[:min_calls]
473
+ query_stats.reject! { |q| q["calls"].to_i < options[:min_calls] }
474
+ end
475
+ query_stats
391
476
  end
392
477
 
393
478
  def slow_queries(options = {})
394
- query_stats(options).select { |q| q["calls"].to_i >= slow_query_calls.to_i && q["average_time"].to_i >= slow_query_ms.to_i }
479
+ query_stats = options[:query_stats] || self.query_stats(options.except(:query_stats))
480
+ query_stats.select { |q| q["calls"].to_i >= slow_query_calls.to_i && q["average_time"].to_i >= slow_query_ms.to_i }
395
481
  end
396
482
 
397
483
  def query_stats_available?
@@ -495,6 +581,14 @@ module PgHero
495
581
  rds_stats("ReplicaLag")
496
582
  end
497
583
 
584
+ def read_iops_stats
585
+ rds_stats("ReadIOPS")
586
+ end
587
+
588
+ def write_iops_stats
589
+ rds_stats("WriteIOPS")
590
+ end
591
+
498
592
  def rds_stats(metric_name)
499
593
  if system_stats_enabled?
500
594
  client =
@@ -655,8 +749,337 @@ module PgHero
655
749
  select_all("SELECT EXTRACT(EPOCH FROM NOW() - pg_last_xact_replay_timestamp()) AS replication_lag").first["replication_lag"].to_f
656
750
  end
657
751
 
752
+ # TODO parse array properly
753
+ # http://stackoverflow.com/questions/2204058/list-columns-with-indexes-in-postgresql
754
+ def indexes
755
+ select_all( <<-SQL
756
+ SELECT
757
+ t.relname AS table,
758
+ ix.relname AS name,
759
+ regexp_replace(pg_get_indexdef(indexrelid), '.*\\((.*)\\)', '\\1') AS columns,
760
+ regexp_replace(pg_get_indexdef(indexrelid), '.* USING (.*) \\(.*', '\\1') AS using,
761
+ indisunique AS unique,
762
+ indisprimary AS primary,
763
+ indisvalid AS valid,
764
+ indexprs::text,
765
+ indpred::text
766
+ FROM
767
+ pg_index i
768
+ INNER JOIN
769
+ pg_class t ON t.oid = i.indrelid
770
+ INNER JOIN
771
+ pg_class ix ON ix.oid = i.indexrelid
772
+ ORDER BY
773
+ 1, 2
774
+ SQL
775
+ ).map { |v| v["columns"] = v["columns"].split(", "); v }
776
+ end
777
+
778
+ def duplicate_indexes
779
+ indexes = []
780
+
781
+ indexes_by_table = self.indexes.group_by { |i| i["table"] }
782
+ indexes_by_table.values.flatten.select { |i| i["primary"] == "f" && i["unique"] == "f" && !i["indexprs"] && !i["indpred"] && i["valid"] == "t" }.each do |index|
783
+ covering_index = indexes_by_table[index["table"]].find { |i| index_covers?(i["columns"], index["columns"]) && i["using"] == index["using"] && i["name"] != index["name"] && i["valid"] == "t" }
784
+ if covering_index
785
+ indexes << {"unneeded_index" => index, "covering_index" => covering_index}
786
+ end
787
+ end
788
+
789
+ indexes.sort_by { |i| ui = i["unneeded_index"]; [ui["table"], ui["columns"]] }
790
+ end
791
+
792
+ def suggested_indexes_enabled?
793
+ defined?(PgQuery) && query_stats_enabled?
794
+ end
795
+
796
+ # TODO clean this mess
797
+ def suggested_indexes_by_query(options = {})
798
+ best_indexes = {}
799
+
800
+ if suggested_indexes_enabled?
801
+ # get most time-consuming queries
802
+ queries = options[:queries] || (options[:query_stats] || self.query_stats(historical: true, start_at: 24.hours.ago)).map { |qs| qs["query"] }
803
+
804
+ # get best indexes for queries
805
+ best_indexes = best_index_helper(queries)
806
+
807
+ if best_indexes.any?
808
+ existing_columns = Hash.new { |hash, key| hash[key] = [] }
809
+ self.indexes.each do |i|
810
+ existing_columns[i["table"]] << i["columns"]
811
+ end
812
+
813
+ best_indexes.each do |query, best_index|
814
+ if best_index[:found]
815
+ index = best_index[:index]
816
+ covering_index = existing_columns[index[:table]].find { |e| index_covers?(e, index[:columns]) }
817
+ if covering_index
818
+ best_index[:covering_index] = covering_index
819
+ best_index[:explanation] = "Covered by index on (#{covering_index.join(", ")})"
820
+ end
821
+ end
822
+ end
823
+ end
824
+ end
825
+
826
+ best_indexes
827
+ end
828
+
829
+ def suggested_indexes(options = {})
830
+ indexes = []
831
+
832
+ (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|
833
+ indexes << index.merge(queries: group.map(&:first))
834
+ end
835
+
836
+ indexes.sort_by { |i| [i[:table], i[:columns]] }
837
+ end
838
+
839
+ def autoindex(options = {})
840
+ suggested_indexes.each do |index|
841
+ p index
842
+ if options[:create]
843
+ connection.execute("CREATE INDEX CONCURRENTLY ON #{quote_table_name(index[:table])} (#{index[:columns].map { |c| quote_table_name(c) }.join(",")})")
844
+ end
845
+ end
846
+ end
847
+
848
+ def autoindex_all(options = {})
849
+ config["databases"].keys.each do |database|
850
+ with(database) do
851
+ puts "Autoindexing #{database}..."
852
+ autoindex(options)
853
+ end
854
+ end
855
+ end
856
+
857
+ def best_index(statement, options = {})
858
+ best_index_helper([statement])[statement]
859
+ end
860
+
861
+ def column_stats(options = {})
862
+ tables = options[:table] ? Array(options[:table]) : nil
863
+ select_all <<-SQL
864
+ SELECT
865
+ tablename AS table,
866
+ attname AS column,
867
+ null_frac,
868
+ n_distinct,
869
+ n_live_tup
870
+ FROM
871
+ pg_stats
872
+ INNER JOIN
873
+ pg_class ON pg_class.relname = pg_stats.tablename
874
+ INNER JOIN
875
+ pg_stat_user_tables ON pg_class.relname = pg_stat_user_tables.relname
876
+ WHERE
877
+ #{tables ? "pg_class.relname IN (#{tables.map { |t| quote(t) }.join(", ")})" : "1 = 1"}
878
+ ORDER BY
879
+ 1, 2
880
+ SQL
881
+ end
882
+
658
883
  private
659
884
 
885
+ def best_index_helper(statements)
886
+ indexes = {}
887
+
888
+ # see if this is a query we understand and can use
889
+ parts = {}
890
+ statements.each do |statement|
891
+ parts[statement] = best_index_structure(statement)
892
+ end
893
+
894
+ # get stats about columns for relevant tables
895
+ tables = parts.values.map { |t| t[:table] }.uniq
896
+ if tables.any?
897
+ column_stats = self.column_stats(table: tables).group_by { |i| i["table"] }
898
+ end
899
+
900
+ # find best index based on query structure and column stats
901
+ parts.each do |statement, structure|
902
+ index = {found: false}
903
+
904
+ if structure[:error]
905
+ index[:explanation] = structure[:error]
906
+ elsif structure[:table].start_with?("pg_")
907
+ index[:explanation] = "System table"
908
+ else
909
+ index[:structure] = structure
910
+
911
+ table = structure[:table]
912
+ where = structure[:where]
913
+ sort = structure[:sort]
914
+
915
+ ranks = Hash[column_stats[table].to_a.map { |r| [r["column"], r] }]
916
+
917
+ columns = (where + sort).map { |c| c[:column] }.uniq
918
+
919
+ if columns.any? && columns.all? { |c| ranks[c] }
920
+ first_desc = sort.index { |c| c[:direction] == "desc" }
921
+ if first_desc
922
+ sort = sort.first(first_desc + 1)
923
+ end
924
+ where = where.sort_by { |c| [row_estimates(ranks[c[:column]], nil, c[:op]), c[:column]] } + sort
925
+
926
+ index[:row_estimates] = Hash[where.map { |c| [c[:column], row_estimates(ranks[c[:column]], nil, c[:op]).round] }]
927
+
928
+ rows_left = ranks[where.first[:column]]["n_live_tup"].to_i
929
+ index[:rows] = rows_left
930
+
931
+ # no index needed if less than 500 rows
932
+ if rows_left >= 500
933
+
934
+ # if most values are unique, no need to index others
935
+ final_where = []
936
+ prev_rows_left = [rows_left]
937
+ where.each do |c|
938
+ next if final_where.include?(c[:column])
939
+ final_where << c[:column]
940
+ rows_left = row_estimates(ranks[c[:column]], rows_left, c[:op])
941
+ prev_rows_left << rows_left
942
+ if rows_left < 50
943
+ break
944
+ end
945
+ end
946
+
947
+ index[:row_progression] = prev_rows_left.map(&:round)
948
+
949
+ # if the last indexes don't give us much, don't include
950
+ if prev_rows_left.last > 50
951
+ prev_rows_left.reverse!
952
+ (prev_rows_left.size - 1).times do |i|
953
+ if prev_rows_left[i] > prev_rows_left[i + 1] * 0.1
954
+ final_where.pop
955
+ else
956
+ break
957
+ end
958
+ end
959
+ end
960
+
961
+ if final_where.any?
962
+ index[:found] = true
963
+ index[:index] = {table: table, columns: final_where}
964
+ end
965
+ else
966
+ index[:explanation] = "No index needed if less than 500 rows"
967
+ end
968
+ else
969
+ index[:explanation] = "No columns to index"
970
+ end
971
+ end
972
+
973
+ indexes[statement] = index
974
+ end
975
+
976
+ indexes
977
+ end
978
+
979
+ def best_index_structure(statement)
980
+ begin
981
+ tree = PgQuery.parse(statement).parsetree
982
+ rescue PgQuery::ParseError
983
+ return {error: "Parse error"}
984
+ end
985
+ return {error: "Unknown structure"} unless tree.size == 1
986
+
987
+ tree = tree.first
988
+ table = parse_table(tree) rescue nil
989
+ unless table
990
+ error =
991
+ case tree.keys.first
992
+ when "INSERT INTO"
993
+ "INSERT statement"
994
+ when "SET"
995
+ "SET statement"
996
+ else
997
+ "Unknown structure"
998
+ end
999
+ return {error: error}
1000
+ end
1001
+
1002
+ select = tree["SELECT"] || tree["DELETE FROM"] || tree["UPDATE"]
1003
+ where = (select["whereClause"] ? parse_where(select["whereClause"]) : []) rescue nil
1004
+ return {error: "Unknown structure"} unless where
1005
+
1006
+ sort = (select["sortClause"] ? parse_sort(select["sortClause"]) : []) rescue nil
1007
+ return {error: "Unknown structure"} unless sort
1008
+
1009
+ {table: table, where: where, sort: sort}
1010
+ end
1011
+
1012
+ def index_covers?(indexed_columns, columns)
1013
+ indexed_columns.first(columns.size) == columns
1014
+ end
1015
+
1016
+ # TODO better row estimation
1017
+ # http://www.postgresql.org/docs/current/static/row-estimation-examples.html
1018
+ def row_estimates(stats, rows_left = nil, op = nil)
1019
+ rows_left ||= stats["n_live_tup"].to_i
1020
+ case op
1021
+ when "null"
1022
+ rows_left * stats["null_frac"].to_f
1023
+ when "not_null"
1024
+ rows_left * (1 - stats["null_frac"].to_f)
1025
+ else
1026
+ rows_left *= (1 - stats["null_frac"].to_f)
1027
+ if stats["n_distinct"].to_f == 0
1028
+ 0
1029
+ elsif stats["n_distinct"].to_f < 0
1030
+ if stats["n_live_tup"].to_i > 0
1031
+ (-1 / stats["n_distinct"].to_f) * (rows_left / stats["n_live_tup"].to_f)
1032
+ else
1033
+ 0
1034
+ end
1035
+ else
1036
+ rows_left / stats["n_distinct"].to_f
1037
+ end
1038
+ end
1039
+ end
1040
+
1041
+ def parse_table(tree)
1042
+ case tree.keys.first
1043
+ when "SELECT"
1044
+ tree["SELECT"]["fromClause"].first["RANGEVAR"]["relname"]
1045
+ when "DELETE FROM"
1046
+ tree["DELETE FROM"]["relation"]["RANGEVAR"]["relname"]
1047
+ when "UPDATE"
1048
+ tree["UPDATE"]["relation"]["RANGEVAR"]["relname"]
1049
+ else
1050
+ nil
1051
+ end
1052
+ end
1053
+
1054
+ # TODO capture values
1055
+ def parse_where(tree)
1056
+ if tree["AEXPR AND"]
1057
+ left = parse_where(tree["AEXPR AND"]["lexpr"])
1058
+ right = parse_where(tree["AEXPR AND"]["rexpr"])
1059
+ if left && right
1060
+ left + right
1061
+ end
1062
+ elsif tree["AEXPR"] && ["="].include?(tree["AEXPR"]["name"].first)
1063
+ [{column: tree["AEXPR"]["lexpr"]["COLUMNREF"]["fields"].last, op: tree["AEXPR"]["name"].first}]
1064
+ elsif tree["AEXPR IN"] && tree["AEXPR IN"]["name"].first == "="
1065
+ [{column: tree["AEXPR IN"]["lexpr"]["COLUMNREF"]["fields"].last, op: "in"}]
1066
+ elsif tree["NULLTEST"]
1067
+ op = tree["NULLTEST"]["nulltesttype"] == 1 ? "not_null" : "null"
1068
+ [{column: tree["NULLTEST"]["arg"]["COLUMNREF"]["fields"].last, op: op}]
1069
+ else
1070
+ nil
1071
+ end
1072
+ end
1073
+
1074
+ def parse_sort(sort_clause)
1075
+ sort_clause.map do |v|
1076
+ {
1077
+ column: v["SORTBY"]["node"]["COLUMNREF"]["fields"].last,
1078
+ direction: v["SORTBY"]["sortby_dir"] == 2 ? "desc" : "asc"
1079
+ }
1080
+ end
1081
+ end
1082
+
660
1083
  def table_grant_commands(privilege, tables, user)
661
1084
  tables.map do |table|
662
1085
  "GRANT #{privilege} ON TABLE #{table} TO #{user}"