ruby-pg-extras 5.7.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a23e8afe840227303cb0b15ddd0c795f94dd4004b45a6ca8239da7724b8ffe3d
4
- data.tar.gz: fd6d80e6150e1a74ae8d360553dba71de05f9241086894038669f412a39c2376
3
+ metadata.gz: 7c63e61eb15277c5b3c2d600c39e64c205188b8f8b8c9db2e3fb85fbdc09ce85
4
+ data.tar.gz: dc3478e08ef5da953a82486bf2a360788a60ba0d647ad7017ac3d147583b37b2
5
5
  SHA512:
6
- metadata.gz: 2cc3296eb55474f029b0189ab1e7356a209690da28c97ef63898f55f16adaf544d940fd553454e4172e0a46d8a3bee4722e4ee6e56109cb686a969161f137d52
7
- data.tar.gz: 1b06eab3cd36804cced29754d5506386d2a4e7eefd445d7b4edc3ac6737b04ace5e27f0aa7913ff5a8ed8e796beda5766cd6ce9a1f9c31172b9186505fba3bfa
6
+ metadata.gz: 3677747e0122ec0ef9b22a1cebcb5e30ac4165d8fcc44b7a000eb6490442f5f666c1ced6c393389d66dc83dfb00c2c6dda305618f11cfdbf04c4d23f1d7ece4c
7
+ data.tar.gz: ecfebfcdf855846eabe00a3b04cede4ac267465a7735511776b4621000ecfe0ccebd1eccec7f08150d47a459650b2806ff275bd7ee7e4204de12f7ea69c635eb
data/README.md CHANGED
@@ -114,6 +114,36 @@ 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`
@@ -725,6 +755,21 @@ RubyPgExtras.vacuum_io_stats
725
755
 
726
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.
727
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
+
728
773
  ### `kill_all`
729
774
 
730
775
  ```ruby
@@ -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
@@ -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;
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyPgExtras
4
- VERSION = "5.7.0"
4
+ VERSION = "5.8.0"
5
5
  end
@@ -59,6 +59,12 @@ describe RubyPgExtras::DiagnoseData do
59
59
  { table: "posts", column_name: "topic_id" },
60
60
  ]
61
61
  }
62
+
63
+ expect(RubyPgExtras)
64
+ .to receive(:update_stats)
65
+ .with(in_format: :hash)
66
+ .twice
67
+ .and_return([])
62
68
  end
63
69
 
64
70
  it "works" do
@@ -76,4 +82,250 @@ describe RubyPgExtras::DiagnoseData do
76
82
  end
77
83
  end
78
84
  end
85
+
86
+ describe "#new_page_updates" do
87
+ let(:diagnose_data) { described_class.new }
88
+
89
+ it "reports tables exceeding the update sample and new-page thresholds" do
90
+ allow(RubyPgExtras).to receive(:update_stats).with(in_format: :hash).and_return(
91
+ [
92
+ {
93
+ "table" => "orders",
94
+ "fillfactor" => "100",
95
+ "total_updates" => "10000",
96
+ "new_page_updates" => "2500",
97
+ "new_page_pct" => "25.00",
98
+ "hot_given_same_page_pct" => "90.00",
99
+ },
100
+ {
101
+ "table" => "users",
102
+ "fillfactor" => "80",
103
+ "total_updates" => "9999",
104
+ "new_page_updates" => "3000",
105
+ "new_page_pct" => "30.00",
106
+ "hot_given_same_page_pct" => "95.00",
107
+ },
108
+ ],
109
+ )
110
+
111
+ result = diagnose_data.send(:new_page_updates)
112
+
113
+ expect(result).to eq(
114
+ ok: false,
115
+ message: <<~MESSAGE.strip,
116
+ High new-page update ratios detected:
117
+
118
+ 'orders':
119
+ new-page updates: 25.00% (2500 of 10000)
120
+ HOT among same-page updates: 90.00%
121
+ fillfactor: 100
122
+
123
+ 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.
124
+
125
+ 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.
126
+
127
+ These counters are cumulative; compare their deltas before and after a change.
128
+ MESSAGE
129
+ )
130
+ end
131
+
132
+ it "does not report tables below either threshold" do
133
+ allow(RubyPgExtras).to receive(:update_stats).with(in_format: :hash).and_return(
134
+ [
135
+ {
136
+ "table" => "orders",
137
+ "total_updates" => "10000",
138
+ "new_page_pct" => "19.99",
139
+ },
140
+ {
141
+ "table" => "users",
142
+ "total_updates" => "9999",
143
+ "new_page_pct" => "25.00",
144
+ },
145
+ ],
146
+ )
147
+
148
+ expect(diagnose_data.send(:new_page_updates)).to eq(
149
+ ok: true,
150
+ message: "No tables with a high new-page update ratio detected.",
151
+ )
152
+ end
153
+
154
+ it "allows overriding the thresholds with environment variables" do
155
+ allow(RubyPgExtras).to receive(:update_stats).with(in_format: :hash).and_return(
156
+ [
157
+ {
158
+ "table" => "orders",
159
+ "fillfactor" => "100",
160
+ "total_updates" => "500",
161
+ "new_page_updates" => "75",
162
+ "new_page_pct" => "15.00",
163
+ "hot_given_same_page_pct" => "90.00",
164
+ },
165
+ ],
166
+ )
167
+ original_max_percent = ENV["PG_EXTRAS_NEW_PAGE_UPDATES_MAX_PERCENT"]
168
+ original_min_sample = ENV["PG_EXTRAS_NEW_PAGE_UPDATES_MIN_SAMPLE"]
169
+ ENV["PG_EXTRAS_NEW_PAGE_UPDATES_MAX_PERCENT"] = "15"
170
+ ENV["PG_EXTRAS_NEW_PAGE_UPDATES_MIN_SAMPLE"] = "500"
171
+
172
+ expect(diagnose_data.send(:new_page_updates).fetch(:ok)).to eq(false)
173
+ ensure
174
+ ENV["PG_EXTRAS_NEW_PAGE_UPDATES_MAX_PERCENT"] = original_max_percent
175
+ ENV["PG_EXTRAS_NEW_PAGE_UPDATES_MIN_SAMPLE"] = original_min_sample
176
+ end
177
+
178
+ it "skips the check when update_stats returns the legacy breakdown" do
179
+ allow(RubyPgExtras).to receive(:update_stats).with(in_format: :hash).and_return(
180
+ [
181
+ {
182
+ "table" => "orders",
183
+ "total_updates" => "10000",
184
+ "hot_updates" => "5000",
185
+ "hot_pct" => "50.00",
186
+ },
187
+ ],
188
+ )
189
+
190
+ expect(diagnose_data.send(:new_page_updates)).to eq(
191
+ ok: true,
192
+ message: "New-page update analysis requires PostgreSQL 16 or newer.",
193
+ )
194
+ end
195
+ end
196
+
197
+ describe "#low_hot_same_page" do
198
+ let(:diagnose_data) { described_class.new }
199
+
200
+ it "reports tables below the HOT-among-same-page threshold" do
201
+ allow(RubyPgExtras).to receive(:update_stats).with(in_format: :hash).and_return(
202
+ [
203
+ {
204
+ "table" => "sessions",
205
+ "fillfactor" => "100",
206
+ "total_updates" => "10000",
207
+ "same_page_pct" => "95.61",
208
+ "new_page_pct" => "4.39",
209
+ "hot_given_same_page_pct" => "0.0",
210
+ },
211
+ {
212
+ "table" => "orders",
213
+ "fillfactor" => "80",
214
+ "total_updates" => "10000",
215
+ "same_page_pct" => "90.00",
216
+ "new_page_pct" => "10.00",
217
+ "hot_given_same_page_pct" => "10.00",
218
+ },
219
+ {
220
+ "table" => "users",
221
+ "fillfactor" => "100",
222
+ "total_updates" => "9999",
223
+ "same_page_pct" => "99.00",
224
+ "new_page_pct" => "1.00",
225
+ "hot_given_same_page_pct" => "0.0",
226
+ },
227
+ ],
228
+ )
229
+
230
+ result = diagnose_data.send(:low_hot_same_page)
231
+
232
+ expect(result).to eq(
233
+ ok: false,
234
+ message: <<~MESSAGE.strip,
235
+ Low HOT-among-same-page update ratios detected:
236
+
237
+ 'sessions':
238
+ HOT among same-page updates: 0.0%
239
+ same-page updates: 95.61%
240
+ new-page updates: 4.39%
241
+ fillfactor: 100
242
+
243
+ 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.
244
+
245
+ These counters are cumulative; compare their deltas before and after a change.
246
+ MESSAGE
247
+ )
248
+ end
249
+
250
+ it "does not report tables at or above the threshold" do
251
+ allow(RubyPgExtras).to receive(:update_stats).with(in_format: :hash).and_return(
252
+ [
253
+ {
254
+ "table" => "orders",
255
+ "total_updates" => "10000",
256
+ "hot_given_same_page_pct" => "10.00",
257
+ },
258
+ {
259
+ "table" => "users",
260
+ "total_updates" => "9999",
261
+ "hot_given_same_page_pct" => "0.0",
262
+ },
263
+ ],
264
+ )
265
+
266
+ expect(diagnose_data.send(:low_hot_same_page)).to eq(
267
+ ok: true,
268
+ message: "No tables with a low HOT-among-same-page update ratio detected.",
269
+ )
270
+ end
271
+
272
+ it "skips tables with a NULL HOT-among-same-page ratio" do
273
+ allow(RubyPgExtras).to receive(:update_stats).with(in_format: :hash).and_return(
274
+ [
275
+ {
276
+ "table" => "orders",
277
+ "total_updates" => "10000",
278
+ "hot_given_same_page_pct" => nil,
279
+ },
280
+ ],
281
+ )
282
+
283
+ expect(diagnose_data.send(:low_hot_same_page)).to eq(
284
+ ok: true,
285
+ message: "No tables with a low HOT-among-same-page update ratio detected.",
286
+ )
287
+ end
288
+
289
+ it "allows overriding the thresholds with environment variables" do
290
+ allow(RubyPgExtras).to receive(:update_stats).with(in_format: :hash).and_return(
291
+ [
292
+ {
293
+ "table" => "sessions",
294
+ "fillfactor" => "100",
295
+ "total_updates" => "500",
296
+ "same_page_pct" => "90.00",
297
+ "new_page_pct" => "10.00",
298
+ "hot_given_same_page_pct" => "4.00",
299
+ },
300
+ ],
301
+ )
302
+ original_min_percent = ENV["PG_EXTRAS_LOW_HOT_SAME_PAGE_MIN_PERCENT"]
303
+ original_min_sample = ENV["PG_EXTRAS_LOW_HOT_SAME_PAGE_MIN_SAMPLE"]
304
+ ENV["PG_EXTRAS_LOW_HOT_SAME_PAGE_MIN_PERCENT"] = "5"
305
+ ENV["PG_EXTRAS_LOW_HOT_SAME_PAGE_MIN_SAMPLE"] = "500"
306
+
307
+ expect(diagnose_data.send(:low_hot_same_page).fetch(:ok)).to eq(false)
308
+ ensure
309
+ ENV["PG_EXTRAS_LOW_HOT_SAME_PAGE_MIN_PERCENT"] = original_min_percent
310
+ ENV["PG_EXTRAS_LOW_HOT_SAME_PAGE_MIN_SAMPLE"] = original_min_sample
311
+ end
312
+
313
+ it "skips the check when update_stats returns the legacy breakdown" do
314
+ allow(RubyPgExtras).to receive(:update_stats).with(in_format: :hash).and_return(
315
+ [
316
+ {
317
+ "table" => "orders",
318
+ "total_updates" => "10000",
319
+ "hot_updates" => "5000",
320
+ "hot_pct" => "50.00",
321
+ },
322
+ ],
323
+ )
324
+
325
+ expect(diagnose_data.send(:low_hot_same_page)).to eq(
326
+ ok: true,
327
+ message: "HOT-among-same-page update analysis requires PostgreSQL 16 or newer.",
328
+ )
329
+ end
330
+ end
79
331
  end
data/spec/smoke_spec.rb CHANGED
@@ -58,6 +58,67 @@ describe RubyPgExtras do
58
58
  end
59
59
  end
60
60
 
61
+ describe "update_stats" do
62
+ it "returns a consistent HOT update breakdown" do
63
+ connection = RubyPgExtras.connection
64
+ server_version_num = connection.exec("SHOW server_version_num").to_a[0].values[0].to_i
65
+
66
+ # Keep this fixture local so every run starts with fresh statistics counters
67
+ # and a controlled fillfactor for producing HOT and non-HOT updates.
68
+ connection.exec("DROP TABLE IF EXISTS update_stats_test")
69
+ connection.exec(<<~SQL)
70
+ CREATE TABLE update_stats_test (
71
+ id INTEGER PRIMARY KEY,
72
+ value TEXT
73
+ ) WITH (fillfactor = 80)
74
+ SQL
75
+ connection.exec("INSERT INTO update_stats_test VALUES (1, 'before')")
76
+ # Updating the unindexed value column produces a HOT update.
77
+ connection.exec("UPDATE update_stats_test SET value = 'after' WHERE id = 1")
78
+ # Updating the primary key requires index maintenance, producing a non-HOT update.
79
+ connection.exec("UPDATE update_stats_test SET id = 2 WHERE id = 1")
80
+ # estimated_heap_bytes_per_live_row divides main-fork heap size by
81
+ # pg_class.reltuples, which is only populated after ANALYZE (or VACUUM).
82
+ connection.exec("ANALYZE update_stats_test")
83
+
84
+ row = nil
85
+ # PostgreSQL publishes cumulative statistics asynchronously, particularly
86
+ # on older supported versions, so poll until both updates are visible.
87
+ 20.times do
88
+ row = RubyPgExtras.update_stats(
89
+ args: { schema: "public" },
90
+ in_format: :hash,
91
+ ).find { |result| result["table"] == "update_stats_test" }
92
+ break if row && row["total_updates"].to_i == 2
93
+
94
+ sleep 0.1
95
+ end
96
+
97
+ expect(row).not_to be_nil
98
+ expect(row["fillfactor"].to_i).to eq(80)
99
+ expect(row["estimated_heap_bytes_per_live_row"].to_i).to be > 0
100
+ expect(row["total_updates"].to_i).to eq(2)
101
+ expect(row["hot_updates"].to_i).to eq(1)
102
+
103
+ if server_version_num >= 160000
104
+ expect(row["same_page_non_hot_updates"].to_i).to eq(1)
105
+ expect(row["new_page_updates"].to_i).to eq(0)
106
+ expect(row["total_updates"].to_i).to eq(
107
+ row["hot_updates"].to_i +
108
+ row["same_page_non_hot_updates"].to_i +
109
+ row["new_page_updates"].to_i,
110
+ )
111
+ else
112
+ expect(row["non_hot_updates"].to_i).to eq(1)
113
+ expect(row["total_updates"].to_i).to eq(
114
+ row["hot_updates"].to_i + row["non_hot_updates"].to_i,
115
+ )
116
+ end
117
+ ensure
118
+ connection&.exec("DROP TABLE IF EXISTS update_stats_test")
119
+ end
120
+ end
121
+
61
122
  describe "#database_url=" do
62
123
  it "setting custom database URL works" do
63
124
  RubyPgExtras.database_url = ENV.fetch("DATABASE_URL")
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-pg-extras
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.7.0
4
+ version: 5.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - pawurb
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-16 00:00:00.000000000 Z
11
+ date: 2026-07-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pg
@@ -164,6 +164,8 @@ files:
164
164
  - lib/ruby_pg_extras/queries/total_index_size.sql
165
165
  - lib/ruby_pg_extras/queries/total_table_size.sql
166
166
  - lib/ruby_pg_extras/queries/unused_indexes.sql
167
+ - lib/ruby_pg_extras/queries/update_stats.sql
168
+ - lib/ruby_pg_extras/queries/update_stats_legacy.sql
167
169
  - lib/ruby_pg_extras/queries/vacuum_io_stats.sql
168
170
  - lib/ruby_pg_extras/queries/vacuum_io_stats_legacy.sql
169
171
  - lib/ruby_pg_extras/queries/vacuum_progress.sql