pgdexter 0.4.3 → 0.5.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
  SHA256:
3
- metadata.gz: d2b2528c0ff8201ce676b1a4ca7ec11de52013bfc608fab1b574122a31dd5ae9
4
- data.tar.gz: 570f24c171e02425a5c492068ccedf48e318194333d825cc384ce6cfeeb6c30a
3
+ metadata.gz: 65822d0d98c9641efdc3146295e098e09e83348ed109b856670a03d74ed2d70b
4
+ data.tar.gz: 6d16c9019172e5e69df056ac358588151fa52ae9e8256159547e1842e2f9b97c
5
5
  SHA512:
6
- metadata.gz: e0975c42916c22b0d8f0b12f0144865370372771526ce32574a4602918aadd786b8ab39cb092ad28590c61c86820c51dcac4f7a41cc5930412868ce2a0590498
7
- data.tar.gz: e91d272a0d6bc737634615118a1a33cbbf2ac2fe957aabb17edae9ae42fa17f596052e83a9caa80b71effa6b20316b12e551bd8aea7734648b3210bdb2bf00ec
6
+ metadata.gz: 4991adea5ee65493ea99abe94c19360fc6cc718048784431409abc08fbaf1b1efe3b304dedbd0994d8b66b38294b41ea6400bf5de1f03f09694723a7b709e77c
7
+ data.tar.gz: 00f47a3efd2de6565dd5f5a3a64caa4b610e282d16620975376876501130a6d0cdbbef9412254b8f3d09db2df7a1f3f62d3a8e581c4fcfecd27ea3224cb6f20f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## 0.5.0 (2023-04-18)
2
+
3
+ - Added support for normalized queries
4
+ - Added `--stdin` option (now required to read from stdin)
5
+ - Added `--enable-hypopg` option (now required to enable HypoPG)
6
+ - Improved output when HypoPG not installed
7
+ - Changed `--pg-stat-activity` to sample 10 times and exit
8
+ - Detect input format based on file extension
9
+ - Dropped support for experimental `--log-table` option
10
+ - Dropped support for Linux packages for Ubuntu 18.04 and Debian 10
11
+ - Dropped support for Ruby < 2.7
12
+ - Dropped support for Postgres < 11
13
+
1
14
  ## 0.4.3 (2023-03-26)
2
15
 
3
16
  - Added experimental `--log-table` option
data/README.md CHANGED
@@ -18,15 +18,15 @@ make
18
18
  make install # may need sudo
19
19
  ```
20
20
 
21
- > Note: If you have issues, make sure `postgresql-server-dev-*` is installed.
21
+ And enable it in databases where you want to use Dexter:
22
22
 
23
- Enable logging for slow queries in your Postgres config file.
24
-
25
- ```ini
26
- log_min_duration_statement = 10 # ms
23
+ ```sql
24
+ CREATE EXTENSION hypopg;
27
25
  ```
28
26
 
29
- And install the command line tool with:
27
+ See the [installation notes](#hypopg-installation-notes) if you run into issues.
28
+
29
+ Then install the command line tool with:
30
30
 
31
31
  ```sh
32
32
  gem install pgdexter
@@ -36,10 +36,10 @@ The command line tool is also available with [Docker](#docker), [Homebrew](#home
36
36
 
37
37
  ## How to Use
38
38
 
39
- Dexter needs a connection to your database and a log file to process.
39
+ Dexter needs a connection to your database and a source of queries (like [pg_stat_statements](https://www.postgresql.org/docs/current/pgstatstatements.html)) to process.
40
40
 
41
41
  ```sh
42
- tail -F -n +1 <log-file> | dexter <connection-options>
42
+ dexter -d dbname --pg-stat-statements
43
43
  ```
44
44
 
45
45
  This finds slow queries and generates output like:
@@ -53,7 +53,6 @@ Index found: public.movies (title)
53
53
  Index found: public.ratings (movie_id)
54
54
  Index found: public.ratings (rating)
55
55
  Index found: public.ratings (user_id)
56
- Processing 12 new query fingerprints
57
56
  ```
58
57
 
59
58
  To be safe, Dexter will not create indexes unless you pass the `--create` flag. In this case, you’ll see:
@@ -84,50 +83,89 @@ and connection strings:
84
83
  host=localhost port=5432 dbname=mydb
85
84
  ```
86
85
 
86
+ Always make sure your [connection is secure](https://ankane.org/postgres-sslmode-explained) when connecting to a database over a network you don’t fully trust.
87
+
87
88
  ## Collecting Queries
88
89
 
89
- There are many ways to collect queries. For real-time indexing, pipe your logfile:
90
+ Dexter can collect queries from a number of sources.
91
+
92
+ - [Query stats](#query-stats)
93
+ - [Live queries](#live-queries)
94
+ - [Log files](#log-file)
95
+ - [SQL files](#sql-files)
96
+
97
+ ### Query Stats
98
+
99
+ Enable [pg_stat_statements](https://www.postgresql.org/docs/current/pgstatstatements.html) in your database.
100
+
101
+ ```psql
102
+ CREATE EXTENSION pg_stat_statements;
103
+ ```
104
+
105
+ And use:
90
106
 
91
107
  ```sh
92
- tail -F -n +1 <log-file> | dexter <connection-options>
108
+ dexter <connection-options> --pg-stat-statements
93
109
  ```
94
110
 
95
- Pass a single statement with:
111
+ ### Live Queries
112
+
113
+ Get live queries from the [pg_stat_activity](https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-ACTIVITY-VIEW) view with:
96
114
 
97
115
  ```sh
98
- dexter <connection-options> -s "SELECT * FROM ..."
116
+ dexter <connection-options> --pg-stat-activity
117
+ ```
118
+
119
+ ### Log Files
120
+
121
+ Enable logging for slow queries in your Postgres config file.
122
+
123
+ ```ini
124
+ log_min_duration_statement = 10 # ms
99
125
  ```
100
126
 
101
- or pass files:
127
+ And use:
102
128
 
103
129
  ```sh
104
- dexter <connection-options> <file1> <file2>
130
+ dexter <connection-options> postgresql.log
105
131
  ```
106
132
 
107
- or collect running queries with:
133
+ Supports `stderr`, `csvlog`, and `jsonlog` formats.
134
+
135
+ For real-time indexing, pipe your logfile:
108
136
 
109
137
  ```sh
110
- dexter <connection-options> --pg-stat-activity
138
+ tail -F -n +1 postgresql.log | dexter <connection-options> --stdin
111
139
  ```
112
140
 
113
- ### Collection Options
141
+ And pass `--input-format csvlog` or `--input-format jsonlog` if needed.
114
142
 
115
- To prevent one-off queries from being indexed, specify a minimum number of calls before a query is considered for indexing
143
+ ### SQL Files
144
+
145
+ Pass a SQL file with:
116
146
 
117
147
  ```sh
118
- dexter --min-calls 100
148
+ dexter <connection-options> queries.sql
119
149
  ```
120
150
 
121
- You can do the same for total time a query has run
151
+ Pass a single query with:
122
152
 
123
153
  ```sh
124
- dexter --min-time 10 # minutes
154
+ dexter <connection-options> -s "SELECT * FROM ..."
125
155
  ```
126
156
 
127
- Specify the format
157
+ ## Collection Options
158
+
159
+ To prevent one-off queries from being indexed, specify a minimum number of calls before a query is considered for indexing
128
160
 
129
161
  ```sh
130
- dexter --input-format csv
162
+ dexter --min-calls 100
163
+ ```
164
+
165
+ You can do the same for total time a query has run
166
+
167
+ ```sh
168
+ dexter --min-time 10 # minutes
131
169
  ```
132
170
 
133
171
  When streaming logs, specify the time to wait between processing queries
@@ -138,16 +176,22 @@ dexter --interval 60 # seconds
138
176
 
139
177
  ## Examples
140
178
 
141
- Ubuntu with PostgreSQL 12
179
+ Postgres package on Ubuntu 22.04
180
+
181
+ ```sh
182
+ sudo -u postgres dexter -d dbname /var/log/postgresql/postgresql-14-main.log
183
+ ```
184
+
185
+ Homebrew Postgres on Mac ARM
142
186
 
143
187
  ```sh
144
- tail -F -n +1 /var/log/postgresql/postgresql-12-main.log | sudo -u postgres dexter dbname
188
+ dexter -d dbname /opt/homebrew/var/log/postgresql@14.log
145
189
  ```
146
190
 
147
- Homebrew on Mac
191
+ Homebrew Postgres on Mac x86-64
148
192
 
149
193
  ```sh
150
- tail -F -n +1 /usr/local/var/postgres/server.log | dexter dbname
194
+ dexter -d dbname /usr/local/var/log/postgresql@14.log
151
195
  ```
152
196
 
153
197
  ## Analyze
@@ -190,6 +234,30 @@ For other providers, see [this guide](guides/Hosted-Postgres.md). To request a n
190
234
  - Google Cloud SQL - vote or comment on [this page](https://issuetracker.google.com/issues/69250435)
191
235
  - DigitalOcean Managed Databases - vote or comment on [this page](https://ideas.digitalocean.com/app-framework-services/p/support-hypopg-for-postgres)
192
236
 
237
+ ## HypoPG Installation Notes
238
+
239
+ ### Postgres Location
240
+
241
+ If your machine has multiple Postgres installations, specify the path to [pg_config](https://www.postgresql.org/docs/current/app-pgconfig.html) with:
242
+
243
+ ```sh
244
+ export PG_CONFIG=/Applications/Postgres.app/Contents/Versions/latest/bin/pg_config
245
+ ```
246
+
247
+ Then re-run the installation instructions (run `make clean` before `make` if needed)
248
+
249
+ ### Missing Header
250
+
251
+ If compilation fails with `fatal error: postgres.h: No such file or directory`, make sure Postgres development files are installed on the server.
252
+
253
+ For Ubuntu and Debian, use:
254
+
255
+ ```sh
256
+ sudo apt-get install postgresql-server-dev-15
257
+ ```
258
+
259
+ Note: Replace `15` with your Postgres server version
260
+
193
261
  ## Additional Installation Methods
194
262
 
195
263
  ### Docker
@@ -213,12 +281,12 @@ For databases on the host machine, use `host.docker.internal` as the hostname (o
213
281
  With Homebrew, you can use:
214
282
 
215
283
  ```sh
216
- brew install ankane/brew/dexter
284
+ brew install dexter
217
285
  ```
218
286
 
219
287
  ## Future Work
220
288
 
221
- [Here are some ideas](https://github.com/ankane/dexter/issues/1)
289
+ [Here are some ideas](https://github.com/ankane/dexter/issues/45)
222
290
 
223
291
  ## Upgrading
224
292
 
@@ -235,9 +303,19 @@ gem install specific_install
235
303
  gem specific_install https://github.com/ankane/dexter.git
236
304
  ```
237
305
 
306
+ ## Upgrade Notes
307
+
308
+ ### 0.5.0
309
+
310
+ The `--stdin` option is now required to read queries from stdin.
311
+
312
+ ```sh
313
+ tail -F -n +1 postgresql.log | dexter <connection-options> --stdin
314
+ ```
315
+
238
316
  ## Thanks
239
317
 
240
- This software wouldn’t be possible without [HypoPG](https://github.com/HypoPG/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.
318
+ This software wouldn’t be possible without [HypoPG](https://github.com/HypoPG/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. Also, thanks to YugabyteDB for [this article](https://dev.to/yugabyte/explain-from-pgstatstatements-normalized-queries-how-to-always-get-the-generic-plan-in--5cfi) on how to explain normalized queries.
241
319
 
242
320
  ## Research
243
321
 
data/lib/dexter/client.rb CHANGED
@@ -7,7 +7,7 @@ module Dexter
7
7
 
8
8
  def self.start
9
9
  Dexter::Client.new(ARGV).perform
10
- rescue Dexter::Abort, PG::UndefinedFile => e
10
+ rescue Dexter::Abort, PG::UndefinedFile, PG::FeatureNotSupported => e
11
11
  abort colorize(e.message.strip, :red)
12
12
  end
13
13
 
@@ -27,13 +27,17 @@ module Dexter
27
27
  Indexer.new(options).process_stat_statements
28
28
  elsif options[:pg_stat_activity]
29
29
  Processor.new(:pg_stat_activity, options).perform
30
- elsif options[:log_table]
31
- Processor.new(:log_table, options).perform
32
30
  elsif arguments.any?
33
31
  ARGV.replace(arguments)
32
+ if !options[:input_format]
33
+ ext = ARGV.map { |v| File.extname(v) }.uniq
34
+ options[:input_format] = ext.first[1..-1] if ext.size == 1
35
+ end
34
36
  Processor.new(ARGF, options).perform
35
- else
37
+ elsif options[:stdin]
36
38
  Processor.new(STDIN, options).perform
39
+ else
40
+ raise Dexter::Abort, "Specify a source of queries: --pg-stat-statements, --pg-stat-activity, --stdin, or a path"
37
41
  end
38
42
  end
39
43
 
@@ -44,10 +48,10 @@ module Dexter
44
48
  o.separator ""
45
49
 
46
50
  o.separator "Input options:"
47
- o.string "--input-format", "input format", default: "stderr"
48
- o.string "--log-table", "log table (experimental)"
49
- o.boolean "--pg-stat-activity", "use pg_stat_activity", default: false, help: false
51
+ o.string "--input-format", "input format"
52
+ o.boolean "--pg-stat-activity", "use pg_stat_activity", default: false
50
53
  o.boolean "--pg-stat-statements", "use pg_stat_statements", default: false, help: false
54
+ o.boolean "--stdin", "use stdin", default: false
51
55
  o.string "-s", "--statement", "process a single statement"
52
56
  o.separator ""
53
57
 
@@ -62,12 +66,12 @@ module Dexter
62
66
  o.integer "--interval", "time to wait between processing queries, in seconds", default: 60
63
67
  o.float "--min-calls", "only process queries that have been called a certain number of times", default: 0
64
68
  o.float "--min-time", "only process queries that have consumed a certain amount of DB time, in minutes", default: 0
65
- o.boolean "--once", "run once", default: false, help: false
66
69
  o.separator ""
67
70
 
68
71
  o.separator "Indexing options:"
69
72
  o.boolean "--analyze", "analyze tables that haven't been analyzed in the past hour", default: false
70
73
  o.boolean "--create", "create indexes", default: false
74
+ o.boolean "--enable-hypopg", "enable the HypoPG extension", default: false
71
75
  o.array "--exclude", "prevent specific tables from being indexed"
72
76
  o.string "--include", "only include specific tables"
73
77
  o.integer "--min-cost-savings-pct", default: 50, help: false
@@ -8,7 +8,7 @@ module Dexter
8
8
  @min_calls = options[:min_calls]
9
9
  end
10
10
 
11
- def add(query, duration)
11
+ def add(query, total_time, calls = 1)
12
12
  fingerprint =
13
13
  begin
14
14
  PgQuery.fingerprint(query)
@@ -19,8 +19,8 @@ module Dexter
19
19
  return unless fingerprint
20
20
 
21
21
  @top_queries[fingerprint] ||= {calls: 0, total_time: 0}
22
- @top_queries[fingerprint][:calls] += 1
23
- @top_queries[fingerprint][:total_time] += duration
22
+ @top_queries[fingerprint][:calls] += calls
23
+ @top_queries[fingerprint][:total_time] += total_time
24
24
  @top_queries[fingerprint][:query] = query
25
25
  @mutex.synchronize do
26
26
  @new_queries << fingerprint
@@ -14,11 +14,15 @@ module Dexter
14
14
  @min_calls = options[:min_calls] || 0
15
15
  @analyze = options[:analyze]
16
16
  @min_cost_savings_pct = options[:min_cost_savings_pct].to_i
17
- @log_table = options[:log_table]
18
17
  @options = options
19
18
  @mutex = Mutex.new
20
19
 
21
- create_extension unless extension_exists?
20
+ if server_version_num < 110000
21
+ raise Dexter::Abort, "This version of Dexter requires Postgres 11+"
22
+ end
23
+
24
+ check_extension
25
+
22
26
  execute("SET lock_timeout = '5s'")
23
27
  end
24
28
 
@@ -28,53 +32,6 @@ module Dexter
28
32
  process_queries(queries)
29
33
  end
30
34
 
31
- def stat_activity
32
- execute <<~SQL
33
- SELECT
34
- pid || ':' || COALESCE(query_start, xact_start) AS id,
35
- query,
36
- EXTRACT(EPOCH FROM NOW() - COALESCE(query_start, xact_start)) * 1000.0 AS duration_ms
37
- FROM
38
- pg_stat_activity
39
- WHERE
40
- datname = current_database()
41
- AND state = 'active'
42
- AND pid != pg_backend_pid()
43
- ORDER BY
44
- 1
45
- SQL
46
- end
47
-
48
- # works with
49
- # file_fdw: https://www.postgresql.org/docs/current/file-fdw.html
50
- # log_fdw: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Appendix.PostgreSQL.CommonDBATasks.Extensions.foreign-data-wrappers.html
51
- def csvlog_activity(last_log_time)
52
- query = <<~SQL
53
- SELECT
54
- log_time,
55
- message,
56
- detail
57
- FROM
58
- #{conn.quote_ident(@log_table)}
59
- WHERE
60
- log_time >= \$1
61
- SQL
62
- execute(query, params: [last_log_time])
63
- end
64
-
65
- # works with
66
- # file_fdw: https://www.postgresql.org/docs/current/file-fdw.html
67
- # log_fdw: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Appendix.PostgreSQL.CommonDBATasks.Extensions.foreign-data-wrappers.html
68
- def stderr_activity
69
- query = <<~SQL
70
- SELECT
71
- log_entry
72
- FROM
73
- #{conn.quote_ident(@log_table)}
74
- SQL
75
- execute(query)
76
- end
77
-
78
35
  def process_queries(queries)
79
36
  # reset hypothetical indexes
80
37
  reset_hypothetical_indexes
@@ -150,19 +107,20 @@ module Dexter
150
107
 
151
108
  private
152
109
 
153
- def create_extension
154
- execute("SET client_min_messages = warning")
155
- begin
156
- execute("CREATE EXTENSION IF NOT EXISTS hypopg")
157
- rescue PG::UndefinedFile
110
+ def check_extension
111
+ extension = execute("SELECT installed_version FROM pg_available_extensions WHERE name = 'hypopg'").first
112
+
113
+ if extension.nil?
158
114
  raise Dexter::Abort, "Install HypoPG first: https://github.com/ankane/dexter#installation"
159
- rescue PG::InsufficientPrivilege
160
- raise Dexter::Abort, "Use a superuser to run: CREATE EXTENSION hypopg"
161
115
  end
162
- end
163
116
 
164
- def extension_exists?
165
- execute("SELECT * FROM pg_available_extensions WHERE name = 'hypopg' AND installed_version IS NOT NULL").any?
117
+ if extension["installed_version"].nil?
118
+ if @options[:enable_hypopg]
119
+ execute("CREATE EXTENSION hypopg")
120
+ else
121
+ raise Dexter::Abort, "Run `CREATE EXTENSION hypopg` or pass --enable-hypopg"
122
+ end
123
+ end
166
124
  end
167
125
 
168
126
  def reset_hypothetical_indexes
@@ -172,7 +130,7 @@ module Dexter
172
130
  def analyze_tables(tables)
173
131
  tables = tables.to_a.sort
174
132
 
175
- analyze_stats = execute <<~SQL
133
+ query = <<~SQL
176
134
  SELECT
177
135
  schemaname || '.' || relname AS table,
178
136
  last_analyze,
@@ -180,8 +138,9 @@ module Dexter
180
138
  FROM
181
139
  pg_stat_user_tables
182
140
  WHERE
183
- schemaname || '.' || relname IN (#{tables.map { |t| quote(t) }.join(", ")})
141
+ schemaname || '.' || relname IN (#{tables.size.times.map { |i| "$#{i + 1}" }.join(", ")})
184
142
  SQL
143
+ analyze_stats = execute(query, params: tables.to_a)
185
144
 
186
145
  last_analyzed = {}
187
146
  analyze_stats.each do |stats|
@@ -212,10 +171,6 @@ module Dexter
212
171
  end
213
172
  begin
214
173
  query.plans << plan(query.statement)
215
- if @log_explain
216
- # Pass format to prevent ANALYZE
217
- puts execute("EXPLAIN (FORMAT TEXT) #{safe_statement(query.statement)}", pretty: false).map { |r| r["QUERY PLAN"] }.join("\n")
218
- end
219
174
  rescue PG::Error, JSON::NestingError => e
220
175
  if @log_explain
221
176
  log e.message
@@ -245,7 +200,7 @@ module Dexter
245
200
  find_columns(query.tree).each do |col|
246
201
  last_col = col["fields"].last
247
202
  if last_col["String"]
248
- possible_columns << last_col["String"]["str"]
203
+ possible_columns << last_col["String"]["sval"]
249
204
  end
250
205
  end
251
206
  end
@@ -541,7 +496,7 @@ module Dexter
541
496
  def conn
542
497
  @conn ||= begin
543
498
  # set connect timeout if none set
544
- ENV["PGCONNECT_TIMEOUT"] ||= "2"
499
+ ENV["PGCONNECT_TIMEOUT"] ||= "3"
545
500
 
546
501
  if @options[:dbname] =~ /\Apostgres(ql)?:\/\//
547
502
  config = @options[:dbname]
@@ -569,16 +524,56 @@ module Dexter
569
524
  # as an extra defense against SQL-injection attacks.
570
525
  # https://www.postgresql.org/docs/current/static/libpq-exec.html
571
526
  query = squish(query) if pretty
572
- log colorize("[sql] #{query}", :cyan) if @log_sql
527
+ log colorize("[sql] #{query}#{params.any? ? " /*#{params.to_json}*/" : ""}", :cyan) if @log_sql
573
528
 
574
529
  @mutex.synchronize do
575
- conn.exec_params(query, params).to_a
530
+ conn.exec_params("#{query} /*dexter*/", params).to_a
576
531
  end
577
532
  end
578
533
 
579
534
  def plan(query)
535
+ prepared = false
536
+ transaction = false
537
+
538
+ # try to EXPLAIN normalized queries
539
+ # https://dev.to/yugabyte/explain-from-pgstatstatements-normalized-queries-how-to-always-get-the-generic-plan-in--5cfi
540
+ explain_normalized = query.include?("$1")
541
+ if explain_normalized
542
+ prepared_name = "dexter_prepared"
543
+ execute("PREPARE #{prepared_name} AS #{safe_statement(query)}", pretty: false)
544
+ prepared = true
545
+ params = execute("SELECT array_length(parameter_types, 1) AS params FROM pg_prepared_statements WHERE name = $1", params: [prepared_name]).first["params"].to_i
546
+ query = "EXECUTE #{prepared_name}(#{params.times.map { "NULL" }.join(", ")})"
547
+
548
+ execute("BEGIN")
549
+ transaction = true
550
+
551
+ if server_version_num >= 120000
552
+ execute("SET LOCAL plan_cache_mode = force_generic_plan")
553
+ else
554
+ execute("SET LOCAL cpu_operator_cost = 1e42")
555
+ 5.times do
556
+ execute("EXPLAIN (FORMAT JSON) #{safe_statement(query)}", pretty: false)
557
+ end
558
+ execute("ROLLBACK")
559
+ execute("BEGIN")
560
+ end
561
+ end
562
+
580
563
  # strip semi-colons as another measure of defense
581
- JSON.parse(execute("EXPLAIN (FORMAT JSON) #{safe_statement(query)}", pretty: false).first["QUERY PLAN"], max_nesting: 1000).first["Plan"]
564
+ plan = JSON.parse(execute("EXPLAIN (FORMAT JSON) #{safe_statement(query)}", pretty: false).first["QUERY PLAN"], max_nesting: 1000).first["Plan"]
565
+
566
+ if @log_explain
567
+ # Pass format to prevent ANALYZE
568
+ puts execute("EXPLAIN (FORMAT TEXT) #{safe_statement(query)}", pretty: false).map { |r| r["QUERY PLAN"] }.join("\n")
569
+ end
570
+
571
+ plan
572
+ ensure
573
+ if explain_normalized
574
+ execute("ROLLBACK") if transaction
575
+ execute("DEALLOCATE #{prepared_name}") if prepared
576
+ end
582
577
  end
583
578
 
584
579
  # TODO for multicolumn indexes, use ordering
@@ -608,17 +603,13 @@ module Dexter
608
603
  end
609
604
 
610
605
  def materialized_views
611
- if server_version_num >= 90300
612
- result = execute <<~SQL
613
- SELECT
614
- schemaname || '.' || matviewname AS table_name
615
- FROM
616
- pg_matviews
617
- SQL
618
- result.map { |r| r["table_name"] }
619
- else
620
- []
621
- end
606
+ result = execute <<~SQL
607
+ SELECT
608
+ schemaname || '.' || matviewname AS table_name
609
+ FROM
610
+ pg_matviews
611
+ SQL
612
+ result.map { |r| r["table_name"] }
622
613
  end
623
614
 
624
615
  def server_version_num
@@ -652,7 +643,7 @@ module Dexter
652
643
 
653
644
  def stat_statements
654
645
  total_time = server_version_num >= 130000 ? "(total_plan_time + total_exec_time)" : "total_time"
655
- result = execute <<~SQL
646
+ sql = <<~SQL
656
647
  SELECT
657
648
  DISTINCT query
658
649
  FROM
@@ -661,18 +652,18 @@ module Dexter
661
652
  pg_database ON pg_database.oid = pg_stat_statements.dbid
662
653
  WHERE
663
654
  datname = current_database()
664
- AND #{total_time} >= #{@min_time * 60000}
665
- AND calls >= #{@min_calls}
655
+ AND #{total_time} >= \$1
656
+ AND calls >= \$2
666
657
  ORDER BY
667
658
  1
668
659
  SQL
669
- result.map { |q| q["query"] }
660
+ execute(sql, params: [@min_time * 60000, @min_calls]).map { |q| q["query"] }
670
661
  end
671
662
 
672
663
  def with_advisory_lock
673
664
  lock_id = 123456
674
665
  first_time = true
675
- while execute("SELECT pg_try_advisory_lock(#{lock_id})").first["pg_try_advisory_lock"] != "t"
666
+ while execute("SELECT pg_try_advisory_lock($1)", params: [lock_id]).first["pg_try_advisory_lock"] != "t"
676
667
  if first_time
677
668
  log "Waiting for lock..."
678
669
  first_time = false
@@ -681,16 +672,19 @@ module Dexter
681
672
  end
682
673
  yield
683
674
  ensure
684
- with_min_messages("error") do
685
- execute("SELECT pg_advisory_unlock(#{lock_id})")
675
+ suppress_messages do
676
+ execute("SELECT pg_advisory_unlock($1)", params: [lock_id])
686
677
  end
687
678
  end
688
679
 
689
- def with_min_messages(value)
690
- execute("SET client_min_messages = #{quote(value)}")
680
+ def suppress_messages
681
+ conn.set_notice_processor do |message|
682
+ # do nothing
683
+ end
691
684
  yield
692
685
  ensure
693
- execute("SET client_min_messages = warning")
686
+ # clear notice processor
687
+ conn.set_notice_processor
694
688
  end
695
689
 
696
690
  def index_exists?(index)
@@ -698,7 +692,7 @@ module Dexter
698
692
  end
699
693
 
700
694
  def columns(tables)
701
- columns = execute <<~SQL
695
+ query = <<~SQL
702
696
  SELECT
703
697
  s.nspname || '.' || t.relname AS table_name,
704
698
  a.attname AS column_name,
@@ -708,11 +702,11 @@ module Dexter
708
702
  JOIN pg_namespace s on t.relnamespace = s.oid
709
703
  WHERE a.attnum > 0
710
704
  AND NOT a.attisdropped
711
- AND s.nspname || '.' || t.relname IN (#{tables.map { |t| quote(t) }.join(", ")})
705
+ AND s.nspname || '.' || t.relname IN (#{tables.size.times.map { |i| "$#{i + 1}" }.join(", ")})
712
706
  ORDER BY
713
707
  1, 2
714
708
  SQL
715
-
709
+ columns = execute(query, params: tables.to_a)
716
710
  columns.map { |v| {table: v["table_name"], column: v["column_name"], type: v["data_type"]} }
717
711
  end
718
712
 
@@ -732,14 +726,14 @@ module Dexter
732
726
  LEFT JOIN
733
727
  pg_stat_user_indexes ui ON ui.indexrelid = i.indexrelid
734
728
  WHERE
735
- schemaname || '.' || t.relname IN (#{tables.map { |t| quote(t) }.join(", ")}) AND
729
+ schemaname || '.' || t.relname IN (#{tables.size.times.map { |i| "$#{i + 1}" }.join(", ")}) AND
736
730
  indisvalid = 't' AND
737
731
  indexprs IS NULL AND
738
732
  indpred IS NULL
739
733
  ORDER BY
740
734
  1, 2
741
735
  SQL
742
- execute(query).map { |v| v["columns"] = v["columns"].sub(") WHERE (", " WHERE ").split(", ").map { |c| unquote(c) }; v }
736
+ execute(query, params: tables.to_a).map { |v| v["columns"] = v["columns"].sub(") WHERE (", " WHERE ").split(", ").map { |c| unquote(c) }; v }
743
737
  end
744
738
 
745
739
  def search_path
@@ -758,19 +752,6 @@ module Dexter
758
752
  value.split(".").map { |v| conn.quote_ident(v) }.join(".")
759
753
  end
760
754
 
761
- def quote(value)
762
- if value.is_a?(String)
763
- "'#{quote_string(value)}'"
764
- else
765
- value
766
- end
767
- end
768
-
769
- # from activerecord
770
- def quote_string(s)
771
- s.gsub(/\\/, '\&\&').gsub(/'/, "''")
772
- end
773
-
774
755
  # from activesupport
775
756
  def squish(str)
776
757
  str.to_s.gsub(/\A[[:space:]]+/, "").gsub(/[[:space:]]+\z/, "").gsub(/[[:space:]]+/, " ")
@@ -3,10 +3,6 @@ module Dexter
3
3
  include Logging
4
4
 
5
5
  REGEX = /duration: (\d+\.\d+) ms (statement|execute [^:]+): (.+)/
6
- LINE_SEPERATOR = ": ".freeze
7
- DETAIL_LINE = "DETAIL: ".freeze
8
-
9
- attr_accessor :once
10
6
 
11
7
  def initialize(logfile, collector)
12
8
  @logfile = logfile
@@ -1,27 +1,50 @@
1
1
  module Dexter
2
2
  class PgStatActivityParser < LogParser
3
3
  def perform
4
- queries = {}
4
+ previous_queries = {}
5
5
 
6
- loop do
7
- new_queries = {}
8
- @logfile.stat_activity.each do |row|
9
- new_queries[row["id"]] = row
6
+ 10.times do
7
+ active_queries = {}
8
+ processed_queries = {}
9
+
10
+ stat_activity.each do |row|
11
+ if row["state"] == "active"
12
+ active_queries[row["id"]] = row
13
+ else
14
+ process_entry(row["query"], row["duration_ms"].to_f)
15
+ processed_queries[row["id"]] = true
16
+ end
10
17
  end
11
18
 
12
19
  # store queries after they complete
13
- queries.each do |id, row|
14
- unless new_queries[id]
20
+ previous_queries.each do |id, row|
21
+ if !active_queries[id] && !processed_queries[id]
15
22
  process_entry(row["query"], row["duration_ms"].to_f)
16
23
  end
17
24
  end
18
25
 
19
- queries = new_queries
26
+ previous_queries = active_queries
20
27
 
21
- break if once
22
-
23
- sleep(1)
28
+ sleep(0.1)
24
29
  end
25
30
  end
31
+
32
+ def stat_activity
33
+ sql = <<~SQL
34
+ SELECT
35
+ pid || ':' || COALESCE(query_start, xact_start) AS id,
36
+ query,
37
+ state,
38
+ EXTRACT(EPOCH FROM NOW() - COALESCE(query_start, xact_start)) * 1000.0 AS duration_ms
39
+ FROM
40
+ pg_stat_activity
41
+ WHERE
42
+ datname = current_database()
43
+ AND pid != pg_backend_pid()
44
+ ORDER BY
45
+ 1
46
+ SQL
47
+ @logfile.send(:execute, sql)
48
+ end
26
49
  end
27
50
  end
@@ -11,12 +11,6 @@ module Dexter
11
11
  @log_parser =
12
12
  if @logfile == :pg_stat_activity
13
13
  PgStatActivityParser.new(@indexer, @collector)
14
- elsif @logfile == :log_table
15
- if options[:input_format] == "csv"
16
- CsvLogTableParser.new(@indexer, @collector)
17
- else
18
- StderrLogTableParser.new(@indexer, @collector)
19
- end
20
14
  elsif options[:input_format] == "csv"
21
15
  CsvLogParser.new(logfile, @collector)
22
16
  elsif options[:input_format] == "json"
@@ -29,7 +23,6 @@ module Dexter
29
23
 
30
24
  @starting_interval = 3
31
25
  @interval = options[:interval]
32
- @log_parser.once = options[:once]
33
26
 
34
27
  @mutex = Mutex.new
35
28
  @last_checked_at = {}
@@ -38,7 +31,7 @@ module Dexter
38
31
  end
39
32
 
40
33
  def perform
41
- if [STDIN, :pg_stat_activity, :log_table].include?(@logfile) && !@log_parser.once
34
+ if [STDIN].include?(@logfile)
42
35
  Thread.abort_on_exception = true
43
36
  Thread.new do
44
37
  sleep(@starting_interval)
@@ -1,5 +1,8 @@
1
1
  module Dexter
2
2
  class StderrLogParser < LogParser
3
+ LINE_SEPERATOR = ": ".freeze
4
+ DETAIL_LINE = "DETAIL: ".freeze
5
+
3
6
  def perform
4
7
  process_stderr(@logfile.each_line)
5
8
  end
@@ -1,3 +1,3 @@
1
1
  module Dexter
2
- VERSION = "0.4.3"
2
+ VERSION = "0.5.0"
3
3
  end
data/lib/dexter.rb CHANGED
@@ -21,12 +21,10 @@ require_relative "dexter/version"
21
21
  # parsers
22
22
  require_relative "dexter/log_parser"
23
23
  require_relative "dexter/csv_log_parser"
24
- require_relative "dexter/csv_log_table_parser"
25
24
  require_relative "dexter/json_log_parser"
26
25
  require_relative "dexter/pg_stat_activity_parser"
27
26
  require_relative "dexter/sql_log_parser"
28
27
  require_relative "dexter/stderr_log_parser"
29
- require_relative "dexter/stderr_log_table_parser"
30
28
 
31
29
  module Dexter
32
30
  class Abort < StandardError; end
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.4.3
4
+ version: 0.5.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: 2023-03-27 00:00:00.000000000 Z
11
+ date: 2023-04-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pg
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '2.1'
33
+ version: '4'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '2.1'
40
+ version: '4'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: slop
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -67,7 +67,6 @@ files:
67
67
  - lib/dexter/client.rb
68
68
  - lib/dexter/collector.rb
69
69
  - lib/dexter/csv_log_parser.rb
70
- - lib/dexter/csv_log_table_parser.rb
71
70
  - lib/dexter/indexer.rb
72
71
  - lib/dexter/json_log_parser.rb
73
72
  - lib/dexter/log_parser.rb
@@ -77,7 +76,6 @@ files:
77
76
  - lib/dexter/query.rb
78
77
  - lib/dexter/sql_log_parser.rb
79
78
  - lib/dexter/stderr_log_parser.rb
80
- - lib/dexter/stderr_log_table_parser.rb
81
79
  - lib/dexter/version.rb
82
80
  homepage: https://github.com/ankane/dexter
83
81
  licenses:
@@ -91,14 +89,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
91
89
  requirements:
92
90
  - - ">="
93
91
  - !ruby/object:Gem::Version
94
- version: '2.5'
92
+ version: '2.7'
95
93
  required_rubygems_version: !ruby/object:Gem::Requirement
96
94
  requirements:
97
95
  - - ">="
98
96
  - !ruby/object:Gem::Version
99
97
  version: '0'
100
98
  requirements: []
101
- rubygems_version: 3.4.6
99
+ rubygems_version: 3.4.10
102
100
  signing_key:
103
101
  specification_version: 4
104
102
  summary: The automatic indexer for Postgres
@@ -1,20 +0,0 @@
1
- module Dexter
2
- class CsvLogTableParser < CsvLogParser
3
- def perform
4
- last_log_time = Time.at(0).iso8601(3)
5
-
6
- loop do
7
- @logfile.csvlog_activity(last_log_time).each do |row|
8
- process_csv_row(row["message"], row["detail"])
9
- last_log_time = row["log_time"]
10
- end
11
-
12
- break
13
-
14
- # possibly enable later
15
- # break if once
16
- # sleep(1)
17
- end
18
- end
19
- end
20
- end
@@ -1,7 +0,0 @@
1
- module Dexter
2
- class StderrLogTableParser < StderrLogParser
3
- def perform
4
- process_stderr(@logfile.stderr_activity.map { |r| r["log_entry"] })
5
- end
6
- end
7
- end