Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion lib/mongo/session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -456,13 +456,26 @@ def with_transaction(options = nil)
Utils.monotonic_time + 120
end
transaction_in_progress = false
transaction_attempt = 0
last_error = nil

loop do
if transaction_attempt > 0
backoff = backoff_seconds_for_retry(transaction_attempt)
if backoff_would_exceed_deadline?(deadline, backoff)
raise(last_error)
end
sleep(backoff)
end

commit_options = {}
if options
commit_options[:write_concern] = options[:write_concern]
end
start_transaction(options)
transaction_in_progress = true
transaction_attempt += 1

begin
rv = yield self
rescue Exception => e
Expand All @@ -479,6 +492,7 @@ def with_transaction(options = nil)
end

if e.is_a?(Mongo::Error) && e.label?('TransientTransactionError')
last_error = e
next
end

Expand All @@ -495,7 +509,7 @@ def with_transaction(options = nil)
return rv
rescue Mongo::Error => e
if e.label?('UnknownTransactionCommitResult')
if deadline_expired?(deadline) ||
if deadline_expired?(deadline) ||
e.is_a?(Error::OperationFailure::Family) && e.max_time_ms_expired?
then
transaction_in_progress = false
Expand All @@ -516,6 +530,7 @@ def with_transaction(options = nil)
transaction_in_progress = false
raise
end
last_error = e
@state = NO_TRANSACTION_STATE
next
else
Expand Down Expand Up @@ -1312,5 +1327,21 @@ def deadline_expired?(deadline)
Utils.monotonic_time >= deadline
end
end

# Exponential backoff settings for with_transaction retries.
BACKOFF_INITIAL = 0.005
BACKOFF_MAX = 0.5
private_constant :BACKOFF_INITIAL, :BACKOFF_MAX

def backoff_seconds_for_retry(transaction_attempt)
exponential = BACKOFF_INITIAL * (1.5 ** (transaction_attempt - 1))
Random.rand * [exponential, BACKOFF_MAX].min
end

def backoff_would_exceed_deadline?(deadline, backoff_seconds)
return false if deadline.zero?

Utils.monotonic_time + backoff_seconds >= deadline
end
end
end
93 changes: 93 additions & 0 deletions spec/mongo/session_transaction_prose_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# frozen_string_literal: true
# rubocop:todo all

require 'spec_helper'

describe Mongo::Session do
require_topology :replica_set
min_server_version '4.4'

describe 'transactions convenient API prose tests' do
let(:client) { authorized_client }
let(:admin_client) { authorized_client.use('admin') }
let(:collection) { client['session-transaction-prose-test'] }

before do
collection.delete_many
end

after do
disable_fail_command
end

# Prose test from:
# specifications/source/transactions-convenient-api/tests/README.md
# ### Retry Backoff is Enforced
it 'adds measurable delay when jitter is enabled' do
skip 'failCommand fail point is not available' unless fail_command_available?

no_backoff_time = with_fixed_jitter(0) do
with_commit_failures(13) do
measure_with_transaction_time do |session|
collection.insert_one({}, session: session)
end
end
end

with_backoff_time = with_fixed_jitter(1) do
with_commit_failures(13) do
measure_with_transaction_time do |session|
collection.insert_one({}, session: session)
end
end
end

# Sum of 13 backoffs per spec is approximately 1.8 seconds.
expect(with_backoff_time).to be_within(0.5).of(no_backoff_time + 1.8)
end

private

def measure_with_transaction_time
start_time = Mongo::Utils.monotonic_time
client.start_session do |session|
session.with_transaction do
yield(session)
end
end
Mongo::Utils.monotonic_time - start_time
end

def with_fixed_jitter(value)
allow(Random).to receive(:rand).and_return(value)
yield
end

def with_commit_failures(times)
admin_client.command(
configureFailPoint: 'failCommand',
mode: { times: times },
data: {
failCommands: ['commitTransaction'],
errorCode: 251,
},
)
yield
ensure
disable_fail_command
end

def disable_fail_command
admin_client.command(configureFailPoint: 'failCommand', mode: 'off')
rescue Mongo::Error
# Ignore cleanup failures.
end

def fail_command_available?
admin_client.command(configureFailPoint: 'failCommand', mode: 'off')
true
rescue Mongo::Error
false
end
end
end
Loading