Skip to content

Commit 313d35e

Browse files
Sync latest CSOT spec tests (#3001)
1 parent f171ec7 commit 313d35e

32 files changed

+3248
-189
lines changed

lib/mongo/client.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class Client
3636
:write, :write_concern,
3737
:retry_reads, :max_read_retries, :read_retry_interval,
3838
:retry_writes, :max_write_retries,
39+
:timeout_ms,
3940

4041
# Options which cannot currently be here:
4142
#

lib/mongo/collection/view/change_stream.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ class ChangeStream
5858
# @api private
5959
attr_reader :cursor
6060

61+
# Refreshes the CSOT timeout for the next iteration. Delegates to the
62+
# underlying cursor's refresh_timeout! method so that each call to
63+
# try_next starts with a fresh timeout deadline, as required by the
64+
# CSOT spec for tailable awaitData cursors.
65+
#
66+
# @api private
67+
def refresh_timeout!
68+
@cursor&.refresh_timeout!
69+
end
70+
6171
# Initialize the change stream for the provided collection view, pipeline
6272
# and options.
6373
#

lib/mongo/csot_timeout_holder.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,10 @@ def calculate_deadline(opts = {}, session = nil)
101101
end
102102

103103
def check_no_override_inside_transaction!(opts, session)
104-
return unless opts[:operation_timeout_ms] && session&.with_transaction_deadline
104+
return unless opts[:operation_timeout_ms] && session&.inside_with_transaction?
105105

106-
raise ArgumentError, 'Cannot override timeout_ms inside with_transaction block'
106+
raise Mongo::Error::InvalidTransactionOperation,
107+
'timeoutMS cannot be overridden inside a withTransaction callback'
107108
end
108109

109110
def calculate_deadline_from_timeout_ms(operation_timeout_ms)

lib/mongo/cursor.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,18 @@ def fully_iterated?
415415
!!@fully_iterated
416416
end
417417

418+
# Refreshes the cursor's CSOT context so that the next getMore starts
419+
# with a fresh timeout deadline. Used by tailable awaitData cursors to
420+
# implement per-iteration timeout refresh as required by the CSOT spec.
421+
# Only refreshes if this is a tailable awaitData cursor with an active timeout.
422+
#
423+
# @api private
424+
def refresh_timeout!
425+
return unless view.cursor_type == :tailable_await && context.timeout?
426+
427+
@context = @context.refresh(view: view)
428+
end
429+
418430
private
419431

420432
def explicitly_closed?
@@ -522,6 +534,10 @@ def execute_operation(op, context: nil)
522534
# @return [ Operation::Context ] the (possibly-refreshed) context.
523535
def possibly_refreshed_context
524536
return context if view.timeout_mode == :cursor_lifetime
537+
# For tailable await cursors with CSOT, the timeout budget is shared across
538+
# all getMore commands (cumulative). Only update the view reference; keep
539+
# the same deadline so remaining time decreases across getMores.
540+
return context.with(view: view) if view.cursor_type == :tailable_await
525541
context.refresh(view: view)
526542
end
527543

lib/mongo/cursor_host.rb

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,19 @@ def validate_timeout_mode!(options, forbid: [])
4949
if timeout_mode == :cursor_lifetime
5050
raise ArgumentError, 'tailable cursors only support `timeout_mode: :iteration`'
5151
end
52-
53-
# "Drivers MUST error if [the maxAwaitTimeMS] option is set,
54-
# timeoutMS is set to a non-zero value, and maxAwaitTimeMS is
55-
# greater than or equal to timeoutMS."
56-
max_await_time_ms = options[:max_await_time_ms] || 0
57-
if cursor_type == :tailable_await && max_await_time_ms >= timeout_ms
58-
raise ArgumentError, ':max_await_time_ms must not be >= :timeout_ms'
59-
end
6052
else
6153
# "For non-tailable cursors, the default value of timeoutMode
6254
# is CURSOR_LIFETIME."
6355
timeout_mode ||= :cursor_lifetime
6456
end
57+
58+
# "Drivers MUST error if [the maxAwaitTimeMS] option is set,
59+
# timeoutMS is set to a non-zero value, and maxAwaitTimeMS is
60+
# greater than or equal to timeoutMS."
61+
max_await_time_ms = options[:max_await_time_ms] || 0
62+
if max_await_time_ms.positive? && max_await_time_ms >= timeout_ms
63+
raise ArgumentError, ':max_await_time_ms must not be >= :timeout_ms'
64+
end
6565
elsif timeout_mode
6666
# "Drivers MUST error if timeoutMode is set and timeoutMS is not."
6767
raise ArgumentError, ':timeout_ms must be set if :timeout_mode is set'

lib/mongo/operation/get_more/op_msg.rb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,17 @@ def apply_get_more_timeouts_to(spec, timeout_ms)
5454
# maxAwaitTimeMS option. Drivers MUST error if this option is set,
5555
# timeoutMS is set to a non-zero value, and maxAwaitTimeMS is greater
5656
# than or equal to timeoutMS. If this option is set, drivers MUST use
57-
# it as the maxTimeMS field on getMore commands.
57+
# it as the maxTimeMS field on getMore commands, capped at remaining
58+
# CSOT timeout if less than maxAwaitTimeMS.
5859
max_await_time_ms = view.respond_to?(:max_await_time_ms) ? view.max_await_time_ms : nil
59-
spec[:maxTimeMS] = max_await_time_ms if max_await_time_ms
60+
if max_await_time_ms
61+
effective_ms = if timeout_ms && timeout_ms < max_await_time_ms
62+
timeout_ms
63+
else
64+
max_await_time_ms
65+
end
66+
spec[:maxTimeMS] = effective_ms
67+
end
6068
end
6169

6270
spec

lib/mongo/server/connection_pool.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,6 +1308,12 @@ def retrieve_and_connect_connection(connection_global_id, context = nil)
13081308
end
13091309

13101310
connection
1311+
rescue Error::ConnectionCheckOutTimeout
1312+
# Per the CSOT spec, if a connection checkout fails because the CSOT
1313+
# deadline expired (rather than a configured waitQueueTimeout), the
1314+
# error must be a CSOT TimeoutError so that callers can distinguish it.
1315+
context&.check_timeout!
1316+
raise
13111317
end
13121318

13131319
# Waits for a connection to become available, or raises is no connection

lib/mongo/session.rb

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ def initialize(server_session, client, options = {})
106106
@cluster_time = nil
107107
@state = NO_TRANSACTION_STATE
108108
@with_transaction_deadline = nil
109+
@with_transaction_timeout_ms = nil
110+
@inside_with_transaction = false
109111
end
110112

111113
# @return [ Hash ] The options for this session.
@@ -452,6 +454,8 @@ def end_session
452454
#
453455
# @since 2.7.0
454456
def with_transaction(options = nil)
457+
@inside_with_transaction = true
458+
@with_transaction_timeout_ms = options&.dig(:timeout_ms) || @options[:default_timeout_ms] || @client.timeout_ms
455459
@with_transaction_deadline = calculate_with_transaction_deadline(options)
456460
deadline = if @with_transaction_deadline
457461
# CSOT enabled, so we have a customer defined deadline.
@@ -471,7 +475,7 @@ def with_transaction(options = nil)
471475
if overload_encountered
472476
delay = @client.retry_policy.backoff_delay(overload_error_count)
473477
if backoff_would_exceed_deadline?(deadline, delay)
474-
raise(last_error)
478+
raise Mongo::Error::TimeoutError, 'CSOT timeout expired waiting to retry withTransaction'
475479
end
476480
unless @client.retry_policy.should_retry_overload?(overload_error_count, delay)
477481
raise(last_error)
@@ -480,7 +484,7 @@ def with_transaction(options = nil)
480484
else
481485
backoff = backoff_seconds_for_retry(transaction_attempt)
482486
if backoff_would_exceed_deadline?(deadline, backoff)
483-
raise(last_error)
487+
raise Mongo::Error::TimeoutError, 'CSOT timeout expired waiting to retry withTransaction'
484488
end
485489
sleep(backoff)
486490
end
@@ -499,7 +503,10 @@ def with_transaction(options = nil)
499503
rescue Exception => e
500504
if within_states?(STARTING_TRANSACTION_STATE, TRANSACTION_IN_PROGRESS_STATE)
501505
log_warn("Aborting transaction due to #{e.class}: #{e}")
502-
@with_transaction_deadline = nil
506+
# CSOT: if the deadline is already expired, clear it so that
507+
# abort_transaction uses a fresh timeout (not the expired deadline).
508+
# If the deadline is not yet expired, keep it so abort uses remaining time.
509+
@with_transaction_deadline = nil if @with_transaction_deadline && deadline_expired?(deadline)
503510
abort_transaction
504511
transaction_in_progress = false
505512
end
@@ -528,6 +535,15 @@ def with_transaction(options = nil)
528535
return rv
529536
end
530537

538+
# CSOT: if the timeout has expired before we can commit, abort the
539+
# transaction instead and raise a client-side timeout error.
540+
if @with_transaction_deadline && deadline_expired?(deadline)
541+
transaction_in_progress = false
542+
@with_transaction_deadline = nil
543+
abort_transaction
544+
raise Mongo::Error::TimeoutError, 'CSOT timeout expired before transaction could be committed'
545+
end
546+
531547
begin
532548
commit_transaction(commit_options)
533549
transaction_in_progress = false
@@ -610,6 +626,8 @@ def with_transaction(options = nil)
610626
end
611627
end
612628
@with_transaction_deadline = nil
629+
@with_transaction_timeout_ms = nil
630+
@inside_with_transaction = false
613631
end
614632

615633
# Places subsequent operations in this session into a new transaction.
@@ -1282,6 +1300,12 @@ def txn_num
12821300
# @api private
12831301
attr_reader :with_transaction_deadline
12841302

1303+
# @return [ Boolean ] Whether we are currently inside a with_transaction block.
1304+
# @api private
1305+
def inside_with_transaction?
1306+
@inside_with_transaction
1307+
end
1308+
12851309
private
12861310

12871311
# Get the read concern the session will use when starting a transaction.
@@ -1355,9 +1379,14 @@ def check_transactions_supported!
13551379

13561380
def operation_timeouts(opts)
13571381
{
1358-
inherited_timeout_ms: @client.timeout_ms
1382+
inherited_timeout_ms: @with_transaction_timeout_ms || @client.timeout_ms
13591383
}.tap do |result|
1360-
if @with_transaction_deadline.nil?
1384+
if @inside_with_transaction
1385+
if opts[:timeout_ms]
1386+
raise Mongo::Error::InvalidTransactionOperation,
1387+
'timeoutMS cannot be overridden inside a withTransaction callback'
1388+
end
1389+
else
13611390
if timeout_ms = opts[:timeout_ms]
13621391
result[:operation_timeout_ms] = timeout_ms
13631392
elsif default_timeout_ms = options[:default_timeout_ms]

spec/mongo/session_transaction_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ class SessionTransactionSpecError < StandardError; end
141141
exc.add_label('TransientTransactionError')
142142
raise exc
143143
end
144-
end.to raise_error(Mongo::Error::OperationFailure, 'timeout test')
144+
end.to raise_error(Mongo::Error::TimeoutError)
145145
end
146146
end
147147

spec/runners/unified/change_stream_operations.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,23 @@ def create_change_stream(op)
2323
def iterate_until_document_or_error(op)
2424
object_id = op.use!('object')
2525
object = entities.get_any(object_id)
26-
object.try_next
26+
# Per CSOT spec, timeoutMS is refreshed for each "next call" on a
27+
# tailable awaitData cursor. Refresh at the start of this iteration
28+
# so that each separate iterateUntilDocumentOrError operation gets a
29+
# fresh deadline, while getMores within the loop share it cumulatively.
30+
object.refresh_timeout! if object.respond_to?(:refresh_timeout!)
31+
loop do
32+
doc = object.try_next
33+
return doc if doc
34+
end
2735
end
2836

2937
def iterate_once(op)
3038
stream_id = op.use!('object')
3139
stream = entities.get_any(stream_id)
40+
# Per CSOT spec, timeoutMS is refreshed for each "next call" on a
41+
# tailable awaitData cursor or change stream.
42+
stream.refresh_timeout! if stream.respond_to?(:refresh_timeout!)
3243
stream.try_next
3344
end
3445

0 commit comments

Comments
 (0)