Skip to content

Commit 070787f

Browse files
committed
Merge pull request #150 from ManageIQ/retry_deadlock_victim_error
Retry deadlock victim error
2 parents 5529234 + ac52389 commit 070787f

File tree

5 files changed

+201
-5
lines changed

5 files changed

+201
-5
lines changed

CHANGELOG

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11

22
* master *
33

4+
* Renamed #with_auto_reconnect to #with_sqlserver_error_handling now that it handles both dropped
5+
connections and deadlock victim errors. Fixes #150 [Joe Rafaniello]
6+
47
* Add activity_stats method that mimics the SQL Server Activity Monitor. Fixes #146 [Joe Rafaniello]
58

69
* Add methods for sqlserver's #product_version, #product_level, #edition and include them in inspect.

lib/active_record/connection_adapters/sqlserver/database_statements.rb

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,91 @@ def supports_statement_cache?
4545
true
4646
end
4747

48+
def transaction(options = {}, &block)
49+
retry_deadlock_victim? ? transaction_with_retry_deadlock_victim(options, &block) : super(options, &block)
50+
end
51+
52+
def transaction_with_retry_deadlock_victim(options = {})
53+
options.assert_valid_keys :requires_new, :joinable
54+
55+
last_transaction_joinable = defined?(@transaction_joinable) ? @transaction_joinable : nil
56+
if options.has_key?(:joinable)
57+
@transaction_joinable = options[:joinable]
58+
else
59+
@transaction_joinable = true
60+
end
61+
requires_new = options[:requires_new] || !last_transaction_joinable
62+
63+
transaction_open = false
64+
@_current_transaction_records ||= []
65+
66+
begin
67+
if block_given?
68+
if requires_new || open_transactions == 0
69+
if open_transactions == 0
70+
begin_db_transaction
71+
elsif requires_new
72+
create_savepoint
73+
end
74+
increment_open_transactions
75+
transaction_open = true
76+
@_current_transaction_records.push([])
77+
end
78+
yield
79+
end
80+
rescue Exception => database_transaction_rollback
81+
if transaction_open && !outside_transaction?
82+
transaction_open = false
83+
decrement_open_transactions
84+
# handle deadlock victim retries at the outermost transaction
85+
if open_transactions == 0
86+
if database_transaction_rollback.is_a?(DeadlockVictim)
87+
# SQL Server has already rolled back, so rollback activerecord's history
88+
rollback_transaction_records(true)
89+
retry
90+
else
91+
rollback_db_transaction
92+
rollback_transaction_records(true)
93+
end
94+
else
95+
rollback_to_savepoint
96+
rollback_transaction_records(false)
97+
end
98+
end
99+
raise unless database_transaction_rollback.is_a?(ActiveRecord::Rollback)
100+
end
101+
ensure
102+
@transaction_joinable = last_transaction_joinable
103+
104+
if outside_transaction?
105+
@open_transactions = 0
106+
elsif transaction_open
107+
decrement_open_transactions
108+
begin
109+
if open_transactions == 0
110+
commit_db_transaction
111+
commit_transaction_records
112+
else
113+
release_savepoint
114+
save_point_records = @_current_transaction_records.pop
115+
unless save_point_records.blank?
116+
@_current_transaction_records.push([]) if @_current_transaction_records.empty?
117+
@_current_transaction_records.last.concat(save_point_records)
118+
end
119+
end
120+
rescue Exception => database_transaction_rollback
121+
if open_transactions == 0
122+
rollback_db_transaction
123+
rollback_transaction_records(true)
124+
else
125+
rollback_to_savepoint
126+
rollback_transaction_records(false)
127+
end
128+
raise
129+
end
130+
end
131+
end
132+
48133
def begin_db_transaction
49134
do_execute "BEGIN TRANSACTION"
50135
end
@@ -307,7 +392,7 @@ def valid_isolation_levels
307392
def do_execute(sql, name = nil)
308393
name ||= 'EXECUTE'
309394
log(sql, name) do
310-
with_auto_reconnect { raw_connection_do(sql) }
395+
with_sqlserver_error_handling { raw_connection_do(sql) }
311396
end
312397
end
313398

@@ -365,7 +450,7 @@ def _raw_select(sql, options={})
365450
end
366451

367452
def raw_connection_run(sql)
368-
with_auto_reconnect do
453+
with_sqlserver_error_handling do
369454
case @connection_options[:mode]
370455
when :dblib
371456
@connection.execute(sql)

lib/active_record/connection_adapters/sqlserver/errors.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ module ActiveRecord
33
class LostConnection < WrappedDatabaseException
44
end
55

6+
class DeadlockVictim < WrappedDatabaseException
7+
end
8+
69
module ConnectionAdapters
710
module Sqlserver
811
module Errors

lib/active_record/connection_adapters/sqlserver_adapter.rb

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ class SQLServerAdapter < AbstractAdapter
179179
attr_reader :database_version, :database_year, :spid, :product_level, :product_version, :edition
180180

181181
cattr_accessor :native_text_database_type, :native_binary_database_type, :native_string_database_type,
182-
:log_info_schema_queries, :enable_default_unicode_types, :auto_connect,
182+
:log_info_schema_queries, :enable_default_unicode_types, :auto_connect, :retry_deadlock_victim,
183183
:cs_equality_operator, :lowercase_schema_reflection, :auto_connect_duration
184184

185185
self.enable_default_unicode_types = true
@@ -338,6 +338,11 @@ def auto_connect_duration
338338
@@auto_connect_duration ||= 10
339339
end
340340

341+
def retry_deadlock_victim
342+
@@retry_deadlock_victim.is_a?(FalseClass) ? false : true
343+
end
344+
alias :retry_deadlock_victim? :retry_deadlock_victim
345+
341346
def native_string_database_type
342347
@@native_string_database_type || (enable_default_unicode_types ? 'nvarchar' : 'varchar')
343348
end
@@ -372,6 +377,8 @@ def translate_exception(e, message)
372377
RecordNotUnique.new(message,e)
373378
when /conflicted with the foreign key constraint/i
374379
InvalidForeignKey.new(message,e)
380+
when /has been chosen as the deadlock victim/i
381+
DeadlockVictim.new(message,e)
375382
when *lost_connection_messages
376383
LostConnection.new(message,e)
377384
else
@@ -472,11 +479,14 @@ def remove_database_connections_and_rollback(database=nil)
472479
end if block_given?
473480
end
474481

475-
def with_auto_reconnect
482+
def with_sqlserver_error_handling
476483
begin
477484
yield
478485
rescue Exception => e
479-
retry if translate_exception(e,e.message).is_a?(LostConnection) && auto_reconnected?
486+
case translate_exception(e,e.message)
487+
when LostConnection; retry if auto_reconnected?
488+
when DeadlockVictim; retry if retry_deadlock_victim? && self.open_transactions == 0
489+
end
480490
raise
481491
end
482492
end

test/cases/connection_test_sqlserver.rb

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,92 @@ def setup
101101
assert @connection.spid.nil?
102102
end
103103

104+
if connection_mode_dblib?
105+
context 'with a deadlock victim exception (1205) outside a transaction' do
106+
setup do
107+
@query = "SELECT 1 as [one]"
108+
@expected = @connection.execute(@query)
109+
110+
# Execute the query to get a handle of the expected result, which will
111+
# be returned after a simulated deadlock victim (1205).
112+
raw_conn = @connection.instance_variable_get(:@connection)
113+
stubbed_handle = raw_conn.execute(@query)
114+
@connection.send(:finish_statement_handle, stubbed_handle)
115+
raw_conn.stubs(:execute).raises(deadlock_victim_exception(@query)).then.returns(stubbed_handle)
116+
end
117+
118+
teardown do
119+
@connection.class.retry_deadlock_victim = nil
120+
end
121+
122+
should 'retry by default' do
123+
assert_nothing_raised do
124+
assert_equal @expected, @connection.execute(@query)
125+
end
126+
end
127+
128+
should 'raise ActiveRecord::DeadlockVictim if retry is disabled' do
129+
@connection.class.retry_deadlock_victim = false
130+
assert_raise(ActiveRecord::DeadlockVictim) do
131+
assert_equal @expected, @connection.execute(@query)
132+
end
133+
end
134+
end
135+
136+
context 'with a deadlock victim exception (1205) within a transaction' do
137+
setup do
138+
@query = "SELECT 1 as [one]"
139+
@expected = @connection.execute(@query)
140+
141+
# "stub" the execute method to simulate raising a deadlock victim exception once
142+
@connection.class.class_eval do
143+
def execute_with_deadlock_exception(sql, *args)
144+
if !@raised_deadlock_exception && sql == "SELECT 1 as [one]"
145+
sql = "RAISERROR('Transaction (Process ID #{Process.pid}) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction.: #{sql}', 13, 1)"
146+
@raised_deadlock_exception = true
147+
elsif @raised_deadlock_exception == true && sql =~ /RAISERROR\('Transaction \(Process ID \d+\) was deadlocked on lock resources with another process and has been chosen as the deadlock victim\. Rerun the transaction\.: SELECT 1 as \[one\]', 13, 1\)/
148+
sql = "SELECT 1 as [one]"
149+
end
150+
151+
execute_without_deadlock_exception(sql, *args)
152+
end
153+
154+
alias :execute_without_deadlock_exception :execute
155+
alias :execute :execute_with_deadlock_exception
156+
end
157+
end
158+
159+
teardown do
160+
# Cleanup the "stubbed" execute method
161+
@connection.class.class_eval do
162+
alias :execute :execute_without_deadlock_exception
163+
remove_method :execute_with_deadlock_exception
164+
remove_method :execute_without_deadlock_exception
165+
end
166+
167+
@connection.send(:remove_instance_variable, :@raised_deadlock_exception)
168+
@connection.class.retry_deadlock_victim = nil
169+
end
170+
171+
should 'retry by default' do
172+
assert_nothing_raised do
173+
ActiveRecord::Base.transaction do
174+
assert_equal @expected, @connection.execute(@query)
175+
end
176+
end
177+
end
178+
179+
should 'raise ActiveRecord::DeadlockVictim if retry disabled' do
180+
@connection.class.retry_deadlock_victim = false
181+
assert_raise(ActiveRecord::DeadlockVictim) do
182+
ActiveRecord::Base.transaction do
183+
assert_equal @expected, @connection.execute(@query)
184+
end
185+
end
186+
end
187+
end
188+
end
189+
104190
should 'be able to disconnect and reconnect at will' do
105191
@connection.disconnect!
106192
assert !@connection.active?
@@ -197,6 +283,15 @@ def assert_all_odbc_statements_used_are_closed(&block)
197283
GC.enable
198284
end
199285

286+
def deadlock_victim_exception(sql)
287+
require 'tiny_tds/error'
288+
error = TinyTds::Error.new("Transaction (Process ID #{Process.pid}) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction.: #{sql}")
289+
error.severity = 13
290+
error.db_error_number = 1205
291+
error
292+
end
293+
294+
200295
def with_auto_connect(boolean)
201296
existing = ActiveRecord::ConnectionAdapters::SQLServerAdapter.auto_connect
202297
ActiveRecord::ConnectionAdapters::SQLServerAdapter.auto_connect = boolean

0 commit comments

Comments
 (0)