Skip to content

Commit 7f8a552

Browse files
fix(spanner): buffer returned rows in autocommit DML statements (#18152)
### Problem In autocommit mode (`connection.autocommit = True`), `cursor.execute()` runs within a `run_in_transaction` callback that commits and terminates the underlying Spanner transaction immediately upon return. Because `PeekIterator` only prefetched the first row from the streaming `StreamedResultSet`, subsequent rows from multi-row DML statements with returning clauses (e.g. `INSERT ... THEN RETURN` or `UPDATE ... THEN RETURN`) were not fetched before transaction closure. Attempting to consume subsequent rows post-commit caused stream errors, resulting in silently dropped rows for multi-row returning queries. ### Solution - Introduced `BufferedIterator` in `google.cloud.spanner_dbapi.utils` to eagerly drain and buffer returned rows from `StreamedResultSet` into memory upon instantiation while converting row lists to tuples per DB-API v2 (PEP 249) expectations. - Updated `_do_execute_update_in_autocommit` in `google.cloud.spanner_dbapi.cursor` to use `BufferedIterator(self._result_set)`, ensuring all rows and result set stats are captured before the transaction commits. - Added comprehensive unit tests in `test_utils.py` and `test_cursor.py` verifying eager draining, list-to-tuple conversions, and multi-row post-commit fetch behavior. Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/google-cloud-python/issues) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes #<issue_number_goes_here> 🦕
1 parent 92008e2 commit 7f8a552

4 files changed

Lines changed: 130 additions & 11 deletions

File tree

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,11 @@
4949
StatementType,
5050
)
5151
from google.cloud.spanner_dbapi.transaction_helper import CursorStatementType
52-
from google.cloud.spanner_dbapi.utils import PeekIterator, StreamedManyResultSets
52+
from google.cloud.spanner_dbapi.utils import (
53+
BufferedIterator,
54+
PeekIterator,
55+
StreamedManyResultSets,
56+
)
5357
from google.cloud.spanner_v1 import RequestOptions
5458
from google.cloud.spanner_v1.merged_result_set import MergedResultSet
5559

@@ -234,7 +238,7 @@ def _do_execute_update_in_autocommit(self, transaction, sql, params):
234238
param_types=get_param_types(params),
235239
last_statement=True,
236240
)
237-
self._itr = PeekIterator(self._result_set)
241+
self._itr = BufferedIterator(self._result_set)
238242
self._row_count = None
239243

240244
def _batch_DDLs(self, sql):

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,36 @@
1717
re_UNICODE_POINTS = re.compile(r"([^\s]*[\u0080-\uFFFF]+[^\s]*)")
1818

1919

20+
class BufferedIterator:
21+
"""
22+
An eager, in-memory iterator that consumes and buffers all rows from a
23+
source stream upon instantiation.
24+
25+
Used when the underlying database transaction is committed and closed before
26+
the caller fetches results (e.g. autocommit DML with THEN RETURN / RETURNING).
27+
If a row is an instance of list, it is converted to a tuple to conform
28+
with DBAPI v2's sequence expectations.
29+
30+
:type source: iterable
31+
:param source: A source iterable/stream of rows.
32+
"""
33+
34+
def __init__(self, source):
35+
self._rows = [
36+
tuple(row) if isinstance(row, list) else row for row in (source or ())
37+
]
38+
self._itr = iter(self._rows)
39+
40+
def __next__(self):
41+
return next(self._itr)
42+
43+
def __iter__(self):
44+
return self
45+
46+
def __len__(self):
47+
return len(self._rows)
48+
49+
2050
class PeekIterator:
2151
"""
2252
Peek at the first element out of an iterator for the sake of operations

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

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

126+
def test_do_execute_update_in_autocommit_eagerly_buffers_returning_rows(self):
127+
from google.cloud.spanner_v1 import ResultSetStats, Type, TypeCode
128+
from google.cloud.spanner_v1.types import ResultSetMetadata, StructType
129+
130+
connection = self._make_connection(self.INSTANCE, mock.MagicMock())
131+
connection.autocommit = True
132+
cursor = self._make_one(connection)
133+
134+
tx_active = True
135+
136+
def streaming_generator():
137+
if not tx_active:
138+
raise ValueError(
139+
"Transaction has already been committed or rolled back"
140+
)
141+
yield [1, "Alice"]
142+
if not tx_active:
143+
raise ValueError(
144+
"Transaction has already been committed or rolled back"
145+
)
146+
yield [2, "Bob"]
147+
148+
field1 = StructType.Field(name="id", type=Type(code=TypeCode.INT64))
149+
field2 = StructType.Field(name="name", type=Type(code=TypeCode.STRING))
150+
row_type = StructType(fields=[field1, field2])
151+
metadata = ResultSetMetadata(row_type=row_type)
152+
153+
mock_result_set = mock.MagicMock()
154+
mock_result_set.__iter__.side_effect = streaming_generator
155+
mock_result_set.metadata = metadata
156+
mock_result_set.stats = ResultSetStats(row_count_exact=2)
157+
158+
def fake_run_in_transaction(func, *args, **kwargs):
159+
nonlocal tx_active
160+
mock_tx = mock.MagicMock()
161+
mock_tx.execute_sql.return_value = mock_result_set
162+
result = func(mock_tx, *args, **kwargs)
163+
tx_active = False
164+
return result
165+
166+
connection.database.run_in_transaction = fake_run_in_transaction
167+
168+
sql = "INSERT INTO table (id, name) VALUES (1, 'Alice'), (2, 'Bob') THEN RETURN id, name"
169+
cursor.execute(sql)
170+
171+
self.assertIsNotNone(cursor.description)
172+
self.assertEqual(len(cursor.description), 2)
173+
self.assertEqual(cursor.description[0].name, "id")
174+
self.assertEqual(cursor.description[1].name, "name")
175+
self.assertEqual(cursor.rowcount, 2)
176+
self.assertEqual(cursor.fetchall(), [(1, "Alice"), (2, "Bob")])
177+
126178
def test_do_batch_update(self):
127179
from google.cloud.spanner_dbapi import connect
128180
from google.cloud.spanner_v1.param_types import INT64

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

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,10 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
import sys
1615
import unittest
1716

1817

1918
class TestUtils(unittest.TestCase):
20-
skip_condition = sys.version_info[0] < 3
21-
skip_message = "Subtests are not supported in Python 2"
22-
23-
@unittest.skipIf(skip_condition, skip_message)
2419
def test_PeekIterator(self):
2520
from google.cloud.spanner_dbapi.utils import PeekIterator
2621

@@ -38,7 +33,6 @@ def test_PeekIterator(self):
3833
actual = list(pitr)
3934
self.assertEqual(actual, expected)
4035

41-
@unittest.skipIf(skip_condition, "Python 2 has an outdated iterator definition")
4236
def test_peekIterator_list_rows_converted_to_tuples(self):
4337
from google.cloud.spanner_dbapi.utils import PeekIterator
4438

@@ -60,7 +54,6 @@ def test_peekIterator_list_rows_converted_to_tuples(self):
6054
pit = PeekIterator([("Clark", "Kent")])
6155
self.assertEqual(next(pit), ("Clark", "Kent"))
6256

63-
@unittest.skipIf(skip_condition, "Python 2 has an outdated iterator definition")
6457
def test_peekIterator_nonlist_rows_unconverted(self):
6558
from google.cloud.spanner_dbapi.utils import PeekIterator
6659

@@ -69,7 +62,6 @@ def test_peekIterator_nonlist_rows_unconverted(self):
6962
want = ["a", "b", "c", "d", "e"]
7063
self.assertEqual(got, want, "Values should be returned unchanged")
7164

72-
@unittest.skipIf(skip_condition, skip_message)
7365
def test_backtick_unicode(self):
7466
from google.cloud.spanner_dbapi.utils import backtick_unicode
7567

@@ -85,7 +77,6 @@ def test_backtick_unicode(self):
8577
got = backtick_unicode(sql)
8678
self.assertEqual(got, want)
8779

88-
@unittest.skipIf(skip_condition, skip_message)
8980
def test_StreamedManyResultSets(self):
9081
from google.cloud.spanner_dbapi.utils import StreamedManyResultSets
9182

@@ -100,3 +91,45 @@ def test_StreamedManyResultSets(self):
10091
stream_result._iterators.append(data_in)
10192
actual = list(stream_result)
10293
self.assertEqual(actual, expected)
94+
95+
def test_BufferedIterator(self):
96+
from google.cloud.spanner_dbapi.utils import BufferedIterator
97+
98+
cases = [
99+
("list_of_lists", [["a", 1], ["b", 2]], [("a", 1), ("b", 2)]),
100+
("iter_of_lists", iter([["a", 1], ["b", 2]]), [("a", 1), ("b", 2)]),
101+
("list_of_tuples", [("a", 1), ("b", 2)], [("a", 1), ("b", 2)]),
102+
("iter_of_tuples", iter([("a", 1), ("b", 2)]), [("a", 1), ("b", 2)]),
103+
("empty_list", [], []),
104+
("empty_tuple", (), []),
105+
("none_source", None, []),
106+
]
107+
108+
for name, data_in, expected in cases:
109+
with self.subTest(name=name):
110+
bitr = BufferedIterator(data_in)
111+
self.assertEqual(len(bitr), len(expected))
112+
actual = list(bitr)
113+
self.assertEqual(actual, expected)
114+
with self.assertRaises(StopIteration):
115+
next(bitr)
116+
117+
def test_BufferedIterator_eagerly_drains_stream(self):
118+
from google.cloud.spanner_dbapi.utils import BufferedIterator
119+
120+
drained = False
121+
122+
def generator():
123+
nonlocal drained
124+
yield ["first", 1]
125+
yield ["second", 2]
126+
drained = True
127+
128+
bitr = BufferedIterator(generator())
129+
# The generator must be completely drained during __init__
130+
self.assertTrue(drained)
131+
self.assertEqual(len(bitr), 2)
132+
self.assertEqual(next(bitr), ("first", 1))
133+
self.assertEqual(next(bitr), ("second", 2))
134+
with self.assertRaises(StopIteration):
135+
next(bitr)

0 commit comments

Comments
 (0)