diff --git a/lib/mongo/session.rb b/lib/mongo/session.rb index 18b27200de..5df118199f 100644 --- a/lib/mongo/session.rb +++ b/lib/mongo/session.rb @@ -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 @@ -479,6 +492,7 @@ def with_transaction(options = nil) end if e.is_a?(Mongo::Error) && e.label?('TransientTransactionError') + last_error = e next end @@ -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 @@ -516,6 +530,7 @@ def with_transaction(options = nil) transaction_in_progress = false raise end + last_error = e @state = NO_TRANSACTION_STATE next else @@ -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 diff --git a/spec/mongo/session_transaction_prose_spec.rb b/spec/mongo/session_transaction_prose_spec.rb new file mode 100644 index 0000000000..6340504c75 --- /dev/null +++ b/spec/mongo/session_transaction_prose_spec.rb @@ -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