Skip to content

Commit 64deeb3

Browse files
committed
chore: faster referential integrity for tests
1 parent 21965ba commit 64deeb3

File tree

4 files changed

+159
-50
lines changed

4 files changed

+159
-50
lines changed

CONTRIBUTING.md

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# Getting started
22

3-
43
## ActiveRecord adapters and you
54

65
There are two repositories for the ActiveRecord adapter. The one you're in
@@ -121,7 +120,6 @@ master branch, with an alpha build of CockroachDB. it would be even
121120
better to be able to test multiple versions of the adapter, and do so
122121
against different versions of CockroachDB.
123122

124-
125123
## Adding feature support
126124

127125
As CockroachDB improves, so do the features that can be supported in
@@ -131,15 +129,34 @@ gates should be toggled. Something that would help this process would be
131129
linking those issues back to this adapter so that part of the feature
132130
completing includes updating the adapter.
133131

134-
135132
## Execute only tests that run with a connection
136133

137-
I have not investigated if this is already possible, but I would assume
138-
no.
139-
140-
A possible way to approach this would be to add a shim to cause any
141-
tests that use it to fail, and grep the tests that pass and then skip
142-
them.
134+
- Check for TODO or NOTE tags that are referencing the old or new version of
135+
rails.
136+
```bash
137+
rg 'TODO|NOTE' --after-context=2
138+
```
139+
- Check postgresql_specific_schema.rb changelog in rails, and apply the changes
140+
you want. Ex:
141+
```bash
142+
git diff v7.1.4..v7.2.1 -- $(fd postgresql_specific_schema)
143+
```
144+
- Verify the written text at the beginning of the test suite, there are likely
145+
some changes in excluded tests.
146+
- Check for some important methods, some will change for sure:
147+
- [ ] `def new_column_from_field(`
148+
- [ ] `def column_definitions(`
149+
- [ ] `def pk_and_sequence_for(`
150+
- [ ] `def foreign_keys(` and `def all_foreign_keys(`
151+
- [ ] ...
152+
- Check for setups containing `drop_table` in the test suite.
153+
Especially if you have tons of failure, this is likely the cause.
154+
- In the same way, run `test/cases/fixtures_test.rb` first, and check
155+
if this corrupted the test database for other tests.
156+
- For both of the above, the diff of `schema.rb` can be useful:
157+
```bash
158+
git diff v7.1.2..v7.2.1 -- activerecord/test/schema/schema.rb
159+
```
143160

144161
## Publishing to Rubygems
145162

@@ -151,7 +168,6 @@ gem build ...
151168
gem publish <output file>
152169
```
153170

154-
155171
# Notes
156172

157173
When executing the test suite, each test file will reload fixtures. This
@@ -182,10 +198,8 @@ cleaned up, or skipped until passing.
182198
The purpose of these was to make the tests grep-able while going through
183199
all the failures.
184200

185-
186201
[cockroachdb/cockroach#20753]: https://github.com/cockroachdb/cockroach/issues/20753#issuecomment-352810425
187202

188-
189203
## Tracked test failures
190204

191205
Some of the skipped failures are:
@@ -224,7 +238,6 @@ most of the touchpoints including test failures and temporary monkey
224238
patches. Some monkey patches were made directly to Rails, which will
225239
need to be cleaned up.
226240

227-
228241
# Notes for the non-Rubyer
229242

230243
rbenv is an environment manager that lets you manage and swap between

lib/active_record/connection_adapters/cockroachdb/referential_integrity.rb

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,16 @@ def all_foreign_keys_valid?
2020
end
2121

2222
def disable_referential_integrity
23-
foreign_keys = tables.map { |table| foreign_keys(table) }.flatten
23+
foreign_keys = all_foreign_keys
2424

2525
foreign_keys.each do |foreign_key|
26-
remove_foreign_key(foreign_key.from_table, name: foreign_key.options[:name])
26+
# We do not use the `#remove_foreign_key` method here because it
27+
# checks for foreign keys existance in the schema cache. This method
28+
# is performance critical and we know the foreign key exist.
29+
at = create_alter_table foreign_key.from_table
30+
at.drop_foreign_key foreign_key.name
31+
32+
execute schema_creation.accept(at)
2733
end
2834

2935
yield
@@ -38,23 +44,83 @@ def disable_referential_integrity
3844
ActiveRecord::Base.table_name_suffix = ""
3945

4046
begin
47+
# Avoid having PG:DuplicateObject error if a test is ran in transaction.
48+
# TODO: verify that there is no cache issue related to running this (e.g: fk
49+
# still in cache but not in db)
50+
#
51+
# We avoid using `foreign_key_exists?` here because it checks the schema cache
52+
# for every key. This method is performance critical for the test suite, hence
53+
# we use the `#all_foreign_keys` method that only make one query to the database.
54+
already_inserted_foreign_keys = all_foreign_keys
4155
foreign_keys.each do |foreign_key|
42-
begin
43-
add_foreign_key(foreign_key.from_table, foreign_key.to_table, **foreign_key.options)
44-
rescue ActiveRecord::StatementInvalid => error
45-
if error.cause.class == PG::DuplicateObject
46-
# This error is safe to ignore because the yielded caller
47-
# already re-added the foreign key constraint.
48-
else
49-
raise error
50-
end
51-
end
56+
next if already_inserted_foreign_keys.any? { |fk| fk.from_table == foreign_key.from_table && fk.options[:name] == foreign_key.options[:name] }
57+
58+
add_foreign_key(foreign_key.from_table, foreign_key.to_table, **foreign_key.options)
5259
end
5360
ensure
5461
ActiveRecord::Base.table_name_prefix = old_prefix
5562
ActiveRecord::Base.table_name_suffix = old_suffix
5663
end
5764
end
65+
66+
private
67+
68+
# Copy/paste of the `#foreign_keys(table)` method adapted to return every single
69+
# foreign key in the database.
70+
def all_foreign_keys
71+
fk_info = internal_exec_query(<<~SQL, "SCHEMA")
72+
SELECT CASE
73+
WHEN n1.nspname = current_schema()
74+
THEN ''
75+
ELSE n1.nspname || '.'
76+
END || t1.relname AS from_table,
77+
CASE
78+
WHEN n2.nspname = current_schema()
79+
THEN ''
80+
ELSE n2.nspname || '.'
81+
END || t2.relname AS to_table,
82+
a1.attname AS column, a2.attname AS primary_key, c.conname AS name, c.confupdtype AS on_update, c.confdeltype AS on_delete, c.convalidated AS valid, c.condeferrable AS deferrable, c.condeferred AS deferred,
83+
c.conkey, c.confkey, c.conrelid, c.confrelid
84+
FROM pg_constraint c
85+
JOIN pg_class t1 ON c.conrelid = t1.oid
86+
JOIN pg_class t2 ON c.confrelid = t2.oid
87+
JOIN pg_attribute a1 ON a1.attnum = c.conkey[1] AND a1.attrelid = t1.oid
88+
JOIN pg_attribute a2 ON a2.attnum = c.confkey[1] AND a2.attrelid = t2.oid
89+
JOIN pg_namespace t3 ON c.connamespace = t3.oid
90+
JOIN pg_namespace n1 ON t1.relnamespace = n1.oid
91+
JOIN pg_namespace n2 ON t2.relnamespace = n2.oid
92+
WHERE c.contype = 'f'
93+
ORDER BY c.conname
94+
SQL
95+
96+
fk_info.map do |row|
97+
from_table = PostgreSQL::Utils.unquote_identifier(row["from_table"])
98+
to_table = PostgreSQL::Utils.unquote_identifier(row["to_table"])
99+
conkey = row["conkey"].scan(/\d+/).map(&:to_i)
100+
confkey = row["confkey"].scan(/\d+/).map(&:to_i)
101+
102+
if conkey.size > 1
103+
column = column_names_from_column_numbers(row["conrelid"], conkey)
104+
primary_key = column_names_from_column_numbers(row["confrelid"], confkey)
105+
else
106+
column = PostgreSQL::Utils.unquote_identifier(row["column"])
107+
primary_key = row["primary_key"]
108+
end
109+
110+
options = {
111+
column: column,
112+
name: row["name"],
113+
primary_key: primary_key
114+
}
115+
options[:on_delete] = extract_foreign_key_action(row["on_delete"])
116+
options[:on_update] = extract_foreign_key_action(row["on_update"])
117+
options[:deferrable] = extract_constraint_deferrable(row["deferrable"], row["deferred"])
118+
119+
options[:validate] = row["valid"]
120+
121+
ForeignKeyDefinition.new(from_table, to_table, options)
122+
end
123+
end
58124
end
59125
end
60126
end

lib/active_record/connection_adapters/cockroachdb/schema_statements.rb

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -245,31 +245,6 @@ def type_to_sql(type, limit: nil, precision: nil, scale: nil, array: nil, **) #
245245
sql
246246
end
247247

248-
# This overrides the method from PostegreSQL adapter
249-
# Resets the sequence of a table's primary key to the maximum value.
250-
def reset_pk_sequence!(table, pk = nil, sequence = nil)
251-
unless pk && sequence
252-
default_pk, default_sequence = pk_and_sequence_for(table)
253-
254-
pk ||= default_pk
255-
sequence ||= default_sequence
256-
end
257-
258-
if @logger && pk && !sequence
259-
@logger.warn "#{table} has primary key #{pk} with no default sequence."
260-
end
261-
262-
if pk && sequence
263-
quoted_sequence = quote_table_name(sequence)
264-
max_pk = query_value("SELECT MAX(#{quote_column_name pk}) FROM #{quote_table_name(table)}", "SCHEMA")
265-
if max_pk.nil?
266-
minvalue = query_value("SELECT seqmin FROM pg_sequence WHERE seqrelid = #{quote(quoted_sequence)}::regclass", "SCHEMA")
267-
end
268-
269-
query_value("SELECT setval(#{quote(quoted_sequence)}, #{max_pk ? max_pk : minvalue}, #{max_pk ? true : false})", "SCHEMA")
270-
end
271-
end
272-
273248
# override
274249
def native_database_types
275250
# Add spatial types

test/support/sql_logger.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
module SQLLogger
4+
module_function
5+
6+
def stdout_log
7+
ActiveRecord::Base.logger = Logger.new(STDOUT)
8+
ActiveRecord::Base.logger.level = Logger::DEBUG
9+
ActiveRecord::LogSubscriber::IGNORE_PAYLOAD_NAMES.clear
10+
ActiveRecord::Base.logger.formatter = proc { |severity, time, progname, msg|
11+
th = Thread.current[:name]
12+
th = "THREAD=#{th}" if th
13+
Logger::Formatter.new.call(severity, time, progname || th, msg)
14+
}
15+
end
16+
17+
def summary_log
18+
ActiveRecord::TotalTimeSubscriber.attach_to :active_record
19+
Minitest.after_run {
20+
detail = ActiveRecord::TotalTimeSubscriber.hash.map { |k,v| [k, [v.sum, v.sum / v.size, v.size]]}.sort_by { |_, (_total, avg, _)| -avg }.to_h
21+
time = detail.values.sum { |(total, _, _)| total } / 1_000
22+
count = detail.values.sum { |(_, _, count)| count }
23+
puts "Total time spent in SQL: #{time}s (#{count} queries)"
24+
puts "Detail per query kind available in tmp/query_time.json (total time in ms, avg time in ms, query count). Sorted by avg time."
25+
File.write(
26+
"tmp/query_time.json",
27+
JSON.pretty_generate(detail)
28+
)
29+
}
30+
end
31+
32+
# Remove content between single quotes and double quotes from keys
33+
# to have a clear idea of which queries are being executed.
34+
def clean_sql(sql)
35+
sql.gsub(/".*?"/m, "\"...\"").gsub("''", "").gsub(/'.*?'/m, "'...'")
36+
end
37+
end
38+
39+
class ActiveRecord::TotalTimeSubscriber < ActiveRecord::LogSubscriber
40+
def self.hash
41+
@@hash
42+
end
43+
44+
def sql(event)
45+
# NOTE: If you want to debug a specific query, you can use a 'binding.irb' here with
46+
# a specific condition on 'event.payload[:sql]' content.
47+
#
48+
# binding.irb if event.payload[:sql].include?("attr.attname, nsp.nspname")
49+
#
50+
@@hash ||= {}
51+
key = SQLLogger.clean_sql(event.payload[:sql])
52+
@@hash[key] ||= []
53+
@@hash[key].push event.duration
54+
end
55+
end

0 commit comments

Comments
 (0)