Skip to content

Commit ce6932d

Browse files
committed
add mysql connection handling
1 parent df30ce4 commit ce6932d

2 files changed

Lines changed: 162 additions & 0 deletions

File tree

lib/arjdbc/mysql/adapter.rb

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,42 @@ def jdbc_column_class
281281
::ActiveRecord::ConnectionAdapters::MySQL::Column
282282
end
283283

284+
# MySQL / MariaDB surface a dropped server connection as a JDBC error in
285+
# SQLState class 08 (connection exception) - most commonly 08S01
286+
# "Communications link failure" - or with one of the "server gone" vendor
287+
# error codes. The driver may also wrap it in a recoverable / non-transient
288+
# connection exception. None of these are caught by the message- and
289+
# error-code-based cases below (which fall through to a plain JDBCError /
290+
# StatementInvalid), so AR's with_raw_connection reconnect/retry machinery
291+
# never kicks in. See https://dev.mysql.com/doc/connector-j/en/connector-j-reference-error-sqlstates.html
292+
CONNECTION_FAILURE_SQL_STATES = %w[
293+
08000
294+
08001
295+
08003
296+
08004
297+
08006
298+
08007
299+
08S01
300+
].freeze
301+
# CR_SERVER_GONE_ERROR (2006), CR_SERVER_LOST (2013),
302+
# ER_SERVER_SHUTDOWN (1053), ER_CONNECTION_KILLED (1927),
303+
# ER_CLIENT_INTERACTION_TIMEOUT (4031).
304+
CONNECTION_FAILURE_ERROR_CODES = [2006, 2013, 1053, 1927, 4031].freeze
305+
CONNECTION_FAILURE_MESSAGES = /
306+
Communications?\ link\ failure |
307+
No\ operations\ allowed\ after\ connection\ closed |
308+
Connection\.*\ refused |
309+
Could\ not\ connect\ to |
310+
Server\ shutdown\ in\ progress |
311+
Connection\ is\ closed
312+
/x.freeze
313+
private_constant :CONNECTION_FAILURE_SQL_STATES, :CONNECTION_FAILURE_ERROR_CODES, :CONNECTION_FAILURE_MESSAGES
314+
284315
def translate_exception(exception, message:, sql:, binds:)
316+
if exception.is_a?(::ActiveRecord::JDBCError) && connection_lost?(exception)
317+
return ::ActiveRecord::ConnectionFailed.new(message, sql: sql, binds: binds, connection_pool: @pool)
318+
end
319+
285320
case message
286321
when /Table .* doesn't exist/i
287322
StatementInvalid.new(message, sql: sql, binds: binds, connection_pool: @pool)
@@ -292,6 +327,25 @@ def translate_exception(exception, message:, sql:, binds:)
292327
end
293328
end
294329

330+
# Detects a lost server connection from a JDBC error so it can be
331+
# translated to ActiveRecord::ConnectionFailed (retryable). Mirrors the
332+
# PostgreSQL adapter's handling of backend disconnects (e.g. a proxy such
333+
# as ProxySQL dropping an idle connection).
334+
def connection_lost?(exception)
335+
state = exception.sql_state if exception.respond_to?(:sql_state)
336+
return true if state && CONNECTION_FAILURE_SQL_STATES.include?(state)
337+
338+
code = exception.error_code if exception.respond_to?(:error_code)
339+
return true if code && CONNECTION_FAILURE_ERROR_CODES.include?(code)
340+
341+
message = exception.message
342+
return true if message && CONNECTION_FAILURE_MESSAGES.match?(message)
343+
344+
cause = exception.cause if exception.respond_to?(:cause)
345+
cause.is_a?(Java::JavaSql::SQLRecoverableException) ||
346+
cause.is_a?(Java::JavaSql::SQLNonTransientConnectionException)
347+
end
348+
295349
# defined in MySQL::DatabaseStatements which is not included
296350
def default_insert_value(column)
297351
super unless column.auto_increment?
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
require 'db/mysql'
2+
3+
# Regression tests for the MySQL/MariaDB connection-lost translation, the
4+
# MySQL analog of the PostgreSQL backend-disconnect patch.
5+
#
6+
# A JDBCError whose SQLState (class 08), vendor error code, message, or wrapped
7+
# Java exception indicates the server connection is gone must translate to
8+
# ActiveRecord::ConnectionFailed so AR's with_raw_connection(allow_retry:)
9+
# machinery will reconnect and retry. Without this, a proxy (e.g. ProxySQL) or
10+
# the server dropping an idle connection surfaces as a raw JDBCError /
11+
# StatementInvalid and the safe retry never triggers.
12+
class MySQLConnectionLostTest < Test::Unit::TestCase
13+
14+
def setup
15+
@adapter = ActiveRecord::Base.connection
16+
end
17+
18+
# https://dev.mysql.com/doc/connector-j/en/connector-j-reference-error-sqlstates.html
19+
# Class 08 - Connection Exception (08S01 = "Communications link failure").
20+
CONNECTION_FAILURE_SQL_STATES = %w[
21+
08000
22+
08001
23+
08003
24+
08004
25+
08006
26+
08007
27+
08S01
28+
]
29+
30+
# CR_SERVER_GONE_ERROR, CR_SERVER_LOST, ER_SERVER_SHUTDOWN,
31+
# ER_CONNECTION_KILLED, ER_CLIENT_INTERACTION_TIMEOUT.
32+
CONNECTION_FAILURE_ERROR_CODES = [2006, 2013, 1053, 1927, 4031]
33+
34+
CONNECTION_FAILURE_MESSAGES = [
35+
'Communications link failure',
36+
'No operations allowed after connection closed',
37+
'Connection refused',
38+
'Could not connect to address=(host=localhost)(port=3306)',
39+
'Server shutdown in progress',
40+
'Connection is closed',
41+
]
42+
43+
CONNECTION_FAILURE_SQL_STATES.each do |state|
44+
define_method("test_translates_sqlstate_#{state}_to_connection_failed") do
45+
err = jdbc_error('boom', sql_state: state)
46+
result = translate(err)
47+
assert_kind_of ActiveRecord::ConnectionFailed, result,
48+
"expected SQLState #{state} to translate to ConnectionFailed, got #{result.class}"
49+
end
50+
end
51+
52+
CONNECTION_FAILURE_ERROR_CODES.each do |code|
53+
define_method("test_translates_error_code_#{code}_to_connection_failed") do
54+
err = jdbc_error('boom', error_code: code)
55+
result = translate(err)
56+
assert_kind_of ActiveRecord::ConnectionFailed, result,
57+
"expected error code #{code} to translate to ConnectionFailed, got #{result.class}"
58+
end
59+
end
60+
61+
CONNECTION_FAILURE_MESSAGES.each_with_index do |msg, i|
62+
define_method("test_translates_message_#{i}_to_connection_failed") do
63+
err = jdbc_error(msg)
64+
result = translate(err)
65+
assert_kind_of ActiveRecord::ConnectionFailed, result,
66+
"expected message #{msg.inspect} to translate to ConnectionFailed, got #{result.class}"
67+
end
68+
end
69+
70+
def test_recoverable_jdbc_exception_translates_to_connection_failed
71+
cause = Java::JavaSql::SQLRecoverableException.new('socket gone')
72+
err = ActiveRecord::JDBCError.new('socket gone', cause)
73+
assert_kind_of ActiveRecord::ConnectionFailed, translate(err)
74+
end
75+
76+
def test_non_transient_connection_exception_translates_to_connection_failed
77+
cause = Java::JavaSql::SQLNonTransientConnectionException.new('link down')
78+
err = ActiveRecord::JDBCError.new('link down', cause)
79+
assert_kind_of ActiveRecord::ConnectionFailed, translate(err)
80+
end
81+
82+
def test_does_not_translate_duplicate_entry_to_connection_failed
83+
# ER_DUP_ENTRY (1062) is a data error, not a connection failure.
84+
err = jdbc_error("Duplicate entry 'x' for key 'PRIMARY'", sql_state: '23000', error_code: 1062)
85+
result = translate(err)
86+
assert_kind_of ActiveRecord::RecordNotUnique, result
87+
assert !result.is_a?(ActiveRecord::ConnectionFailed)
88+
end
89+
90+
def test_does_not_translate_syntax_error_to_connection_failed
91+
# ER_PARSE_ERROR (1064) must not be mistaken for a connection failure.
92+
err = jdbc_error('You have an error in your SQL syntax', sql_state: '42000', error_code: 1064)
93+
result = translate(err)
94+
assert !result.is_a?(ActiveRecord::ConnectionFailed),
95+
"syntax error should not translate to ConnectionFailed, got #{result.class}"
96+
end
97+
98+
private
99+
100+
def translate(jdbc_error)
101+
@adapter.send(:translate_exception_class, jdbc_error, 'SELECT 1', [])
102+
end
103+
104+
def jdbc_error(message, sql_state: nil, error_code: 0)
105+
cause = Java::JavaSql::SQLException.new(message, sql_state, error_code)
106+
ActiveRecord::JDBCError.new(message, cause)
107+
end
108+
end

0 commit comments

Comments
 (0)