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
data/spec/diagnose_data_spec.rb
CHANGED
|
@@ -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/index_info_spec.rb
CHANGED
|
@@ -12,8 +12,38 @@ describe RubyPgExtras::IndexInfo do
|
|
|
12
12
|
before do
|
|
13
13
|
expect(RubyPgExtras).to receive(:indexes) {
|
|
14
14
|
[
|
|
15
|
-
{
|
|
16
|
-
|
|
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: "
|
|
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
|
|
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
|
|
29
|
-
{ table: "
|
|
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
|
|
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")
|