Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
32 changes: 31 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
Comment thread
comandeo-mongo marked this conversation as resolved.
else
Expand Down Expand Up @@ -1312,5 +1327,20 @@ 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
Comment thread
comandeo-mongo marked this conversation as resolved.

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
92 changes: 92 additions & 0 deletions spec/mongo/session_transaction_prose_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# frozen_string_literal: true
# rubocop:todo all

require 'spec_helper'

describe Mongo::Session do
require_topology :replica_set

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,
Comment thread
comandeo-mongo marked this conversation as resolved.
},
)
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
40 changes: 40 additions & 0 deletions spec/mongo/session_transaction_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -264,5 +264,45 @@ class SessionTransactionSpecError < StandardError; end
end
end
end

context 'backoff calculation' do
require_topology :replica_set

it 'calculates exponential backoff correctly' do
# Test backoff formula: jitter * min(BACKOFF_INITIAL * 1.5^(attempt-1), BACKOFF_MAX)
backoff_initial = Mongo::Session::BACKOFF_INITIAL
backoff_max = Mongo::Session::BACKOFF_MAX

# Test attempt 1: 1.5^0 = 1
expected_attempt_1 = backoff_initial * (1.5 ** 0)
expect(expected_attempt_1).to eq(0.005)

# Test attempt 2: 1.5^1 = 1.5
expected_attempt_2 = backoff_initial * (1.5 ** 1)
expect(expected_attempt_2).to eq(0.0075)

# Test attempt 3: 1.5^2 = 2.25
expected_attempt_3 = backoff_initial * (1.5 ** 2)
expect(expected_attempt_3).to eq(0.01125)

# Test cap at BACKOFF_MAX
expected_attempt_large = [backoff_initial * (1.5 ** 20), backoff_max].min
expect(expected_attempt_large).to eq(backoff_max)
end

it 'applies jitter to backoff' do
# Jitter should be a random value between 0 and 1
# When multiplied with backoff, it should reduce the actual sleep time
backoff = 0.100 # 100ms
jitter_min = 0
jitter_max = 1

actual_min = jitter_min * backoff
actual_max = jitter_max * backoff

expect(actual_min).to eq(0)
expect(actual_max).to eq(0.100)
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These examples don’t exercise the production backoff implementation: they only re-compute the math from the constants (and in the jitter case, just multiply fixed numbers), so they would still pass even if backoff_seconds_for_retry were broken or jitter were not applied. Consider stubbing Random.rand and asserting against backoff_seconds_for_retry (via send since it’s private), and use a tolerance matcher for floats to avoid brittleness.

Suggested change
# Test backoff formula: jitter * min(BACKOFF_INITIAL * 1.5^(attempt-1), BACKOFF_MAX)
backoff_initial = Mongo::Session::BACKOFF_INITIAL
backoff_max = Mongo::Session::BACKOFF_MAX
# Test attempt 1: 1.5^0 = 1
expected_attempt_1 = backoff_initial * (1.5 ** 0)
expect(expected_attempt_1).to eq(0.005)
# Test attempt 2: 1.5^1 = 1.5
expected_attempt_2 = backoff_initial * (1.5 ** 1)
expect(expected_attempt_2).to eq(0.0075)
# Test attempt 3: 1.5^2 = 2.25
expected_attempt_3 = backoff_initial * (1.5 ** 2)
expect(expected_attempt_3).to eq(0.01125)
# Test cap at BACKOFF_MAX
expected_attempt_large = [backoff_initial * (1.5 ** 20), backoff_max].min
expect(expected_attempt_large).to eq(backoff_max)
end
it 'applies jitter to backoff' do
# Jitter should be a random value between 0 and 1
# When multiplied with backoff, it should reduce the actual sleep time
backoff = 0.100 # 100ms
jitter_min = 0
jitter_max = 1
actual_min = jitter_min * backoff
actual_max = jitter_max * backoff
expect(actual_min).to eq(0)
expect(actual_max).to eq(0.100)
backoff_initial = Mongo::Session::BACKOFF_INITIAL
backoff_max = Mongo::Session::BACKOFF_MAX
# Stub jitter to 1.0 so that backoff is equal to the base value
allow(Random).to receive(:rand).and_return(1.0)
# Attempt 1: 1.5^0 = 1
backoff_attempt_1 = session.send(:backoff_seconds_for_retry, 1)
expected_attempt_1 = [backoff_initial * (1.5 ** 0), backoff_max].min
expect(backoff_attempt_1).to be_within(1e-9).of(expected_attempt_1)
# Attempt 2: 1.5^1 = 1.5
backoff_attempt_2 = session.send(:backoff_seconds_for_retry, 2)
expected_attempt_2 = [backoff_initial * (1.5 ** 1), backoff_max].min
expect(backoff_attempt_2).to be_within(1e-9).of(expected_attempt_2)
# Attempt 3: 1.5^2 = 2.25
backoff_attempt_3 = session.send(:backoff_seconds_for_retry, 3)
expected_attempt_3 = [backoff_initial * (1.5 ** 2), backoff_max].min
expect(backoff_attempt_3).to be_within(1e-9).of(expected_attempt_3)
# Large attempt should be capped at BACKOFF_MAX
backoff_attempt_large = session.send(:backoff_seconds_for_retry, 20)
expected_attempt_large = [backoff_initial * (1.5 ** 19), backoff_max].min
expect(backoff_attempt_large).to be_within(1e-9).of(expected_attempt_large)
end
it 'applies jitter to backoff' do
backoff_initial = Mongo::Session::BACKOFF_INITIAL
attempt = 2
base_backoff = [backoff_initial * (1.5 ** (attempt - 1)), Mongo::Session::BACKOFF_MAX].min
# First call: jitter 0.25
# Second call: jitter 0.75
allow(Random).to receive(:rand).and_return(0.25, 0.75)
backoff_low_jitter = session.send(:backoff_seconds_for_retry, attempt)
backoff_high_jitter = session.send(:backoff_seconds_for_retry, attempt)
expect(backoff_low_jitter).to be_within(1e-9).of(base_backoff * 0.25)
expect(backoff_high_jitter).to be_within(1e-9).of(base_backoff * 0.75)
expect(backoff_high_jitter).to be > backoff_low_jitter

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I agree with copilot here -- these tests would be far more meaningful if they tested the code that performs the calculations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point; removed the unnecessary tests.

end
end
end
end
Loading