pgdexter 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: c513808e2cdd9690c477c30548979fad122575a4
4
- data.tar.gz: 6f4c605a0b68baa0275a523155b7be4ea9bc4d56
3
+ metadata.gz: 16f61626f5003a801ec12cda3f663390a57e69d8
4
+ data.tar.gz: 238ee1b24dab3f473accc2ca9a5156fd31e8d19e
5
5
  SHA512:
6
- metadata.gz: 3b38a53e96516a485394f3ec594ee0418c1a7ab94fe8726b3228cc5443843dd7f7ba2bd1fe4bb0cc5c4dbd877bd9b9b036d5bfd5b4cda275dc7176dbdf9da82c
7
- data.tar.gz: cbe64d0cf9a40b96bf2644a80519bb837d235d6956ca9b61b24533867130958c95932ffbf4d69672218a10628c32b87a1f1e87f86a4968577f1629fc871ef440
6
+ metadata.gz: ccae6266196fc84ea0fc69177b80144f8747996ff0693b6a7492e34d095b2cb400e9ce3299741326c50a5eb267ef48be368384a2ab27553002eb93ac7b6d0922
7
+ data.tar.gz: 944bc27bb1ffff546c2f33aa10aa1b8371ba43ae2be889a6b9445c3d3a93ca36fee0cfd57487134a5ea807e37583f74b1e5e9de7714fa30364ab28f64a5679e8
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## 0.3.0
2
+
3
+ - Added support for schemas
4
+ - Added support for csv format
5
+ - Added `--analyze` option and do not analyze by default
6
+ - Added `--min-calls` option
7
+ - Fixed debug output when indexes not found
8
+
9
+ ## 0.2.1
10
+
11
+ - Fixed bad suggestions
12
+ - Improved debugging output
13
+
1
14
  ## 0.2.0
2
15
 
3
16
  - Added same connection options as `psql`
data/README.md CHANGED
@@ -12,8 +12,8 @@ First, install [HypoPG](https://github.com/dalibo/hypopg) on your database serve
12
12
 
13
13
  ```sh
14
14
  cd /tmp
15
- curl -L https://github.com/dalibo/hypopg/archive/1.0.0.tar.gz | tar xz
16
- cd hypopg-1.0.0
15
+ curl -L https://github.com/dalibo/hypopg/archive/1.1.0.tar.gz | tar xz
16
+ cd hypopg-1.1.0
17
17
  make
18
18
  make install # may need sudo
19
19
  ```
@@ -45,23 +45,23 @@ tail -F -n +1 <log-file> | dexter <connection-options>
45
45
  This finds slow queries and generates output like:
46
46
 
47
47
  ```
48
- 2017-06-25T17:52:19+00:00 Started
49
- 2017-06-25T17:52:22+00:00 Processing 189 new query fingerprints
50
- 2017-06-25T17:52:22+00:00 Index found: genres_movies (genre_id)
51
- 2017-06-25T17:52:22+00:00 Index found: genres_movies (movie_id)
52
- 2017-06-25T17:52:22+00:00 Index found: movies (title)
53
- 2017-06-25T17:52:22+00:00 Index found: ratings (movie_id)
54
- 2017-06-25T17:52:22+00:00 Index found: ratings (rating)
55
- 2017-06-25T17:52:22+00:00 Index found: ratings (user_id)
56
- 2017-06-25T17:53:22+00:00 Processing 12 new query fingerprints
48
+ Started
49
+ Processing 189 new query fingerprints
50
+ Index found: public.genres_movies (genre_id)
51
+ Index found: public.genres_movies (movie_id)
52
+ Index found: public.movies (title)
53
+ Index found: public.ratings (movie_id)
54
+ Index found: public.ratings (rating)
55
+ Index found: public.ratings (user_id)
56
+ Processing 12 new query fingerprints
57
57
  ```
58
58
 
59
59
  To be safe, Dexter will not create indexes unless you pass the `--create` flag. In this case, you’ll see:
60
60
 
61
61
  ```
62
- 2017-06-25T17:52:22+00:00 Index found: ratings (user_id)
63
- 2017-06-25T17:52:22+00:00 Creating index: CREATE INDEX CONCURRENTLY ON "ratings" ("user_id")
64
- 2017-06-25T17:52:37+00:00 Index created: 15243 ms
62
+ Index found: public.ratings (user_id)
63
+ Creating index: CREATE INDEX CONCURRENTLY ON "public"."ratings" ("user_id")
64
+ Index created: 15243 ms
65
65
  ```
66
66
 
67
67
  ## Connection Options
@@ -84,30 +84,58 @@ and connection strings:
84
84
  host=localhost port=5432 dbname=mydb
85
85
  ```
86
86
 
87
- ## Options
87
+ ## Collecting Queries
88
88
 
89
- Name | Description | Default
90
- --- | --- | ---
91
- exclude | prevent specific tables from being indexed | None
92
- interval | time to wait between processing queries, in seconds | 60
93
- log-level | `debug` gives additional info for suggested indexes<br />`debug2` gives additional info for processed queries<br />`error` suppresses logging | info
94
- log-sql | log SQL statements executed | false
95
- min-time | only process queries consuming a min amount of DB time, in minutes | 0
89
+ There are many ways to collect queries. For real-time indexing, pipe your logfile:
96
90
 
97
- ## Non-Streaming Modes
91
+ ```sh
92
+ tail -F -n +1 <log-file> | dexter <connection-options>
93
+ ```
98
94
 
99
- You can pass a single statement with:
95
+ Pass a single statement with:
100
96
 
101
97
  ```sh
102
98
  dexter <connection-options> -s "SELECT * FROM ..."
103
99
  ```
104
100
 
105
- or files with:
101
+ or pass files:
106
102
 
107
103
  ```sh
108
104
  dexter <connection-options> <file1> <file2>
109
105
  ```
110
106
 
107
+ or use the [pg_stat_statements](https://www.postgresql.org/docs/current/static/pgstatstatements.html) extension:
108
+
109
+ ```sh
110
+ dexter <connection-options> --pg-stat-statements
111
+ ```
112
+
113
+ ### Collection Options
114
+
115
+ To prevent one-off queries from being indexed, specify a minimum number of calls before a query is considered for indexing
116
+
117
+ ```sh
118
+ dexter --min-calls 100
119
+ ```
120
+
121
+ You can do the same for total time a query has run
122
+
123
+ ```sh
124
+ dexter --min-time 10 # minutes
125
+ ```
126
+
127
+ Specify the format
128
+
129
+ ```sh
130
+ dexter --input-format csv
131
+ ```
132
+
133
+ When steaming logs, specify the time to wait between processing queries
134
+
135
+ ```sh
136
+ dexter --interval 60 # seconds
137
+ ```
138
+
111
139
  ## Examples
112
140
 
113
141
  Ubuntu with PostgreSQL 9.6
@@ -122,6 +150,36 @@ Homebrew on Mac
122
150
  tail -F -n +1 /usr/local/var/postgres/server.log | dexter dbname
123
151
  ```
124
152
 
153
+ ## Tables
154
+
155
+ You can exclude large or write-heavy tables from indexing with:
156
+
157
+ ```sh
158
+ dexter --exclude table1,table2
159
+ ```
160
+
161
+ Alternatively, you can specify which tables to index with:
162
+
163
+ ```sh
164
+ dexter --include table3,table4
165
+ ```
166
+
167
+ ## Debugging
168
+
169
+ See how Dexter is processing queries with:
170
+
171
+ ```sh
172
+ dexter --log-sql --log-level debug2
173
+ ```
174
+
175
+ ## Analyze
176
+
177
+ For best results, make sure your tables have been recently analyzed so statistics are up-to-date. You can ask Dexter to analyze tables it comes across that haven’t been analyzed in the past hour with:
178
+
179
+ ```sh
180
+ dexter --analyze
181
+ ```
182
+
125
183
  ## Hosted Postgres
126
184
 
127
185
  Some hosted providers like Amazon RDS and Heroku do not support the HypoPG extension, which Dexter needs to run. See [how to use Dexter](guides/Hosted-Postgres.md) in these cases.
@@ -130,6 +188,21 @@ Some hosted providers like Amazon RDS and Heroku do not support the HypoPG exten
130
188
 
131
189
  [Here are some ideas](https://github.com/ankane/dexter/issues/1)
132
190
 
191
+ ## Upgrading
192
+
193
+ Run:
194
+
195
+ ```sh
196
+ gem install pgdexter
197
+ ```
198
+
199
+ To use master, run:
200
+
201
+ ```sh
202
+ gem install specific_install
203
+ gem specific_install https://github.com/ankane/dexter.git
204
+ ```
205
+
133
206
  ## Thanks
134
207
 
135
208
  This software wouldn’t be possible without [HypoPG](https://github.com/dalibo/hypopg), which allows you to create hypothetical indexes, and [pg_query](https://github.com/lfittl/pg_query), which allows you to parse and fingerprint queries. A big thanks to Dalibo and Lukas Fittl respectively.
@@ -8,12 +8,12 @@ Linux
8
8
 
9
9
  ```sh
10
10
  sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
11
- sudo apt-get install wget ca-certificates
11
+ sudo apt-get install -y wget ca-certificates
12
12
  wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
13
13
  sudo apt-get update
14
- sudo apt-get install postgresql-9.6 postgresql-server-dev-9.6
14
+ sudo apt-get install -y postgresql-9.6 postgresql-server-dev-9.6
15
15
  sudo -u postgres createuser $(whoami) -s
16
- sudo apt-get install ruby2.2 ruby2.2-dev
16
+ sudo apt-get install -y ruby2.2 ruby2.2-dev
17
17
  ```
18
18
 
19
19
  Mac
@@ -83,7 +83,7 @@ heroku logs -p postgres > postgresql.log
83
83
  We recommend creating a new instance from a snapshot for the dump to avoid affecting customers.
84
84
 
85
85
  ```sh
86
- pg_dump -v -j 8 -Fd -f /tmp/newout.dir <connection-string>
86
+ pg_dump -v -j 8 -Fd -f /tmp/newout.dir <connection-options>
87
87
  ```
88
88
 
89
89
  Then shutdown the dump instance. Restore with:
@@ -96,7 +96,7 @@ pg_restore -v -j 8 -x -O --format=d -d dexter_restore /tmp/newout.dir/
96
96
  ### Run Dexter
97
97
 
98
98
  ```sh
99
- dexter dexter_restore postgresql.log*
99
+ dexter dexter_restore postgresql.log* --analyze
100
100
  ```
101
101
 
102
102
  :tada:
data/lib/dexter/client.rb CHANGED
@@ -29,16 +29,20 @@ module Dexter
29
29
  dexter [options]
30
30
 
31
31
  Options:)
32
+ o.boolean "--analyze", "analyze tables that haven't been analyzed in the past hour", default: false
32
33
  o.boolean "--create", "create indexes", default: false
33
34
  o.array "--exclude", "prevent specific tables from being indexed"
34
35
  o.string "--include", "only include specific tables"
36
+ o.string "--input-format", "input format", default: "stderr"
35
37
  o.integer "--interval", "time to wait between processing queries, in seconds", default: 60
38
+ o.float "--min-calls", "only process queries that have been called a certain number of times", default: 0
36
39
  o.float "--min-time", "only process queries that have consumed a certain amount of DB time, in minutes", default: 0
37
40
  o.boolean "--pg-stat-statements", "use pg_stat_statements", default: false, help: false
38
41
  o.boolean "--log-explain", "log explain", default: false, help: false
39
42
  o.string "--log-level", "log level", default: "info"
40
43
  o.boolean "--log-sql", "log sql", default: false
41
44
  o.string "-s", "--statement", "process a single statement"
45
+ # separator must go here to show up correctly - slop bug?
42
46
  o.separator ""
43
47
  o.separator "Connection options:"
44
48
  o.on "-v", "--version", "print the version" do
@@ -62,7 +66,7 @@ Options:)
62
66
 
63
67
  # TODO don't use global var
64
68
  $log_level = options[:log_level].to_s.downcase
65
- abort "Unknown log level" unless ["error", "info", "debug", "debug2"].include?($log_level)
69
+ abort "Unknown log level" unless ["error", "info", "debug", "debug2", "debug3"].include?($log_level)
66
70
 
67
71
  [arguments, options]
68
72
  rescue Slop::Error => e
@@ -5,6 +5,7 @@ module Dexter
5
5
  @new_queries = Set.new
6
6
  @mutex = Mutex.new
7
7
  @min_time = options[:min_time] * 60000 # convert minutes to ms
8
+ @min_calls = options[:min_calls]
8
9
  end
9
10
 
10
11
  def add(query, duration)
@@ -36,8 +37,11 @@ module Dexter
36
37
 
37
38
  queries = []
38
39
  @top_queries.each do |k, v|
39
- if new_queries.include?(k) && v[:total_time] > @min_time
40
- queries << Query.new(v[:query], k)
40
+ if new_queries.include?(k) && v[:total_time] >= @min_time && v[:calls] >= @min_calls
41
+ query = Query.new(v[:query], k)
42
+ query.total_time = v[:total_time]
43
+ query.calls = v[:calls]
44
+ queries << query
41
45
  end
42
46
  end
43
47
 
@@ -0,0 +1,13 @@
1
+ require "csv"
2
+
3
+ module Dexter
4
+ class CsvLogParser < LogParser
5
+ def perform
6
+ CSV.foreach(@logfile.file) do |row|
7
+ if (m = REGEX.match(row[13]))
8
+ process_entry(m[3], m[1].to_f)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -10,6 +10,8 @@ module Dexter
10
10
  @log_sql = options[:log_sql]
11
11
  @log_explain = options[:log_explain]
12
12
  @min_time = options[:min_time] || 0
13
+ @min_calls = options[:min_calls] || 0
14
+ @analyze = options[:analyze]
13
15
  @options = options
14
16
 
15
17
  create_extension unless extension_exists?
@@ -26,24 +28,39 @@ module Dexter
26
28
  # reset hypothetical indexes
27
29
  reset_hypothetical_indexes
28
30
 
29
- # filter queries from other databases and system tables
30
- tables = possible_tables(queries)
31
- queries.each do |query|
32
- query.missing_tables = !query.tables.all? { |t| tables.include?(t) }
33
- end
31
+ tables = Set.new(database_tables)
34
32
 
35
33
  if @include_tables
36
- tables = Set.new(tables.to_a & @include_tables)
34
+ include_set = Set.new(@include_tables)
35
+ tables.keep_if { |t| include_set.include?(t) || include_set.include?(t.split(".")[-1]) }
36
+ end
37
+
38
+ if @exclude_tables.any?
39
+ exclude_set = Set.new(@exclude_tables)
40
+ tables.delete_if { |t| exclude_set.include?(t) || exclude_set.include?(t.split(".")[-1]) }
37
41
  end
38
42
 
39
- # exclude user specified tables
40
- # TODO exclude write-heavy tables
41
- @exclude_tables.each do |table|
42
- tables.delete(table)
43
+ # map tables without schema to schema
44
+ no_schema_tables = {}
45
+ search_path_index = Hash[search_path.map.with_index.to_a]
46
+ tables.group_by { |t| t.split(".")[-1] }.each do |group, t2|
47
+ no_schema_tables[group] = t2.sort_by { |t| search_path_index[t.split(".")[0]] || 1000000 }[0]
48
+ end
49
+
50
+ # filter queries from other databases and system tables
51
+ queries.each do |query|
52
+ # add schema to table if needed
53
+ query.tables = query.tables.map { |t| no_schema_tables[t] || t }
54
+
55
+ # check for missing tables
56
+ query.missing_tables = !query.tables.all? { |t| tables.include?(t) }
43
57
  end
44
58
 
59
+ # set tables
60
+ tables = Set.new(queries.reject(&:missing_tables).flat_map(&:tables))
61
+
45
62
  # analyze tables if needed
46
- analyze_tables(tables) if tables.any?
63
+ analyze_tables(tables) if tables.any? && (@analyze || @log_level == "debug2")
47
64
 
48
65
  # create hypothetical indexes and explain queries
49
66
  candidates = tables.any? ? create_hypothetical_indexes(queries.reject(&:missing_tables), tables) : {}
@@ -52,7 +69,7 @@ module Dexter
52
69
  new_indexes = determine_indexes(queries, candidates, tables)
53
70
 
54
71
  # display and create new indexes
55
- show_and_create_indexes(new_indexes)
72
+ show_and_create_indexes(new_indexes, queries, tables)
56
73
  end
57
74
 
58
75
  private
@@ -81,14 +98,13 @@ module Dexter
81
98
 
82
99
  analyze_stats = execute <<-SQL
83
100
  SELECT
84
- schemaname AS schema,
85
- relname AS table,
101
+ schemaname || '.' || relname AS table,
86
102
  last_analyze,
87
103
  last_autoanalyze
88
104
  FROM
89
105
  pg_stat_user_tables
90
106
  WHERE
91
- relname IN (#{tables.map { |t| quote(t) }.join(", ")})
107
+ schemaname || '.' || relname IN (#{tables.map { |t| quote(t) }.join(", ")})
92
108
  SQL
93
109
 
94
110
  last_analyzed = {}
@@ -97,7 +113,14 @@ module Dexter
97
113
  end
98
114
 
99
115
  tables.each do |table|
100
- if !last_analyzed[table] || last_analyzed[table] < Time.now - 3600
116
+ la = last_analyzed[table]
117
+
118
+ if @log_level == "debug2"
119
+ time_str = la ? la.iso8601 : "Unknown"
120
+ log "Last analyze: #{table} : #{time_str}"
121
+ end
122
+
123
+ if @analyze && (!la || la < Time.now - 3600)
101
124
  statement = "ANALYZE #{quote_ident(table)}"
102
125
  log "Running analyze: #{statement}"
103
126
  execute(statement)
@@ -133,17 +156,11 @@ module Dexter
133
156
  tables = Set.new(explainable_queries.flat_map(&:tables))
134
157
 
135
158
  if tables.any?
136
- # get existing indexes
137
- index_set = Set.new
138
- indexes(tables).each do |index|
139
- # TODO make sure btree
140
- index_set << [index["table"], index["columns"]]
141
- end
142
-
143
159
  # since every set of multi-column indexes are expensive
144
160
  # try to parse out columns
145
161
  possible_columns = Set.new
146
162
  explainable_queries.each do |query|
163
+ log "Finding columns: #{query.statement}" if @log_level == "debug3"
147
164
  find_columns(query.tree).each do |col|
148
165
  last_col = col["fields"].last
149
166
  if last_col["String"]
@@ -156,13 +173,13 @@ module Dexter
156
173
  columns_by_table = columns(tables).select { |c| possible_columns.include?(c[:column]) }.group_by { |c| c[:table] }
157
174
 
158
175
  # create single column indexes
159
- create_hypothetical_indexes_helper(columns_by_table, 1, index_set, candidates)
176
+ create_hypothetical_indexes_helper(columns_by_table, 1, candidates)
160
177
 
161
178
  # get next round of costs
162
179
  calculate_plan(explainable_queries)
163
180
 
164
181
  # create multicolumn indexes
165
- create_hypothetical_indexes_helper(columns_by_table, 2, index_set, candidates)
182
+ create_hypothetical_indexes_helper(columns_by_table, 2, candidates)
166
183
 
167
184
  # get next round of costs
168
185
  calculate_plan(explainable_queries)
@@ -196,56 +213,145 @@ module Dexter
196
213
  indexes
197
214
  end
198
215
 
216
+ def hypo_indexes_from_plan(index_name_to_columns, plan, index_set)
217
+ query_indexes = []
218
+
219
+ find_indexes(plan).uniq.sort.each do |index_name|
220
+ col_set = index_name_to_columns[index_name]
221
+
222
+ if col_set
223
+ index = {
224
+ table: col_set[0][:table],
225
+ columns: col_set.map { |c| c[:column] }
226
+ }
227
+
228
+ unless index_set.include?([index[:table], index[:columns]])
229
+ query_indexes << index
230
+ end
231
+ end
232
+ end
233
+
234
+ query_indexes
235
+ end
236
+
199
237
  def determine_indexes(queries, candidates, tables)
200
238
  new_indexes = {}
201
239
  index_name_to_columns = candidates.invert
202
240
 
241
+ # filter out existing indexes
242
+ # this must happen at end of process
243
+ # since sometimes hypothetical indexes
244
+ # can give lower cost than actual indexes
245
+ index_set = Set.new
246
+ if tables.any?
247
+ indexes(tables).each do |index|
248
+ if index["using"] == "btree"
249
+ # don't add indexes that are already covered
250
+ index_set << [index["table"], index["columns"].first(1)]
251
+ index_set << [index["table"], index["columns"].first(2)]
252
+ end
253
+ end
254
+ end
255
+
203
256
  queries.each do |query|
204
257
  if query.explainable? && query.high_cost?
205
258
  new_cost, new_cost2 = query.costs[1..2]
206
259
 
207
260
  cost_savings = new_cost < query.initial_cost * 0.5
261
+
208
262
  # set high bar for multicolumn indexes
209
263
  cost_savings2 = new_cost > 100 && new_cost2 < new_cost * 0.5
210
264
 
211
- query.new_cost = cost_savings2 ? new_cost2 : new_cost
212
-
213
- query_indexes = []
214
265
  key = cost_savings2 ? 2 : 1
215
- indexes = find_indexes(query.plans[key]).uniq.sort
266
+ query_indexes = hypo_indexes_from_plan(index_name_to_columns, query.plans[key], index_set)
216
267
 
217
- indexes.each do |index_name|
218
- col_set = index_name_to_columns[index_name]
268
+ # likely a bad suggestion, so try single column
269
+ if cost_savings2 && query_indexes.size > 1
270
+ query_indexes = hypo_indexes_from_plan(index_name_to_columns, query.plans[1], index_set)
271
+ cost_savings2 = false
272
+ end
219
273
 
220
- if col_set
221
- index = {
222
- table: col_set[0][:table],
223
- columns: col_set.map { |c| c[:column] }
224
- }
225
- query_indexes << index
274
+ # TODO if multiple indexes are found (for either single or multicolumn)
275
+ # determine the impact of each individually
276
+ # for now, be conservative and don't suggest if more than one index
277
+ suggest_index = (cost_savings || cost_savings2) && query_indexes.size == 1
226
278
 
227
- if cost_savings
228
- new_indexes[index] ||= index.dup
229
- (new_indexes[index][:queries] ||= []) << query
230
- end
279
+ if suggest_index
280
+ query_indexes.each do |index|
281
+ new_indexes[index] ||= index.dup
282
+ (new_indexes[index][:queries] ||= []) << query
231
283
  end
232
284
  end
285
+
286
+ query.indexes = query_indexes
287
+ query.suggest_index = suggest_index
288
+ query.new_cost =
289
+ if suggest_index
290
+ cost_savings2 ? new_cost2 : new_cost
291
+ else
292
+ query.initial_cost
293
+ end
294
+
295
+ # TODO optimize
296
+ if @log_level.start_with?("debug")
297
+ query.pass1_indexes = hypo_indexes_from_plan(index_name_to_columns, query.plans[1], index_set)
298
+ query.pass2_indexes = hypo_indexes_from_plan(index_name_to_columns, query.plans[2], index_set)
299
+ end
300
+ end
301
+ end
302
+
303
+ # filter out covered indexes
304
+ covered = Set.new
305
+ new_indexes.values.each do |index|
306
+ if index[:columns].size > 1
307
+ covered << [index[:table], index[:columns].first(1)]
233
308
  end
309
+ end
234
310
 
311
+ new_indexes.values.reject { |i| covered.include?([i[:table], i[:columns]]) }.sort_by(&:to_a)
312
+ end
313
+
314
+ def log_indexes(indexes)
315
+ if indexes.any?
316
+ indexes.map { |i| "#{i[:table]} (#{i[:columns].join(", ")})" }.join(", ")
317
+ else
318
+ "None"
319
+ end
320
+ end
321
+
322
+ def show_and_create_indexes(new_indexes, queries, tables)
323
+ # print summary
324
+ if new_indexes.any?
325
+ new_indexes.each do |index|
326
+ log "Index found: #{index[:table]} (#{index[:columns].join(", ")})"
327
+ end
328
+ else
329
+ log "No new indexes found"
330
+ end
331
+
332
+ # debug info
333
+ if @log_level.start_with?("debug")
334
+ index_queries = new_indexes.flat_map { |i| i[:queries].sort_by(&:fingerprint) }
235
335
  if @log_level == "debug2"
236
- log "Processed #{query.fingerprint}"
336
+ fingerprints = Set.new(index_queries.map(&:fingerprint))
337
+ index_queries.concat(queries.reject { |q| fingerprints.include?(q.fingerprint) }.sort_by(&:fingerprint))
338
+ end
339
+ index_queries.each do |query|
340
+ log "-" * 80
341
+ log "Query #{query.fingerprint}"
342
+ 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.total_time
237
343
  if tables.empty?
238
344
  log "No candidate tables for indexes"
239
345
  elsif query.explainable? && !query.high_cost?
240
346
  log "Low initial cost: #{query.initial_cost}"
241
347
  elsif query.explainable?
242
- log "Cost: #{query.initial_cost} -> #{query.new_cost}"
243
-
244
- if query_indexes.any?
245
- log "Indexes: #{query_indexes.map { |i| "#{i[:table]} (#{i[:columns].join(", ")})" }.join(", ")}"
246
- log "Need 50% cost savings to suggest index" unless cost_savings || cost_savings2
247
- else
248
- log "Indexes: None"
348
+ query_indexes = query.indexes || []
349
+ log "Start: #{query.costs[0]}"
350
+ log "Pass1: #{query.costs[1]} : #{log_indexes(query.pass1_indexes || [])}"
351
+ log "Pass2: #{query.costs[2]} : #{log_indexes(query.pass2_indexes || [])}"
352
+ log "Final: #{query.new_cost} : #{log_indexes(query.suggest_index ? query_indexes : [])}"
353
+ if query_indexes.any? && !query.suggest_index
354
+ log "Need 50% cost savings to suggest index"
249
355
  end
250
356
  elsif query.fingerprint == "unknown"
251
357
  log "Could not parse query"
@@ -256,54 +362,33 @@ module Dexter
256
362
  else
257
363
  log "Could not run explain"
258
364
  end
259
-
260
- puts
261
- puts query.statement
262
- puts
365
+ log
366
+ log query.statement
367
+ log
263
368
  end
264
369
  end
265
370
 
266
- new_indexes.values.sort_by(&:to_a)
267
- end
268
-
269
- def show_and_create_indexes(new_indexes)
270
- if new_indexes.any?
271
- new_indexes.each do |index|
272
- log "Index found: #{index[:table]} (#{index[:columns].join(", ")})"
273
-
274
- if @log_level.start_with?("debug")
275
- index[:queries].sort_by(&:fingerprint).each do |query|
276
- log "Query #{query.fingerprint} (Cost: #{query.initial_cost} -> #{query.new_cost})"
277
- puts
278
- puts query.statement
279
- puts
280
- end
281
- end
282
- end
283
-
284
- if @create
285
- # 1. create lock
286
- # 2. refresh existing index list
287
- # 3. create indexes that still don't exist
288
- # 4. release lock
289
- with_advisory_lock do
290
- new_indexes.each do |index|
291
- unless index_exists?(index)
292
- statement = "CREATE INDEX CONCURRENTLY ON #{quote_ident(index[:table])} (#{index[:columns].map { |c| quote_ident(c) }.join(", ")})"
293
- log "Creating index: #{statement}"
294
- started_at = Time.now
295
- begin
296
- execute(statement)
297
- log "Index created: #{((Time.now - started_at) * 1000).to_i} ms"
298
- rescue PG::LockNotAvailable => e
299
- log "Could not acquire lock: #{index[:table]}"
300
- end
371
+ # create
372
+ if @create && new_indexes.any?
373
+ # 1. create lock
374
+ # 2. refresh existing index list
375
+ # 3. create indexes that still don't exist
376
+ # 4. release lock
377
+ with_advisory_lock do
378
+ new_indexes.each do |index|
379
+ unless index_exists?(index)
380
+ statement = "CREATE INDEX CONCURRENTLY ON #{quote_ident(index[:table])} (#{index[:columns].map { |c| quote_ident(c) }.join(", ")})"
381
+ log "Creating index: #{statement}"
382
+ started_at = Time.now
383
+ begin
384
+ execute(statement)
385
+ log "Index created: #{((Time.now - started_at) * 1000).to_i} ms"
386
+ rescue PG::LockNotAvailable
387
+ log "Could not acquire lock: #{index[:table]}"
301
388
  end
302
389
  end
303
390
  end
304
391
  end
305
- else
306
- log "No new indexes found"
307
392
  end
308
393
 
309
394
  new_indexes
@@ -347,13 +432,11 @@ module Dexter
347
432
  end
348
433
 
349
434
  # TODO for multicolumn indexes, use ordering
350
- def create_hypothetical_indexes_helper(columns_by_table, n, index_set, candidates)
435
+ def create_hypothetical_indexes_helper(columns_by_table, n, candidates)
351
436
  columns_by_table.each do |table, cols|
352
437
  # no reason to use btree index for json columns
353
438
  cols.reject { |c| ["json", "jsonb"].include?(c[:type]) }.permutation(n) do |col_set|
354
- if !index_set.include?([table, col_set.map { |col| col[:column] }])
355
- candidates[col_set] = execute("SELECT * FROM hypopg_create_index('CREATE INDEX ON #{quote_ident(table)} (#{col_set.map { |c| quote_ident(c[:column]) }.join(", ")})')").first["indexname"]
356
- end
439
+ candidates[col_set] = execute("SELECT * FROM hypopg_create_index('CREATE INDEX ON #{quote_ident(table)} (#{col_set.map { |c| quote_ident(c[:column]) }.join(", ")})')").first["indexname"]
357
440
  end
358
441
  end
359
442
  end
@@ -361,7 +444,7 @@ module Dexter
361
444
  def database_tables
362
445
  result = execute <<-SQL
363
446
  SELECT
364
- table_name
447
+ table_schema || '.' || table_name AS table_name
365
448
  FROM
366
449
  information_schema.tables
367
450
  WHERE
@@ -383,16 +466,13 @@ module Dexter
383
466
  WHERE
384
467
  datname = current_database()
385
468
  AND total_time >= #{@min_time * 60000}
469
+ AND calls >= #{@min_calls}
386
470
  ORDER BY
387
471
  1
388
472
  SQL
389
473
  result.map { |q| q["query"] }
390
474
  end
391
475
 
392
- def possible_tables(queries)
393
- Set.new(queries.flat_map(&:tables).uniq & database_tables)
394
- end
395
-
396
476
  def with_advisory_lock
397
477
  lock_id = 123456
398
478
  first_time = true
@@ -424,14 +504,13 @@ module Dexter
424
504
  def columns(tables)
425
505
  columns = execute <<-SQL
426
506
  SELECT
427
- table_name,
507
+ table_schema || '.' || table_name AS table_name,
428
508
  column_name,
429
509
  data_type
430
510
  FROM
431
511
  information_schema.columns
432
512
  WHERE
433
- table_schema = 'public' AND
434
- table_name IN (#{tables.map { |t| quote(t) }.join(", ")})
513
+ table_schema || '.' || table_name IN (#{tables.map { |t| quote(t) }.join(", ")})
435
514
  ORDER BY
436
515
  1, 2
437
516
  SQL
@@ -442,8 +521,7 @@ module Dexter
442
521
  def indexes(tables)
443
522
  execute(<<-SQL
444
523
  SELECT
445
- schemaname AS schema,
446
- t.relname AS table,
524
+ schemaname || '.' || t.relname AS table,
447
525
  ix.relname AS name,
448
526
  regexp_replace(pg_get_indexdef(i.indexrelid), '^[^\\(]*\\((.*)\\)$', '\\1') AS columns,
449
527
  regexp_replace(pg_get_indexdef(i.indexrelid), '.* USING ([^ ]*) \\(.*', '\\1') AS using
@@ -456,8 +534,7 @@ module Dexter
456
534
  LEFT JOIN
457
535
  pg_stat_user_indexes ui ON ui.indexrelid = i.indexrelid
458
536
  WHERE
459
- t.relname IN (#{tables.map { |t| quote(t) }.join(", ")}) AND
460
- schemaname IS NOT NULL AND
537
+ schemaname || '.' || t.relname IN (#{tables.map { |t| quote(t) }.join(", ")}) AND
461
538
  indisvalid = 't' AND
462
539
  indexprs IS NULL AND
463
540
  indpred IS NULL
@@ -467,8 +544,12 @@ module Dexter
467
544
  ).map { |v| v["columns"] = v["columns"].sub(") WHERE (", " WHERE ").split(", ").map { |c| unquote(c) }; v }
468
545
  end
469
546
 
547
+ def search_path
548
+ execute("SHOW search_path")[0]["search_path"].split(",").map(&:strip)
549
+ end
550
+
470
551
  def unquote(part)
471
- if part && part.start_with?('"')
552
+ if part && part.start_with?('"') && part.end_with?('"')
472
553
  part[1..-2]
473
554
  else
474
555
  part
@@ -476,7 +557,7 @@ module Dexter
476
557
  end
477
558
 
478
559
  def quote_ident(value)
479
- conn.quote_ident(value)
560
+ value.split(".").map { |v| conn.quote_ident(v) }.join(".")
480
561
  end
481
562
 
482
563
  def quote(value)
@@ -1,6 +1,6 @@
1
1
  module Dexter
2
2
  class LogParser
3
- REGEX = /duration: (\d+\.\d+) ms (statement|execute <unnamed>): (.+)/
3
+ REGEX = /duration: (\d+\.\d+) ms (statement|execute <unnamed>|parse <unnamed>): (.+)/
4
4
  LINE_SEPERATOR = ": ".freeze
5
5
 
6
6
  def initialize(logfile, collector)
@@ -12,7 +12,7 @@ module Dexter
12
12
  active_line = nil
13
13
  duration = nil
14
14
 
15
- each_line do |line|
15
+ @logfile.each_line do |line|
16
16
  if active_line
17
17
  if line.include?(LINE_SEPERATOR)
18
18
  process_entry(active_line, duration)
@@ -22,7 +22,7 @@ module Dexter
22
22
  end
23
23
  end
24
24
 
25
- if !active_line && m = REGEX.match(line.chomp)
25
+ if !active_line && (m = REGEX.match(line.chomp))
26
26
  duration = m[1].to_f
27
27
  active_line = m[3]
28
28
  end
@@ -32,12 +32,6 @@ module Dexter
32
32
 
33
33
  private
34
34
 
35
- def each_line
36
- @logfile.each_line do |line|
37
- yield line
38
- end
39
- end
40
-
41
35
  def process_entry(query, duration)
42
36
  @collector.add(query, duration)
43
37
  end
@@ -1,7 +1,7 @@
1
1
  module Dexter
2
2
  module Logging
3
- def log(message)
4
- puts "#{Time.now.iso8601} #{message}" unless $log_level == "error"
3
+ def log(message = "")
4
+ puts message unless $log_level == "error"
5
5
  end
6
6
  end
7
7
  end
@@ -5,8 +5,14 @@ module Dexter
5
5
  def initialize(logfile, options)
6
6
  @logfile = logfile
7
7
 
8
- @collector = Collector.new(min_time: options[:min_time])
9
- @log_parser = LogParser.new(logfile, @collector)
8
+ @collector = Collector.new(min_time: options[:min_time], min_calls: options[:min_calls])
9
+ @log_parser =
10
+ if options[:input_format] == "csv"
11
+ CsvLogParser.new(logfile, @collector)
12
+ else
13
+ LogParser.new(logfile, @collector)
14
+ end
15
+
10
16
  @indexer = Indexer.new(options)
11
17
 
12
18
  @starting_interval = 3
data/lib/dexter/query.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  module Dexter
2
2
  class Query
3
3
  attr_reader :statement, :fingerprint, :plans
4
- attr_accessor :missing_tables, :new_cost
4
+ attr_writer :tables
5
+ attr_accessor :missing_tables, :new_cost, :total_time, :calls, :indexes, :suggest_index, :pass1_indexes, :pass2_indexes
5
6
 
6
7
  def initialize(statement, fingerprint = nil)
7
8
  @statement = statement
@@ -13,7 +14,15 @@ module Dexter
13
14
  end
14
15
 
15
16
  def tables
16
- @tables ||= parse ? parse.tables : []
17
+ @tables ||= begin
18
+ parse ? parse.tables : []
19
+ rescue => e
20
+ # possible pg_query bug
21
+ $stderr.puts "Error extracting tables. Please report to https://github.com/ankane/dexter/issues"
22
+ $stderr.puts "#{e.class.name}: #{e.message}"
23
+ $stderr.puts statement
24
+ []
25
+ end
17
26
  end
18
27
 
19
28
  def tree
@@ -1,3 +1,3 @@
1
1
  module Dexter
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/dexter.rb CHANGED
@@ -10,5 +10,6 @@ require "dexter/client"
10
10
  require "dexter/collector"
11
11
  require "dexter/indexer"
12
12
  require "dexter/log_parser"
13
+ require "dexter/csv_log_parser"
13
14
  require "dexter/processor"
14
15
  require "dexter/query"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgdexter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2017-08-28 00:00:00.000000000 Z
11
+ date: 2017-12-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: slop
@@ -115,6 +115,7 @@ files:
115
115
  - lib/dexter.rb
116
116
  - lib/dexter/client.rb
117
117
  - lib/dexter/collector.rb
118
+ - lib/dexter/csv_log_parser.rb
118
119
  - lib/dexter/indexer.rb
119
120
  - lib/dexter/log_parser.rb
120
121
  - lib/dexter/logging.rb
@@ -141,7 +142,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
142
  version: '0'
142
143
  requirements: []
143
144
  rubyforge_project:
144
- rubygems_version: 2.6.11
145
+ rubygems_version: 2.6.13
145
146
  signing_key:
146
147
  specification_version: 4
147
148
  summary: The automatic indexer for Postgres