Skip to content

Commit cc5b9c4

Browse files
authored
PYTHON-5716 - Clarify expected error if backoff exceeds CSOT's deadli… (#2719)
1 parent 359ddfa commit cc5b9c4

File tree

4 files changed

+132
-48
lines changed

4 files changed

+132
-48
lines changed

pymongo/asynchronous/client_session.py

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,9 @@
163163
from pymongo.errors import (
164164
ConfigurationError,
165165
ConnectionFailure,
166+
ExecutionTimeout,
166167
InvalidOperation,
168+
NetworkTimeout,
167169
OperationFailure,
168170
PyMongoError,
169171
WTimeoutError,
@@ -480,14 +482,20 @@ def _max_time_expired_error(exc: PyMongoError) -> bool:
480482
_BACKOFF_INITIAL = 0.005 # 5ms initial backoff
481483

482484

483-
def _within_time_limit(start_time: float) -> bool:
485+
def _within_time_limit(start_time: float, backoff: float = 0) -> bool:
484486
"""Are we within the with_transaction retry limit?"""
485-
return time.monotonic() - start_time < _WITH_TRANSACTION_RETRY_TIME_LIMIT
487+
remaining = _csot.remaining()
488+
if remaining is not None and remaining <= 0:
489+
return False
490+
return time.monotonic() + backoff - start_time < _WITH_TRANSACTION_RETRY_TIME_LIMIT
486491

487492

488-
def _would_exceed_time_limit(start_time: float, backoff: float) -> bool:
489-
"""Is the backoff within the with_transaction retry limit?"""
490-
return time.monotonic() + backoff - start_time >= _WITH_TRANSACTION_RETRY_TIME_LIMIT
493+
def _make_timeout_error(error: BaseException) -> PyMongoError:
494+
"""Convert error to a NetworkTimeout or ExecutionTimeout as appropriate."""
495+
if _csot.remaining() is not None:
496+
return ExecutionTimeout(str(error), 50, {"ok": 0, "errmsg": str(error), "code": 50})
497+
else:
498+
return NetworkTimeout(str(error))
491499

492500

493501
_T = TypeVar("_T")
@@ -722,9 +730,9 @@ async def callback(session, custom_arg, custom_kwarg=None):
722730
if retry: # Implement exponential backoff on retry.
723731
jitter = random.random() # noqa: S311
724732
backoff = jitter * min(_BACKOFF_INITIAL * (1.5**retry), _BACKOFF_MAX)
725-
if _would_exceed_time_limit(start_time, backoff):
733+
if not _within_time_limit(start_time, backoff):
726734
assert last_error is not None
727-
raise last_error
735+
raise _make_timeout_error(last_error) from last_error
728736
await asyncio.sleep(backoff)
729737
retry += 1
730738
await self.start_transaction(
@@ -737,13 +745,13 @@ async def callback(session, custom_arg, custom_kwarg=None):
737745
last_error = exc
738746
if self.in_transaction:
739747
await self.abort_transaction()
740-
if (
741-
isinstance(exc, PyMongoError)
742-
and exc.has_error_label("TransientTransactionError")
743-
and _within_time_limit(start_time)
748+
if isinstance(exc, PyMongoError) and exc.has_error_label(
749+
"TransientTransactionError"
744750
):
745-
# Retry the entire transaction.
746-
continue
751+
if _within_time_limit(start_time):
752+
# Retry the entire transaction.
753+
continue
754+
raise _make_timeout_error(last_error) from exc
747755
raise
748756

749757
if not self.in_transaction:
@@ -754,17 +762,16 @@ async def callback(session, custom_arg, custom_kwarg=None):
754762
try:
755763
await self.commit_transaction()
756764
except PyMongoError as exc:
757-
if (
758-
exc.has_error_label("UnknownTransactionCommitResult")
759-
and _within_time_limit(start_time)
760-
and not _max_time_expired_error(exc)
761-
):
765+
last_error = exc
766+
if not _within_time_limit(start_time):
767+
raise _make_timeout_error(last_error) from exc
768+
if exc.has_error_label(
769+
"UnknownTransactionCommitResult"
770+
) and not _max_time_expired_error(exc):
762771
# Retry the commit.
763772
continue
764773

765-
if exc.has_error_label("TransientTransactionError") and _within_time_limit(
766-
start_time
767-
):
774+
if exc.has_error_label("TransientTransactionError"):
768775
# Retry the entire transaction.
769776
break
770777
raise

pymongo/synchronous/client_session.py

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,9 @@
160160
from pymongo.errors import (
161161
ConfigurationError,
162162
ConnectionFailure,
163+
ExecutionTimeout,
163164
InvalidOperation,
165+
NetworkTimeout,
164166
OperationFailure,
165167
PyMongoError,
166168
WTimeoutError,
@@ -478,14 +480,20 @@ def _max_time_expired_error(exc: PyMongoError) -> bool:
478480
_BACKOFF_INITIAL = 0.005 # 5ms initial backoff
479481

480482

481-
def _within_time_limit(start_time: float) -> bool:
483+
def _within_time_limit(start_time: float, backoff: float = 0) -> bool:
482484
"""Are we within the with_transaction retry limit?"""
483-
return time.monotonic() - start_time < _WITH_TRANSACTION_RETRY_TIME_LIMIT
485+
remaining = _csot.remaining()
486+
if remaining is not None and remaining <= 0:
487+
return False
488+
return time.monotonic() + backoff - start_time < _WITH_TRANSACTION_RETRY_TIME_LIMIT
484489

485490

486-
def _would_exceed_time_limit(start_time: float, backoff: float) -> bool:
487-
"""Is the backoff within the with_transaction retry limit?"""
488-
return time.monotonic() + backoff - start_time >= _WITH_TRANSACTION_RETRY_TIME_LIMIT
491+
def _make_timeout_error(error: BaseException) -> PyMongoError:
492+
"""Convert error to a NetworkTimeout or ExecutionTimeout as appropriate."""
493+
if _csot.remaining() is not None:
494+
return ExecutionTimeout(str(error), 50, {"ok": 0, "errmsg": str(error), "code": 50})
495+
else:
496+
return NetworkTimeout(str(error))
489497

490498

491499
_T = TypeVar("_T")
@@ -720,9 +728,9 @@ def callback(session, custom_arg, custom_kwarg=None):
720728
if retry: # Implement exponential backoff on retry.
721729
jitter = random.random() # noqa: S311
722730
backoff = jitter * min(_BACKOFF_INITIAL * (1.5**retry), _BACKOFF_MAX)
723-
if _would_exceed_time_limit(start_time, backoff):
731+
if not _within_time_limit(start_time, backoff):
724732
assert last_error is not None
725-
raise last_error
733+
raise _make_timeout_error(last_error) from last_error
726734
time.sleep(backoff)
727735
retry += 1
728736
self.start_transaction(read_concern, write_concern, read_preference, max_commit_time_ms)
@@ -733,13 +741,13 @@ def callback(session, custom_arg, custom_kwarg=None):
733741
last_error = exc
734742
if self.in_transaction:
735743
self.abort_transaction()
736-
if (
737-
isinstance(exc, PyMongoError)
738-
and exc.has_error_label("TransientTransactionError")
739-
and _within_time_limit(start_time)
744+
if isinstance(exc, PyMongoError) and exc.has_error_label(
745+
"TransientTransactionError"
740746
):
741-
# Retry the entire transaction.
742-
continue
747+
if _within_time_limit(start_time):
748+
# Retry the entire transaction.
749+
continue
750+
raise _make_timeout_error(last_error) from exc
743751
raise
744752

745753
if not self.in_transaction:
@@ -750,17 +758,16 @@ def callback(session, custom_arg, custom_kwarg=None):
750758
try:
751759
self.commit_transaction()
752760
except PyMongoError as exc:
753-
if (
754-
exc.has_error_label("UnknownTransactionCommitResult")
755-
and _within_time_limit(start_time)
756-
and not _max_time_expired_error(exc)
757-
):
761+
last_error = exc
762+
if not _within_time_limit(start_time):
763+
raise _make_timeout_error(last_error) from exc
764+
if exc.has_error_label(
765+
"UnknownTransactionCommitResult"
766+
) and not _max_time_expired_error(exc):
758767
# Retry the commit.
759768
continue
760769

761-
if exc.has_error_label("TransientTransactionError") and _within_time_limit(
762-
start_time
763-
):
770+
if exc.has_error_label("TransientTransactionError"):
764771
# Retry the entire transaction.
765772
break
766773
raise

test/asynchronous/test_transactions.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import time
2222
from io import BytesIO
2323

24+
import pymongo
2425
from gridfs.asynchronous.grid_file import AsyncGridFS, AsyncGridFSBucket
2526
from pymongo.asynchronous.pool import PoolState
2627
from pymongo.server_selectors import writable_server_selector
@@ -47,7 +48,9 @@
4748
CollectionInvalid,
4849
ConfigurationError,
4950
ConnectionFailure,
51+
ExecutionTimeout,
5052
InvalidOperation,
53+
NetworkTimeout,
5154
OperationFailure,
5255
)
5356
from pymongo.operations import IndexModel, InsertOne
@@ -497,7 +500,7 @@ async def callback(session):
497500
listener.reset()
498501
async with client.start_session() as s:
499502
with PatchSessionTimeout(0):
500-
with self.assertRaises(OperationFailure):
503+
with self.assertRaises(NetworkTimeout):
501504
await s.with_transaction(callback)
502505

503506
self.assertEqual(listener.started_command_names(), ["insert", "abortTransaction"])
@@ -531,7 +534,7 @@ async def callback(session):
531534

532535
async with client.start_session() as s:
533536
with PatchSessionTimeout(0):
534-
with self.assertRaises(OperationFailure):
537+
with self.assertRaises(NetworkTimeout):
535538
await s.with_transaction(callback)
536539

537540
self.assertEqual(listener.started_command_names(), ["insert", "commitTransaction"])
@@ -562,7 +565,7 @@ async def callback(session):
562565

563566
async with client.start_session() as s:
564567
with PatchSessionTimeout(0):
565-
with self.assertRaises(ConnectionFailure):
568+
with self.assertRaises(NetworkTimeout):
566569
await s.with_transaction(callback)
567570

568571
# One insert for the callback and two commits (includes the automatic
@@ -571,6 +574,38 @@ async def callback(session):
571574
listener.started_command_names(), ["insert", "commitTransaction", "commitTransaction"]
572575
)
573576

577+
@async_client_context.require_transactions
578+
async def test_callback_not_retried_after_csot_timeout(self):
579+
listener = OvertCommandListener()
580+
client = await self.async_rs_client(event_listeners=[listener])
581+
coll = client[self.db.name].test
582+
583+
async def callback(session):
584+
await coll.insert_one({}, session=session)
585+
err: dict = {
586+
"ok": 0,
587+
"errmsg": "Transaction 7819 has been aborted.",
588+
"code": 251,
589+
"codeName": "NoSuchTransaction",
590+
"errorLabels": ["TransientTransactionError"],
591+
}
592+
raise OperationFailure(err["errmsg"], err["code"], err)
593+
594+
# Create the collection.
595+
await coll.insert_one({})
596+
listener.reset()
597+
async with client.start_session() as s:
598+
with pymongo.timeout(1.0):
599+
with self.assertRaises(ExecutionTimeout):
600+
await s.with_transaction(callback)
601+
602+
# At least two attempts: the original and one or more retries.
603+
inserts = len([x for x in listener.started_command_names() if x == "insert"])
604+
aborts = len([x for x in listener.started_command_names() if x == "abortTransaction"])
605+
606+
self.assertGreaterEqual(inserts, 2)
607+
self.assertGreaterEqual(aborts, 2)
608+
574609
# Tested here because this supports Motor's convenient transactions API.
575610
@async_client_context.require_transactions
576611
async def test_in_transaction_property(self):

test/test_transactions.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import time
2222
from io import BytesIO
2323

24+
import pymongo
2425
from gridfs.synchronous.grid_file import GridFS, GridFSBucket
2526
from pymongo.server_selectors import writable_server_selector
2627
from pymongo.synchronous.pool import PoolState
@@ -42,7 +43,9 @@
4243
CollectionInvalid,
4344
ConfigurationError,
4445
ConnectionFailure,
46+
ExecutionTimeout,
4547
InvalidOperation,
48+
NetworkTimeout,
4649
OperationFailure,
4750
)
4851
from pymongo.operations import IndexModel, InsertOne
@@ -489,7 +492,7 @@ def callback(session):
489492
listener.reset()
490493
with client.start_session() as s:
491494
with PatchSessionTimeout(0):
492-
with self.assertRaises(OperationFailure):
495+
with self.assertRaises(NetworkTimeout):
493496
s.with_transaction(callback)
494497

495498
self.assertEqual(listener.started_command_names(), ["insert", "abortTransaction"])
@@ -521,7 +524,7 @@ def callback(session):
521524

522525
with client.start_session() as s:
523526
with PatchSessionTimeout(0):
524-
with self.assertRaises(OperationFailure):
527+
with self.assertRaises(NetworkTimeout):
525528
s.with_transaction(callback)
526529

527530
self.assertEqual(listener.started_command_names(), ["insert", "commitTransaction"])
@@ -550,7 +553,7 @@ def callback(session):
550553

551554
with client.start_session() as s:
552555
with PatchSessionTimeout(0):
553-
with self.assertRaises(ConnectionFailure):
556+
with self.assertRaises(NetworkTimeout):
554557
s.with_transaction(callback)
555558

556559
# One insert for the callback and two commits (includes the automatic
@@ -559,6 +562,38 @@ def callback(session):
559562
listener.started_command_names(), ["insert", "commitTransaction", "commitTransaction"]
560563
)
561564

565+
@client_context.require_transactions
566+
def test_callback_not_retried_after_csot_timeout(self):
567+
listener = OvertCommandListener()
568+
client = self.rs_client(event_listeners=[listener])
569+
coll = client[self.db.name].test
570+
571+
def callback(session):
572+
coll.insert_one({}, session=session)
573+
err: dict = {
574+
"ok": 0,
575+
"errmsg": "Transaction 7819 has been aborted.",
576+
"code": 251,
577+
"codeName": "NoSuchTransaction",
578+
"errorLabels": ["TransientTransactionError"],
579+
}
580+
raise OperationFailure(err["errmsg"], err["code"], err)
581+
582+
# Create the collection.
583+
coll.insert_one({})
584+
listener.reset()
585+
with client.start_session() as s:
586+
with pymongo.timeout(1.0):
587+
with self.assertRaises(ExecutionTimeout):
588+
s.with_transaction(callback)
589+
590+
# At least two attempts: the original and one or more retries.
591+
inserts = len([x for x in listener.started_command_names() if x == "insert"])
592+
aborts = len([x for x in listener.started_command_names() if x == "abortTransaction"])
593+
594+
self.assertGreaterEqual(inserts, 2)
595+
self.assertGreaterEqual(aborts, 2)
596+
562597
# Tested here because this supports Motor's convenient transactions API.
563598
@client_context.require_transactions
564599
def test_in_transaction_property(self):

0 commit comments

Comments
 (0)