Skip to content

Commit 40ce1b6

Browse files
RUBY-3770 Implement makeTimeoutError semantics in withTransaction (mongodb#3006)
1 parent 058632f commit 40ce1b6

3 files changed

Lines changed: 267 additions & 7 deletions

File tree

lib/mongo/session.rb

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ def with_transaction(options = nil)
475475
if overload_encountered
476476
delay = @client.retry_policy.backoff_delay(overload_error_count)
477477
if backoff_would_exceed_deadline?(deadline, delay)
478-
raise Mongo::Error::TimeoutError, 'CSOT timeout expired waiting to retry withTransaction'
478+
make_timeout_error_from(last_error, 'CSOT timeout expired waiting to retry withTransaction')
479479
end
480480
unless @client.retry_policy.should_retry_overload?(overload_error_count, delay)
481481
raise(last_error)
@@ -484,7 +484,7 @@ def with_transaction(options = nil)
484484
else
485485
backoff = backoff_seconds_for_retry(transaction_attempt)
486486
if backoff_would_exceed_deadline?(deadline, backoff)
487-
raise Mongo::Error::TimeoutError, 'CSOT timeout expired waiting to retry withTransaction'
487+
make_timeout_error_from(last_error, 'CSOT timeout expired waiting to retry withTransaction')
488488
end
489489
sleep(backoff)
490490
end
@@ -513,7 +513,7 @@ def with_transaction(options = nil)
513513

514514
if deadline_expired?(deadline)
515515
transaction_in_progress = false
516-
raise
516+
make_timeout_error_from(e, 'CSOT timeout expired during withTransaction callback')
517517
end
518518

519519
if e.is_a?(Mongo::Error) && e.label?('TransientTransactionError')
@@ -554,7 +554,11 @@ def with_transaction(options = nil)
554554
e.is_a?(Error::OperationFailure::Family) && e.max_time_ms_expired?
555555
then
556556
transaction_in_progress = false
557-
raise
557+
if @with_transaction_timeout_ms && deadline_expired?(deadline)
558+
make_timeout_error_from(e, 'CSOT timeout expired during withTransaction commit')
559+
else
560+
raise
561+
end
558562
end
559563

560564
if e.label?('SystemOverloadedError')
@@ -569,7 +573,7 @@ def with_transaction(options = nil)
569573
delay = @client.retry_policy.backoff_delay(overload_error_count)
570574
if backoff_would_exceed_deadline?(deadline, delay)
571575
transaction_in_progress = false
572-
raise
576+
make_timeout_error_from(e, 'CSOT timeout expired during withTransaction commit')
573577
end
574578
unless @client.retry_policy.should_retry_overload?(overload_error_count, delay)
575579
transaction_in_progress = false
@@ -591,7 +595,7 @@ def with_transaction(options = nil)
591595
elsif e.label?('TransientTransactionError')
592596
if Utils.monotonic_time >= deadline
593597
transaction_in_progress = false
594-
raise
598+
make_timeout_error_from(e, 'CSOT timeout expired during withTransaction commit')
595599
end
596600
last_error = e
597601
if e.label?('SystemOverloadedError')
@@ -1436,5 +1440,17 @@ def backoff_would_exceed_deadline?(deadline, backoff_seconds)
14361440

14371441
Utils.monotonic_time + backoff_seconds >= deadline
14381442
end
1443+
1444+
# Implements makeTimeoutError(lastError) from the transactions-convenient-api spec.
1445+
# In CSOT mode raises TimeoutError with last_error's message included as a substring.
1446+
# In non-CSOT mode re-raises last_error directly.
1447+
def make_timeout_error_from(last_error, timeout_message)
1448+
if @with_transaction_timeout_ms
1449+
raise Mongo::Error::TimeoutError, "#{timeout_message}: #{last_error}"
1450+
else
1451+
raise last_error
1452+
end
1453+
end
1454+
14391455
end
14401456
end
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
# Prose tests for the "Retry Timeout is Enforced" and "Backoff Deadline is
6+
# Enforced" sections of the transactions-convenient-api spec README.
7+
#
8+
# specifications/source/transactions-convenient-api/tests/README.md
9+
#
10+
# Note 1 from spec: "The error SHOULD be propagated as a timeout error if
11+
# the language allows to expose the underlying error as a cause of a timeout
12+
# error." Ruby supports this via Exception#cause.
13+
describe 'Mongo::Session#with_transaction timeout enforcement' do
14+
let(:retry_policy) { Mongo::Retryable::RetryPolicy.new(adaptive_retries: false) }
15+
16+
let(:client) do
17+
instance_double(Mongo::Client).tap do |c|
18+
allow(c).to receive(:retry_policy).and_return(retry_policy)
19+
allow(c).to receive(:timeout_ms).and_return(nil)
20+
end
21+
end
22+
23+
let(:session) do
24+
sess = Mongo::Session.allocate
25+
sess.instance_variable_set(:@client, client)
26+
sess.instance_variable_set(:@options, {})
27+
sess.instance_variable_set(:@state, Mongo::Session::NO_TRANSACTION_STATE)
28+
sess.instance_variable_set(:@lock, Mutex.new)
29+
allow(sess).to receive(:check_transactions_supported!).and_return(true)
30+
allow(sess).to receive(:check_if_ended!)
31+
allow(sess).to receive(:log_warn)
32+
allow(sess).to receive(:session_id).and_return(BSON::Document.new('id' => 'test'))
33+
sess
34+
end
35+
36+
before do
37+
allow(session).to receive(:start_transaction) do |*_args|
38+
session.instance_variable_set(:@state, Mongo::Session::STARTING_TRANSACTION_STATE)
39+
end
40+
allow(session).to receive(:abort_transaction) do
41+
session.instance_variable_set(:@state, Mongo::Session::TRANSACTION_ABORTED_STATE)
42+
end
43+
allow(session).to receive(:commit_transaction) do
44+
session.instance_variable_set(:@state, Mongo::Session::TRANSACTION_COMMITTED_STATE)
45+
end
46+
allow(session).to receive(:sleep)
47+
end
48+
49+
# Stubs Mongo::Utils.monotonic_time: first `initial_calls` invocations
50+
# return 100.0 (deadline ≈ 100.001 s with timeout_ms: 1), all subsequent
51+
# calls return 200.0, making every deadline check return "expired".
52+
def with_expired_deadline_after(initial_calls:)
53+
call_count = 0
54+
allow(Mongo::Utils).to receive(:monotonic_time) do
55+
call_count += 1
56+
(call_count <= initial_calls) ? 100.0 : 200.0
57+
end
58+
yield
59+
end
60+
61+
# CSOT time control: monotonic_time always 100.0.
62+
# With timeout_ms: 1, deadline = 100.001.
63+
# Backoffs (0.005 s, 0.1 s) exceed that deadline; deadline_expired? stays false.
64+
def with_csot_backoff_time_control
65+
allow(Mongo::Utils).to receive(:monotonic_time).and_return(100.0)
66+
allow(Random).to receive(:rand).and_return(1.0)
67+
yield
68+
end
69+
70+
# non-CSOT time control: first call → 100.0 (deadline = 220.0),
71+
# subsequent calls → 219.996.
72+
# deadline_expired? = false; backoffs (0.005, 0.1) exceed the 220.0 deadline.
73+
def with_non_csot_backoff_time_control
74+
call_count = 0
75+
allow(Mongo::Utils).to receive(:monotonic_time) do
76+
call_count += 1
77+
(call_count == 1) ? 100.0 : 219.996
78+
end
79+
allow(Random).to receive(:rand).and_return(1.0)
80+
yield
81+
end
82+
83+
def make_transient_error
84+
Mongo::Error::OperationFailure.new('transient').tap do |e|
85+
e.add_label('TransientTransactionError')
86+
end
87+
end
88+
89+
def make_commit_unknown_error
90+
Mongo::Error::OperationFailure.new('commit unknown').tap do |e|
91+
e.add_label('UnknownTransactionCommitResult')
92+
end
93+
end
94+
95+
def make_commit_transient_error
96+
Mongo::Error::OperationFailure.new('commit transient').tap do |e|
97+
e.add_label('TransientTransactionError')
98+
end
99+
end
100+
101+
def make_transient_overload_error
102+
Mongo::Error::OperationFailure.new('transient overload').tap do |e|
103+
e.add_label('TransientTransactionError')
104+
e.add_label('SystemOverloadedError')
105+
end
106+
end
107+
108+
def make_commit_overload_error
109+
Mongo::Error::OperationFailure.new('commit overload').tap do |e|
110+
e.add_label('UnknownTransactionCommitResult')
111+
e.add_label('SystemOverloadedError')
112+
end
113+
end
114+
115+
# ---------------------------------------------------------------------------
116+
# "Retry Timeout is Enforced" — three sub-cases from the spec README
117+
# ---------------------------------------------------------------------------
118+
119+
describe '"Retry Timeout is Enforced" prose tests' do
120+
context 'when callback raises TransientTransactionError and retry timeout is exceeded' do
121+
let(:transient_error) { make_transient_error }
122+
123+
it 'propagates the error as TimeoutError including the transient error message' do
124+
with_expired_deadline_after(initial_calls: 1) do
125+
ex = expect { session.with_transaction(timeout_ms: 1) { raise transient_error } }
126+
ex.to raise_error(Mongo::Error::TimeoutError) { |e| expect(e.message).to include(transient_error.message) }
127+
end
128+
end
129+
end
130+
131+
context 'when commit raises UnknownTransactionCommitResult and retry timeout is exceeded' do
132+
let(:commit_error) { make_commit_unknown_error }
133+
134+
before { allow(session).to receive(:commit_transaction) { raise commit_error } }
135+
136+
it 'propagates the error as TimeoutError including the commit error message' do
137+
with_expired_deadline_after(initial_calls: 2) do
138+
ex = expect do
139+
session.with_transaction(timeout_ms: 1) do
140+
session.instance_variable_set(:@state, Mongo::Session::TRANSACTION_IN_PROGRESS_STATE)
141+
end
142+
end
143+
ex.to raise_error(Mongo::Error::TimeoutError) { |e| expect(e.message).to include(commit_error.message) }
144+
end
145+
end
146+
end
147+
148+
context 'when commit raises TransientTransactionError and retry timeout is exceeded' do
149+
let(:commit_error) { make_commit_transient_error }
150+
151+
before { allow(session).to receive(:commit_transaction) { raise commit_error } }
152+
153+
it 'propagates the error as TimeoutError including the commit error message' do
154+
with_expired_deadline_after(initial_calls: 2) do
155+
ex = expect do
156+
session.with_transaction(timeout_ms: 1) do
157+
session.instance_variable_set(:@state, Mongo::Session::TRANSACTION_IN_PROGRESS_STATE)
158+
end
159+
end
160+
ex.to raise_error(Mongo::Error::TimeoutError) { |e| expect(e.message).to include(commit_error.message) }
161+
end
162+
end
163+
end
164+
end
165+
166+
# ---------------------------------------------------------------------------
167+
# "Backoff Deadline is Enforced" — backoff-would-exceed-deadline paths
168+
# ---------------------------------------------------------------------------
169+
170+
describe '"Backoff Deadline is Enforced" prose tests' do
171+
before do
172+
allow(retry_policy).to receive(:backoff_delay).and_wrap_original do |m, attempt, **_|
173+
m.call(attempt, jitter: 1.0)
174+
end
175+
end
176+
177+
context 'when regular backoff would exceed CSOT deadline' do
178+
let(:last_error) { make_transient_error }
179+
180+
it 'raises TimeoutError including last_error message' do
181+
with_csot_backoff_time_control do
182+
ex = expect { session.with_transaction(timeout_ms: 1) { raise last_error } }
183+
ex.to raise_error(Mongo::Error::TimeoutError) { |e| expect(e.message).to include(last_error.message) }
184+
end
185+
end
186+
end
187+
188+
context 'when regular backoff would exceed the 120 s deadline (non-CSOT)' do
189+
let(:last_error) { make_transient_error }
190+
191+
it 'raises last_error directly (not TimeoutError)' do
192+
with_non_csot_backoff_time_control do
193+
ex = expect { session.with_transaction { raise last_error } }
194+
ex.to raise_error(Mongo::Error::OperationFailure) do |e|
195+
expect(e).to eq(last_error)
196+
expect(e).not_to be_a(Mongo::Error::TimeoutError)
197+
end
198+
end
199+
end
200+
end
201+
202+
context 'when overload backoff would exceed CSOT deadline' do
203+
let(:last_error) { make_transient_overload_error }
204+
205+
it 'raises TimeoutError including last_error message' do
206+
with_csot_backoff_time_control do
207+
ex = expect { session.with_transaction(timeout_ms: 1) { raise last_error } }
208+
ex.to raise_error(Mongo::Error::TimeoutError) { |e| expect(e.message).to include(last_error.message) }
209+
end
210+
end
211+
end
212+
213+
context 'when overload backoff would exceed the 120 s deadline (non-CSOT)' do
214+
let(:last_error) { make_transient_overload_error }
215+
216+
it 'raises last_error directly (not TimeoutError)' do
217+
with_non_csot_backoff_time_control do
218+
ex = expect { session.with_transaction { raise last_error } }
219+
ex.to raise_error(Mongo::Error::OperationFailure) do |e|
220+
expect(e).to eq(last_error)
221+
expect(e).not_to be_a(Mongo::Error::TimeoutError)
222+
end
223+
end
224+
end
225+
end
226+
227+
context 'when commit overload backoff would exceed CSOT deadline' do
228+
let(:commit_error) { make_commit_overload_error }
229+
230+
before { allow(session).to receive(:commit_transaction) { raise commit_error } }
231+
232+
it 'raises TimeoutError including the commit error message' do
233+
with_csot_backoff_time_control do
234+
ex = expect do
235+
session.with_transaction(timeout_ms: 1) do
236+
session.instance_variable_set(:@state, Mongo::Session::TRANSACTION_IN_PROGRESS_STATE)
237+
end
238+
end
239+
ex.to raise_error(Mongo::Error::TimeoutError) { |e| expect(e.message).to include(commit_error.message) }
240+
end
241+
end
242+
end
243+
end
244+
end

spec/mongo/session_transaction_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ class SessionTransactionSpecError < StandardError; end
136136
allow(session).to receive('check_transactions_supported!').and_return true
137137

138138
expect do
139-
session.with_transaction do
139+
session.with_transaction(timeout_ms: 5000) do
140140
exc = Mongo::Error::OperationFailure.new('timeout test')
141141
exc.add_label('TransientTransactionError')
142142
raise exc

0 commit comments

Comments
 (0)