ruby-pg-extras 5.6.18 → 5.8.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/README.md +57 -9
- data/lib/ruby-pg-extras.rb +12 -1
- data/lib/ruby_pg_extras/diagnose_data.rb +120 -0
- data/lib/ruby_pg_extras/index_info.rb +31 -1
- data/lib/ruby_pg_extras/index_info_print.rb +13 -0
- data/lib/ruby_pg_extras/missing_fk_indexes.rb +45 -1
- data/lib/ruby_pg_extras/queries/indexes.sql +76 -6
- data/lib/ruby_pg_extras/queries/update_stats.sql +71 -0
- data/lib/ruby_pg_extras/queries/update_stats_legacy.sql +46 -0
- data/lib/ruby_pg_extras/version.rb +1 -1
- data/spec/diagnose_data_spec.rb +252 -0
- data/spec/index_info_spec.rb +111 -2
- data/spec/missing_fk_indexes_spec.rb +113 -5
- data/spec/smoke_spec.rb +61 -0
- data/spec/spec_helper.rb +87 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7c63e61eb15277c5b3c2d600c39e64c205188b8f8b8c9db2e3fb85fbdc09ce85
|
|
4
|
+
data.tar.gz: dc3478e08ef5da953a82486bf2a360788a60ba0d647ad7017ac3d147583b37b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3677747e0122ec0ef9b22a1cebcb5e30ac4165d8fcc44b7a000eb6490442f5f666c1ced6c393389d66dc83dfb00c2c6dda305618f11cfdbf04c4d23f1d7ece4c
|
|
7
|
+
data.tar.gz: ecfebfcdf855846eabe00a3b04cede4ac267465a7735511776b4621000ecfe0ccebd1eccec7f08150d47a459650b2806ff275bd7ee7e4204de12f7ea69c635eb
|
data/README.md
CHANGED
|
@@ -114,12 +114,44 @@ RubyPgExtras.diagnose
|
|
|
114
114
|
|
|
115
115
|
Keep reading to learn about methods that `diagnose` uses under the hood.
|
|
116
116
|
|
|
117
|
+
### `new_page_updates`
|
|
118
|
+
|
|
119
|
+
This is a `diagnose` check, not a standalone query method. On PostgreSQL 16 and newer, it uses the [`update_stats`](#update_stats) breakdown to flag tables where a significant share of updates placed the new row version on another heap page instead of staying on the original page. Those tables are worth reviewing for page-space pressure, row growth, and whether a lower table `fillfactor` would help.
|
|
120
|
+
|
|
121
|
+
By default, a table is reported when it has at least 10,000 cumulative updates and 20% or more of its updates are new-page updates. The report includes each table's new-page ratio, current `fillfactor`, and the percentage of same-page updates that were HOT. A low HOT-among-same-page value suggests indexed-column changes are also preventing HOT, so lowering `fillfactor` alone may not be enough.
|
|
122
|
+
|
|
123
|
+
You can override the default thresholds with environment variables:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
ENV["PG_EXTRAS_NEW_PAGE_UPDATES_MIN_SAMPLE"] = "5000"
|
|
127
|
+
ENV["PG_EXTRAS_NEW_PAGE_UPDATES_MAX_PERCENT"] = "15"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The underlying counters are cumulative, so compare their values over time rather than treating a single snapshot as definitive.
|
|
131
|
+
|
|
132
|
+
### `low_hot_same_page`
|
|
133
|
+
|
|
134
|
+
This is a `diagnose` check, not a standalone query method. On PostgreSQL 16 and newer, it uses the [`update_stats`](#update_stats) breakdown to flag tables where updates that stayed on the original heap page were almost never HOT. That usually means those updates modified indexed columns, so lowering `fillfactor` alone will not help.
|
|
135
|
+
|
|
136
|
+
By default, a table is reported when it has at least 10,000 cumulative updates and fewer than 10% of its same-page updates were HOT. The report includes each table's HOT-among-same-page ratio, same-page and new-page ratios, and current `fillfactor`. Review which columns your application updates and which indexes cover them; removing or adjusting indexes on frequently updated columns (or avoiding updating those columns) can restore HOT updates.
|
|
137
|
+
|
|
138
|
+
You can override the default thresholds with environment variables:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
ENV["PG_EXTRAS_LOW_HOT_SAME_PAGE_MIN_SAMPLE"] = "5000"
|
|
142
|
+
ENV["PG_EXTRAS_LOW_HOT_SAME_PAGE_MIN_PERCENT"] = "5"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The underlying counters are cumulative, so compare their values over time rather than treating a single snapshot as definitive.
|
|
146
|
+
|
|
117
147
|
## Available methods
|
|
118
148
|
|
|
119
149
|
### `missing_fk_indexes`
|
|
120
150
|
|
|
121
151
|
This method lists **actual foreign key columns** (based on existing foreign key constraints) which don't have a supporting index. It's recommended to always index foreign key columns because they are commonly used for lookups and join conditions.
|
|
122
152
|
|
|
153
|
+
Composite indexes only support a foreign key when the foreign key column is the leftmost key column. For example, an index on `(user_id, topic_id)` supports `user_id` lookups, but `topic_id` still needs its own index or an index that starts with `topic_id`. Partial indexes are ignored for this check unless their predicate is exactly the foreign key column `IS NOT NULL`, because foreign key checks only need non-null values.
|
|
154
|
+
|
|
123
155
|
You can add indexes on the columns returned by this query and later check if they are receiving scans using the [unused_indexes method](#unused_indexes). Please remember that each index decreases write performance and autovacuuming overhead, so be careful when adding multiple indexes to often updated tables.
|
|
124
156
|
|
|
125
157
|
```ruby
|
|
@@ -225,20 +257,21 @@ RubyPgExtras.table_foreign_keys(args: { table_name: "users" })
|
|
|
225
257
|
|
|
226
258
|
### `index_info`
|
|
227
259
|
|
|
228
|
-
This method returns summary info about database indexes. You can check index size, how often it is used and what percentage of its total size are NULL values. Like the previous method, it aggregates data from other helper methods in an easy-to-digest format.
|
|
260
|
+
This method returns summary info about database indexes. You can check index size, how often it is used, whether it is unique/primary/partial, what predicate it uses, and what percentage of its total size are NULL values. Like the previous method, it aggregates data from other helper methods in an easy-to-digest format.
|
|
261
|
+
|
|
262
|
+
Index columns are read from PostgreSQL catalog metadata instead of parsing `CREATE INDEX` strings. This keeps expression indexes, operator classes, sort order, collations, partial predicates, and `INCLUDE` columns represented correctly. `Columns` shows the display form of key columns, while `Included columns` shows non-key columns added with `INCLUDE (...)`.
|
|
229
263
|
|
|
230
264
|
```ruby
|
|
231
265
|
|
|
232
266
|
RubyPgExtras.index_info(args: { table_name: "users" })
|
|
233
267
|
|
|
234
|
-
| Index name
|
|
235
|
-
|
|
236
|
-
| users_pkey
|
|
237
|
-
|
|
|
238
|
-
|
|
|
239
|
-
|
|
|
240
|
-
|
|
|
241
|
-
| index_users_on_api_auth_token | users | api_auth_token | 1744 kB | 156 | 0.00% |
|
|
268
|
+
| Index name | Table name | Columns | Included columns | Method | Unique | Primary | Partial | Predicate | Index size | Index scans | Null frac |
|
|
269
|
+
+------------------------------+------------+----------------------------+------------------+--------+--------+---------+---------+--------------------+------------+-------------+-----------+
|
|
270
|
+
| users_pkey | users | id | | btree | true | true | false | | 1152 kB | 163007 | 0.00% |
|
|
271
|
+
| index_users_on_email_pattern | users | email text_pattern_ops | | btree | false | false | false | | 1080 kB | 258870 | 0.00% |
|
|
272
|
+
| index_users_on_created_at | users | created_at DESC NULLS LAST | | btree | false | false | false | | 816 kB | 70962 | 0.00% |
|
|
273
|
+
| index_users_on_active_email | users | email | | btree | false | false | true | deleted_at IS NULL | 1032 kB | 0 | 0.00% |
|
|
274
|
+
| index_users_on_email_include | users | email | id | btree | false | false | false | | 776 kB | 19502 | 100.00% |
|
|
242
275
|
|
|
243
276
|
```
|
|
244
277
|
|
|
@@ -722,6 +755,21 @@ RubyPgExtras.vacuum_io_stats
|
|
|
722
755
|
|
|
723
756
|
This command surfaces cumulative I/O statistics for autovacuum-related VACUUM activity, based on the `pg_stat_io` view introduced in PostgreSQL 16 ([pg_stat_io documentation](https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-IO-VIEW)). It shows how many blocks autovacuum workers have read and written, how many buffer evictions and ring-buffer reuses occurred, and when the statistics were last reset; this is useful for determining whether autovacuum is responsible for I/O spikes, as described in the pganalyze article on `pg_stat_io` ([Tracking cumulative I/O activity by autovacuum and manual VACUUMs](https://pganalyze.com/blog/pg-stat-io#tracking-cumulative-io-activity-by-autovacuum-and-manual-vacuums)). On PostgreSQL versions below 16 this method returns a single informational row indicating that the feature is unavailable.
|
|
724
757
|
|
|
758
|
+
### `update_stats`
|
|
759
|
+
|
|
760
|
+
```ruby
|
|
761
|
+
|
|
762
|
+
RubyPgExtras.update_stats
|
|
763
|
+
|
|
764
|
+
table | fillfactor | estimated_heap_bytes_per_live_row | total_updates | hot_updates | hot_pct | same_page_non_hot_updates | same_page_non_hot_pct | new_page_updates | new_page_pct | same_page_pct | hot_given_same_page_pct
|
|
765
|
+
--------+------------+-----------------------------------+---------------+-------------+---------+---------------------------+-----------------------+------------------+--------------+---------------+-------------------------
|
|
766
|
+
users | 100 | 256 | 1250000 | 980000 | 78.40 | 45000 | 3.60 | 225000 | 18.00 | 82.00 | 95.61
|
|
767
|
+
orders | 80 | 128 | 450000 | 410000 | 91.11 | 12000 | 2.67 | 28000 | 6.22 | 93.78 | 97.16
|
|
768
|
+
(truncated results for brevity)
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
This command breaks down table updates into HOT, same-page non-HOT, and new-page updates using `pg_stat_user_tables` columns including `n_tup_newpage_upd` ([pg_stat_all_tables documentation](https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-ALL-TABLES-VIEW)). HOT updates require that changed columns are not indexed and that the new row version fits on the same page ([HOT updates in PostgreSQL for better performance](https://www.cybertec-postgresql.com/en/hot-updates-in-postgresql-for-better-performance/), [Heap-Only Tuples](https://www.postgresql.org/docs/current/storage-hot.html)). High `same_page_non_hot_pct` usually points to updates of indexed columns, while high `new_page_pct` often means pages are too full and lowering `fillfactor` (then rewriting the table with `VACUUM FULL` or `CLUSTER`) may help. `estimated_heap_bytes_per_live_row` divides main-fork heap size by `pg_class.reltuples` when that estimate is positive; it reflects physical storage per estimated live row (including page overhead, fillfactor free space, and bloat) rather than logical tuple width, and is NULL until the table has been analyzed or vacuumed. Larger values often call for a lower `fillfactor`. These counters are cumulative and can be reset with PostgreSQL statistics-reset functions. On PostgreSQL versions below 16, where `n_tup_newpage_upd` is unavailable, the method returns a reduced breakdown of total, HOT, and non-HOT updates.
|
|
772
|
+
|
|
725
773
|
### `kill_all`
|
|
726
774
|
|
|
727
775
|
```ruby
|
data/lib/ruby-pg-extras.rb
CHANGED
|
@@ -28,7 +28,7 @@ module RubyPgExtras
|
|
|
28
28
|
records_rank seq_scans table_index_scans table_indexes_size
|
|
29
29
|
table_size total_index_size total_table_size
|
|
30
30
|
unused_indexes duplicate_indexes vacuum_stats vacuum_progress vacuum_io_stats
|
|
31
|
-
analyze_progress
|
|
31
|
+
analyze_progress update_stats
|
|
32
32
|
kill_all kill_pid
|
|
33
33
|
pg_stat_statements_reset buffercache_stats
|
|
34
34
|
buffercache_usage ssl_used connections
|
|
@@ -58,6 +58,8 @@ module RubyPgExtras
|
|
|
58
58
|
vacuum_io_stats: {},
|
|
59
59
|
vacuum_io_stats_legacy: {},
|
|
60
60
|
analyze_progress: {},
|
|
61
|
+
update_stats: { schema: DEFAULT_SCHEMA },
|
|
62
|
+
update_stats_legacy: { schema: DEFAULT_SCHEMA },
|
|
61
63
|
buffercache_stats: { limit: 10 },
|
|
62
64
|
buffercache_usage: { limit: 20 },
|
|
63
65
|
unused_indexes: { max_scans: 50, schema: DEFAULT_SCHEMA },
|
|
@@ -119,6 +121,15 @@ module RubyPgExtras
|
|
|
119
121
|
end
|
|
120
122
|
end
|
|
121
123
|
|
|
124
|
+
# The detailed update breakdown relies on n_tup_newpage_upd, available from PostgreSQL 16.
|
|
125
|
+
# Older versions fall back to the HOT/non-HOT breakdown in update_stats_legacy.
|
|
126
|
+
if query_name == :update_stats
|
|
127
|
+
server_version_num = conn.send(exec_method, "SHOW server_version_num").to_a[0].values[0].to_i
|
|
128
|
+
if server_version_num < 160000
|
|
129
|
+
query_name = :update_stats_legacy
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
122
133
|
REQUIRED_ARGS.fetch(query_name) { [] }.each do |arg_name|
|
|
123
134
|
if args[arg_name].nil?
|
|
124
135
|
raise ArgumentError, "'#{arg_name}' is required"
|
|
@@ -10,6 +10,10 @@ module RubyPgExtras
|
|
|
10
10
|
PG_EXTRAS_NULL_MIN_NULL_FRAC_PERCENT = 50 # 50%
|
|
11
11
|
PG_EXTRAS_BLOAT_MIN_VALUE = 10
|
|
12
12
|
PG_EXTRAS_OUTLIERS_MIN_EXEC_RATIO = 33 # 33%
|
|
13
|
+
PG_EXTRAS_NEW_PAGE_UPDATES_MAX_PERCENT = 20 # 20%
|
|
14
|
+
PG_EXTRAS_NEW_PAGE_UPDATES_MIN_SAMPLE = 10_000
|
|
15
|
+
PG_EXTRAS_LOW_HOT_SAME_PAGE_MIN_PERCENT = 10 # 10%
|
|
16
|
+
PG_EXTRAS_LOW_HOT_SAME_PAGE_MIN_SAMPLE = 10_000
|
|
13
17
|
|
|
14
18
|
def self.call
|
|
15
19
|
new.call
|
|
@@ -26,6 +30,8 @@ module RubyPgExtras
|
|
|
26
30
|
:unused_indexes,
|
|
27
31
|
:null_indexes,
|
|
28
32
|
:bloat,
|
|
33
|
+
:new_page_updates,
|
|
34
|
+
:low_hot_same_page,
|
|
29
35
|
:duplicate_indexes,
|
|
30
36
|
].yield_self do |checks|
|
|
31
37
|
extensions_data = query_module.extensions(in_format: :hash)
|
|
@@ -292,6 +298,120 @@ module RubyPgExtras
|
|
|
292
298
|
end
|
|
293
299
|
end
|
|
294
300
|
|
|
301
|
+
def new_page_updates
|
|
302
|
+
max_percent = ENV.fetch(
|
|
303
|
+
"PG_EXTRAS_NEW_PAGE_UPDATES_MAX_PERCENT",
|
|
304
|
+
PG_EXTRAS_NEW_PAGE_UPDATES_MAX_PERCENT,
|
|
305
|
+
).to_f
|
|
306
|
+
min_sample = ENV.fetch(
|
|
307
|
+
"PG_EXTRAS_NEW_PAGE_UPDATES_MIN_SAMPLE",
|
|
308
|
+
PG_EXTRAS_NEW_PAGE_UPDATES_MIN_SAMPLE,
|
|
309
|
+
).to_i
|
|
310
|
+
|
|
311
|
+
tables = query_module.update_stats(in_format: :hash)
|
|
312
|
+
|
|
313
|
+
if tables.any? && !tables.first.key?("new_page_pct")
|
|
314
|
+
return {
|
|
315
|
+
ok: true,
|
|
316
|
+
message: "New-page update analysis requires PostgreSQL 16 or newer.",
|
|
317
|
+
}
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
tables = tables.select do |table|
|
|
321
|
+
table.fetch("total_updates").to_i >= min_sample &&
|
|
322
|
+
table.fetch("new_page_pct").to_f >= max_percent
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
if tables.empty?
|
|
326
|
+
{
|
|
327
|
+
ok: true,
|
|
328
|
+
message: "No tables with a high new-page update ratio detected.",
|
|
329
|
+
}
|
|
330
|
+
else
|
|
331
|
+
table_details = tables.map do |table|
|
|
332
|
+
<<~DETAIL.strip
|
|
333
|
+
'#{table.fetch("table")}':
|
|
334
|
+
new-page updates: #{table.fetch("new_page_pct")}% (#{table.fetch("new_page_updates")} of #{table.fetch("total_updates")})
|
|
335
|
+
HOT among same-page updates: #{table.fetch("hot_given_same_page_pct")}%
|
|
336
|
+
fillfactor: #{table.fetch("fillfactor")}
|
|
337
|
+
DETAIL
|
|
338
|
+
end.join("\n\n")
|
|
339
|
+
|
|
340
|
+
{
|
|
341
|
+
ok: false,
|
|
342
|
+
message: <<~MESSAGE.strip,
|
|
343
|
+
High new-page update ratios detected:
|
|
344
|
+
|
|
345
|
+
#{table_details}
|
|
346
|
+
|
|
347
|
+
A high new-page ratio means many successor tuple versions were placed on another heap page and therefore could not be HOT. This commonly indicates insufficient reusable space on the original page. `n_tup_newpage_upd` records that placement directly; it does not identify the underlying reason or whether the update would otherwise have been HOT-eligible. Investigate page-space pressure, row growth, long-lived transactions, large update batches, and whether a lower table fillfactor is appropriate.
|
|
348
|
+
|
|
349
|
+
The HOT-among-same-page percentage provides additional context: a low value suggests indexed-column changes are preventing HOT on updates that did stay on the same page, so changing fillfactor alone may not resolve the issue.
|
|
350
|
+
|
|
351
|
+
These counters are cumulative; compare their deltas before and after a change.
|
|
352
|
+
MESSAGE
|
|
353
|
+
}
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def low_hot_same_page
|
|
358
|
+
min_percent = ENV.fetch(
|
|
359
|
+
"PG_EXTRAS_LOW_HOT_SAME_PAGE_MIN_PERCENT",
|
|
360
|
+
PG_EXTRAS_LOW_HOT_SAME_PAGE_MIN_PERCENT,
|
|
361
|
+
).to_f
|
|
362
|
+
min_sample = ENV.fetch(
|
|
363
|
+
"PG_EXTRAS_LOW_HOT_SAME_PAGE_MIN_SAMPLE",
|
|
364
|
+
PG_EXTRAS_LOW_HOT_SAME_PAGE_MIN_SAMPLE,
|
|
365
|
+
).to_i
|
|
366
|
+
|
|
367
|
+
tables = query_module.update_stats(in_format: :hash)
|
|
368
|
+
|
|
369
|
+
if tables.any? && !tables.first.key?("hot_given_same_page_pct")
|
|
370
|
+
return {
|
|
371
|
+
ok: true,
|
|
372
|
+
message: "HOT-among-same-page update analysis requires PostgreSQL 16 or newer.",
|
|
373
|
+
}
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
tables = tables.select do |table|
|
|
377
|
+
hot_given_same_page_pct = table["hot_given_same_page_pct"]
|
|
378
|
+
next false if hot_given_same_page_pct.nil?
|
|
379
|
+
|
|
380
|
+
table.fetch("total_updates").to_i >= min_sample &&
|
|
381
|
+
hot_given_same_page_pct.to_f < min_percent
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
if tables.empty?
|
|
385
|
+
{
|
|
386
|
+
ok: true,
|
|
387
|
+
message: "No tables with a low HOT-among-same-page update ratio detected.",
|
|
388
|
+
}
|
|
389
|
+
else
|
|
390
|
+
table_details = tables.map do |table|
|
|
391
|
+
<<~DETAIL.strip
|
|
392
|
+
'#{table.fetch("table")}':
|
|
393
|
+
HOT among same-page updates: #{table.fetch("hot_given_same_page_pct")}%
|
|
394
|
+
same-page updates: #{table.fetch("same_page_pct")}%
|
|
395
|
+
new-page updates: #{table.fetch("new_page_pct")}%
|
|
396
|
+
fillfactor: #{table.fetch("fillfactor")}
|
|
397
|
+
DETAIL
|
|
398
|
+
end.join("\n\n")
|
|
399
|
+
|
|
400
|
+
{
|
|
401
|
+
ok: false,
|
|
402
|
+
message: <<~MESSAGE.strip,
|
|
403
|
+
Low HOT-among-same-page update ratios detected:
|
|
404
|
+
|
|
405
|
+
#{table_details}
|
|
406
|
+
|
|
407
|
+
A low HOT-among-same-page ratio means updates that stayed on the original heap page still could not be HOT. That usually means those updates modified indexed columns. Review which columns your application updates and which indexes cover them; removing or adjusting indexes on frequently updated columns (or avoiding updating those columns) can restore HOT updates and reduce index and vacuum overhead.
|
|
408
|
+
|
|
409
|
+
These counters are cumulative; compare their deltas before and after a change.
|
|
410
|
+
MESSAGE
|
|
411
|
+
}
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
295
415
|
def outliers
|
|
296
416
|
queries = query_module.outliers(in_format: :hash).select do |q|
|
|
297
417
|
q.fetch("prop_exec_time").gsub("%", "").to_f >= PG_EXTRAS_OUTLIERS_MIN_EXEC_RATIO
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
1
5
|
module RubyPgExtras
|
|
2
6
|
class IndexInfo
|
|
3
7
|
def self.call(table_name = nil)
|
|
@@ -19,7 +23,17 @@ module RubyPgExtras
|
|
|
19
23
|
{
|
|
20
24
|
index_name: index_name,
|
|
21
25
|
table_name: index_data.fetch("tablename"),
|
|
22
|
-
|
|
26
|
+
# Prefer JSON arrays from indexes.sql so expressions containing commas are not split incorrectly.
|
|
27
|
+
columns: array_value(index_data, json_key: "columns_json", fallback_key: "columns"),
|
|
28
|
+
# Clean key column names are used separately from display columns that may include opclasses/order/collations.
|
|
29
|
+
key_columns: array_value(index_data, json_key: "key_column_names", fallback_key: "key_columns"),
|
|
30
|
+
# INCLUDE columns are stored separately because they are not part of the index search key.
|
|
31
|
+
included_columns: array_value(index_data, json_key: "included_columns_json", fallback_key: "included_columns"),
|
|
32
|
+
index_method: index_data.fetch("index_method", "N/A"),
|
|
33
|
+
unique: boolean_value(index_data.fetch("is_unique", false)),
|
|
34
|
+
primary: boolean_value(index_data.fetch("is_primary", false)),
|
|
35
|
+
partial: boolean_value(index_data.fetch("is_partial", false)),
|
|
36
|
+
predicate: index_data.fetch("predicate", nil),
|
|
23
37
|
index_size: index_size_data.find do |el|
|
|
24
38
|
el.fetch("name") == index_name
|
|
25
39
|
end.fetch("size", "N/A"),
|
|
@@ -54,6 +68,22 @@ module RubyPgExtras
|
|
|
54
68
|
|
|
55
69
|
private
|
|
56
70
|
|
|
71
|
+
def array_value(index_data, json_key:, fallback_key:)
|
|
72
|
+
# Older/stubbed callers may only provide comma-separated fields, so keep a fallback path.
|
|
73
|
+
if index_data.key?(json_key) && index_data.fetch(json_key) != nil
|
|
74
|
+
JSON.parse(index_data.fetch(json_key)).compact
|
|
75
|
+
elsif index_data.key?(fallback_key) && index_data.fetch(fallback_key) != nil
|
|
76
|
+
index_data.fetch(fallback_key).split(",").map(&:strip).reject(&:empty?)
|
|
77
|
+
else
|
|
78
|
+
[]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def boolean_value(value)
|
|
83
|
+
# PG::Result returns booleans as "t"/"f"; specs may provide real Ruby booleans.
|
|
84
|
+
[true, "t", "true"].include?(value)
|
|
85
|
+
end
|
|
86
|
+
|
|
57
87
|
def query_module
|
|
58
88
|
RubyPgExtras
|
|
59
89
|
end
|
|
@@ -14,6 +14,13 @@ module RubyPgExtras
|
|
|
14
14
|
el.fetch(:index_name),
|
|
15
15
|
el.fetch(:table_name),
|
|
16
16
|
el.fetch(:columns).join(", "),
|
|
17
|
+
# Included columns are displayed separately because they do not affect the index key order.
|
|
18
|
+
el.fetch(:included_columns, []).join(", "),
|
|
19
|
+
el.fetch(:index_method, "N/A"),
|
|
20
|
+
el.fetch(:unique, false),
|
|
21
|
+
el.fetch(:primary, false),
|
|
22
|
+
el.fetch(:partial, false),
|
|
23
|
+
el.fetch(:predicate, nil),
|
|
17
24
|
el.fetch(:index_size),
|
|
18
25
|
el.fetch(:index_scans),
|
|
19
26
|
el.fetch(:null_frac),
|
|
@@ -25,6 +32,12 @@ module RubyPgExtras
|
|
|
25
32
|
"Index name",
|
|
26
33
|
"Table name",
|
|
27
34
|
"Columns",
|
|
35
|
+
"Included columns",
|
|
36
|
+
"Method",
|
|
37
|
+
"Unique",
|
|
38
|
+
"Primary",
|
|
39
|
+
"Partial",
|
|
40
|
+
"Predicate",
|
|
28
41
|
"Index size",
|
|
29
42
|
"Index scans",
|
|
30
43
|
"Null frac",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module RubyPgExtras
|
|
4
6
|
class MissingFkIndexes
|
|
5
7
|
# ignore_list: array (or comma-separated string) of entries like:
|
|
@@ -33,7 +35,7 @@ module RubyPgExtras
|
|
|
33
35
|
# Skip columns explicitly excluded via ignore list.
|
|
34
36
|
next if ignore_list_matcher.ignored?(table: table, column_name: column_name)
|
|
35
37
|
|
|
36
|
-
if index_info.none? { |row| row
|
|
38
|
+
if index_info.none? { |row| usable_index?(row, column_name: column_name) }
|
|
37
39
|
agg.push(
|
|
38
40
|
{
|
|
39
41
|
table: table,
|
|
@@ -49,6 +51,48 @@ module RubyPgExtras
|
|
|
49
51
|
|
|
50
52
|
private
|
|
51
53
|
|
|
54
|
+
def usable_index?(row, column_name:)
|
|
55
|
+
# PostgreSQL can use a composite index for FK checks only when the FK column is leftmost.
|
|
56
|
+
return false unless first_key_column(row) == column_name
|
|
57
|
+
|
|
58
|
+
# A full index on the FK column is always usable once the leftmost-column check passes.
|
|
59
|
+
return true unless boolean_value(row.fetch("is_partial", false))
|
|
60
|
+
|
|
61
|
+
# Nullable FK checks only need non-null values, so this partial index is still usable.
|
|
62
|
+
not_null_predicate_on_column?(row.fetch("predicate", nil), column_name: column_name)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def first_key_column(row)
|
|
66
|
+
# New index metadata exposes clean key column names; fall back for legacy/stubbed rows.
|
|
67
|
+
if row.key?("key_column_names") && row.fetch("key_column_names") != nil
|
|
68
|
+
JSON.parse(row.fetch("key_column_names")).first
|
|
69
|
+
elsif row.key?("key_columns") && row.fetch("key_columns") != nil
|
|
70
|
+
row.fetch("key_columns").split(",").map(&:strip).first
|
|
71
|
+
else
|
|
72
|
+
row.fetch("columns").split(",").map(&:strip).first
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def not_null_predicate_on_column?(predicate, column_name:)
|
|
77
|
+
normalized_predicate = normalized_predicate(predicate)
|
|
78
|
+
|
|
79
|
+
# Keep this intentionally narrow: only `fk_column IS NOT NULL` guarantees FK coverage.
|
|
80
|
+
normalized_predicate.match?(/\A"?#{Regexp.escape(column_name)}"?\s+IS\s+NOT\s+NULL\z/i)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalized_predicate(predicate)
|
|
84
|
+
predicate.to_s.strip.then do |value|
|
|
85
|
+
# pg_get_expr can wrap simple predicates in parentheses, e.g. `(topic_id IS NOT NULL)`.
|
|
86
|
+
value = value[1...-1].strip while value.start_with?("(") && value.end_with?(")")
|
|
87
|
+
value
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def boolean_value(value)
|
|
92
|
+
# PG::Result returns booleans as "t"/"f"; specs may provide real Ruby booleans.
|
|
93
|
+
[true, "t", "true"].include?(value)
|
|
94
|
+
end
|
|
95
|
+
|
|
52
96
|
def query_module
|
|
53
97
|
RubyPgExtras
|
|
54
98
|
end
|
|
@@ -1,9 +1,79 @@
|
|
|
1
1
|
/* List all the indexes with their corresponding tables and columns. */
|
|
2
2
|
|
|
3
3
|
SELECT
|
|
4
|
-
schemaname,
|
|
5
|
-
indexname,
|
|
6
|
-
tablename,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
n.nspname AS schemaname,
|
|
5
|
+
i.relname AS indexname,
|
|
6
|
+
t.relname AS tablename,
|
|
7
|
+
string_agg(key_column.display_column, ', ' ORDER BY key_column.position) AS columns,
|
|
8
|
+
json_agg(key_column.display_column ORDER BY key_column.position)::text AS columns_json,
|
|
9
|
+
COALESCE(
|
|
10
|
+
string_agg(key_column.attname, ', ' ORDER BY key_column.position) FILTER (WHERE key_column.attname IS NOT NULL),
|
|
11
|
+
''
|
|
12
|
+
) AS key_columns,
|
|
13
|
+
json_agg(key_column.attname ORDER BY key_column.position)::text AS key_column_names,
|
|
14
|
+
COALESCE(included_columns.columns, '') AS included_columns,
|
|
15
|
+
COALESCE(included_columns.columns_json, '[]') AS included_columns_json,
|
|
16
|
+
am.amname AS index_method,
|
|
17
|
+
ix.indisunique AS is_unique,
|
|
18
|
+
ix.indisprimary AS is_primary,
|
|
19
|
+
(ix.indpred IS NOT NULL) AS is_partial,
|
|
20
|
+
pg_get_expr(ix.indpred, ix.indrelid) AS predicate
|
|
21
|
+
FROM pg_index ix
|
|
22
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
23
|
+
JOIN pg_class t ON t.oid = ix.indrelid
|
|
24
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
25
|
+
JOIN pg_am am ON am.oid = i.relam
|
|
26
|
+
-- Expand each index into one row per key position so column/opclass/collation/options stay aligned.
|
|
27
|
+
CROSS JOIN LATERAL (
|
|
28
|
+
SELECT
|
|
29
|
+
key_position.position,
|
|
30
|
+
a.attname,
|
|
31
|
+
concat_ws(
|
|
32
|
+
' ',
|
|
33
|
+
pg_get_indexdef(i.oid, key_position.position, true),
|
|
34
|
+
CASE
|
|
35
|
+
WHEN c.oid IS NOT NULL AND c.collname <> 'default' AND (a.attcollation IS NULL OR c.oid <> a.attcollation)
|
|
36
|
+
THEN 'COLLATE ' || quote_ident(c.collname)
|
|
37
|
+
END,
|
|
38
|
+
CASE
|
|
39
|
+
WHEN oc.oid IS NOT NULL AND oc.opcdefault = false THEN oc.opcname
|
|
40
|
+
END,
|
|
41
|
+
CASE
|
|
42
|
+
WHEN (index_option.option_value & 1) = 1 THEN 'DESC'
|
|
43
|
+
END,
|
|
44
|
+
CASE
|
|
45
|
+
WHEN (index_option.option_value & 2) = 2 THEN 'NULLS FIRST'
|
|
46
|
+
WHEN (index_option.option_value & 1) = 1 THEN 'NULLS LAST'
|
|
47
|
+
END
|
|
48
|
+
) AS display_column
|
|
49
|
+
FROM generate_series(1, ix.indnkeyatts) AS key_position(position)
|
|
50
|
+
LEFT JOIN pg_attribute a
|
|
51
|
+
ON a.attrelid = t.oid
|
|
52
|
+
AND a.attnum = (string_to_array(ix.indkey::text, ' '))[key_position.position]::int
|
|
53
|
+
LEFT JOIN pg_opclass oc
|
|
54
|
+
ON oc.oid = (string_to_array(ix.indclass::text, ' '))[key_position.position]::oid
|
|
55
|
+
LEFT JOIN pg_collation c
|
|
56
|
+
ON c.oid = (string_to_array(ix.indcollation::text, ' '))[key_position.position]::oid
|
|
57
|
+
CROSS JOIN LATERAL (
|
|
58
|
+
SELECT COALESCE((string_to_array(ix.indoption::text, ' '))[key_position.position]::int, 0) AS option_value
|
|
59
|
+
) index_option
|
|
60
|
+
) key_column
|
|
61
|
+
-- INCLUDE columns are stored after key columns in pg_index and must be reported separately.
|
|
62
|
+
LEFT JOIN LATERAL (
|
|
63
|
+
SELECT
|
|
64
|
+
string_agg(pg_get_indexdef(i.oid, included_position.position, true), ', ' ORDER BY included_position.position) AS columns,
|
|
65
|
+
json_agg(pg_get_indexdef(i.oid, included_position.position, true) ORDER BY included_position.position)::text AS columns_json
|
|
66
|
+
FROM generate_series(ix.indnkeyatts + 1, ix.indnatts) AS included_position(position)
|
|
67
|
+
) included_columns ON true
|
|
68
|
+
WHERE t.oid IN (SELECT relid FROM pg_statio_user_tables)
|
|
69
|
+
GROUP BY
|
|
70
|
+
n.nspname,
|
|
71
|
+
i.relname,
|
|
72
|
+
t.relname,
|
|
73
|
+
included_columns.columns,
|
|
74
|
+
included_columns.columns_json,
|
|
75
|
+
am.amname,
|
|
76
|
+
ix.indisunique,
|
|
77
|
+
ix.indisprimary,
|
|
78
|
+
ix.indpred,
|
|
79
|
+
ix.indrelid;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/* HOT, same-page non-HOT, and new-page update statistics (PostgreSQL 16+) */
|
|
2
|
+
|
|
3
|
+
WITH table_stats AS (
|
|
4
|
+
SELECT
|
|
5
|
+
s.relid,
|
|
6
|
+
s.schemaname,
|
|
7
|
+
s.relname,
|
|
8
|
+
s.n_tup_upd,
|
|
9
|
+
s.n_tup_hot_upd,
|
|
10
|
+
s.n_tup_newpage_upd,
|
|
11
|
+
c.reltuples,
|
|
12
|
+
COALESCE(
|
|
13
|
+
(
|
|
14
|
+
SELECT option_value::integer
|
|
15
|
+
FROM pg_options_to_table(c.reloptions)
|
|
16
|
+
WHERE option_name = 'fillfactor'
|
|
17
|
+
),
|
|
18
|
+
100
|
|
19
|
+
) AS fillfactor
|
|
20
|
+
FROM pg_stat_user_tables s
|
|
21
|
+
INNER JOIN pg_class c ON c.oid = s.relid
|
|
22
|
+
WHERE s.schemaname = '%{schema}'
|
|
23
|
+
)
|
|
24
|
+
SELECT
|
|
25
|
+
relname AS table,
|
|
26
|
+
fillfactor,
|
|
27
|
+
CASE
|
|
28
|
+
WHEN reltuples > 0 THEN
|
|
29
|
+
ROUND(
|
|
30
|
+
pg_relation_size(relid)::numeric / reltuples
|
|
31
|
+
)::bigint
|
|
32
|
+
END AS estimated_heap_bytes_per_live_row,
|
|
33
|
+
n_tup_upd AS total_updates,
|
|
34
|
+
n_tup_hot_upd AS hot_updates,
|
|
35
|
+
ROUND(
|
|
36
|
+
100.0 * n_tup_hot_upd
|
|
37
|
+
/ NULLIF(n_tup_upd, 0),
|
|
38
|
+
2
|
|
39
|
+
) AS hot_pct,
|
|
40
|
+
n_tup_upd
|
|
41
|
+
- n_tup_hot_upd
|
|
42
|
+
- n_tup_newpage_upd
|
|
43
|
+
AS same_page_non_hot_updates,
|
|
44
|
+
ROUND(
|
|
45
|
+
100.0 * (
|
|
46
|
+
n_tup_upd
|
|
47
|
+
- n_tup_hot_upd
|
|
48
|
+
- n_tup_newpage_upd
|
|
49
|
+
)
|
|
50
|
+
/ NULLIF(n_tup_upd, 0),
|
|
51
|
+
2
|
|
52
|
+
) AS same_page_non_hot_pct,
|
|
53
|
+
n_tup_newpage_upd AS new_page_updates,
|
|
54
|
+
ROUND(
|
|
55
|
+
100.0 * n_tup_newpage_upd
|
|
56
|
+
/ NULLIF(n_tup_upd, 0),
|
|
57
|
+
2
|
|
58
|
+
) AS new_page_pct,
|
|
59
|
+
ROUND(
|
|
60
|
+
100.0 * (n_tup_upd - n_tup_newpage_upd)
|
|
61
|
+
/ NULLIF(n_tup_upd, 0),
|
|
62
|
+
2
|
|
63
|
+
) AS same_page_pct,
|
|
64
|
+
ROUND(
|
|
65
|
+
100.0 * n_tup_hot_upd
|
|
66
|
+
/ NULLIF(n_tup_upd - n_tup_newpage_upd, 0),
|
|
67
|
+
2
|
|
68
|
+
) AS hot_given_same_page_pct
|
|
69
|
+
FROM table_stats
|
|
70
|
+
WHERE n_tup_upd > 0
|
|
71
|
+
ORDER BY n_tup_upd DESC;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/* HOT and non-HOT update statistics (PostgreSQL 15 and older) */
|
|
2
|
+
|
|
3
|
+
WITH table_stats AS (
|
|
4
|
+
SELECT
|
|
5
|
+
s.relid,
|
|
6
|
+
s.relname,
|
|
7
|
+
s.n_tup_upd,
|
|
8
|
+
s.n_tup_hot_upd,
|
|
9
|
+
c.reltuples,
|
|
10
|
+
COALESCE(
|
|
11
|
+
(
|
|
12
|
+
SELECT option_value::integer
|
|
13
|
+
FROM pg_options_to_table(c.reloptions)
|
|
14
|
+
WHERE option_name = 'fillfactor'
|
|
15
|
+
),
|
|
16
|
+
100
|
|
17
|
+
) AS fillfactor
|
|
18
|
+
FROM pg_stat_user_tables s
|
|
19
|
+
INNER JOIN pg_class c ON c.oid = s.relid
|
|
20
|
+
WHERE s.schemaname = '%{schema}'
|
|
21
|
+
)
|
|
22
|
+
SELECT
|
|
23
|
+
relname AS table,
|
|
24
|
+
fillfactor,
|
|
25
|
+
CASE
|
|
26
|
+
WHEN reltuples > 0 THEN
|
|
27
|
+
ROUND(
|
|
28
|
+
pg_relation_size(relid)::numeric / reltuples
|
|
29
|
+
)::bigint
|
|
30
|
+
END AS estimated_heap_bytes_per_live_row,
|
|
31
|
+
n_tup_upd AS total_updates,
|
|
32
|
+
n_tup_hot_upd AS hot_updates,
|
|
33
|
+
ROUND(
|
|
34
|
+
100.0 * n_tup_hot_upd
|
|
35
|
+
/ NULLIF(n_tup_upd, 0),
|
|
36
|
+
2
|
|
37
|
+
) AS hot_pct,
|
|
38
|
+
n_tup_upd - n_tup_hot_upd AS non_hot_updates,
|
|
39
|
+
ROUND(
|
|
40
|
+
100.0 * (n_tup_upd - n_tup_hot_upd)
|
|
41
|
+
/ NULLIF(n_tup_upd, 0),
|
|
42
|
+
2
|
|
43
|
+
) AS non_hot_pct
|
|
44
|
+
FROM table_stats
|
|
45
|
+
WHERE n_tup_upd > 0
|
|
46
|
+
ORDER BY n_tup_upd DESC;
|