Skip to content
This repository was archived by the owner on Mar 31, 2026. It is now read-only.

Commit b73fa31

Browse files
committed
feat(dbapi): wire timeout parameter through Connection to execute_sql
The DBAPI layer calls _SnapshotBase.execute_sql() in three code paths (snapshot reads, transaction reads/writes, autocommit DML) but never passes the timeout= argument. This causes all queries to use the gRPC default timeout of 3600 seconds. Add a timeout property to Connection and pass it through to execute_sql() in cursor._handle_DQL_with_snapshot(), cursor._do_execute_update_in_autocommit(), and connection.run_statement(). Fixes #1534
1 parent e5638ab commit b73fa31

File tree

4 files changed

+151
-5
lines changed

4 files changed

+151
-5
lines changed

google/cloud/spanner_dbapi/connection.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def __init__(self, instance, database=None, read_only=False, **kwargs):
111111
self._read_only = read_only
112112
self._staleness = None
113113
self.request_priority = None
114+
self._timeout = None
114115
self._transaction_begin_marked = False
115116
self._transaction_isolation_level = None
116117
# whether transaction started at Spanner. This means that we had
@@ -347,6 +348,30 @@ def staleness(self, value):
347348

348349
self._staleness = value
349350

351+
@property
352+
def timeout(self):
353+
"""Timeout in seconds for the next SQL operation on this connection.
354+
355+
When set, this value is passed as the ``timeout`` argument to
356+
``execute_sql`` calls on the underlying Spanner client, controlling
357+
the gRPC deadline for those calls.
358+
359+
Returns:
360+
Optional[float]: The timeout in seconds, or None to use the
361+
default gRPC timeout (3600s).
362+
"""
363+
return self._timeout
364+
365+
@timeout.setter
366+
def timeout(self, value):
367+
"""Set the timeout for subsequent SQL operations.
368+
369+
Args:
370+
value (Optional[float]): Timeout in seconds. Set to None to
371+
revert to the default gRPC timeout.
372+
"""
373+
self._timeout = value
374+
350375
def _session_checkout(self):
351376
"""Get a Cloud Spanner session from the pool.
352377
@@ -559,11 +584,16 @@ def run_statement(
559584
checksum of this statement results.
560585
"""
561586
transaction = self.transaction_checkout()
587+
kwargs = dict(
588+
param_types=statement.param_types,
589+
request_options=request_options or self.request_options,
590+
)
591+
if self._timeout is not None:
592+
kwargs["timeout"] = self._timeout
562593
return transaction.execute_sql(
563594
statement.sql,
564595
statement.params,
565-
param_types=statement.param_types,
566-
request_options=request_options or self.request_options,
596+
**kwargs,
567597
)
568598

569599
@check_not_closed

google/cloud/spanner_dbapi/cursor.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,12 +227,17 @@ def _do_execute_update_in_autocommit(self, transaction, sql, params):
227227
"""This function should only be used in autocommit mode."""
228228
self.connection._transaction = transaction
229229
self.connection._snapshot = None
230-
self._result_set = transaction.execute_sql(
231-
sql,
230+
kwargs = dict(
232231
params=params,
233232
param_types=get_param_types(params),
234233
last_statement=True,
235234
)
235+
if self.connection._timeout is not None:
236+
kwargs["timeout"] = self.connection._timeout
237+
self._result_set = transaction.execute_sql(
238+
sql,
239+
**kwargs,
240+
)
236241
self._itr = PeekIterator(self._result_set)
237242
self._row_count = None
238243

@@ -541,11 +546,14 @@ def _fetch(self, cursor_statement_type, size=None):
541546
return rows
542547

543548
def _handle_DQL_with_snapshot(self, snapshot, sql, params):
549+
kwargs = dict(request_options=self.request_options)
550+
if self.connection._timeout is not None:
551+
kwargs["timeout"] = self.connection._timeout
544552
self._result_set = snapshot.execute_sql(
545553
sql,
546554
params,
547555
get_param_types(params),
548-
request_options=self.request_options,
556+
**kwargs,
549557
)
550558
# Read the first element so that the StreamedResultSet can
551559
# return the metadata after a DQL statement.

tests/unit/spanner_dbapi/test_connection.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,56 @@ def test_request_priority(self):
838838
sql, params, param_types=param_types, request_options=None
839839
)
840840

841+
def test_timeout_default_none(self):
842+
connection = self._make_connection()
843+
self.assertIsNone(connection.timeout)
844+
845+
def test_timeout_property(self):
846+
connection = self._make_connection()
847+
connection.timeout = 60
848+
self.assertEqual(connection.timeout, 60)
849+
850+
connection.timeout = None
851+
self.assertIsNone(connection.timeout)
852+
853+
def test_timeout_passed_to_run_statement(self):
854+
from google.cloud.spanner_dbapi.parsed_statement import Statement
855+
856+
sql = "SELECT 1"
857+
params = []
858+
param_types = {}
859+
860+
connection = self._make_connection()
861+
connection._spanner_transaction_started = True
862+
connection._transaction = mock.Mock()
863+
connection._transaction.execute_sql = mock.Mock()
864+
865+
connection.timeout = 60
866+
867+
connection.run_statement(Statement(sql, params, param_types))
868+
869+
connection._transaction.execute_sql.assert_called_with(
870+
sql, params, param_types=param_types, request_options=None, timeout=60
871+
)
872+
873+
def test_timeout_not_passed_when_none(self):
874+
from google.cloud.spanner_dbapi.parsed_statement import Statement
875+
876+
sql = "SELECT 1"
877+
params = []
878+
param_types = {}
879+
880+
connection = self._make_connection()
881+
connection._spanner_transaction_started = True
882+
connection._transaction = mock.Mock()
883+
connection._transaction.execute_sql = mock.Mock()
884+
885+
connection.run_statement(Statement(sql, params, param_types))
886+
887+
connection._transaction.execute_sql.assert_called_with(
888+
sql, params, param_types=param_types, request_options=None
889+
)
890+
841891
def test_custom_client_connection(self):
842892
from google.cloud.spanner_dbapi import connect
843893

tests/unit/spanner_dbapi/test_cursor.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,64 @@ def test_do_execute_update(self):
122122
self.assertEqual(cursor._result_set, result_set)
123123
self.assertEqual(cursor.rowcount, 1234)
124124

125+
def test_do_execute_update_with_timeout(self):
126+
connection = self._make_connection(self.INSTANCE, self.DATABASE)
127+
connection._timeout = 30
128+
cursor = self._make_one(connection)
129+
transaction = mock.MagicMock()
130+
131+
cursor._do_execute_update_in_autocommit(
132+
transaction=transaction,
133+
sql="UPDATE t SET x=1 WHERE true",
134+
params={},
135+
)
136+
137+
transaction.execute_sql.assert_called_once_with(
138+
"UPDATE t SET x=1 WHERE true",
139+
params={},
140+
param_types={},
141+
last_statement=True,
142+
timeout=30,
143+
)
144+
145+
def test_handle_DQL_with_snapshot_timeout(self):
146+
connection = self._make_connection(self.INSTANCE, self.DATABASE)
147+
connection._timeout = 45
148+
cursor = self._make_one(connection)
149+
150+
snapshot = mock.MagicMock()
151+
result_set = mock.MagicMock()
152+
result_set.metadata.transaction.read_timestamp = None
153+
snapshot.execute_sql.return_value = result_set
154+
155+
cursor._handle_DQL_with_snapshot(snapshot, "SELECT 1", None)
156+
157+
snapshot.execute_sql.assert_called_once_with(
158+
"SELECT 1",
159+
None,
160+
None,
161+
request_options=None,
162+
timeout=45,
163+
)
164+
165+
def test_handle_DQL_with_snapshot_no_timeout(self):
166+
connection = self._make_connection(self.INSTANCE, self.DATABASE)
167+
cursor = self._make_one(connection)
168+
169+
snapshot = mock.MagicMock()
170+
result_set = mock.MagicMock()
171+
result_set.metadata.transaction.read_timestamp = None
172+
snapshot.execute_sql.return_value = result_set
173+
174+
cursor._handle_DQL_with_snapshot(snapshot, "SELECT 1", None)
175+
176+
snapshot.execute_sql.assert_called_once_with(
177+
"SELECT 1",
178+
None,
179+
None,
180+
request_options=None,
181+
)
182+
125183
def test_do_batch_update(self):
126184
from google.cloud.spanner_dbapi import connect
127185
from google.cloud.spanner_v1.param_types import INT64

0 commit comments

Comments
 (0)