Skip to content

Commit 3c49ed8

Browse files
committed
fix: roll back the connection when a chunked read_sql_query fails
read_sql_query() wraps both of its return paths in a try/except that calls con.rollback() before re-raising. For chunksize=None that works, but the chunked path returns a generator, so nothing inside _iterate_results() runs until the caller iterates -- long after the except clause has been unwound. A failing statement therefore left the connection in an aborted-transaction state, and every later query on that connection failed with a confusing secondary error (e.g. PostgreSQL's "current transaction is aborted"). Move the rollback into _iterate_results() so both paths behave identically. GeneratorExit is not an Exception, so abandoning the iterator early still does not trigger a rollback.
1 parent 609db3c commit 3c49ed8

2 files changed

Lines changed: 142 additions & 24 deletions

File tree

awswrangler/_databases.py

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -211,30 +211,37 @@ def _iterate_results(
211211
timestamp_as_object: bool,
212212
dtype_backend: Literal["numpy_nullable", "pyarrow"],
213213
) -> Iterator[pd.DataFrame]:
214-
with con.cursor() as cursor:
215-
cursor.execute(*cursor_args)
216-
if _oracledb_found:
217-
decimal_dtypes = oracle.detect_oracle_decimal_datatype(cursor)
218-
_logger.debug("steporig: %s", dtype)
219-
if decimal_dtypes and dtype is not None:
220-
dtype = dict(list(decimal_dtypes.items()) + list(dtype.items()))
221-
elif decimal_dtypes:
222-
dtype = decimal_dtypes
223-
224-
cols_names = _get_cols_names(cursor.description)
225-
while True:
226-
records = cursor.fetchmany(chunksize)
227-
if not records:
228-
break
229-
yield _records2df(
230-
records=records,
231-
cols_names=cols_names,
232-
index=index_col,
233-
safe=safe,
234-
dtype=dtype,
235-
timestamp_as_object=timestamp_as_object,
236-
dtype_backend=dtype_backend,
237-
)
214+
# This generator runs lazily, so the caller's `try` block is already unwound by the time
215+
# the statement is executed. The rollback must therefore happen here.
216+
try:
217+
with con.cursor() as cursor:
218+
cursor.execute(*cursor_args)
219+
if _oracledb_found:
220+
decimal_dtypes = oracle.detect_oracle_decimal_datatype(cursor)
221+
_logger.debug("steporig: %s", dtype)
222+
if decimal_dtypes and dtype is not None:
223+
dtype = dict(list(decimal_dtypes.items()) + list(dtype.items()))
224+
elif decimal_dtypes:
225+
dtype = decimal_dtypes
226+
227+
cols_names = _get_cols_names(cursor.description)
228+
while True:
229+
records = cursor.fetchmany(chunksize)
230+
if not records:
231+
break
232+
yield _records2df(
233+
records=records,
234+
cols_names=cols_names,
235+
index=index_col,
236+
safe=safe,
237+
dtype=dtype,
238+
timestamp_as_object=timestamp_as_object,
239+
dtype_backend=dtype_backend,
240+
)
241+
except Exception as ex:
242+
con.rollback()
243+
_logger.error(ex)
244+
raise
238245

239246

240247
def _fetch_all_results(

tests/unit/test_databases.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Unit tests for the generic database helpers in ``awswrangler._databases``.
2+
3+
These tests use an in-memory stub connection (DB-API shaped), so they require no
4+
database server and no AWS credentials.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import Any, Iterator
10+
11+
import pytest
12+
13+
from awswrangler import _databases as _db_utils
14+
15+
16+
class _StubCursor:
17+
"""Minimal DB-API cursor. Raises on ``execute`` or on the Nth ``fetchmany`` call."""
18+
19+
description = [("col0",)]
20+
21+
def __init__(self, fail_on_execute: bool, fail_on_fetch: bool) -> None:
22+
self._fail_on_execute = fail_on_execute
23+
self._fail_on_fetch = fail_on_fetch
24+
self._fetch_calls = 0
25+
self.closed = False
26+
27+
def __enter__(self) -> "_StubCursor":
28+
return self
29+
30+
def __exit__(self, *args: object) -> None:
31+
self.closed = True
32+
33+
def execute(self, *args: Any, **kwargs: Any) -> None:
34+
if self._fail_on_execute:
35+
raise RuntimeError("syntax error at or near ...")
36+
37+
def fetchall(self) -> list[tuple[Any, ...]]:
38+
return [(1,), (2,)]
39+
40+
def fetchmany(self, size: int) -> list[tuple[Any, ...]]:
41+
self._fetch_calls += 1
42+
if self._fetch_calls == 1:
43+
return [(1,)]
44+
if self._fail_on_fetch:
45+
raise RuntimeError("connection reset by peer")
46+
return []
47+
48+
49+
class _StubConnection:
50+
def __init__(self, fail_on_execute: bool = False, fail_on_fetch: bool = False) -> None:
51+
self._fail_on_execute = fail_on_execute
52+
self._fail_on_fetch = fail_on_fetch
53+
self.rollback_count = 0
54+
self.cursors: list[_StubCursor] = []
55+
56+
def cursor(self) -> _StubCursor:
57+
cursor = _StubCursor(fail_on_execute=self._fail_on_execute, fail_on_fetch=self._fail_on_fetch)
58+
self.cursors.append(cursor)
59+
return cursor
60+
61+
def rollback(self) -> None:
62+
self.rollback_count += 1
63+
64+
65+
@pytest.mark.parametrize("chunksize", [None, 1])
66+
def test_read_sql_query_rolls_back_on_execute_error(chunksize: int | None) -> None:
67+
con = _StubConnection(fail_on_execute=True)
68+
69+
with pytest.raises(RuntimeError):
70+
result = _db_utils.read_sql_query("SELECT 1", con=con, chunksize=chunksize)
71+
if chunksize is not None:
72+
list(result)
73+
74+
assert con.rollback_count == 1
75+
assert all(cursor.closed for cursor in con.cursors)
76+
77+
78+
def test_read_sql_query_chunked_rolls_back_on_fetch_error() -> None:
79+
con = _StubConnection(fail_on_fetch=True)
80+
iterator: Iterator[Any] = _db_utils.read_sql_query("SELECT 1", con=con, chunksize=1)
81+
82+
# The first chunk is produced normally; the failure happens mid-iteration.
83+
next(iterator)
84+
assert con.rollback_count == 0
85+
86+
with pytest.raises(RuntimeError):
87+
next(iterator)
88+
89+
assert con.rollback_count == 1
90+
assert all(cursor.closed for cursor in con.cursors)
91+
92+
93+
def test_read_sql_query_chunked_does_not_roll_back_on_success() -> None:
94+
con = _StubConnection()
95+
96+
chunks = list(_db_utils.read_sql_query("SELECT 1", con=con, chunksize=1))
97+
98+
assert len(chunks) == 1
99+
assert chunks[0]["col0"].to_list() == [1]
100+
assert con.rollback_count == 0
101+
102+
103+
def test_read_sql_query_chunked_does_not_roll_back_on_early_exit() -> None:
104+
con = _StubConnection(fail_on_fetch=True)
105+
iterator: Iterator[Any] = _db_utils.read_sql_query("SELECT 1", con=con, chunksize=1)
106+
107+
next(iterator)
108+
# Abandoning the iterator throws GeneratorExit into it, which is not an error.
109+
iterator.close() # type: ignore[union-attr]
110+
111+
assert con.rollback_count == 0

0 commit comments

Comments
 (0)