pg_reports 0.9.1 → 1.0.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +12 -8
  4. data/app/controllers/pg_reports/dashboard_controller.rb +230 -221
  5. data/app/controllers/pg_reports/metrics_controller.rb +13 -4
  6. data/app/views/layouts/pg_reports/application.html.erb +126 -80
  7. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +74 -66
  8. data/app/views/pg_reports/dashboard/index.html.erb +310 -124
  9. data/bin/pg_reports +2 -2
  10. data/config/brakeman.ignore +10 -56
  11. data/config/locales/en.yml +39 -8
  12. data/config/locales/ru.yml +38 -8
  13. data/config/locales/uk.yml +38 -8
  14. data/config/routes.rb +1 -0
  15. data/lib/pg_reports/access_policy.rb +39 -0
  16. data/lib/pg_reports/compatibility.rb +10 -2
  17. data/lib/pg_reports/configuration.rb +33 -2
  18. data/lib/pg_reports/connection/error_translator.rb +21 -0
  19. data/lib/pg_reports/connection/target.rb +43 -10
  20. data/lib/pg_reports/csv_cell.rb +20 -0
  21. data/lib/pg_reports/dashboard/reports_registry.rb +37 -30
  22. data/lib/pg_reports/definitions/connections/connection_stats.yml +4 -0
  23. data/lib/pg_reports/definitions/connections/idle_connections.yml +4 -0
  24. data/lib/pg_reports/definitions/connections/locks.yml +1 -0
  25. data/lib/pg_reports/definitions/connections/long_running_queries.yml +0 -1
  26. data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +1 -2
  27. data/lib/pg_reports/definitions/indexes/index_correlation.yml +2 -1
  28. data/lib/pg_reports/definitions/indexes/missing_indexes.yml +1 -0
  29. data/lib/pg_reports/definitions/queries/frequent_queries.yml +50 -0
  30. data/lib/pg_reports/definitions/system/cache_stats.yml +2 -1
  31. data/lib/pg_reports/definitions/tables/bloated_tables.yml +1 -2
  32. data/lib/pg_reports/definitions/tables/recently_modified.yml +3 -3
  33. data/lib/pg_reports/definitions/tables/unused_tables.yml +1 -1
  34. data/lib/pg_reports/drop_index_migration.rb +56 -0
  35. data/lib/pg_reports/engine.rb +4 -3
  36. data/lib/pg_reports/executor.rb +46 -9
  37. data/lib/pg_reports/modules/queries.rb +1 -0
  38. data/lib/pg_reports/query_monitor.rb +478 -192
  39. data/lib/pg_reports/rate_limiter.rb +63 -0
  40. data/lib/pg_reports/report.rb +1 -1
  41. data/lib/pg_reports/report_definition.rb +11 -1
  42. data/lib/pg_reports/sql/connections/connection_stats.sql +2 -1
  43. data/lib/pg_reports/sql/connections/idle_connections.sql +1 -0
  44. data/lib/pg_reports/sql/connections/locks.sql +1 -0
  45. data/lib/pg_reports/sql/indexes/bloated_indexes.sql +16 -8
  46. data/lib/pg_reports/sql/queries/activity_sample.sql +22 -0
  47. data/lib/pg_reports/sql/queries/frequent_queries.sql +42 -0
  48. data/lib/pg_reports/sql_redactor.rb +32 -0
  49. data/lib/pg_reports/standalone.rb +83 -20
  50. data/lib/pg_reports/version.rb +1 -1
  51. data/lib/pg_reports.rb +7 -2
  52. metadata +17 -9
@@ -13,6 +13,29 @@ module PgReports
13
13
  class Target
14
14
  class ConnectionFailed < PgReports::Error; end
15
15
 
16
+ DATABASE_SIZES_TTL = 60 # seconds
17
+
18
+ DATABASE_NAMES_SQL = <<~SQL
19
+ #{Executor::QUERY_TAG}
20
+ SELECT datname AS name
21
+ FROM pg_database
22
+ WHERE datistemplate = false AND datallowconn = true
23
+ ORDER BY datname
24
+ SQL
25
+
26
+ # pg_database_size raises for a database the role cannot connect to, which
27
+ # would take the whole list down over one unreadable size.
28
+ DATABASE_SIZES_SQL = <<~SQL
29
+ #{Executor::QUERY_TAG}
30
+ SELECT datname AS name,
31
+ CASE WHEN has_database_privilege(datname, 'CONNECT')
32
+ THEN pg_size_pretty(pg_database_size(datname))
33
+ END AS size
34
+ FROM pg_database
35
+ WHERE datistemplate = false AND datallowconn = true
36
+ ORDER BY datname
37
+ SQL
38
+
16
39
  attr_reader :name
17
40
 
18
41
  def initialize(name, spec)
@@ -48,17 +71,15 @@ module PgReports
48
71
 
49
72
  # List all databases visible on this target's cluster (using pg_database).
50
73
  # Result rows: { "name" => String, "size" => String, "current" => Boolean }
51
- def list_databases(current: nil)
52
- rows = connection_for.exec_query(<<~SQL, "PgReports").to_a
53
- SELECT datname AS name,
54
- pg_size_pretty(pg_database_size(datname)) AS size
55
- FROM pg_database
56
- WHERE datistemplate = false AND datallowconn = true
57
- ORDER BY datname
58
- SQL
74
+ #
75
+ # pg_database_size walks every file of every database, which on a cluster with
76
+ # many tables takes far longer than anything else the dashboard asks for. Only
77
+ # a page showing the selector wants the sizes, and they change slowly, so ask
78
+ # for `sizes: false` to validate a name and take cached sizes otherwise.
79
+ def list_databases(current: nil, sizes: true)
80
+ rows = sizes ? databases_with_sizes : connection_for.exec_query(DATABASE_NAMES_SQL, "PgReports").to_a
59
81
  current_db = (current || default_database).to_s
60
- rows.each { |r| r["current"] = (r["name"].to_s == current_db) }
61
- rows
82
+ rows.map { |r| r.merge("current" => r["name"].to_s == current_db) }
62
83
  end
63
84
 
64
85
  # Close all derived pools we own. The :primary AR::Base pool is never touched.
@@ -70,10 +91,22 @@ module PgReports
70
91
  # Best-effort cleanup
71
92
  end
72
93
  @pools.clear
94
+ @sized_databases = nil
73
95
  end
74
96
 
75
97
  private
76
98
 
99
+ def databases_with_sizes
100
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
101
+ cached = @sized_databases
102
+
103
+ return cached[:rows] if cached && now - cached[:at] < DATABASE_SIZES_TTL
104
+
105
+ rows = connection_for.exec_query(DATABASE_SIZES_SQL, "PgReports").to_a
106
+ @sized_databases = {at: now, rows: rows}
107
+ rows
108
+ end
109
+
77
110
  def normalize_spec(spec)
78
111
  hash = case spec
79
112
  when Hash
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ # Keeps database text from running as a formula when a CSV opens in a spreadsheet.
5
+ #
6
+ # A table named =HYPERLINK(...) is ordinary data here and a live formula in Excel,
7
+ # so anyone able to name a table or send a query could reach whoever opens the
8
+ # export. A leading apostrophe makes the spreadsheet read the cell as text. The
9
+ # exported value changes only for cells that would otherwise be evaluated.
10
+ module CsvCell
11
+ FORMULA_START = /\A\s*[=+\-@]/
12
+ NUMBER = /\A\s*[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?\s*\z/i
13
+
14
+ def self.safe(value)
15
+ return value unless value.is_a?(String) && value.match?(FORMULA_START) && !value.match?(NUMBER)
16
+
17
+ "'#{value}"
18
+ end
19
+ end
20
+ end
@@ -16,16 +16,20 @@ module PgReports
16
16
  thresholds: {calls: {warning: 10000, critical: 100000}},
17
17
  problem_fields: ["calls"]
18
18
  },
19
+ frequent_queries: {
20
+ thresholds: {calls_per_second: {warning: 100, critical: 1000}},
21
+ problem_fields: ["calls_per_second"]
22
+ },
19
23
  expensive_queries: {
20
24
  thresholds: {total_time_ms: {warning: 60000, critical: 300000}},
21
25
  problem_fields: ["total_time_ms"]
22
26
  },
23
27
  missing_index_queries: {
24
- thresholds: {seq_tup_read: {warning: 100000, critical: 1000000}},
25
- problem_fields: ["seq_tup_read", "seq_scan"]
28
+ thresholds: {rows_per_call: {warning: 1000, critical: 10000}},
29
+ problem_fields: ["rows_per_call"]
26
30
  },
27
31
  low_cache_hit_queries: {
28
- thresholds: {cache_hit_ratio: {warning: 0.95, critical: 0.80, inverted: true}},
32
+ thresholds: {cache_hit_ratio: {warning: 95, critical: 80, inverted: true}},
29
33
  problem_fields: ["cache_hit_ratio"]
30
34
  },
31
35
  all_queries: {
@@ -51,8 +55,8 @@ module PgReports
51
55
  problem_fields: []
52
56
  },
53
57
  missing_indexes: {
54
- thresholds: {seq_scan_ratio: {warning: 0.5, critical: 0.9}},
55
- problem_fields: ["seq_scan", "seq_tup_read"]
58
+ thresholds: {avg_seq_tup_read: {warning: 10000, critical: 100000}},
59
+ problem_fields: ["avg_seq_tup_read"]
56
60
  },
57
61
  inefficient_indexes: {
58
62
  thresholds: {read_to_fetch_ratio: {warning: 10, critical: 100}},
@@ -62,31 +66,33 @@ module PgReports
62
66
  thresholds: {},
63
67
  problem_fields: []
64
68
  },
69
+ # No limits until the estimate improves: it assumes 8 bytes per entry, so a
70
+ # freshly built index reads as 65-75% bloated and every row would be critical
65
71
  bloated_indexes: {
66
- thresholds: {bloat_ratio: {warning: 0.3, critical: 0.5}},
67
- problem_fields: ["bloat_ratio", "bloat_size"]
72
+ thresholds: {},
73
+ problem_fields: []
68
74
  },
69
75
  index_sizes: {
70
- thresholds: {size_bytes: {warning: 1073741824, critical: 10737418240}},
71
- problem_fields: ["size_bytes"]
76
+ thresholds: {index_size_mb: {warning: 1024, critical: 10240}},
77
+ problem_fields: ["index_size_mb"]
72
78
  },
73
79
  fk_without_indexes: {
74
80
  thresholds: {},
75
81
  problem_fields: ["child_table_size_mb"]
76
82
  },
77
83
  index_correlation: {
78
- thresholds: {correlation: {warning: 0.5, critical: 0.2, inverted: true}},
79
- problem_fields: ["correlation"]
84
+ thresholds: {abs_correlation: {warning: 0.5, critical: 0.2, inverted: true}},
85
+ problem_fields: ["abs_correlation"]
80
86
  },
81
87
 
82
88
  # === TABLES ===
83
89
  table_sizes: {
84
- thresholds: {total_size_bytes: {warning: 10737418240, critical: 107374182400}},
85
- problem_fields: ["total_size_bytes"]
90
+ thresholds: {total_size_mb: {warning: 10240, critical: 102400}},
91
+ problem_fields: ["total_size_mb"]
86
92
  },
87
93
  bloated_tables: {
88
- thresholds: {dead_tuple_ratio: {warning: 0.1, critical: 0.2}},
89
- problem_fields: ["dead_tuple_ratio", "n_dead_tup"]
94
+ thresholds: {bloat_percent: {warning: 10, critical: 20}},
95
+ problem_fields: ["bloat_percent"]
90
96
  },
91
97
  vacuum_needed: {
92
98
  thresholds: {n_dead_tup: {warning: 10000, critical: 100000}},
@@ -97,7 +103,7 @@ module PgReports
97
103
  problem_fields: []
98
104
  },
99
105
  cache_hit_ratios: {
100
- thresholds: {cache_hit_ratio: {warning: 0.95, critical: 0.80, inverted: true}},
106
+ thresholds: {cache_hit_ratio: {warning: 95, critical: 80, inverted: true}},
101
107
  problem_fields: ["cache_hit_ratio"]
102
108
  },
103
109
  seq_scans: {
@@ -115,8 +121,8 @@ module PgReports
115
121
 
116
122
  # === CONNECTIONS ===
117
123
  active_connections: {
118
- thresholds: {connection_count: {warning: 50, critical: 100}},
119
- problem_fields: ["connection_count"]
124
+ thresholds: {},
125
+ problem_fields: []
120
126
  },
121
127
  connection_stats: {
122
128
  thresholds: {idle_in_transaction: {warning: 5, critical: 20}},
@@ -124,19 +130,19 @@ module PgReports
124
130
  },
125
131
  long_running_queries: {
126
132
  thresholds: {duration_seconds: {warning: 60, critical: 300}},
127
- problem_fields: ["duration_seconds", "duration"]
133
+ problem_fields: ["duration_seconds"]
128
134
  },
129
135
  blocking_queries: {
130
- thresholds: {blocked_count: {warning: 1, critical: 5}},
131
- problem_fields: ["blocked_count"]
136
+ thresholds: {blocked_duration: {warning: 10, critical: 60}},
137
+ problem_fields: ["blocked_duration"]
132
138
  },
133
139
  locks: {
134
- thresholds: {waiting_locks: {warning: 5, critical: 20}},
135
- problem_fields: ["waiting_locks"]
140
+ thresholds: {waiting_seconds: {warning: 10, critical: 60}},
141
+ problem_fields: ["waiting_seconds"]
136
142
  },
137
143
  idle_connections: {
138
- thresholds: {idle_count: {warning: 30, critical: 80}},
139
- problem_fields: ["idle_count"]
144
+ thresholds: {idle_in_transaction_seconds: {warning: 60, critical: 300}},
145
+ problem_fields: ["idle_in_transaction_seconds"]
140
146
  },
141
147
  pool_usage: {
142
148
  thresholds: {utilization_pct: {warning: 70, critical: 85}},
@@ -160,8 +166,8 @@ module PgReports
160
166
 
161
167
  # === SYSTEM ===
162
168
  database_sizes: {
163
- thresholds: {size_bytes: {warning: 10737418240, critical: 107374182400}},
164
- problem_fields: ["size_bytes"]
169
+ thresholds: {size_mb: {warning: 10240, critical: 102400}},
170
+ problem_fields: ["size_mb"]
165
171
  },
166
172
  settings: {
167
173
  thresholds: {},
@@ -176,8 +182,8 @@ module PgReports
176
182
  problem_fields: []
177
183
  },
178
184
  cache_stats: {
179
- thresholds: {cache_hit_ratio: {warning: 0.95, critical: 0.90, inverted: true}},
180
- problem_fields: ["cache_hit_ratio"]
185
+ thresholds: {heap_hit_ratio: {warning: 95, critical: 90, inverted: true}},
186
+ problem_fields: ["heap_hit_ratio"]
181
187
  },
182
188
  wraparound_risk: {
183
189
  thresholds: {pct_towards_wraparound: {warning: 50, critical: 75}},
@@ -228,7 +234,7 @@ module PgReports
228
234
  },
229
235
  unused_tables: {
230
236
  thresholds: {total_size_mb: {warning: 10, critical: 100}},
231
- problem_fields: ["table_name"]
237
+ problem_fields: ["total_size_mb"]
232
238
  }
233
239
  }.freeze
234
240
 
@@ -244,6 +250,7 @@ module PgReports
244
250
  reports: {
245
251
  slow_queries: {name: "Slow Queries", description: "Queries with high mean execution time"},
246
252
  heavy_queries: {name: "Heavy Queries", description: "Most frequently called queries"},
253
+ frequent_queries: {name: "Most Frequent Queries", description: "Queries executed at the highest rate (calls per second)", new: true},
247
254
  expensive_queries: {name: "Expensive Queries", description: "Queries consuming most total time"},
248
255
  missing_index_queries: {name: "Missing Index Queries", description: "Queries potentially missing indexes"},
249
256
  low_cache_hit_queries: {name: "Low Cache Hit", description: "Queries with poor cache utilization"},
@@ -16,3 +16,7 @@ report:
16
16
  - database
17
17
  - state
18
18
  - count
19
+ - idle_in_transaction
20
+
21
+ problem_explanations:
22
+ idle_in_transaction: idle_in_transaction
@@ -18,4 +18,8 @@ report:
18
18
  - username
19
19
  - application
20
20
  - idle_duration
21
+ - idle_in_transaction_seconds
21
22
  - state_change
23
+
24
+ problem_explanations:
25
+ idle_in_transaction_seconds: idle_in_transaction
@@ -20,3 +20,4 @@ report:
20
20
  - mode
21
21
  - granted
22
22
  - waiting
23
+ - waiting_seconds
@@ -40,4 +40,3 @@ report:
40
40
 
41
41
  problem_explanations:
42
42
  duration_seconds: long_running
43
- duration: long_running
@@ -39,5 +39,4 @@ report:
39
39
  cast: float
40
40
 
41
41
  problem_explanations:
42
- bloat_ratio: high_bloat
43
- bloat_size: high_bloat
42
+ bloat_percent: high_bloat
@@ -18,6 +18,7 @@ report:
18
18
  - column_name
19
19
  - index_name
20
20
  - correlation
21
+ - abs_correlation
21
22
  - table_size_mb
22
23
  - idx_scan
23
24
 
@@ -28,4 +29,4 @@ report:
28
29
  description: "Maximum number of results"
29
30
 
30
31
  problem_explanations:
31
- correlation: low_correlation
32
+ abs_correlation: low_correlation
@@ -17,6 +17,7 @@ report:
17
17
  - table_name
18
18
  - seq_scan
19
19
  - seq_tup_read
20
+ - avg_seq_tup_read
20
21
  - idx_scan
21
22
  - table_size_mb
22
23
 
@@ -0,0 +1,50 @@
1
+ # Frequent queries - queries executed at the highest rate (calls per second)
2
+ # Unlike heavy_queries, which ranks by the cumulative call count, this ranks by
3
+ # rate, so a query's position does not depend on how long it has been tracked
4
+
5
+ report:
6
+ name: frequent_queries
7
+ module: queries
8
+ description: "Queries executed at the highest rate"
9
+
10
+ sql:
11
+ category: queries
12
+ file: frequent_queries
13
+
14
+ title: "Most Frequent Queries (calls/sec >= ${threshold})"
15
+ title_vars:
16
+ threshold:
17
+ source: config
18
+ key: frequent_query_threshold_per_second
19
+
20
+ columns:
21
+ - query
22
+ - source
23
+ - calls_per_second
24
+ - calls
25
+ - mean_time_ms
26
+ - total_time_ms
27
+ - tracked_since
28
+
29
+ parameters:
30
+ limit:
31
+ type: integer
32
+ default: 20
33
+ description: "Maximum number of results"
34
+
35
+ filters:
36
+ - field: calls_per_second
37
+ operator: gte
38
+ value:
39
+ source: config
40
+ key: frequent_query_threshold_per_second
41
+ cast: float
42
+
43
+ enrichment:
44
+ hook: enrich_with_annotations
45
+ module: queries
46
+
47
+ problem_explanations:
48
+ calls_per_second: high_calls
49
+ total_time_ms: high_total_time
50
+ mean_time_ms: high_mean_time
@@ -15,4 +15,5 @@ report:
15
15
  columns:
16
16
  - database
17
17
  - heap_hit_ratio
18
- - index_hit_ratio
18
+ - blks_hit
19
+ - blks_read
@@ -39,5 +39,4 @@ report:
39
39
  cast: float
40
40
 
41
41
  problem_explanations:
42
- dead_tuple_ratio: many_dead_tuples
43
- n_dead_tup: many_dead_tuples
42
+ bloat_percent: many_dead_tuples
@@ -15,9 +15,9 @@ report:
15
15
  columns:
16
16
  - schema
17
17
  - table_name
18
- - n_tup_ins
19
- - n_tup_upd
20
- - n_tup_del
18
+ - inserts
19
+ - updates
20
+ - deletes
21
21
  - last_analyze
22
22
 
23
23
  parameters:
@@ -27,4 +27,4 @@ report:
27
27
  description: "Maximum number of results"
28
28
 
29
29
  problem_explanations:
30
- table_name: unused_table
30
+ total_size_mb: unused_table
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ # Writes the Rails migration that drops one index.
5
+ #
6
+ # Identifiers come from the database and anyone with DDL rights chooses them, so
7
+ # they only ever reach the generated Ruby through #inspect, which escapes quotes,
8
+ # newlines and `#{`. A name is never spliced in as a symbol or left bare: this
9
+ # file is executed by `rails db:migrate`.
10
+ #
11
+ # It drops with SQL rather than remove_index, which parses the name and, for one
12
+ # holding a quote or a dot, looks up something else. With if_exists that is a
13
+ # migration that reports success and leaves the index in place.
14
+ class DropIndexMigration
15
+ # @param index [Hash] a pg_indexes row: schemaname, indexname, indexdef
16
+ # @param version [#to_s] the Migration[] version, from the host's Rails
17
+ def initialize(index, version:)
18
+ @schema = index.fetch("schemaname")
19
+ @index = index.fetch("indexname")
20
+ @definition = index.fetch("indexdef")
21
+ @version = version
22
+ end
23
+
24
+ # Which migrations this would clash with: Rails refuses two of the same name.
25
+ def name
26
+ slug = @index.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
27
+ "remove_#{slug.empty? ? "index" : slug}"
28
+ end
29
+
30
+ def file_name(timestamp)
31
+ "#{timestamp}_#{name}.rb"
32
+ end
33
+
34
+ def code
35
+ <<~RUBY
36
+ # frozen_string_literal: true
37
+
38
+ class #{name.camelize} < ActiveRecord::Migration[#{@version}]
39
+ def up
40
+ execute #{drop_statement.inspect}
41
+ end
42
+
43
+ def down
44
+ execute #{@definition.inspect}
45
+ end
46
+ end
47
+ RUBY
48
+ end
49
+
50
+ private
51
+
52
+ def drop_statement
53
+ "DROP INDEX IF EXISTS #{PG::Connection.quote_ident([@schema, @index])}"
54
+ end
55
+ end
56
+ end
@@ -13,9 +13,10 @@ module PgReports
13
13
  config.i18n.load_path += Dir[root.join("config", "locales", "*.yml")]
14
14
  end
15
15
 
16
- initializer "pg_reports.compatibility_check" do
17
- ActiveSupport.on_load(:active_record) do
18
- PgReports::Compatibility.check_postgresql!
16
+ # after_initialize so that the host's initializers have set the configuration
17
+ config.after_initialize do
18
+ if PgReports.config.query_monitor_enabled && PgReports.config.query_monitor_available_sources.include?(:notifications)
19
+ PgReports::QueryMonitor.instance.subscribe
19
20
  end
20
21
  end
21
22
 
@@ -7,25 +7,39 @@ module PgReports
7
7
  # context set by PgReports.with_target / with_database is honored even when an
8
8
  # Executor instance has been memoized at the module level.
9
9
  class Executor
10
+ # Marks the SQL as ours where a statement name cannot reach: inside a host
11
+ # application these queries share its connections and application_name, so
12
+ # this is all that tells them apart in pg_stat_activity.
13
+ QUERY_TAG = "/* pg_reports */"
14
+
15
+ # A report that needs a lock someone else holds has nothing to wait for: during
16
+ # an incident the answer is more use now than after the full statement timeout.
17
+ REPORT_LOCK_TIMEOUT_MS = 3_000
18
+
19
+ # Not the second colon of a :: cast, nor the middle of a word
20
+ PLACEHOLDER = /(?<![:\w]):([a-zA-Z_]\w*)/
21
+
10
22
  def initialize(connection: nil)
11
23
  @connection_override = connection
12
24
  end
13
25
 
14
- # Execute SQL from a file and return results as array of hashes
26
+ # Execute SQL from a file and return results as array of hashes.
27
+ #
28
+ # These files only read, so they run under the report time limits.
15
29
  def execute_from_file(category, name, **params)
16
30
  sql = SqlLoader.load(category, name)
17
- execute(sql, **params)
31
+ within_report_limits { execute(sql, **params) }
18
32
  end
19
33
 
20
34
  # Execute raw SQL and return results as array of hashes.
21
35
  #
22
36
  # Every query is tagged with the "PgReports" AR statement name so the Query
23
- # Monitor can skip our own queries by name (see QueryMonitor#should_skip?),
37
+ # Monitor can skip our own queries by name (see QueryMonitor#skip_by_payload?),
24
38
  # reliably and independent of backtrace depth — the internal live_metrics /
25
39
  # status polling would otherwise leak into the monitor's history.
26
40
  def execute(sql, **params)
27
41
  processed_sql = interpolate_params(sql, params)
28
- result = connection.exec_query(processed_sql, "PgReports")
42
+ result = connection.exec_query("#{QUERY_TAG} #{processed_sql}", "PgReports")
29
43
  result.to_a
30
44
  end
31
45
 
@@ -36,14 +50,37 @@ module PgReports
36
50
 
37
51
  private
38
52
 
39
- # Simple parameter interpolation for SQL
40
- # Replaces :param_name with quoted values
53
+ # The limits are local to a transaction, and a savepoint inside the host
54
+ # application's transaction would hand them on to it when released. Rolling back
55
+ # discards them in both cases, and there is nothing here to keep.
56
+ def within_report_limits
57
+ timeout_ms = PgReports.config.report_statement_timeout_ms.to_i
58
+ return yield unless timeout_ms.positive?
59
+
60
+ rows = nil
61
+ connection.transaction(requires_new: true) do
62
+ connection.exec_query(report_limits_sql(timeout_ms), "PgReports")
63
+ rows = yield
64
+ raise ActiveRecord::Rollback
65
+ end
66
+ rows
67
+ end
68
+
69
+ def report_limits_sql(timeout_ms)
70
+ lock_timeout_ms = [timeout_ms, REPORT_LOCK_TIMEOUT_MS].min
71
+
72
+ "#{QUERY_TAG} SELECT set_config('statement_timeout', '#{timeout_ms}', true), " \
73
+ "set_config('lock_timeout', '#{lock_timeout_ms}', true)"
74
+ end
75
+
76
+ # Replaces :param_name with quoted values in a single pass, so a value is never
77
+ # read back as SQL: one holding ":other" stays text, and :limit leaves :limit_x alone.
41
78
  def interpolate_params(sql, params)
42
79
  return sql if params.empty?
43
80
 
44
- params.reduce(sql) do |query, (key, value)|
45
- quoted_value = quote_value(value)
46
- query.gsub(":#{key}", quoted_value)
81
+ sql.gsub(PLACEHOLDER) do |placeholder|
82
+ key = Regexp.last_match(1).to_sym
83
+ params.key?(key) ? quote_value(params[key]) : placeholder
47
84
  end
48
85
  end
49
86
 
@@ -10,6 +10,7 @@ module PgReports
10
10
  # The following methods are auto-generated from YAML:
11
11
  # - slow_queries(limit: 20)
12
12
  # - heavy_queries(limit: 20)
13
+ # - frequent_queries(limit: 20)
13
14
  # - expensive_queries(limit: 20)
14
15
  # - missing_index_queries(limit: 20)
15
16
  # - low_cache_hit_queries(limit: 20, min_calls: 100)