Skip to content

Commit 9ad36e0

Browse files
committed
Eliminate queries loading dumped model schema on Postgres
Commit 835eb8a (rails#48743) adds a comment making it clear the intention is to avoid connections being opened/used at app boot: For resiliency, it is critical that a Rails application should be able to boot without depending on the database (or any other service) being responsive. Additionally the followup commit 35a614c (rails#48793) is even more explicit with the title: "Never connect to the database in define_attribute_methods initializer". And in commit 618db13 (rails#49452) the commit message noted: Note, this really isn't good, ideally types should be stored in the schema cache so that we don't have to extract types every time. Unfortunately neither of these changes added any test coverage for those stated invariants (they simply modified existing tests to pass). I assume that the code likely had internal testing done where it was developed in house (I think using MySQL) and was validated in production, but the lack of test coverage in Rails itself led to missing the fact that the invariant is _not_ respected by the PostgreSQL adapater because looking up the cast types (used when defining default attributes) requires a connection to be open to retrieve the type map (which in the PostgreSQL adapter is OID based rather than type name based). Opening a connection not only requires the database to be available at boot, it also triggers multiple queries to load the type map as well as configuring things like timezones which, beyond causing resiliency concerns, further slows down app boot. In this commit we store the cast type value in the schema dump so that it can be loaded along with the dumped schema file at boot rather than, even when using cached schemas, having to connect to the database to finish defining attribute methods.
1 parent 8106ee7 commit 9ad36e0

File tree

20 files changed

+118
-45
lines changed

20 files changed

+118
-45
lines changed

activerecord/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
* Eliminate queries loading dumped schema cache on Postgres
2+
3+
Improve resiliency by avoiding needing to open a database connection to load the
4+
type map while defining attribute methods at boot when a schema cache file is
5+
configured on PostgreSQL databases.
6+
7+
*James Coleman*
8+
19
* `ActiveRecord::Coder::JSON` can be instantiated
210

311
Options can now be passed to `ActiveRecord::Coder::JSON` when instantiating the coder. This allows:

activerecord/lib/active_record/attributes.rb

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,8 @@ def define_attribute(
241241

242242
def _default_attributes # :nodoc:
243243
@default_attributes ||= begin
244-
attributes_hash = with_connection do |connection|
245-
columns_hash.transform_values do |column|
246-
ActiveModel::Attribute.from_database(column.name, column.default, type_for_column(connection, column))
247-
end
244+
attributes_hash = columns_hash.transform_values do |column|
245+
ActiveModel::Attribute.from_database(column.name, column.default, type_for_column(column))
248246
end
249247

250248
attribute_set = ActiveModel::AttributeSet.new(attributes_hash)
@@ -299,7 +297,7 @@ def resolve_type_name(name, **options)
299297
Type.lookup(name, **options, adapter: Type.adapter_name_from(self))
300298
end
301299

302-
def type_for_column(connection, column)
300+
def type_for_column(column)
303301
hook_attribute_type(column.name, super)
304302
end
305303
end

activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -624,8 +624,7 @@ def build_fixture_sql(fixtures, table_name)
624624

625625
columns.map do |name, column|
626626
if fixture.key?(name)
627-
type = lookup_cast_type_from_column(column)
628-
with_yaml_fallback(type.serialize(fixture[name]))
627+
with_yaml_fallback(column.cast_type.serialize(fixture[name]))
629628
else
630629
default_insert_value(column)
631630
end

activerecord/lib/active_record/connection_adapters/abstract/quoting.rb

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -114,19 +114,6 @@ def cast_bound_value(value) # :nodoc:
114114
value
115115
end
116116

117-
# If you are having to call this function, you are likely doing something
118-
# wrong. The column does not have sufficient type information if the user
119-
# provided a custom type on the class level either explicitly (via
120-
# Attributes::ClassMethods#attribute) or implicitly (via
121-
# AttributeMethods::Serialization::ClassMethods#serialize, +time_zone_aware_attributes+).
122-
# In almost all cases, the sql type should only be used to change quoting behavior, when the primitive to
123-
# represent the type doesn't sufficiently reflect the differences
124-
# (varchar vs binary) for example. The type used to get this primitive
125-
# should have been provided before reaching the connection adapter.
126-
def lookup_cast_type_from_column(column) # :nodoc:
127-
lookup_cast_type(column.sql_type)
128-
end
129-
130117
# Quotes a string, escaping any ' (single quote) and \ (backslash)
131118
# characters.
132119
def quote_string(s)
@@ -159,7 +146,8 @@ def quote_default_expression(value, column) # :nodoc:
159146
if value.is_a?(Proc)
160147
value.call
161148
else
162-
value = lookup_cast_type(column.sql_type).serialize(value)
149+
cast_type = column.respond_to?(:cast_type) ? column.cast_type : lookup_cast_type(column.sql_type)
150+
value = cast_type.serialize(value)
163151
quote(value)
164152
end
165153
end
@@ -232,6 +220,8 @@ def type_casted_binds(binds)
232220
end
233221
end
234222

223+
# In some adapters this executes queries; it should not be used in any
224+
# hot paths. Instead prefer using `column.cast_type`.
235225
def lookup_cast_type(sql_type)
236226
type_map.lookup(sql_type)
237227
end

activerecord/lib/active_record/connection_adapters/abstract/schema_dumper.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def schema_scale(column)
8585

8686
def schema_default(column)
8787
return unless column.has_default?
88-
type = @connection.lookup_cast_type_from_column(column)
88+
type = column.cast_type
8989
default = type.deserialize(column.default)
9090
if default.nil?
9191
schema_expression(column)

activerecord/lib/active_record/connection_adapters/column.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ module ConnectionAdapters
77
class Column
88
include Deduplicable
99

10-
attr_reader :name, :default, :sql_type_metadata, :null, :default_function, :collation, :comment
10+
attr_reader :name, :cast_type, :default, :sql_type_metadata, :null, :default_function, :collation, :comment
1111

1212
delegate :precision, :scale, :limit, :type, :sql_type, to: :sql_type_metadata, allow_nil: true
1313

@@ -17,8 +17,9 @@ class Column
1717
# +default+ is the type-casted default value, such as +new+ in <tt>sales_stage varchar(20) default 'new'</tt>.
1818
# +sql_type_metadata+ is various information about the type of the column
1919
# +null+ determines if this column allows +NULL+ values.
20-
def initialize(name, default, sql_type_metadata = nil, null = true, default_function = nil, collation: nil, comment: nil, **)
20+
def initialize(name, cast_type, default, sql_type_metadata = nil, null = true, default_function = nil, collation: nil, comment: nil, **)
2121
@name = name.freeze
22+
@cast_type = cast_type
2223
@sql_type_metadata = sql_type_metadata
2324
@null = null
2425
@default = default
@@ -45,6 +46,7 @@ def human_name
4546

4647
def init_with(coder)
4748
@name = coder["name"]
49+
@cast_type = coder["cast_type"]
4850
@sql_type_metadata = coder["sql_type_metadata"]
4951
@null = coder["null"]
5052
@default = coder["default"]
@@ -55,6 +57,7 @@ def init_with(coder)
5557

5658
def encode_with(coder)
5759
coder["name"] = @name
60+
coder["cast_type"] = @cast_type
5861
coder["sql_type_metadata"] = @sql_type_metadata
5962
coder["null"] = @null
6063
coder["default"] = @default
@@ -75,6 +78,7 @@ def auto_populated?
7578
def ==(other)
7679
other.is_a?(Column) &&
7780
name == other.name &&
81+
cast_type == other.cast_type &&
7882
default == other.default &&
7983
sql_type_metadata == other.sql_type_metadata &&
8084
null == other.null &&
@@ -88,6 +92,7 @@ def hash
8892
Column.hash ^
8993
name.hash ^
9094
name.encoding.hash ^
95+
cast_type.hash ^
9196
default.hash ^
9297
sql_type_metadata.hash ^
9398
null.hash ^
@@ -114,7 +119,7 @@ def deduplicated
114119

115120
class NullColumn < Column
116121
def initialize(name, **)
117-
super(name, nil)
122+
super(name, nil, nil)
118123
end
119124
end
120125
end

activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ def new_column_from_field(table_name, field, _definitions)
209209

210210
MySQL::Column.new(
211211
field["Field"],
212+
lookup_cast_type(type_metadata.sql_type),
212213
default,
213214
type_metadata,
214215
field["Null"] == "YES",

activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,14 @@ def quoted_binary(value) # :nodoc:
153153
"'#{escape_bytea(value.to_s)}'"
154154
end
155155

156+
# `column` may be either an instance of Column or ColumnDefinition.
156157
def quote_default_expression(value, column) # :nodoc:
157158
if value.is_a?(Proc)
158159
value.call
159160
elsif column.type == :uuid && value.is_a?(String) && value.include?("()")
160161
value # Does not quote function default values for UUID columns
161162
elsif column.respond_to?(:array?)
162-
type = lookup_cast_type_from_column(column)
163-
quote(type.serialize(value))
163+
quote(column.cast_type.serialize(value))
164164
else
165165
super
166166
end
@@ -186,11 +186,6 @@ def type_cast(value) # :nodoc:
186186
end
187187
end
188188

189-
def lookup_cast_type_from_column(column) # :nodoc:
190-
verify! if type_map.nil?
191-
type_map.lookup(column.oid, column.fmod, column.sql_type)
192-
end
193-
194189
private
195190
def lookup_cast_type(sql_type)
196191
super(query_value("SELECT #{quote(sql_type)}::regtype::oid", "SCHEMA").to_i)

activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,7 @@ def new_column_from_field(table_name, field, _definitions)
10011001

10021002
PostgreSQL::Column.new(
10031003
column_name,
1004+
get_oid_type(oid.to_i, fmod.to_i, column_name, type),
10041005
default_value,
10051006
type_metadata,
10061007
!notnull,

activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ def new_column_from_field(table_name, field, definitions)
157157

158158
Column.new(
159159
field["name"],
160+
lookup_cast_type(field["type"]),
160161
default_value,
161162
type_metadata,
162163
field["notnull"].to_i == 0,

0 commit comments

Comments
 (0)