Skip to content

Commit fa600a3

Browse files
RUBY-3701 Apply backpressure in with_transaction (#2992)
1 parent cc37229 commit fa600a3

File tree

2 files changed

+125
-1
lines changed

2 files changed

+125
-1
lines changed

lib/mongo/session.rb

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,13 +456,26 @@ def with_transaction(options = nil)
456456
Utils.monotonic_time + 120
457457
end
458458
transaction_in_progress = false
459+
transaction_attempt = 0
460+
last_error = nil
461+
459462
loop do
463+
if transaction_attempt > 0
464+
backoff = backoff_seconds_for_retry(transaction_attempt)
465+
if backoff_would_exceed_deadline?(deadline, backoff)
466+
raise(last_error)
467+
end
468+
sleep(backoff)
469+
end
470+
460471
commit_options = {}
461472
if options
462473
commit_options[:write_concern] = options[:write_concern]
463474
end
464475
start_transaction(options)
465476
transaction_in_progress = true
477+
transaction_attempt += 1
478+
466479
begin
467480
rv = yield self
468481
rescue Exception => e
@@ -479,6 +492,7 @@ def with_transaction(options = nil)
479492
end
480493

481494
if e.is_a?(Mongo::Error) && e.label?('TransientTransactionError')
495+
last_error = e
482496
next
483497
end
484498

@@ -495,7 +509,7 @@ def with_transaction(options = nil)
495509
return rv
496510
rescue Mongo::Error => e
497511
if e.label?('UnknownTransactionCommitResult')
498-
if deadline_expired?(deadline) ||
512+
if deadline_expired?(deadline) ||
499513
e.is_a?(Error::OperationFailure::Family) && e.max_time_ms_expired?
500514
then
501515
transaction_in_progress = false
@@ -516,6 +530,7 @@ def with_transaction(options = nil)
516530
transaction_in_progress = false
517531
raise
518532
end
533+
last_error = e
519534
@state = NO_TRANSACTION_STATE
520535
next
521536
else
@@ -1312,5 +1327,21 @@ def deadline_expired?(deadline)
13121327
Utils.monotonic_time >= deadline
13131328
end
13141329
end
1330+
1331+
# Exponential backoff settings for with_transaction retries.
1332+
BACKOFF_INITIAL = 0.005
1333+
BACKOFF_MAX = 0.5
1334+
private_constant :BACKOFF_INITIAL, :BACKOFF_MAX
1335+
1336+
def backoff_seconds_for_retry(transaction_attempt)
1337+
exponential = BACKOFF_INITIAL * (1.5 ** (transaction_attempt - 1))
1338+
Random.rand * [exponential, BACKOFF_MAX].min
1339+
end
1340+
1341+
def backoff_would_exceed_deadline?(deadline, backoff_seconds)
1342+
return false if deadline.zero?
1343+
1344+
Utils.monotonic_time + backoff_seconds >= deadline
1345+
end
13151346
end
13161347
end
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# frozen_string_literal: true
2+
# rubocop:todo all
3+
4+
require 'spec_helper'
5+
6+
describe Mongo::Session do
7+
require_topology :replica_set
8+
min_server_version '4.4'
9+
10+
describe 'transactions convenient API prose tests' do
11+
let(:client) { authorized_client }
12+
let(:admin_client) { authorized_client.use('admin') }
13+
let(:collection) { client['session-transaction-prose-test'] }
14+
15+
before do
16+
collection.delete_many
17+
end
18+
19+
after do
20+
disable_fail_command
21+
end
22+
23+
# Prose test from:
24+
# specifications/source/transactions-convenient-api/tests/README.md
25+
# ### Retry Backoff is Enforced
26+
it 'adds measurable delay when jitter is enabled' do
27+
skip 'failCommand fail point is not available' unless fail_command_available?
28+
29+
no_backoff_time = with_fixed_jitter(0) do
30+
with_commit_failures(13) do
31+
measure_with_transaction_time do |session|
32+
collection.insert_one({}, session: session)
33+
end
34+
end
35+
end
36+
37+
with_backoff_time = with_fixed_jitter(1) do
38+
with_commit_failures(13) do
39+
measure_with_transaction_time do |session|
40+
collection.insert_one({}, session: session)
41+
end
42+
end
43+
end
44+
45+
# Sum of 13 backoffs per spec is approximately 1.8 seconds.
46+
expect(with_backoff_time).to be_within(0.5).of(no_backoff_time + 1.8)
47+
end
48+
49+
private
50+
51+
def measure_with_transaction_time
52+
start_time = Mongo::Utils.monotonic_time
53+
client.start_session do |session|
54+
session.with_transaction do
55+
yield(session)
56+
end
57+
end
58+
Mongo::Utils.monotonic_time - start_time
59+
end
60+
61+
def with_fixed_jitter(value)
62+
allow(Random).to receive(:rand).and_return(value)
63+
yield
64+
end
65+
66+
def with_commit_failures(times)
67+
admin_client.command(
68+
configureFailPoint: 'failCommand',
69+
mode: { times: times },
70+
data: {
71+
failCommands: ['commitTransaction'],
72+
errorCode: 251,
73+
},
74+
)
75+
yield
76+
ensure
77+
disable_fail_command
78+
end
79+
80+
def disable_fail_command
81+
admin_client.command(configureFailPoint: 'failCommand', mode: 'off')
82+
rescue Mongo::Error
83+
# Ignore cleanup failures.
84+
end
85+
86+
def fail_command_available?
87+
admin_client.command(configureFailPoint: 'failCommand', mode: 'off')
88+
true
89+
rescue Mongo::Error
90+
false
91+
end
92+
end
93+
end

0 commit comments

Comments
 (0)