pghero_fork 2.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +391 -0
- data/CONTRIBUTING.md +42 -0
- data/LICENSE.txt +22 -0
- data/README.md +3 -0
- data/app/assets/images/pghero/favicon.png +0 -0
- data/app/assets/javascripts/pghero/Chart.bundle.js +20755 -0
- data/app/assets/javascripts/pghero/application.js +158 -0
- data/app/assets/javascripts/pghero/chartkick.js +2436 -0
- data/app/assets/javascripts/pghero/highlight.pack.js +2 -0
- data/app/assets/javascripts/pghero/jquery.js +10872 -0
- data/app/assets/javascripts/pghero/nouislider.js +2672 -0
- data/app/assets/stylesheets/pghero/application.css +514 -0
- data/app/assets/stylesheets/pghero/arduino-light.css +86 -0
- data/app/assets/stylesheets/pghero/nouislider.css +310 -0
- data/app/controllers/pg_hero/home_controller.rb +449 -0
- data/app/helpers/pg_hero/home_helper.rb +30 -0
- data/app/views/layouts/pg_hero/application.html.erb +68 -0
- data/app/views/pg_hero/home/_connections_table.html.erb +16 -0
- data/app/views/pg_hero/home/_live_queries_table.html.erb +51 -0
- data/app/views/pg_hero/home/_queries_table.html.erb +72 -0
- data/app/views/pg_hero/home/_query_stats_slider.html.erb +16 -0
- data/app/views/pg_hero/home/_suggested_index.html.erb +18 -0
- data/app/views/pg_hero/home/connections.html.erb +32 -0
- data/app/views/pg_hero/home/explain.html.erb +27 -0
- data/app/views/pg_hero/home/index.html.erb +518 -0
- data/app/views/pg_hero/home/index_bloat.html.erb +72 -0
- data/app/views/pg_hero/home/live_queries.html.erb +11 -0
- data/app/views/pg_hero/home/maintenance.html.erb +55 -0
- data/app/views/pg_hero/home/queries.html.erb +33 -0
- data/app/views/pg_hero/home/relation_space.html.erb +14 -0
- data/app/views/pg_hero/home/show_query.html.erb +106 -0
- data/app/views/pg_hero/home/space.html.erb +83 -0
- data/app/views/pg_hero/home/system.html.erb +34 -0
- data/app/views/pg_hero/home/tune.html.erb +53 -0
- data/config/routes.rb +32 -0
- data/lib/generators/pghero/config_generator.rb +13 -0
- data/lib/generators/pghero/query_stats_generator.rb +18 -0
- data/lib/generators/pghero/space_stats_generator.rb +18 -0
- data/lib/generators/pghero/templates/config.yml.tt +46 -0
- data/lib/generators/pghero/templates/query_stats.rb.tt +15 -0
- data/lib/generators/pghero/templates/space_stats.rb.tt +13 -0
- data/lib/pghero.rb +246 -0
- data/lib/pghero/connection.rb +5 -0
- data/lib/pghero/database.rb +175 -0
- data/lib/pghero/engine.rb +16 -0
- data/lib/pghero/methods/basic.rb +160 -0
- data/lib/pghero/methods/connections.rb +77 -0
- data/lib/pghero/methods/constraints.rb +30 -0
- data/lib/pghero/methods/explain.rb +29 -0
- data/lib/pghero/methods/indexes.rb +332 -0
- data/lib/pghero/methods/kill.rb +28 -0
- data/lib/pghero/methods/maintenance.rb +93 -0
- data/lib/pghero/methods/queries.rb +75 -0
- data/lib/pghero/methods/query_stats.rb +349 -0
- data/lib/pghero/methods/replication.rb +74 -0
- data/lib/pghero/methods/sequences.rb +124 -0
- data/lib/pghero/methods/settings.rb +37 -0
- data/lib/pghero/methods/space.rb +141 -0
- data/lib/pghero/methods/suggested_indexes.rb +329 -0
- data/lib/pghero/methods/system.rb +287 -0
- data/lib/pghero/methods/tables.rb +68 -0
- data/lib/pghero/methods/users.rb +87 -0
- data/lib/pghero/query_stats.rb +5 -0
- data/lib/pghero/space_stats.rb +5 -0
- data/lib/pghero/stats.rb +6 -0
- data/lib/pghero/version.rb +3 -0
- data/lib/tasks/pghero.rake +27 -0
- data/licenses/LICENSE-chart.js.txt +9 -0
- data/licenses/LICENSE-chartkick.js.txt +22 -0
- data/licenses/LICENSE-highlight.js.txt +29 -0
- data/licenses/LICENSE-jquery.txt +20 -0
- data/licenses/LICENSE-moment.txt +22 -0
- data/licenses/LICENSE-nouislider.txt +21 -0
- metadata +130 -0
@@ -0,0 +1,74 @@
|
|
1
|
+
module PgHero
|
2
|
+
module Methods
|
3
|
+
module Replication
|
4
|
+
def replica?
|
5
|
+
unless defined?(@replica)
|
6
|
+
@replica = select_one("SELECT pg_is_in_recovery()")
|
7
|
+
end
|
8
|
+
@replica
|
9
|
+
end
|
10
|
+
|
11
|
+
# https://www.postgresql.org/message-id/CADKbJJWz9M0swPT3oqe8f9+tfD4-F54uE6Xtkh4nERpVsQnjnw@mail.gmail.com
|
12
|
+
def replication_lag
|
13
|
+
with_feature_support(:replication_lag) do
|
14
|
+
lag_condition =
|
15
|
+
if server_version_num >= 100000
|
16
|
+
"pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn()"
|
17
|
+
else
|
18
|
+
"pg_last_xlog_receive_location() = pg_last_xlog_replay_location()"
|
19
|
+
end
|
20
|
+
|
21
|
+
select_one <<-SQL
|
22
|
+
SELECT
|
23
|
+
CASE
|
24
|
+
WHEN NOT pg_is_in_recovery() OR #{lag_condition} THEN 0
|
25
|
+
ELSE EXTRACT (EPOCH FROM NOW() - pg_last_xact_replay_timestamp())
|
26
|
+
END
|
27
|
+
AS replication_lag
|
28
|
+
SQL
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def replication_slots
|
33
|
+
if server_version_num >= 90400
|
34
|
+
with_feature_support(:replication_slots, []) do
|
35
|
+
select_all <<-SQL
|
36
|
+
SELECT
|
37
|
+
slot_name,
|
38
|
+
database,
|
39
|
+
active
|
40
|
+
FROM pg_replication_slots
|
41
|
+
SQL
|
42
|
+
end
|
43
|
+
else
|
44
|
+
[]
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def replicating?
|
49
|
+
with_feature_support(:replicating?, false) do
|
50
|
+
select_all("SELECT state FROM pg_stat_replication").any?
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def feature_support
|
57
|
+
@feature_support ||= {}
|
58
|
+
end
|
59
|
+
|
60
|
+
def with_feature_support(cache_key, default = nil)
|
61
|
+
# cache feature support to minimize errors in logs
|
62
|
+
return default if feature_support[cache_key] == false
|
63
|
+
|
64
|
+
begin
|
65
|
+
yield
|
66
|
+
rescue ActiveRecord::StatementInvalid => e
|
67
|
+
raise unless e.message.start_with?("PG::FeatureNotSupported:")
|
68
|
+
feature_support[cache_key] = false
|
69
|
+
default
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
module PgHero
|
2
|
+
module Methods
|
3
|
+
module Sequences
|
4
|
+
def sequences
|
5
|
+
# get columns with default values
|
6
|
+
# use pg_get_expr to get correct default value
|
7
|
+
# it's what information_schema.columns uses
|
8
|
+
# also, exclude temporary tables to prevent error
|
9
|
+
# when accessing across sessions
|
10
|
+
sequences = select_all <<-SQL
|
11
|
+
SELECT
|
12
|
+
n.nspname AS table_schema,
|
13
|
+
c.relname AS table,
|
14
|
+
attname AS column,
|
15
|
+
format_type(a.atttypid, a.atttypmod) AS column_type,
|
16
|
+
pg_get_expr(d.adbin, d.adrelid) AS default_value
|
17
|
+
FROM
|
18
|
+
pg_catalog.pg_attribute a
|
19
|
+
INNER JOIN
|
20
|
+
pg_catalog.pg_class c ON c.oid = a.attrelid
|
21
|
+
INNER JOIN
|
22
|
+
pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
23
|
+
INNER JOIN
|
24
|
+
pg_catalog.pg_attrdef d ON (a.attrelid, a.attnum) = (d.adrelid, d.adnum)
|
25
|
+
WHERE
|
26
|
+
NOT a.attisdropped
|
27
|
+
AND a.attnum > 0
|
28
|
+
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval%'
|
29
|
+
AND n.nspname NOT LIKE 'pg\\_temp\\_%'
|
30
|
+
SQL
|
31
|
+
|
32
|
+
# parse out sequence
|
33
|
+
sequences.each do |column|
|
34
|
+
column[:max_value] = column[:column_type] == 'integer' ? 2147483647 : 9223372036854775807
|
35
|
+
|
36
|
+
column[:schema], column[:sequence] = parse_default_value(column[:default_value])
|
37
|
+
column.delete(:default_value) if column[:sequence]
|
38
|
+
end
|
39
|
+
|
40
|
+
add_sequence_attributes(sequences)
|
41
|
+
|
42
|
+
sequences.select { |s| s[:readable] }.each_slice(1024) do |slice|
|
43
|
+
sql = slice.map { |s| "SELECT last_value FROM #{quote_ident(s[:schema])}.#{quote_ident(s[:sequence])}" }.join(" UNION ALL ")
|
44
|
+
|
45
|
+
select_all(sql).zip(slice) do |row, seq|
|
46
|
+
seq[:last_value] = row[:last_value]
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
sequences.sort_by { |s| s[:sequence] }
|
51
|
+
end
|
52
|
+
|
53
|
+
def sequence_danger(threshold: 0.9, sequences: nil)
|
54
|
+
sequences ||= self.sequences
|
55
|
+
sequences.select { |s| s[:last_value] && s[:last_value] / s[:max_value].to_f > threshold }.sort_by { |s| s[:max_value] - s[:last_value] }
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
# can parse
|
61
|
+
# nextval('id_seq'::regclass)
|
62
|
+
# nextval(('id_seq'::text)::regclass)
|
63
|
+
def parse_default_value(default_value)
|
64
|
+
m = /^nextval\('(.+)'\:\:regclass\)$/.match(default_value)
|
65
|
+
m = /^nextval\(\('(.+)'\:\:text\)\:\:regclass\)$/.match(default_value) unless m
|
66
|
+
if m
|
67
|
+
unquote_ident(m[1])
|
68
|
+
else
|
69
|
+
[]
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def unquote_ident(value)
|
74
|
+
schema, seq = value.split(".")
|
75
|
+
unless seq
|
76
|
+
seq = schema
|
77
|
+
schema = nil
|
78
|
+
end
|
79
|
+
[unquote(schema), unquote(seq)]
|
80
|
+
end
|
81
|
+
|
82
|
+
# adds readable attribute to all sequences
|
83
|
+
# also adds schema if missing
|
84
|
+
def add_sequence_attributes(sequences)
|
85
|
+
# fetch data
|
86
|
+
sequence_attributes = select_all <<-SQL
|
87
|
+
SELECT
|
88
|
+
n.nspname AS schema,
|
89
|
+
c.relname AS sequence,
|
90
|
+
has_sequence_privilege(c.oid, 'SELECT') AS readable
|
91
|
+
FROM
|
92
|
+
pg_class c
|
93
|
+
INNER JOIN
|
94
|
+
pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
95
|
+
WHERE
|
96
|
+
c.relkind = 'S'
|
97
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
98
|
+
SQL
|
99
|
+
|
100
|
+
# first populate missing schemas
|
101
|
+
missing_schema = sequences.select { |s| s[:schema].nil? && s[:sequence] }
|
102
|
+
if missing_schema.any?
|
103
|
+
sequence_schemas = sequence_attributes.group_by { |s| s[:sequence] }
|
104
|
+
|
105
|
+
missing_schema.each do |sequence|
|
106
|
+
schemas = sequence_schemas[sequence[:sequence]] || []
|
107
|
+
|
108
|
+
if schemas.size == 1
|
109
|
+
sequence[:schema] = schemas[0][:schema]
|
110
|
+
end
|
111
|
+
# otherwise, do nothing, will be marked as unreadable
|
112
|
+
# TODO better message for multiple schemas
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# then populate attributes
|
117
|
+
readable = Hash[sequence_attributes.map { |s| [[s[:schema], s[:sequence]], s[:readable]] }]
|
118
|
+
sequences.each do |sequence|
|
119
|
+
sequence[:readable] = readable[[sequence[:schema], sequence[:sequence]]] || false
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module PgHero
|
2
|
+
module Methods
|
3
|
+
module Settings
|
4
|
+
def settings
|
5
|
+
names =
|
6
|
+
if server_version_num >= 90500
|
7
|
+
%i(
|
8
|
+
max_connections shared_buffers effective_cache_size work_mem
|
9
|
+
maintenance_work_mem min_wal_size max_wal_size checkpoint_completion_target
|
10
|
+
wal_buffers default_statistics_target
|
11
|
+
)
|
12
|
+
else
|
13
|
+
%i(
|
14
|
+
max_connections shared_buffers effective_cache_size work_mem
|
15
|
+
maintenance_work_mem checkpoint_segments checkpoint_completion_target
|
16
|
+
wal_buffers default_statistics_target
|
17
|
+
)
|
18
|
+
end
|
19
|
+
fetch_settings(names)
|
20
|
+
end
|
21
|
+
|
22
|
+
def autovacuum_settings
|
23
|
+
fetch_settings %i(autovacuum autovacuum_max_workers autovacuum_vacuum_cost_limit autovacuum_vacuum_scale_factor autovacuum_analyze_scale_factor)
|
24
|
+
end
|
25
|
+
|
26
|
+
def vacuum_settings
|
27
|
+
fetch_settings %i(vacuum_cost_limit)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def fetch_settings(names)
|
33
|
+
Hash[names.map { |name| [name, select_one("SHOW #{name}")] }]
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
module PgHero
|
2
|
+
module Methods
|
3
|
+
module Space
|
4
|
+
def database_size
|
5
|
+
PgHero.pretty_size select_one("SELECT pg_database_size(current_database())")
|
6
|
+
end
|
7
|
+
|
8
|
+
def relation_sizes
|
9
|
+
select_all_size <<-SQL
|
10
|
+
SELECT
|
11
|
+
n.nspname AS schema,
|
12
|
+
c.relname AS relation,
|
13
|
+
CASE WHEN c.relkind = 'r' THEN 'table' ELSE 'index' END AS type,
|
14
|
+
pg_table_size(c.oid) AS size_bytes
|
15
|
+
FROM
|
16
|
+
pg_class c
|
17
|
+
LEFT JOIN
|
18
|
+
pg_namespace n ON n.oid = c.relnamespace
|
19
|
+
WHERE
|
20
|
+
n.nspname NOT IN ('pg_catalog', 'information_schema')
|
21
|
+
AND n.nspname !~ '^pg_toast'
|
22
|
+
AND c.relkind IN ('r', 'i')
|
23
|
+
ORDER BY
|
24
|
+
pg_table_size(c.oid) DESC,
|
25
|
+
2 ASC
|
26
|
+
SQL
|
27
|
+
end
|
28
|
+
|
29
|
+
def table_sizes
|
30
|
+
select_all_size <<-SQL
|
31
|
+
SELECT
|
32
|
+
n.nspname AS schema,
|
33
|
+
c.relname AS table,
|
34
|
+
pg_total_relation_size(c.oid) AS size_bytes
|
35
|
+
FROM
|
36
|
+
pg_class c
|
37
|
+
LEFT JOIN
|
38
|
+
pg_namespace n ON n.oid = c.relnamespace
|
39
|
+
WHERE
|
40
|
+
n.nspname NOT IN ('pg_catalog', 'information_schema')
|
41
|
+
AND n.nspname !~ '^pg_toast'
|
42
|
+
AND c.relkind = 'r'
|
43
|
+
ORDER BY
|
44
|
+
pg_total_relation_size(c.oid) DESC,
|
45
|
+
2 ASC
|
46
|
+
SQL
|
47
|
+
end
|
48
|
+
|
49
|
+
def space_growth(days: 7, relation_sizes: nil)
|
50
|
+
if space_stats_enabled?
|
51
|
+
relation_sizes ||= self.relation_sizes
|
52
|
+
sizes = Hash[ relation_sizes.map { |r| [[r[:schema], r[:relation]], r[:size_bytes]] } ]
|
53
|
+
start_at = days.days.ago
|
54
|
+
|
55
|
+
stats = select_all_stats <<-SQL
|
56
|
+
WITH t AS (
|
57
|
+
SELECT
|
58
|
+
schema,
|
59
|
+
relation,
|
60
|
+
array_agg(size ORDER BY captured_at) AS sizes
|
61
|
+
FROM
|
62
|
+
pghero_space_stats
|
63
|
+
WHERE
|
64
|
+
database = #{quote(id)}
|
65
|
+
AND captured_at >= #{quote(start_at)}
|
66
|
+
GROUP BY
|
67
|
+
1, 2
|
68
|
+
)
|
69
|
+
SELECT
|
70
|
+
schema,
|
71
|
+
relation,
|
72
|
+
sizes[1] AS size_bytes
|
73
|
+
FROM
|
74
|
+
t
|
75
|
+
ORDER BY
|
76
|
+
1, 2
|
77
|
+
SQL
|
78
|
+
|
79
|
+
stats.each do |r|
|
80
|
+
relation = [r[:schema], r[:relation]]
|
81
|
+
if sizes[relation]
|
82
|
+
r[:growth_bytes] = sizes[relation] - r[:size_bytes]
|
83
|
+
end
|
84
|
+
r.delete(:size_bytes)
|
85
|
+
end
|
86
|
+
stats
|
87
|
+
else
|
88
|
+
raise NotEnabled, "Space stats not enabled"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def relation_space_stats(relation, schema: "public")
|
93
|
+
if space_stats_enabled?
|
94
|
+
relation_sizes ||= self.relation_sizes
|
95
|
+
sizes = Hash[ relation_sizes.map { |r| [[r[:schema], r[:relation]], r[:size_bytes]] } ]
|
96
|
+
start_at = 30.days.ago
|
97
|
+
|
98
|
+
stats = select_all_stats <<-SQL
|
99
|
+
SELECT
|
100
|
+
captured_at,
|
101
|
+
size AS size_bytes
|
102
|
+
FROM
|
103
|
+
pghero_space_stats
|
104
|
+
WHERE
|
105
|
+
database = #{quote(id)}
|
106
|
+
AND captured_at >= #{quote(start_at)}
|
107
|
+
AND schema = #{quote(schema)}
|
108
|
+
AND relation = #{quote(relation)}
|
109
|
+
ORDER BY
|
110
|
+
1 ASC
|
111
|
+
SQL
|
112
|
+
|
113
|
+
stats << {
|
114
|
+
captured_at: Time.now,
|
115
|
+
size_bytes: sizes[[schema, relation]].to_i
|
116
|
+
}
|
117
|
+
else
|
118
|
+
raise NotEnabled, "Space stats not enabled"
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def capture_space_stats
|
123
|
+
now = Time.now
|
124
|
+
columns = %w(database schema relation size captured_at)
|
125
|
+
values = []
|
126
|
+
relation_sizes.each do |rs|
|
127
|
+
values << [id, rs[:schema], rs[:relation], rs[:size_bytes].to_i, now]
|
128
|
+
end
|
129
|
+
insert_stats("pghero_space_stats", columns, values) if values.any?
|
130
|
+
end
|
131
|
+
|
132
|
+
def clean_space_stats
|
133
|
+
PgHero::SpaceStats.where(database: id).where("captured_at < ?", 90.days.ago).delete_all
|
134
|
+
end
|
135
|
+
|
136
|
+
def space_stats_enabled?
|
137
|
+
table_exists?("pghero_space_stats")
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,329 @@
|
|
1
|
+
module PgHero
|
2
|
+
module Methods
|
3
|
+
module SuggestedIndexes
|
4
|
+
def suggested_indexes_enabled?
|
5
|
+
defined?(PgQuery) && Gem::Version.new(PgQuery::VERSION) >= Gem::Version.new("0.9.0") && query_stats_enabled?
|
6
|
+
end
|
7
|
+
|
8
|
+
# TODO clean this mess
|
9
|
+
def suggested_indexes_by_query(queries: nil, query_stats: nil, indexes: nil)
|
10
|
+
best_indexes = {}
|
11
|
+
|
12
|
+
if suggested_indexes_enabled?
|
13
|
+
# get most time-consuming queries
|
14
|
+
queries ||= (query_stats || self.query_stats(historical: true, start_at: 24.hours.ago)).map { |qs| qs[:query] }
|
15
|
+
|
16
|
+
# get best indexes for queries
|
17
|
+
best_indexes = best_index_helper(queries)
|
18
|
+
|
19
|
+
if best_indexes.any?
|
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|
|
23
|
+
inds.each do |i|
|
24
|
+
existing_columns[group][i[:table]] << i[:columns]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
indexes_by_table = indexes.group_by { |i| i[:table] }
|
28
|
+
|
29
|
+
best_indexes.each do |_query, best_index|
|
30
|
+
if best_index[:found]
|
31
|
+
index = best_index[:index]
|
32
|
+
best_index[:table_indexes] = indexes_by_table[index[:table]].to_a
|
33
|
+
|
34
|
+
# indexes of same type
|
35
|
+
indexes = existing_columns[index[:using] || "btree"][index[:table]]
|
36
|
+
|
37
|
+
if best_index[:structure][:sort].empty?
|
38
|
+
# gist indexes without an opclass
|
39
|
+
# (opclass is part of column name, so columns won't match if opclass present)
|
40
|
+
indexes += existing_columns["gist"][index[:table]]
|
41
|
+
|
42
|
+
# hash indexes work for equality
|
43
|
+
indexes += existing_columns["hash"][index[:table]] if best_index[:structure][:where].all? { |v| v[:op] == "=" }
|
44
|
+
|
45
|
+
# brin indexes work for all
|
46
|
+
indexes += existing_columns["brin"][index[:table]]
|
47
|
+
end
|
48
|
+
|
49
|
+
covering_index = indexes.find { |e| index_covers?(e.map { |v| v.sub(/ inet_ops\z/, "") }, index[:columns]) }
|
50
|
+
if covering_index
|
51
|
+
best_index[:covering_index] = covering_index
|
52
|
+
best_index[:explanation] = "Covered by index on (#{covering_index.join(", ")})"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
else
|
58
|
+
raise NotEnabled, "Suggested indexes not enabled"
|
59
|
+
end
|
60
|
+
|
61
|
+
best_indexes
|
62
|
+
end
|
63
|
+
|
64
|
+
def suggested_indexes(suggested_indexes_by_query: nil, **options)
|
65
|
+
indexes = []
|
66
|
+
|
67
|
+
(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|
|
68
|
+
details = {}
|
69
|
+
group.map(&:second).each do |g|
|
70
|
+
details = details.except(:index).deep_merge(g)
|
71
|
+
end
|
72
|
+
indexes << index.merge(queries: group.map(&:first), details: details)
|
73
|
+
end
|
74
|
+
|
75
|
+
indexes.sort_by { |i| [i[:table], i[:columns]] }
|
76
|
+
end
|
77
|
+
|
78
|
+
def autoindex(create: false)
|
79
|
+
suggested_indexes.each do |index|
|
80
|
+
p index
|
81
|
+
if create
|
82
|
+
connection.execute("CREATE INDEX CONCURRENTLY ON #{quote_table_name(index[:table])} (#{index[:columns].map { |c| quote_table_name(c) }.join(",")})")
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def best_index(statement)
|
88
|
+
best_index_helper([statement])[statement]
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def best_index_helper(statements)
|
94
|
+
indexes = {}
|
95
|
+
|
96
|
+
# see if this is a query we understand and can use
|
97
|
+
parts = {}
|
98
|
+
statements.each do |statement|
|
99
|
+
parts[statement] = best_index_structure(statement)
|
100
|
+
end
|
101
|
+
|
102
|
+
# get stats about columns for relevant tables
|
103
|
+
tables = parts.values.map { |t| t[:table] }.uniq
|
104
|
+
# TODO get schema from query structure, then try search path
|
105
|
+
schema = PgHero.connection_config(connection_model)[:schema] || "public"
|
106
|
+
if tables.any?
|
107
|
+
row_stats = Hash[table_stats(table: tables, schema: schema).map { |i| [i[:table], i[:estimated_rows]] }]
|
108
|
+
col_stats = column_stats(table: tables, schema: schema).group_by { |i| i[:table] }
|
109
|
+
end
|
110
|
+
|
111
|
+
# find best index based on query structure and column stats
|
112
|
+
parts.each do |statement, structure|
|
113
|
+
index = {found: false}
|
114
|
+
|
115
|
+
if structure[:error]
|
116
|
+
index[:explanation] = structure[:error]
|
117
|
+
elsif structure[:table].start_with?("pg_")
|
118
|
+
index[:explanation] = "System table"
|
119
|
+
else
|
120
|
+
index[:structure] = structure
|
121
|
+
|
122
|
+
table = structure[:table]
|
123
|
+
where = structure[:where].uniq
|
124
|
+
sort = structure[:sort]
|
125
|
+
|
126
|
+
total_rows = row_stats[table].to_i
|
127
|
+
index[:rows] = total_rows
|
128
|
+
|
129
|
+
ranks = Hash[col_stats[table].to_a.map { |r| [r[:column], r] }]
|
130
|
+
columns = (where + sort).map { |c| c[:column] }.uniq
|
131
|
+
|
132
|
+
if columns.any?
|
133
|
+
if columns.all? { |c| ranks[c] }
|
134
|
+
first_desc = sort.index { |c| c[:direction] == "desc" }
|
135
|
+
sort = sort.first(first_desc + 1) if first_desc
|
136
|
+
where = where.sort_by { |c| [row_estimates(ranks[c[:column]], total_rows, total_rows, c[:op]), c[:column]] } + sort
|
137
|
+
|
138
|
+
index[:row_estimates] = Hash[where.map { |c| ["#{c[:column]} (#{c[:op] || "sort"})", row_estimates(ranks[c[:column]], total_rows, total_rows, c[:op]).round] }]
|
139
|
+
|
140
|
+
# no index needed if less than 500 rows
|
141
|
+
if total_rows >= 500
|
142
|
+
|
143
|
+
if ["~~", "~~*"].include?(where.first[:op])
|
144
|
+
index[:found] = true
|
145
|
+
index[:row_progression] = [total_rows, index[:row_estimates].values.first]
|
146
|
+
index[:index] = {table: table, columns: ["#{where.first[:column]} gist_trgm_ops"], using: "gist"}
|
147
|
+
else
|
148
|
+
# if most values are unique, no need to index others
|
149
|
+
rows_left = total_rows
|
150
|
+
final_where = []
|
151
|
+
prev_rows_left = [rows_left]
|
152
|
+
where.reject { |c| ["~~", "~~*"].include?(c[:op]) }.each do |c|
|
153
|
+
next if final_where.include?(c[:column])
|
154
|
+
final_where << c[:column]
|
155
|
+
rows_left = row_estimates(ranks[c[:column]], total_rows, rows_left, c[:op])
|
156
|
+
prev_rows_left << rows_left
|
157
|
+
if rows_left < 50 || final_where.size >= 2 || [">", ">=", "<", "<=", "~~", "~~*", "BETWEEN"].include?(c[:op])
|
158
|
+
break
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
index[:row_progression] = prev_rows_left.map(&:round)
|
163
|
+
|
164
|
+
# if the last indexes don't give us much, don't include
|
165
|
+
prev_rows_left.reverse!
|
166
|
+
(prev_rows_left.size - 1).times do |i|
|
167
|
+
if prev_rows_left[i] > prev_rows_left[i + 1] * 0.3
|
168
|
+
final_where.pop
|
169
|
+
else
|
170
|
+
break
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
if final_where.any?
|
175
|
+
index[:found] = true
|
176
|
+
index[:index] = {table: table, columns: final_where}
|
177
|
+
end
|
178
|
+
end
|
179
|
+
else
|
180
|
+
index[:explanation] = "No index needed if less than 500 rows"
|
181
|
+
end
|
182
|
+
else
|
183
|
+
index[:explanation] = "Stats not found"
|
184
|
+
end
|
185
|
+
else
|
186
|
+
index[:explanation] = "No columns to index"
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
indexes[statement] = index
|
191
|
+
end
|
192
|
+
|
193
|
+
indexes
|
194
|
+
end
|
195
|
+
|
196
|
+
def best_index_structure(statement)
|
197
|
+
return {error: "Too large"} if statement.to_s.length > 10000
|
198
|
+
|
199
|
+
begin
|
200
|
+
tree = PgQuery.parse(statement).tree
|
201
|
+
rescue PgQuery::ParseError
|
202
|
+
return {error: "Parse error"}
|
203
|
+
end
|
204
|
+
return {error: "Unknown structure"} unless tree.size == 1
|
205
|
+
|
206
|
+
tree = tree.first
|
207
|
+
|
208
|
+
# pg_query 1.0.0
|
209
|
+
tree = tree["RawStmt"]["stmt"] if tree["RawStmt"]
|
210
|
+
|
211
|
+
table = parse_table(tree) rescue nil
|
212
|
+
unless table
|
213
|
+
error =
|
214
|
+
case tree.keys.first
|
215
|
+
when "InsertStmt"
|
216
|
+
"INSERT statement"
|
217
|
+
when "VariableSetStmt"
|
218
|
+
"SET statement"
|
219
|
+
when "SelectStmt"
|
220
|
+
if (tree["SelectStmt"]["fromClause"].first["JoinExpr"] rescue false)
|
221
|
+
"JOIN not supported yet"
|
222
|
+
end
|
223
|
+
end
|
224
|
+
return {error: error || "Unknown structure"}
|
225
|
+
end
|
226
|
+
|
227
|
+
select = tree.values.first
|
228
|
+
where = (select["whereClause"] ? parse_where(select["whereClause"]) : []) rescue nil
|
229
|
+
return {error: "Unknown structure"} unless where
|
230
|
+
|
231
|
+
sort = (select["sortClause"] ? parse_sort(select["sortClause"]) : []) rescue []
|
232
|
+
|
233
|
+
{table: table, where: where, sort: sort}
|
234
|
+
end
|
235
|
+
|
236
|
+
# TODO better row estimation
|
237
|
+
# https://www.postgresql.org/docs/current/static/row-estimation-examples.html
|
238
|
+
def row_estimates(stats, total_rows, rows_left, op)
|
239
|
+
case op
|
240
|
+
when "null"
|
241
|
+
rows_left * stats[:null_frac].to_f
|
242
|
+
when "not_null"
|
243
|
+
rows_left * (1 - stats[:null_frac].to_f)
|
244
|
+
else
|
245
|
+
rows_left *= (1 - stats[:null_frac].to_f)
|
246
|
+
ret =
|
247
|
+
if stats[:n_distinct].to_f == 0
|
248
|
+
0
|
249
|
+
elsif stats[:n_distinct].to_f < 0
|
250
|
+
if total_rows > 0
|
251
|
+
(-1 / stats[:n_distinct].to_f) * (rows_left / total_rows.to_f)
|
252
|
+
else
|
253
|
+
0
|
254
|
+
end
|
255
|
+
else
|
256
|
+
rows_left / stats[:n_distinct].to_f
|
257
|
+
end
|
258
|
+
|
259
|
+
case op
|
260
|
+
when ">", ">=", "<", "<=", "~~", "~~*", "BETWEEN"
|
261
|
+
(rows_left + ret) / 10.0 # TODO better approximation
|
262
|
+
when "<>"
|
263
|
+
rows_left - ret
|
264
|
+
else
|
265
|
+
ret
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
def parse_table(tree)
|
271
|
+
case tree.keys.first
|
272
|
+
when "SelectStmt"
|
273
|
+
tree["SelectStmt"]["fromClause"].first["RangeVar"]["relname"]
|
274
|
+
when "DeleteStmt"
|
275
|
+
tree["DeleteStmt"]["relation"]["RangeVar"]["relname"]
|
276
|
+
when "UpdateStmt"
|
277
|
+
tree["UpdateStmt"]["relation"]["RangeVar"]["relname"]
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
281
|
+
# TODO capture values
|
282
|
+
def parse_where(tree)
|
283
|
+
aexpr = tree["A_Expr"]
|
284
|
+
|
285
|
+
if tree["BoolExpr"]
|
286
|
+
if tree["BoolExpr"]["boolop"] == 0
|
287
|
+
tree["BoolExpr"]["args"].flat_map { |v| parse_where(v) }
|
288
|
+
else
|
289
|
+
raise "Not Implemented"
|
290
|
+
end
|
291
|
+
elsif aexpr && ["=", "<>", ">", ">=", "<", "<=", "~~", "~~*", "BETWEEN"].include?(aexpr["name"].first["String"]["str"])
|
292
|
+
[{column: aexpr["lexpr"]["ColumnRef"]["fields"].last["String"]["str"], op: aexpr["name"].first["String"]["str"]}]
|
293
|
+
elsif tree["NullTest"]
|
294
|
+
op = tree["NullTest"]["nulltesttype"] == 1 ? "not_null" : "null"
|
295
|
+
[{column: tree["NullTest"]["arg"]["ColumnRef"]["fields"].last["String"]["str"], op: op}]
|
296
|
+
else
|
297
|
+
raise "Not Implemented"
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
def parse_sort(sort_clause)
|
302
|
+
sort_clause.map do |v|
|
303
|
+
{
|
304
|
+
column: v["SortBy"]["node"]["ColumnRef"]["fields"].last["String"]["str"],
|
305
|
+
direction: v["SortBy"]["sortby_dir"] == 2 ? "desc" : "asc"
|
306
|
+
}
|
307
|
+
end
|
308
|
+
end
|
309
|
+
|
310
|
+
def column_stats(schema: nil, table: nil)
|
311
|
+
select_all <<-SQL
|
312
|
+
SELECT
|
313
|
+
schemaname AS schema,
|
314
|
+
tablename AS table,
|
315
|
+
attname AS column,
|
316
|
+
null_frac,
|
317
|
+
n_distinct
|
318
|
+
FROM
|
319
|
+
pg_stats
|
320
|
+
WHERE
|
321
|
+
schemaname = #{quote(schema)}
|
322
|
+
#{table ? "AND tablename IN (#{Array(table).map { |t| quote(t) }.join(", ")})" : ""}
|
323
|
+
ORDER BY
|
324
|
+
1, 2, 3
|
325
|
+
SQL
|
326
|
+
end
|
327
|
+
end
|
328
|
+
end
|
329
|
+
end
|