Skip to content

Commit da52b0d

Browse files
committed
Separate two groups of retryable DB exceptions
Connectivity issues need a reconnect to be retried, but there are also in-server errors that can also be retried (lock timeouts and deadlocks). To handle this better, we move translation into #with_raw_connection: it's a much better representation of the boundary between the underlying adapter library and Active Record's abstractions anyway. (It doesn't know about the query being run, though, so we let #log enrich that into the exception later.)
1 parent b9f44e6 commit da52b0d

File tree

6 files changed

+90
-32
lines changed

6 files changed

+90
-32
lines changed

activerecord/lib/active_record/connection_adapters/abstract_adapter.rb

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -859,22 +859,39 @@ def with_raw_connection(allow_retry: false, uses_transaction: true)
859859
result = yield @raw_connection
860860
@verified = true
861861
result
862-
rescue => ex
863-
if retries_available > 0 && retryable_error?(ex) && reconnect_can_restore_state?
862+
rescue => original_exception
863+
translated_exception = translate_exception_class(original_exception, nil, nil)
864+
865+
if retries_available > 0
864866
retries_available -= 1
865-
reconnect!(restore_transactions: true)
866-
retry
867+
868+
if retryable_query_error?(translated_exception)
869+
backoff(connection_retries - retries_available)
870+
retry
871+
elsif retryable_connection_error?(translated_exception)
872+
reconnect!(restore_transactions: true)
873+
retry
874+
end
867875
end
868876

869-
raise
877+
raise translated_exception
870878
ensure
871879
dirty_current_transaction if uses_transaction
872880
end
873881
end
874882
end
875883

876-
def retryable_error?(exception)
877-
false
884+
def retryable_connection_error?(exception)
885+
exception.is_a?(ConnectionNotEstablished)
886+
end
887+
888+
def retryable_query_error?(exception)
889+
exception.is_a?(Deadlocked) ||
890+
exception.is_a?(LockWaitTimeout)
891+
end
892+
893+
def backoff(counter)
894+
sleep 0.1 * counter
878895
end
879896

880897
# Returns a raw connection for internal use with methods that are known
@@ -923,7 +940,7 @@ def translate_exception_class(e, sql, binds)
923940
exception
924941
end
925942

926-
def log(sql, name = "SQL", binds = [], type_casted_binds = [], statement_name = nil, async: false) # :doc:
943+
def log(sql, name = "SQL", binds = [], type_casted_binds = [], statement_name = nil, async: false, &block) # :doc:
927944
@instrumenter.instrument(
928945
"sql.active_record",
929946
sql: sql,
@@ -932,11 +949,11 @@ def log(sql, name = "SQL", binds = [], type_casted_binds = [], statement_name =
932949
type_casted_binds: type_casted_binds,
933950
statement_name: statement_name,
934951
async: async,
935-
connection: self) do
936-
yield
937-
rescue => e
938-
raise translate_exception_class(e, sql, binds)
939-
end
952+
connection: self,
953+
&block
954+
)
955+
rescue ActiveRecord::StatementInvalid => ex
956+
raise ex.set_query(sql, binds)
940957
end
941958

942959
def transform_query(sql)

activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -723,11 +723,6 @@ def translate_exception(exception, message:, sql:, binds:)
723723
end
724724
end
725725

726-
def retryable_error?(exception)
727-
error_number(exception).nil? &&
728-
exception.message.match?(/MySQL client is not connected/i)
729-
end
730-
731726
def change_column_for_alter(table_name, column_name, type, **options)
732727
column = column_for(table_name, column_name)
733728
type ||= column.sql_type
@@ -865,7 +860,7 @@ def build_statement_pool
865860
StatementPool.new(self.class.type_cast_config_to_integer(@config[:statement_limit]))
866861
end
867862

868-
def mismatched_foreign_key(message, sql:, binds:)
863+
def mismatched_foreign_key_details(message:, sql:)
869864
foreign_key_pat =
870865
/Referencing column '(\w+)' and referenced/i =~ message ? $1 : '\w+'
871866

@@ -875,11 +870,7 @@ def mismatched_foreign_key(message, sql:, binds:)
875870
REFERENCES\s*(`?(?<target_table>\w+)`?)\s*\(`?(?<primary_key>\w+)`?\)
876871
/xmi.match(sql)
877872

878-
options = {
879-
message: message,
880-
sql: sql,
881-
binds: binds,
882-
}
873+
options = {}
883874

884875
if match
885876
options[:table] = match[:table]
@@ -889,6 +880,22 @@ def mismatched_foreign_key(message, sql:, binds:)
889880
options[:primary_key_column] = column_for(match[:target_table], match[:primary_key])
890881
end
891882

883+
options
884+
end
885+
886+
def mismatched_foreign_key(message, sql:, binds:)
887+
options = {
888+
message: message,
889+
sql: sql,
890+
binds: binds,
891+
}
892+
893+
if sql
894+
options.update mismatched_foreign_key_details(message: message, sql: sql)
895+
else
896+
options[:query_parser] = ->(sql) { mismatched_foreign_key_details(message: message, sql: sql) }
897+
end
898+
892899
MismatchedForeignKey.new(**options)
893900
end
894901

activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,8 @@ def translate_exception(exception, message:, sql:, binds:)
693693
when nil
694694
if exception.message.match?(/connection is closed/i)
695695
ConnectionNotEstablished.new(exception)
696+
elsif exception.is_a?(PG::ConnectionBad) && !exception.message.end_with?("\n")
697+
ConnectionNotEstablished.new(exception)
696698
else
697699
super
698700
end
@@ -721,7 +723,10 @@ def translate_exception(exception, message:, sql:, binds:)
721723
end
722724
end
723725

724-
def retryable_error?(exception)
726+
def retryable_query_error?(exception)
727+
end
728+
729+
def retryable_connection_error?(exception)
725730
case exception
726731
when PG::ConnectionBad; !exception.message.end_with?("\n")
727732
end

activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -577,10 +577,6 @@ def translate_exception(exception, message:, sql:, binds:)
577577
end
578578
end
579579

580-
def retryable_error?(exception)
581-
exception.message.match?(/called on a closed database/i)
582-
end
583-
584580
COLLATE_REGEX = /.*"(\w+)".*collate\s+"(\w+)".*/i.freeze
585581

586582
def table_structure_with_collation(table_name, basic_structure)

activerecord/lib/active_record/errors.rb

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,15 @@ def initialize(message = nil, sql: nil, binds: nil)
166166
end
167167

168168
attr_reader :sql, :binds
169+
170+
def set_query(sql, binds)
171+
unless @sql
172+
@sql = sql
173+
@binds = binds
174+
end
175+
176+
self
177+
end
169178
end
170179

171180
# Defunct wrapper class kept for compatibility.
@@ -192,8 +201,12 @@ def initialize(
192201
foreign_key: nil,
193202
target_table: nil,
194203
primary_key: nil,
195-
primary_key_column: nil
204+
primary_key_column: nil,
205+
query_parser: nil
196206
)
207+
@original_message = message
208+
@query_parser = query_parser
209+
197210
if table
198211
type = primary_key_column.bigint? ? :bigint : primary_key_column.type
199212
msg = <<~EOM.squish
@@ -211,8 +224,24 @@ def initialize(
211224
if message
212225
msg << "\nOriginal message: #{message}"
213226
end
227+
214228
super(msg, sql: sql, binds: binds)
215229
end
230+
231+
def set_query(sql, binds)
232+
if @query_parser && !@sql
233+
self.class.new(
234+
message: @original_message,
235+
sql: sql,
236+
binds: binds,
237+
**@query_parser.call(sql)
238+
).tap do |exception|
239+
exception.set_backtrace backtrace
240+
end
241+
else
242+
super
243+
end
244+
end
216245
end
217246

218247
# Raised when a record cannot be inserted or updated because it would violate a not null constraint.

activerecord/test/cases/statement_invalid_test.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ def error_number
2121
sql = Book.where(author_id: 96, cover: "hard").to_sql
2222
error = assert_raises(ActiveRecord::StatementInvalid) do
2323
Book.connection.send(:log, sql, Book.name) do
24-
raise MockDatabaseError
24+
Book.connection.send(:with_raw_connection) do
25+
raise MockDatabaseError
26+
end
2527
end
2628
end
2729
assert_not error.message.include?("SELECT")
@@ -32,7 +34,9 @@ def error_number
3234
binds = [Minitest::Mock.new, Minitest::Mock.new]
3335
error = assert_raises(ActiveRecord::StatementInvalid) do
3436
Book.connection.send(:log, sql, Book.name, binds) do
35-
raise MockDatabaseError
37+
Book.connection.send(:with_raw_connection) do
38+
raise MockDatabaseError
39+
end
3640
end
3741
end
3842
assert_equal error.sql, sql

0 commit comments

Comments
 (0)