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.
@@ -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
@@ -12,8 +12,38 @@ describe RubyPgExtras::IndexInfo do
12
12
  before do
13
13
  expect(RubyPgExtras).to receive(:indexes) {
14
14
  [
15
- { "schemaname" => "public", "indexname" => "index_users_on_api_auth_token", "tablename" => "users", "columns" => "api_auth_token, column2" },
16
- { "schemaname" => "public", "indexname" => "index_teams_on_slack_id", "tablename" => "teams", "columns" => "slack_id" },
15
+ {
16
+ "schemaname" => "public",
17
+ "indexname" => "index_users_on_api_auth_token",
18
+ "tablename" => "users",
19
+ "columns" => "api_auth_token, column2",
20
+ "columns_json" => '["api_auth_token","column2"]',
21
+ "key_columns" => "api_auth_token, column2",
22
+ "key_column_names" => '["api_auth_token","column2"]',
23
+ "included_columns" => "",
24
+ "included_columns_json" => "[]",
25
+ "index_method" => "btree",
26
+ "is_unique" => "f",
27
+ "is_primary" => "f",
28
+ "is_partial" => "f",
29
+ "predicate" => nil,
30
+ },
31
+ {
32
+ "schemaname" => "public",
33
+ "indexname" => "index_teams_on_slack_id",
34
+ "tablename" => "teams",
35
+ "columns" => "slack_id",
36
+ "columns_json" => '["slack_id"]',
37
+ "key_columns" => "slack_id",
38
+ "key_column_names" => '["slack_id"]',
39
+ "included_columns" => "external_id",
40
+ "included_columns_json" => '["external_id"]',
41
+ "index_method" => "btree",
42
+ "is_unique" => "t",
43
+ "is_primary" => "f",
44
+ "is_partial" => "t",
45
+ "predicate" => "external_id IS NOT NULL",
46
+ },
17
47
  ]
18
48
  }
19
49
 
@@ -43,6 +73,23 @@ describe RubyPgExtras::IndexInfo do
43
73
  RubyPgExtras::IndexInfoPrint.call(result)
44
74
  }.not_to raise_error
45
75
  end
76
+
77
+ it "returns structured index metadata" do
78
+ # The service keeps display columns and logical key columns as separate arrays.
79
+ expect(result).to include(
80
+ include(
81
+ index_name: "index_teams_on_slack_id",
82
+ columns: ["slack_id"],
83
+ key_columns: ["slack_id"],
84
+ included_columns: ["external_id"],
85
+ index_method: "btree",
86
+ unique: true,
87
+ primary: false,
88
+ partial: true,
89
+ predicate: "external_id IS NOT NULL",
90
+ )
91
+ )
92
+ end
46
93
  end
47
94
 
48
95
  context "real data" do
@@ -51,6 +98,68 @@ describe RubyPgExtras::IndexInfo do
51
98
  RubyPgExtras::IndexInfoPrint.call(result)
52
99
  }.not_to raise_error
53
100
  end
101
+
102
+ it "handles expression indexes without splitting on parentheses" do
103
+ index = result.find { |el| el.fetch(:index_name) == "index_users_on_lower_email" }
104
+
105
+ # Expression indexes can contain nested parentheses, so DDL string splitting is unsafe.
106
+ expect(index.fetch(:columns).first).to include("lower")
107
+ end
108
+
109
+ it "keeps partial index predicates separate from columns" do
110
+ index = result.find { |el| el.fetch(:index_name) == "index_users_on_email_partial" }
111
+
112
+ # Partial index predicates describe row coverage and should not be mixed into columns.
113
+ expect(index).to include(
114
+ columns: ["email"],
115
+ key_columns: ["email"],
116
+ partial: true,
117
+ )
118
+ expect(index.fetch(:predicate)).to include("customer_id IS NOT NULL")
119
+ end
120
+
121
+ it "keeps operator classes in display columns and clean key column names" do
122
+ index = result.find { |el| el.fetch(:index_name) == "index_users_on_email_pattern" }
123
+
124
+ # Opclasses affect index behavior, but FK checks still need the plain column name.
125
+ expect(index.fetch(:columns)).to eq(["email text_pattern_ops"])
126
+ expect(index.fetch(:key_columns)).to eq(["email"])
127
+ end
128
+
129
+ it "keeps sort order in display columns and clean key column names" do
130
+ index = result.find { |el| el.fetch(:index_name) == "index_posts_on_external_id_desc" }
131
+
132
+ # Sort/null options are display metadata, not part of the logical column name.
133
+ expect(index.fetch(:columns)).to eq(["external_id DESC NULLS LAST"])
134
+ expect(index.fetch(:key_columns)).to eq(["external_id"])
135
+ end
136
+
137
+ it "keeps per-position options on composite indexes" do
138
+ index = result.find { |el| el.fetch(:index_name) == "index_posts_on_user_id_desc_and_title_pattern" }
139
+
140
+ # Each position can have distinct options, so the query must pair metadata by position.
141
+ expect(index.fetch(:columns)).to eq(["user_id DESC NULLS FIRST", 'title COLLATE "C" text_pattern_ops'])
142
+ expect(index.fetch(:key_columns)).to eq(["user_id", "title"])
143
+ end
144
+
145
+ it "keeps collations in display columns and clean key column names" do
146
+ index = result.find { |el| el.fetch(:index_name) == "index_users_on_email_collate_c" }
147
+
148
+ # Explicit non-default collations should remain visible in index_info output.
149
+ expect(index.fetch(:columns).first).to include('COLLATE "C"')
150
+ expect(index.fetch(:key_columns)).to eq(["email"])
151
+ end
152
+
153
+ it "keeps included columns separate from key columns" do
154
+ index = result.find { |el| el.fetch(:index_name) == "index_users_on_email_include_customer_id" }
155
+
156
+ # INCLUDE columns can support index-only scans but are not search key columns.
157
+ expect(index).to include(
158
+ columns: ["email"],
159
+ key_columns: ["email"],
160
+ included_columns: ["customer_id"],
161
+ )
162
+ end
54
163
  end
55
164
  end
56
165
  end
@@ -7,13 +7,18 @@ describe "missing_fk_indexes" do
7
7
  it "detects missing indexes for all tables" do
8
8
  result = RubyPgExtras.missing_fk_indexes(in_format: :hash)
9
9
  expect(result).to match_array([
10
- { table: "users", column_name: "company_id" },
10
+ { table: "expression_not_null_partial_indexed_posts", column_name: "topic_id" },
11
+ { table: "expression_indexed_posts", column_name: "topic_id" },
12
+ { table: "null_partial_indexed_posts", column_name: "topic_id" },
13
+ { table: "partial_indexed_posts", column_name: "topic_id" },
11
14
  { table: "posts", column_name: "topic_id" },
12
15
  ])
13
16
  end
14
17
 
15
- it "detects missing indexes for specific table" do
18
+ it "detects foreign keys that are only indexed after another column" do
16
19
  result = RubyPgExtras.missing_fk_indexes(args: { table_name: "posts" }, in_format: :hash)
20
+
21
+ # posts.topic_id exists in index_posts_on_user_id, but not as the leftmost key column.
17
22
  expect(result).to eq([
18
23
  { table: "posts", column_name: "topic_id" },
19
24
  ])
@@ -25,8 +30,11 @@ describe "missing_fk_indexes" do
25
30
  in_format: :hash
26
31
  )
27
32
 
28
- expect(result).to eq([
29
- { table: "users", column_name: "company_id" },
33
+ expect(result).to match_array([
34
+ { table: "expression_not_null_partial_indexed_posts", column_name: "topic_id" },
35
+ { table: "expression_indexed_posts", column_name: "topic_id" },
36
+ { table: "null_partial_indexed_posts", column_name: "topic_id" },
37
+ { table: "partial_indexed_posts", column_name: "topic_id" },
30
38
  ])
31
39
  end
32
40
 
@@ -36,8 +44,108 @@ describe "missing_fk_indexes" do
36
44
  in_format: :hash
37
45
  )
38
46
 
39
- expect(result).to eq([
47
+ expect(result).to match_array([
48
+ { table: "expression_not_null_partial_indexed_posts", column_name: "topic_id" },
49
+ { table: "expression_indexed_posts", column_name: "topic_id" },
50
+ { table: "null_partial_indexed_posts", column_name: "topic_id" },
51
+ { table: "partial_indexed_posts", column_name: "topic_id" },
40
52
  { table: "posts", column_name: "topic_id" },
41
53
  ])
42
54
  end
55
+
56
+ it "does not flag foreign keys covered by sorted indexes" do
57
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "users" }, in_format: :hash)
58
+
59
+ # users.company_id is leftmost in a sorted index, so it still supports FK checks.
60
+ expect(result).to eq([])
61
+ end
62
+
63
+ it "detects foreign keys covered only by an expression index" do
64
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "expression_indexed_posts" }, in_format: :hash)
65
+
66
+ # Expression indexes do not support raw FK lookups such as WHERE topic_id = ?.
67
+ expect(result).to eq([
68
+ { table: "expression_indexed_posts", column_name: "topic_id" },
69
+ ])
70
+ end
71
+
72
+ it "detects foreign keys covered by an expression index with a not-null predicate" do
73
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "expression_not_null_partial_indexed_posts" }, in_format: :hash)
74
+
75
+ # The predicate is compatible, but the index key is topic_id::text rather than raw topic_id.
76
+ expect(result).to eq([
77
+ { table: "expression_not_null_partial_indexed_posts", column_name: "topic_id" },
78
+ ])
79
+ end
80
+
81
+ it "detects foreign keys covered only by a partial index with an unrelated predicate" do
82
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "partial_indexed_posts" }, in_format: :hash)
83
+
84
+ # WHERE id > 0 is unrelated to topic_id and does not guarantee coverage for every FK value.
85
+ expect(result).to eq([
86
+ { table: "partial_indexed_posts", column_name: "topic_id" },
87
+ ])
88
+ end
89
+
90
+ it "does not flag foreign keys covered by a not-null partial index" do
91
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "not_null_partial_indexed_posts" }, in_format: :hash)
92
+
93
+ # FK checks only need non-null values, so WHERE topic_id IS NOT NULL still covers them.
94
+ expect(result).to eq([])
95
+ end
96
+
97
+ it "normalizes nested parentheses around not-null predicates" do
98
+ allow(RubyPgExtras).to receive(:indexes).and_return([
99
+ {
100
+ "tablename" => "stubbed_posts",
101
+ "columns" => "topic_id",
102
+ "key_column_names" => '["topic_id"]',
103
+ "is_partial" => "t",
104
+ "predicate" => "((topic_id IS NOT NULL))",
105
+ },
106
+ ])
107
+ allow(RubyPgExtras).to receive(:foreign_keys).and_return([
108
+ { "table_name" => "stubbed_posts", "column_name" => "topic_id" },
109
+ ])
110
+
111
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "stubbed_posts" }, in_format: :hash)
112
+
113
+ expect(result).to eq([])
114
+ end
115
+
116
+ it "detects foreign keys covered only by a null partial index" do
117
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "null_partial_indexed_posts" }, in_format: :hash)
118
+
119
+ # WHERE topic_id IS NULL cannot support FK lookups for concrete topic_id values.
120
+ expect(result).to eq([
121
+ { table: "null_partial_indexed_posts", column_name: "topic_id" },
122
+ ])
123
+ end
124
+
125
+ it "does not flag foreign keys covered by indexes with included columns" do
126
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "included_indexed_posts" }, in_format: :hash)
127
+
128
+ # INCLUDE columns are ignored for key matching; topic_id is still the leftmost key column.
129
+ expect(result).to eq([])
130
+ end
131
+
132
+ it "does not flag foreign keys covered by indexes with operator classes" do
133
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "opclass_indexed_codes" }, in_format: :hash)
134
+
135
+ # The display column includes text_pattern_ops, but the logical key column remains code.
136
+ expect(result).to eq([])
137
+ end
138
+
139
+ it "does not flag foreign keys covered by indexes with collations" do
140
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "collated_indexed_codes" }, in_format: :hash)
141
+
142
+ # The display column includes COLLATE "C", but the logical key column remains code.
143
+ expect(result).to eq([])
144
+ end
145
+
146
+ it "does not flag foreign keys covered by sorted indexes on their own table" do
147
+ result = RubyPgExtras.missing_fk_indexes(args: { table_name: "sorted_indexed_posts" }, in_format: :hash)
148
+
149
+ expect(result).to eq([])
150
+ end
43
151
  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")