Skip to content

Commit 1fac066

Browse files
committed
feat(dbapi): add retry_aborts_internally option to Connection
Add a retry_aborts_internally flag to the DBAPI Connection class and the connect() function. When set to False, aborted transactions raise RetryAborted directly from commit() instead of entering the internal statement-replay retry loop. This aligns with RETRY_ABORTS_INTERNALLY in the Spanner JDBC driver and avoids nested retry loops when the application manages its own transaction retry logic. Fixes #16491
1 parent c3bd6c0 commit 1fac066

2 files changed

Lines changed: 147 additions & 5 deletions

File tree

packages/google-cloud-spanner/google/cloud/spanner_dbapi/connection.py

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,28 @@ class Connection:
9191
the read-only transaction is semantically the same, and only indicates that the read-only transaction
9292
should end a that a new one should be started when the next statement is executed.
9393
94+
:type retry_aborts_internally: bool
95+
:param retry_aborts_internally:
96+
(Optional) Default True. When True, the connection will automatically retry aborted
97+
transactions by replaying all statements and validating checksums of read results.
98+
This is the recommended setting for interactive use and ORMs like Django that build
99+
transactions incrementally through individual cursor.execute() calls.
100+
101+
Set to False when the application already implements its own transaction retry logic
102+
(e.g. by wrapping the entire transaction in a callable and re-invoking it on abort,
103+
similar to ``Session.run_in_transaction``). In this mode, ``Aborted`` errors from
104+
``commit()`` will be raised directly as ``RetryAborted`` without entering the
105+
internal statement-replay loop. This avoids nested retry loops and the associated
106+
contention amplification under concurrent writes.
107+
108+
This is equivalent to ``RETRY_ABORTS_INTERNALLY`` in the Spanner JDBC driver.
109+
94110
**kwargs: Initial value for connection variables.
95111
"""
96112

97-
def __init__(self, instance, database=None, read_only=False, **kwargs):
113+
def __init__(
114+
self, instance, database=None, read_only=False, retry_aborts_internally=True, **kwargs
115+
):
98116
self._instance = instance
99117
self._database = database
100118
self._ddl_statements = []
@@ -110,6 +128,7 @@ def __init__(self, instance, database=None, read_only=False, **kwargs):
110128
# connection close
111129
self._own_pool = True
112130
self._read_only = read_only
131+
self._retry_aborts_internally = retry_aborts_internally
113132
self._staleness = None
114133
self.request_priority = None
115134
self._transaction_begin_marked = False
@@ -248,6 +267,33 @@ def read_only(self, value):
248267
)
249268
self._read_only = value
250269

270+
@property
271+
def retry_aborts_internally(self):
272+
"""Flag: whether the connection retries aborted transactions internally.
273+
274+
Returns:
275+
bool:
276+
True if the connection will retry aborted transactions using
277+
statement replay with checksum validation (default). False if
278+
aborted transactions will raise ``RetryAborted`` directly.
279+
"""
280+
return self._retry_aborts_internally
281+
282+
@retry_aborts_internally.setter
283+
def retry_aborts_internally(self, value):
284+
"""``retry_aborts_internally`` flag setter.
285+
286+
Args:
287+
value (bool): True to enable internal retry (default), False to disable.
288+
"""
289+
if self._spanner_transaction_started:
290+
raise ValueError(
291+
"retry_aborts_internally can't be changed while a transaction "
292+
"is in progress. Commit or rollback the current transaction "
293+
"and try again."
294+
)
295+
self._retry_aborts_internally = value
296+
251297
@property
252298
def request_options(self):
253299
"""Options for the next SQL operations.
@@ -491,9 +537,12 @@ def commit(self):
491537
try:
492538
if self._spanner_transaction_started and not self._read_only:
493539
self._transaction.commit()
494-
except Aborted:
495-
self._transaction_helper.retry_transaction()
496-
self.commit()
540+
except Aborted as exc:
541+
if self._retry_aborts_internally:
542+
self._transaction_helper.retry_transaction()
543+
self.commit()
544+
else:
545+
raise RetryAborted(str(exc)) from exc
497546
finally:
498547
self._reset_post_commit_or_rollback()
499548

@@ -747,6 +796,7 @@ def connect(
747796
ca_certificate=None,
748797
client_certificate=None,
749798
client_key=None,
799+
retry_aborts_internally=True,
750800
**kwargs,
751801
):
752802
"""Creates a connection to a Google Cloud Spanner database.
@@ -822,6 +872,13 @@ def connect(
822872
:param client_key: (Optional) The path to the client key file used for mTLS connection.
823873
This is intended only for experimental host spanner endpoints.
824874
This is mandatory if the experimental_host requires an mTLS connection.
875+
876+
:type retry_aborts_internally: bool
877+
:param retry_aborts_internally:
878+
(Optional) Default True. When True, the connection will automatically retry
879+
aborted transactions internally by replaying all statements and validating
880+
checksums. Set to False when the application manages its own transaction retry
881+
logic. See ``Connection.retry_aborts_internally`` for details.
825882
"""
826883
if client is None:
827884
client_info = ClientInfo(
@@ -866,7 +923,12 @@ def connect(
866923
database = instance.database(
867924
database_id, pool=pool, database_role=database_role, logger=logger
868925
)
869-
conn = Connection(instance, database, **kwargs)
926+
conn = Connection(
927+
instance,
928+
database,
929+
retry_aborts_internally=retry_aborts_internally,
930+
**kwargs,
931+
)
870932
if pool is not None:
871933
conn._own_pool = False
872934

packages/google-cloud-spanner/tests/unit/spanner_dbapi/test_connection.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,61 @@ def test_commit_database_error(self):
355355
with pytest.raises(ValueError):
356356
connection.commit()
357357

358+
def test_retry_aborts_internally_defaults_true(self):
359+
connection = self._make_connection()
360+
self.assertTrue(connection.retry_aborts_internally)
361+
362+
def test_retry_aborts_internally_set_false(self):
363+
connection = self._make_connection(retry_aborts_internally=False)
364+
self.assertFalse(connection.retry_aborts_internally)
365+
366+
def test_retry_aborts_internally_setter(self):
367+
connection = self._make_connection()
368+
connection.retry_aborts_internally = False
369+
self.assertFalse(connection.retry_aborts_internally)
370+
371+
def test_retry_aborts_internally_setter_while_transaction_active(self):
372+
connection = self._make_connection()
373+
connection._spanner_transaction_started = True
374+
with pytest.raises(ValueError, match="retry_aborts_internally can't be changed"):
375+
connection.retry_aborts_internally = False
376+
377+
def test_commit_retries_internally_when_enabled(self):
378+
from google.api_core.exceptions import Aborted
379+
380+
self._under_test._transaction = mock_transaction = mock.MagicMock()
381+
self._under_test._spanner_transaction_started = True
382+
mock_transaction.commit = mock.MagicMock(
383+
side_effect=[Aborted("aborted"), None]
384+
)
385+
self._under_test._retry_aborts_internally = True
386+
387+
with mock.patch.object(
388+
self._under_test._transaction_helper, "retry_transaction"
389+
) as mock_retry, mock.patch(
390+
"google.cloud.spanner_dbapi.connection.Connection._release_session"
391+
):
392+
self._under_test.commit()
393+
394+
mock_retry.assert_called_once()
395+
396+
def test_commit_raises_retry_aborted_when_internal_retry_disabled(self):
397+
from google.api_core.exceptions import Aborted
398+
from google.cloud.spanner_dbapi.exceptions import RetryAborted
399+
400+
self._under_test._transaction = mock_transaction = mock.MagicMock()
401+
self._under_test._spanner_transaction_started = True
402+
mock_transaction.commit = mock.MagicMock(
403+
side_effect=Aborted("aborted")
404+
)
405+
self._under_test._retry_aborts_internally = False
406+
407+
with mock.patch(
408+
"google.cloud.spanner_dbapi.connection.Connection._release_session"
409+
):
410+
with pytest.raises(RetryAborted, match="aborted"):
411+
self._under_test.commit()
412+
358413
@mock.patch.object(warnings, "warn")
359414
def test_rollback_spanner_transaction_not_started(self, mock_warn):
360415
self._under_test._spanner_transaction_started = False
@@ -882,6 +937,31 @@ def test_connection_wo_database(self):
882937
)
883938
self.assertTrue(connection.database is None)
884939

940+
def test_connect_retry_aborts_internally_default(self):
941+
from google.cloud.spanner_dbapi import connect
942+
943+
connection = connect(
944+
"test-instance",
945+
"test-database",
946+
project="test-project",
947+
credentials=AnonymousCredentials(),
948+
client_options={"api_endpoint": "none"},
949+
)
950+
self.assertTrue(connection.retry_aborts_internally)
951+
952+
def test_connect_retry_aborts_internally_false(self):
953+
from google.cloud.spanner_dbapi import connect
954+
955+
connection = connect(
956+
"test-instance",
957+
"test-database",
958+
project="test-project",
959+
credentials=AnonymousCredentials(),
960+
client_options={"api_endpoint": "none"},
961+
retry_aborts_internally=False,
962+
)
963+
self.assertFalse(connection.retry_aborts_internally)
964+
885965

886966
def exit_ctx_func(self, exc_type, exc_value, traceback):
887967
"""Context __exit__ method mock."""

0 commit comments

Comments
 (0)