pgdexter 0.5.6 → 0.6.0
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 +4 -4
- data/CHANGELOG.md +8 -0
- data/LICENSE.txt +1 -1
- data/README.md +4 -14
- data/lib/dexter/client.rb +45 -28
- data/lib/dexter/collector.rb +11 -19
- data/lib/dexter/column_resolver.rb +74 -0
- data/lib/dexter/connection.rb +92 -0
- data/lib/dexter/indexer.rb +189 -377
- data/lib/dexter/parsers/csv_log_parser.rb +25 -0
- data/lib/dexter/{json_log_parser.rb → parsers/json_log_parser.rb} +5 -3
- data/lib/dexter/{log_parser.rb → parsers/log_parser.rb} +1 -8
- data/lib/dexter/{sql_log_parser.rb → parsers/sql_log_parser.rb} +3 -2
- data/lib/dexter/{stderr_log_parser.rb → parsers/stderr_log_parser.rb} +4 -8
- data/lib/dexter/processor.rb +10 -24
- data/lib/dexter/query.rb +14 -31
- data/lib/dexter/sources/log_source.rb +26 -0
- data/lib/dexter/{pg_stat_activity_parser.rb → sources/pg_stat_activity_source.rb} +10 -6
- data/lib/dexter/sources/pg_stat_statements_source.rb +34 -0
- data/lib/dexter/sources/statement_source.rb +11 -0
- data/lib/dexter/table_resolver.rb +120 -0
- data/lib/dexter/version.rb +1 -1
- data/lib/dexter.rb +15 -7
- data/lib/pgdexter.rb +1 -0
- metadata +19 -12
- data/lib/dexter/csv_log_parser.rb +0 -24
data/lib/dexter/indexer.rb
CHANGED
@@ -2,77 +2,75 @@ module Dexter
|
|
2
2
|
class Indexer
|
3
3
|
include Logging
|
4
4
|
|
5
|
-
def initialize(options)
|
5
|
+
def initialize(connection:, **options)
|
6
|
+
@connection = connection
|
6
7
|
@create = options[:create]
|
7
8
|
@tablespace = options[:tablespace]
|
8
9
|
@log_level = options[:log_level]
|
9
10
|
@exclude_tables = options[:exclude]
|
10
11
|
@include_tables = Array(options[:include].split(",")) if options[:include]
|
11
|
-
@log_sql = options[:log_sql]
|
12
12
|
@log_explain = options[:log_explain]
|
13
|
-
@min_time = options[:min_time] || 0
|
14
|
-
@min_calls = options[:min_calls] || 0
|
15
13
|
@analyze = options[:analyze]
|
14
|
+
@min_cost = options[:min_cost].to_i
|
16
15
|
@min_cost_savings_pct = options[:min_cost_savings_pct].to_i
|
17
16
|
@options = options
|
18
|
-
@
|
19
|
-
|
20
|
-
if server_version_num < 110000
|
21
|
-
raise Dexter::Abort, "This version of Dexter requires Postgres 11+"
|
22
|
-
end
|
23
|
-
|
24
|
-
check_extension
|
25
|
-
|
26
|
-
execute("SET lock_timeout = '5s'")
|
27
|
-
end
|
28
|
-
|
29
|
-
def process_stat_statements
|
30
|
-
queries = stat_statements.map { |q| Query.new(q) }.sort_by(&:fingerprint).group_by(&:fingerprint).map { |_, v| v.first }
|
31
|
-
log "Processing #{queries.size} new query fingerprints"
|
32
|
-
process_queries(queries)
|
17
|
+
@server_version_num = @connection.server_version_num
|
33
18
|
end
|
34
19
|
|
20
|
+
# TODO recheck server version?
|
35
21
|
def process_queries(queries)
|
36
|
-
|
37
|
-
|
22
|
+
TableResolver.new(@connection, queries, log_level: @log_level).perform
|
23
|
+
candidate_queries = queries.reject(&:missing_tables)
|
38
24
|
|
39
|
-
tables =
|
40
|
-
|
41
|
-
|
42
|
-
no_schema_tables = {}
|
43
|
-
search_path_index = Hash[search_path.map.with_index.to_a]
|
44
|
-
tables.group_by { |t| t.split(".")[-1] }.each do |group, t2|
|
45
|
-
no_schema_tables[group] = t2.sort_by { |t| [search_path_index[t.split(".")[0]] || 1000000, t] }[0]
|
25
|
+
tables = determine_tables(candidate_queries)
|
26
|
+
candidate_queries.each do |query|
|
27
|
+
query.candidate_tables = query.tables.select { |t| tables.include?(t) }.sort
|
46
28
|
end
|
29
|
+
candidate_queries.select! { |q| q.candidate_tables.any? }
|
47
30
|
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
31
|
+
if tables.any?
|
32
|
+
# analyze tables if needed
|
33
|
+
analyze_tables(tables) if @analyze || @log_level == "debug2"
|
34
|
+
|
35
|
+
# get initial costs for queries
|
36
|
+
reset_hypothetical_indexes
|
37
|
+
calculate_plan(candidate_queries)
|
38
|
+
candidate_queries.select! { |q| q.initial_cost && q.initial_cost >= @min_cost }
|
39
|
+
|
40
|
+
# find columns
|
41
|
+
ColumnResolver.new(@connection, candidate_queries, log_level: @log_level).perform
|
42
|
+
candidate_queries.each do |query|
|
43
|
+
# no reason to use btree index for json columns
|
44
|
+
query.candidate_columns = query.columns.reject { |c| ["json", "jsonb"].include?(c[:type]) }.sort_by { |c| [c[:table], c[:column]] }
|
45
|
+
end
|
46
|
+
candidate_queries.select! { |q| q.candidate_columns.any? }
|
53
47
|
|
54
|
-
|
55
|
-
|
56
|
-
view_tables.each do |v, vt|
|
57
|
-
view_tables[v] = vt.flat_map { |t| view_tables[t] || [t] }.uniq
|
48
|
+
# create hypothetical indexes and explain queries
|
49
|
+
batch_hypothetical_indexes(candidate_queries)
|
58
50
|
end
|
59
51
|
|
60
|
-
#
|
61
|
-
queries
|
62
|
-
# add schema to table if needed
|
63
|
-
query.tables = query.tables.map { |t| no_schema_tables[t] || t }
|
52
|
+
# see if new indexes were used and meet bar
|
53
|
+
new_indexes = determine_indexes(queries, tables)
|
64
54
|
|
65
|
-
|
66
|
-
|
67
|
-
query.tables_from_views = new_tables - query.tables
|
68
|
-
query.tables = new_tables
|
55
|
+
# display new indexes
|
56
|
+
show_new_indexes(new_indexes)
|
69
57
|
|
70
|
-
|
71
|
-
|
72
|
-
|
58
|
+
# display debug info
|
59
|
+
show_debug_info(new_indexes, queries) if @log_level.start_with?("debug")
|
60
|
+
|
61
|
+
# create new indexes
|
62
|
+
create_indexes(new_indexes) if @create && new_indexes.any?
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def reset_hypothetical_indexes
|
68
|
+
execute("SELECT hypopg_reset()")
|
69
|
+
end
|
73
70
|
|
71
|
+
def determine_tables(candidate_queries)
|
74
72
|
# set tables
|
75
|
-
tables = Set.new(
|
73
|
+
tables = Set.new(candidate_queries.flat_map(&:tables))
|
76
74
|
|
77
75
|
# must come after missing tables set
|
78
76
|
if @include_tables
|
@@ -88,54 +86,10 @@ module Dexter
|
|
88
86
|
# remove system tables
|
89
87
|
tables.delete_if { |t| t.start_with?("information_schema.", "pg_catalog.") }
|
90
88
|
|
91
|
-
|
92
|
-
query.candidate_tables = !query.missing_tables && query.tables.any? { |t| tables.include?(t) }
|
93
|
-
end
|
94
|
-
|
95
|
-
# analyze tables if needed
|
96
|
-
analyze_tables(tables) if tables.any? && (@analyze || @log_level == "debug2")
|
97
|
-
|
98
|
-
# create hypothetical indexes and explain queries
|
99
|
-
if tables.any?
|
100
|
-
# process in batches to prevent "hypopg: not more oid available" error
|
101
|
-
# https://hypopg.readthedocs.io/en/rel1_stable/usage.html#configuration
|
102
|
-
queries.select(&:candidate_tables).each_slice(500) do |batch|
|
103
|
-
create_hypothetical_indexes(batch)
|
104
|
-
end
|
105
|
-
end
|
106
|
-
|
107
|
-
# see if new indexes were used and meet bar
|
108
|
-
new_indexes = determine_indexes(queries, tables)
|
109
|
-
|
110
|
-
# display and create new indexes
|
111
|
-
show_and_create_indexes(new_indexes, queries)
|
112
|
-
end
|
113
|
-
|
114
|
-
private
|
115
|
-
|
116
|
-
def check_extension
|
117
|
-
extension = execute("SELECT installed_version FROM pg_available_extensions WHERE name = 'hypopg'").first
|
118
|
-
|
119
|
-
if extension.nil?
|
120
|
-
raise Dexter::Abort, "Install HypoPG first: https://github.com/ankane/dexter#installation"
|
121
|
-
end
|
122
|
-
|
123
|
-
if extension["installed_version"].nil?
|
124
|
-
if @options[:enable_hypopg]
|
125
|
-
execute("CREATE EXTENSION hypopg")
|
126
|
-
else
|
127
|
-
raise Dexter::Abort, "Run `CREATE EXTENSION hypopg` or pass --enable-hypopg"
|
128
|
-
end
|
129
|
-
end
|
130
|
-
end
|
131
|
-
|
132
|
-
def reset_hypothetical_indexes
|
133
|
-
execute("SELECT hypopg_reset()")
|
89
|
+
tables
|
134
90
|
end
|
135
91
|
|
136
|
-
def
|
137
|
-
tables = tables.to_a.sort
|
138
|
-
|
92
|
+
def analyze_stats(tables)
|
139
93
|
query = <<~SQL
|
140
94
|
SELECT
|
141
95
|
schemaname || '.' || relname AS table,
|
@@ -146,10 +100,14 @@ module Dexter
|
|
146
100
|
WHERE
|
147
101
|
schemaname || '.' || relname IN (#{tables.size.times.map { |i| "$#{i + 1}" }.join(", ")})
|
148
102
|
SQL
|
149
|
-
|
103
|
+
execute(query, params: tables)
|
104
|
+
end
|
105
|
+
|
106
|
+
def analyze_tables(tables)
|
107
|
+
tables = tables.to_a.sort
|
150
108
|
|
151
109
|
last_analyzed = {}
|
152
|
-
analyze_stats.each do |stats|
|
110
|
+
analyze_stats(tables).each do |stats|
|
153
111
|
last_analyzed[stats["table"]] = Time.parse(stats["last_analyze"]) if stats["last_analyze"]
|
154
112
|
end
|
155
113
|
|
@@ -186,71 +144,79 @@ module Dexter
|
|
186
144
|
end
|
187
145
|
end
|
188
146
|
|
189
|
-
|
190
|
-
|
147
|
+
# process in batches to prevent "hypopg: not more oid available" error
|
148
|
+
# https://hypopg.readthedocs.io/en/rel1_stable/usage.html#configuration
|
149
|
+
def batch_hypothetical_indexes(candidate_queries)
|
150
|
+
batch_count = 0
|
151
|
+
batch = []
|
152
|
+
single_column_indexes = Set.new
|
153
|
+
multicolumn_indexes = Set.new
|
191
154
|
|
192
|
-
|
155
|
+
# sort to improve batching
|
156
|
+
# TODO improve
|
157
|
+
candidate_queries.sort_by! { |q| q.candidate_columns.map { |c| [c[:table], c[:column]] } }
|
193
158
|
|
194
|
-
|
195
|
-
|
196
|
-
explainable_queries = queries.select { |q| q.plans.any? && q.high_cost? }
|
159
|
+
candidate_queries.each do |query|
|
160
|
+
batch << query
|
197
161
|
|
198
|
-
|
199
|
-
tables = Set.new(explainable_queries.flat_map(&:tables))
|
200
|
-
tables_from_views = Set.new(explainable_queries.flat_map(&:tables_from_views))
|
162
|
+
single_column_indexes.merge(query.candidate_columns)
|
201
163
|
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
end
|
215
|
-
rescue JSON::NestingError
|
216
|
-
if @log_level.start_with?("debug")
|
217
|
-
log colorize("ERROR: Cannot get columns", :red)
|
218
|
-
end
|
219
|
-
end
|
164
|
+
# TODO for multicolumn indexes, use ordering
|
165
|
+
columns_by_table = query.candidate_columns.group_by { |c| c[:table] }
|
166
|
+
columns_by_table.each do |_, columns|
|
167
|
+
multicolumn_indexes.merge(columns.permutation(2).to_a)
|
168
|
+
end
|
169
|
+
|
170
|
+
if single_column_indexes.size + multicolumn_indexes.size >= 500
|
171
|
+
create_hypothetical_indexes(batch, single_column_indexes, multicolumn_indexes, batch_count)
|
172
|
+
batch_count += 1
|
173
|
+
batch.clear
|
174
|
+
single_column_indexes.clear
|
175
|
+
multicolumn_indexes.clear
|
220
176
|
end
|
177
|
+
end
|
221
178
|
|
222
|
-
|
223
|
-
|
224
|
-
|
179
|
+
if batch.any?
|
180
|
+
create_hypothetical_indexes(batch, single_column_indexes, multicolumn_indexes, batch_count)
|
181
|
+
end
|
182
|
+
end
|
225
183
|
|
226
|
-
|
227
|
-
|
184
|
+
def create_candidate_indexes(candidate_indexes, index_mapping)
|
185
|
+
candidate_indexes.each do |columns|
|
186
|
+
index_name = create_hypothetical_index(columns[0][:table], columns.map { |c| c[:column] })
|
187
|
+
index_mapping[index_name] = columns
|
188
|
+
end
|
189
|
+
rescue PG::InternalError
|
190
|
+
# hypopg: not more oid available
|
191
|
+
log colorize("WARNING: Limiting index candidates", :yellow) if @log_level == "debug2"
|
192
|
+
end
|
228
193
|
|
229
|
-
|
230
|
-
|
194
|
+
def create_hypothetical_indexes(queries, single_column_indexes, multicolumn_indexes, batch_count)
|
195
|
+
index_mapping = {}
|
196
|
+
reset_hypothetical_indexes
|
231
197
|
|
232
|
-
|
233
|
-
|
198
|
+
# check single column indexes
|
199
|
+
create_candidate_indexes(single_column_indexes.map { |c| [c] }, index_mapping)
|
200
|
+
calculate_plan(queries)
|
234
201
|
|
235
|
-
|
236
|
-
|
237
|
-
|
202
|
+
# check multicolumn indexes
|
203
|
+
create_candidate_indexes(multicolumn_indexes, index_mapping)
|
204
|
+
calculate_plan(queries)
|
238
205
|
|
206
|
+
# save index mapping for analysis
|
239
207
|
queries.each do |query|
|
240
|
-
query.
|
208
|
+
query.index_mapping = index_mapping
|
241
209
|
end
|
242
|
-
end
|
243
210
|
|
244
|
-
|
245
|
-
|
246
|
-
find_by_key(plan, "ColumnRef")
|
211
|
+
# TODO different log level?
|
212
|
+
log "Batch #{batch_count + 1}: #{queries.size} queries, #{index_mapping.size} hypothetical indexes" if @log_level == "debug2"
|
247
213
|
end
|
248
214
|
|
249
215
|
def find_indexes(plan)
|
250
|
-
find_by_key(plan, "Index Name")
|
216
|
+
self.class.find_by_key(plan, "Index Name")
|
251
217
|
end
|
252
218
|
|
253
|
-
def find_by_key(plan, key)
|
219
|
+
def self.find_by_key(plan, key)
|
254
220
|
result = []
|
255
221
|
queue = [plan]
|
256
222
|
while queue.any?
|
@@ -271,16 +237,16 @@ module Dexter
|
|
271
237
|
result
|
272
238
|
end
|
273
239
|
|
274
|
-
def hypo_indexes_from_plan(
|
240
|
+
def hypo_indexes_from_plan(index_mapping, plan, index_set)
|
275
241
|
query_indexes = []
|
276
242
|
|
277
243
|
find_indexes(plan).uniq.sort.each do |index_name|
|
278
|
-
|
244
|
+
columns = index_mapping[index_name]
|
279
245
|
|
280
|
-
if
|
246
|
+
if columns
|
281
247
|
index = {
|
282
|
-
table:
|
283
|
-
columns:
|
248
|
+
table: columns[0][:table],
|
249
|
+
columns: columns.map { |c| c[:column] }
|
284
250
|
}
|
285
251
|
|
286
252
|
unless index_set.include?([index[:table], index[:columns]])
|
@@ -310,10 +276,10 @@ module Dexter
|
|
310
276
|
end
|
311
277
|
end
|
312
278
|
|
313
|
-
savings_ratio =
|
279
|
+
savings_ratio = 1 - @min_cost_savings_pct / 100.0
|
314
280
|
|
315
281
|
queries.each do |query|
|
316
|
-
if query.
|
282
|
+
if query.fully_analyzed?
|
317
283
|
new_cost, new_cost2 = query.costs[1..2]
|
318
284
|
|
319
285
|
cost_savings = new_cost < query.initial_cost * savings_ratio
|
@@ -322,11 +288,11 @@ module Dexter
|
|
322
288
|
cost_savings2 = new_cost > 100 && new_cost2 < new_cost * savings_ratio
|
323
289
|
|
324
290
|
key = cost_savings2 ? 2 : 1
|
325
|
-
query_indexes = hypo_indexes_from_plan(query.
|
291
|
+
query_indexes = hypo_indexes_from_plan(query.index_mapping, query.plans[key], index_set)
|
326
292
|
|
327
293
|
# likely a bad suggestion, so try single column
|
328
294
|
if cost_savings2 && query_indexes.size > 1
|
329
|
-
query_indexes = hypo_indexes_from_plan(query.
|
295
|
+
query_indexes = hypo_indexes_from_plan(query.index_mapping, query.plans[1], index_set)
|
330
296
|
cost_savings2 = false
|
331
297
|
end
|
332
298
|
|
@@ -348,7 +314,7 @@ module Dexter
|
|
348
314
|
|
349
315
|
query_indexes.each do |query_index|
|
350
316
|
reset_hypothetical_indexes
|
351
|
-
create_hypothetical_index(query_index[:table], query_index[:columns]
|
317
|
+
create_hypothetical_index(query_index[:table], query_index[:columns])
|
352
318
|
plan3 = plan(query.statement)
|
353
319
|
cost3 = plan3["Total Cost"]
|
354
320
|
|
@@ -399,8 +365,8 @@ module Dexter
|
|
399
365
|
|
400
366
|
# TODO optimize
|
401
367
|
if @log_level.start_with?("debug")
|
402
|
-
query.pass1_indexes = hypo_indexes_from_plan(query.
|
403
|
-
query.pass2_indexes = hypo_indexes_from_plan(query.
|
368
|
+
query.pass1_indexes = hypo_indexes_from_plan(query.index_mapping, query.plans[1], index_set)
|
369
|
+
query.pass2_indexes = hypo_indexes_from_plan(query.index_mapping, query.plans[2], index_set)
|
404
370
|
end
|
405
371
|
end
|
406
372
|
end
|
@@ -424,8 +390,7 @@ module Dexter
|
|
424
390
|
end
|
425
391
|
end
|
426
392
|
|
427
|
-
def
|
428
|
-
# print summary
|
393
|
+
def show_new_indexes(new_indexes)
|
429
394
|
if new_indexes.any?
|
430
395
|
new_indexes.each do |index|
|
431
396
|
log colorize("Index found: #{index[:table]} (#{index[:columns].join(", ")})", :green)
|
@@ -433,75 +398,75 @@ module Dexter
|
|
433
398
|
else
|
434
399
|
log "No new indexes found"
|
435
400
|
end
|
401
|
+
end
|
436
402
|
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
log "Need #{@min_cost_savings_pct}% cost savings to suggest index"
|
470
|
-
end
|
471
|
-
else
|
472
|
-
log "Could not run explain"
|
403
|
+
def show_debug_info(new_indexes, queries)
|
404
|
+
index_queries = new_indexes.flat_map { |i| i[:queries].sort_by(&:fingerprint) }
|
405
|
+
if @log_level == "debug2"
|
406
|
+
fingerprints = Set.new(index_queries.map(&:fingerprint))
|
407
|
+
index_queries.concat(queries.reject { |q| fingerprints.include?(q.fingerprint) }.sort_by(&:fingerprint))
|
408
|
+
end
|
409
|
+
index_queries.each do |query|
|
410
|
+
log "-" * 80
|
411
|
+
log "Query #{query.fingerprint}"
|
412
|
+
log "Total time: #{(query.total_time / 60000.0).round(1)} min, avg time: #{(query.total_time / query.calls.to_f).round} ms, calls: #{query.calls}" if query.calls > 0
|
413
|
+
|
414
|
+
if query.fingerprint == "unknown"
|
415
|
+
log "Could not parse query"
|
416
|
+
elsif query.tables.empty?
|
417
|
+
log "No tables"
|
418
|
+
elsif query.missing_tables
|
419
|
+
log "Tables not present in current database"
|
420
|
+
elsif query.candidate_tables.empty?
|
421
|
+
log "No candidate tables for indexes"
|
422
|
+
elsif !query.initial_cost
|
423
|
+
log "Could not run explain"
|
424
|
+
elsif query.initial_cost < @min_cost
|
425
|
+
log "Low initial cost: #{query.initial_cost}"
|
426
|
+
elsif query.candidate_columns.empty?
|
427
|
+
log "No candidate columns for indexes"
|
428
|
+
elsif query.fully_analyzed?
|
429
|
+
query_indexes = query.indexes || []
|
430
|
+
log "Start: #{query.costs[0]}"
|
431
|
+
log "Pass1: #{query.costs[1]} : #{log_indexes(query.pass1_indexes || [])}"
|
432
|
+
log "Pass2: #{query.costs[2]} : #{log_indexes(query.pass2_indexes || [])}"
|
433
|
+
if query.costs[3]
|
434
|
+
log "Pass3: #{query.costs[3]} : #{log_indexes(query.pass3_indexes || [])}"
|
473
435
|
end
|
474
|
-
log
|
475
|
-
|
476
|
-
|
436
|
+
log "Final: #{query.new_cost} : #{log_indexes(query.suggest_index ? query_indexes : [])}"
|
437
|
+
if (query.pass1_indexes.any? || query.pass2_indexes.any?) && !query.suggest_index
|
438
|
+
log "Need #{@min_cost_savings_pct}% cost savings to suggest index"
|
439
|
+
end
|
440
|
+
else
|
441
|
+
log "Could not run explain"
|
477
442
|
end
|
443
|
+
log
|
444
|
+
log query.statement
|
445
|
+
log
|
478
446
|
end
|
447
|
+
end
|
479
448
|
|
480
|
-
|
481
|
-
|
482
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
log "Could not acquire lock: #{index[:table]}"
|
498
|
-
end
|
449
|
+
# 1. create lock
|
450
|
+
# 2. refresh existing index list
|
451
|
+
# 3. create indexes that still don't exist
|
452
|
+
# 4. release lock
|
453
|
+
def create_indexes(new_indexes)
|
454
|
+
with_advisory_lock do
|
455
|
+
new_indexes.each do |index|
|
456
|
+
unless index_exists?(index)
|
457
|
+
statement = String.new("CREATE INDEX CONCURRENTLY ON #{quote_ident(index[:table])} (#{index[:columns].map { |c| quote_ident(c) }.join(", ")})")
|
458
|
+
statement << " TABLESPACE #{quote_ident(@tablespace)}" if @tablespace
|
459
|
+
log "Creating index: #{statement}"
|
460
|
+
started_at = monotonic_time
|
461
|
+
begin
|
462
|
+
execute(statement)
|
463
|
+
log "Index created: #{((monotonic_time - started_at) * 1000).to_i} ms"
|
464
|
+
rescue PG::LockNotAvailable
|
465
|
+
log "Could not acquire lock: #{index[:table]}"
|
499
466
|
end
|
500
467
|
end
|
501
468
|
end
|
502
469
|
end
|
503
|
-
|
504
|
-
new_indexes
|
505
470
|
end
|
506
471
|
|
507
472
|
def monotonic_time
|
@@ -509,45 +474,11 @@ module Dexter
|
|
509
474
|
end
|
510
475
|
|
511
476
|
def conn
|
512
|
-
@conn
|
513
|
-
# set connect timeout if none set
|
514
|
-
ENV["PGCONNECT_TIMEOUT"] ||= "3"
|
515
|
-
|
516
|
-
if @options[:dbname].start_with?("postgres://", "postgresql://")
|
517
|
-
config = @options[:dbname]
|
518
|
-
else
|
519
|
-
config = {
|
520
|
-
host: @options[:host],
|
521
|
-
port: @options[:port],
|
522
|
-
dbname: @options[:dbname],
|
523
|
-
user: @options[:username]
|
524
|
-
}.reject { |_, value| value.to_s.empty? }
|
525
|
-
config = config[:dbname] if config.keys == [:dbname] && config[:dbname].include?("=")
|
526
|
-
end
|
527
|
-
PG::Connection.new(config)
|
528
|
-
end
|
529
|
-
rescue PG::ConnectionBad => e
|
530
|
-
raise Dexter::Abort, e.message
|
477
|
+
@connection.send(:conn)
|
531
478
|
end
|
532
479
|
|
533
|
-
def execute(
|
534
|
-
|
535
|
-
#
|
536
|
-
# Unlike PQexec, PQexecParams allows at most one SQL command in the given string.
|
537
|
-
# (There can be semicolons in it, but not more than one nonempty command.)
|
538
|
-
# This is a limitation of the underlying protocol, but has some usefulness
|
539
|
-
# as an extra defense against SQL-injection attacks.
|
540
|
-
# https://www.postgresql.org/docs/current/static/libpq-exec.html
|
541
|
-
query = squish(query) if pretty
|
542
|
-
log colorize("[sql] #{query}#{params.any? ? " /*#{params.to_json}*/" : ""}", :cyan) if @log_sql
|
543
|
-
|
544
|
-
@mutex.synchronize do
|
545
|
-
if use_exec
|
546
|
-
conn.exec("#{query} /*dexter*/").to_a
|
547
|
-
else
|
548
|
-
conn.exec_params("#{query} /*dexter*/", params).to_a
|
549
|
-
end
|
550
|
-
end
|
480
|
+
def execute(...)
|
481
|
+
@connection.execute(...)
|
551
482
|
end
|
552
483
|
|
553
484
|
def plan(query)
|
@@ -557,7 +488,7 @@ module Dexter
|
|
557
488
|
# try to EXPLAIN normalized queries
|
558
489
|
# https://dev.to/yugabyte/explain-from-pgstatstatements-normalized-queries-how-to-always-get-the-generic-plan-in--5cfi
|
559
490
|
normalized = query.include?("$1")
|
560
|
-
generic_plan = normalized && server_version_num >= 160000
|
491
|
+
generic_plan = normalized && @server_version_num >= 160000
|
561
492
|
explain_normalized = normalized && !generic_plan
|
562
493
|
if explain_normalized
|
563
494
|
prepared_name = "dexter_prepared"
|
@@ -569,16 +500,7 @@ module Dexter
|
|
569
500
|
execute("BEGIN")
|
570
501
|
transaction = true
|
571
502
|
|
572
|
-
|
573
|
-
execute("SET LOCAL plan_cache_mode = force_generic_plan")
|
574
|
-
else
|
575
|
-
execute("SET LOCAL cpu_operator_cost = 1e42")
|
576
|
-
5.times do
|
577
|
-
execute("EXPLAIN (FORMAT JSON) #{safe_statement(query)}", pretty: false)
|
578
|
-
end
|
579
|
-
execute("ROLLBACK")
|
580
|
-
execute("BEGIN")
|
581
|
-
end
|
503
|
+
execute("SET LOCAL plan_cache_mode = force_generic_plan")
|
582
504
|
end
|
583
505
|
|
584
506
|
explain_prefix = generic_plan ? "GENERIC_PLAN, " : ""
|
@@ -599,90 +521,8 @@ module Dexter
|
|
599
521
|
end
|
600
522
|
end
|
601
523
|
|
602
|
-
|
603
|
-
|
604
|
-
columns_by_table.each do |table, cols|
|
605
|
-
# no reason to use btree index for json columns
|
606
|
-
cols.reject { |c| ["json", "jsonb"].include?(c[:type]) }.permutation(n) do |col_set|
|
607
|
-
index_name = create_hypothetical_index(table, col_set)
|
608
|
-
candidates[index_name] = col_set
|
609
|
-
end
|
610
|
-
end
|
611
|
-
end
|
612
|
-
|
613
|
-
def create_hypothetical_index(table, col_set)
|
614
|
-
execute("SELECT * FROM hypopg_create_index('CREATE INDEX ON #{quote_ident(table)} (#{col_set.map { |c| quote_ident(c[:column]) }.join(", ")})')").first["indexname"]
|
615
|
-
end
|
616
|
-
|
617
|
-
def database_tables
|
618
|
-
result = execute <<~SQL
|
619
|
-
SELECT
|
620
|
-
table_schema || '.' || table_name AS table_name
|
621
|
-
FROM
|
622
|
-
information_schema.tables
|
623
|
-
WHERE
|
624
|
-
table_catalog = current_database()
|
625
|
-
AND table_type IN ('BASE TABLE', 'VIEW')
|
626
|
-
SQL
|
627
|
-
result.map { |r| r["table_name"] }
|
628
|
-
end
|
629
|
-
|
630
|
-
def materialized_views
|
631
|
-
result = execute <<~SQL
|
632
|
-
SELECT
|
633
|
-
schemaname || '.' || matviewname AS table_name
|
634
|
-
FROM
|
635
|
-
pg_matviews
|
636
|
-
SQL
|
637
|
-
result.map { |r| r["table_name"] }
|
638
|
-
end
|
639
|
-
|
640
|
-
def server_version_num
|
641
|
-
execute("SHOW server_version_num").first["server_version_num"].to_i
|
642
|
-
end
|
643
|
-
|
644
|
-
def database_view_tables
|
645
|
-
result = execute <<~SQL
|
646
|
-
SELECT
|
647
|
-
schemaname || '.' || viewname AS table_name,
|
648
|
-
definition
|
649
|
-
FROM
|
650
|
-
pg_views
|
651
|
-
WHERE
|
652
|
-
schemaname NOT IN ('information_schema', 'pg_catalog')
|
653
|
-
SQL
|
654
|
-
|
655
|
-
view_tables = {}
|
656
|
-
result.each do |row|
|
657
|
-
begin
|
658
|
-
view_tables[row["table_name"]] = PgQuery.parse(row["definition"]).tables
|
659
|
-
rescue PgQuery::ParseError
|
660
|
-
if @log_level.start_with?("debug")
|
661
|
-
log colorize("ERROR: Cannot parse view definition: #{row["table_name"]}", :red)
|
662
|
-
end
|
663
|
-
end
|
664
|
-
end
|
665
|
-
|
666
|
-
view_tables
|
667
|
-
end
|
668
|
-
|
669
|
-
def stat_statements
|
670
|
-
total_time = server_version_num >= 130000 ? "(total_plan_time + total_exec_time)" : "total_time"
|
671
|
-
sql = <<~SQL
|
672
|
-
SELECT
|
673
|
-
DISTINCT query
|
674
|
-
FROM
|
675
|
-
pg_stat_statements
|
676
|
-
INNER JOIN
|
677
|
-
pg_database ON pg_database.oid = pg_stat_statements.dbid
|
678
|
-
WHERE
|
679
|
-
datname = current_database()
|
680
|
-
AND #{total_time} >= \$1
|
681
|
-
AND calls >= \$2
|
682
|
-
ORDER BY
|
683
|
-
1
|
684
|
-
SQL
|
685
|
-
execute(sql, params: [@min_time * 60000, @min_calls.to_i]).map { |q| q["query"] }
|
524
|
+
def create_hypothetical_index(table, columns)
|
525
|
+
execute("SELECT * FROM hypopg_create_index('CREATE INDEX ON #{quote_ident(table)} (#{columns.map { |c| quote_ident(c) }.join(", ")})')").first["indexname"]
|
686
526
|
end
|
687
527
|
|
688
528
|
def with_advisory_lock
|
@@ -716,25 +556,6 @@ module Dexter
|
|
716
556
|
indexes([index[:table]]).find { |i| i["columns"] == index[:columns] }
|
717
557
|
end
|
718
558
|
|
719
|
-
def columns(tables)
|
720
|
-
query = <<~SQL
|
721
|
-
SELECT
|
722
|
-
s.nspname || '.' || t.relname AS table_name,
|
723
|
-
a.attname AS column_name,
|
724
|
-
pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type
|
725
|
-
FROM pg_attribute a
|
726
|
-
JOIN pg_class t on a.attrelid = t.oid
|
727
|
-
JOIN pg_namespace s on t.relnamespace = s.oid
|
728
|
-
WHERE a.attnum > 0
|
729
|
-
AND NOT a.attisdropped
|
730
|
-
AND s.nspname || '.' || t.relname IN (#{tables.size.times.map { |i| "$#{i + 1}" }.join(", ")})
|
731
|
-
ORDER BY
|
732
|
-
1, 2
|
733
|
-
SQL
|
734
|
-
columns = execute(query, params: tables.to_a)
|
735
|
-
columns.map { |v| {table: v["table_name"], column: v["column_name"], type: v["data_type"]} }
|
736
|
-
end
|
737
|
-
|
738
559
|
def indexes(tables)
|
739
560
|
query = <<~SQL
|
740
561
|
SELECT
|
@@ -761,10 +582,6 @@ module Dexter
|
|
761
582
|
execute(query, params: tables.to_a).map { |v| v["columns"] = v["columns"].sub(") WHERE (", " WHERE ").split(", ").map { |c| unquote(c) }; v }
|
762
583
|
end
|
763
584
|
|
764
|
-
def search_path
|
765
|
-
execute("SELECT current_schemas(true)")[0]["current_schemas"][1..-2].split(",")
|
766
|
-
end
|
767
|
-
|
768
585
|
def unquote(part)
|
769
586
|
if part && part.start_with?('"') && part.end_with?('"')
|
770
587
|
part[1..-2]
|
@@ -777,11 +594,6 @@ module Dexter
|
|
777
594
|
value.split(".").map { |v| conn.quote_ident(v) }.join(".")
|
778
595
|
end
|
779
596
|
|
780
|
-
# from activesupport
|
781
|
-
def squish(str)
|
782
|
-
str.to_s.gsub(/\A[[:space:]]+/, "").gsub(/[[:space:]]+\z/, "").gsub(/[[:space:]]+/, " ")
|
783
|
-
end
|
784
|
-
|
785
597
|
def safe_statement(statement)
|
786
598
|
statement.gsub(";", "")
|
787
599
|
end
|