Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions pyathena/aio/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,14 +143,17 @@ async def _async_get_query_results(
return cast(dict[str, Any], response)

async def _async_fetch(self) -> None:
"""Fetch the next page of rows into the result set.
"""Fetch the next page of rows, if any, into the buffer.

Nothing is fetched when no page remains, as after ``close()``.

Raises:
ProgrammingError: If there is no next page.
ProgrammingError: If the next page is requested and the query ID is
missing, the query has not succeeded, or the result set is closed.
OperationalError: If the request fails.
"""
if not self._next_token:
raise ProgrammingError("NextToken is none or empty.")
return
response = await self._async_get_query_results(self._arraysize, self._next_token)
rows, self._next_token = self._parse_result_rows(response)
self._process_rows(rows)
Expand Down Expand Up @@ -183,10 +186,10 @@ async def fetchone( # type: ignore[override]
The next row (a tuple, or a dict for ``AthenaAioDictResultSet``), or
None if no more rows.
"""
if not self._rows and self._next_token:
await self._async_fetch()
if not self._rows:
return None
await self._async_fetch()
if not self._rows:
return None
row = self._rows.popleft()
# As _count_rows() does, inline on the per-row path.
rownumber = self._rownumber
Expand Down Expand Up @@ -230,8 +233,8 @@ async def _async_take_rows(
) -> list[tuple[Any | None, ...] | dict[Any, Any | None]]:
"""Take the next rows and count them in ``rownumber``.

The buffered rows are taken in bulk. When the buffer is empty, ``fetchone()``
takes the next row, so that it fetches the next page into the buffer.
The buffered rows are taken in bulk, and ``_async_fetch()`` refills the buffer
when it is empty.

Args:
size: The maximum number of rows, or None for all remaining rows.
Expand All @@ -241,13 +244,11 @@ async def _async_take_rows(
"""
rows: list[tuple[Any | None, ...] | dict[Any, Any | None]] = []
while size is None or len(rows) < size:
if self._rows:
self._take_buffered_rows(rows, size)
else:
row = await self.fetchone()
if row is None:
if not self._rows:
await self._async_fetch()
if not self._rows:
break
rows.append(row)
self._take_buffered_rows(rows, size)
return rows

@override
Expand Down
15 changes: 0 additions & 15 deletions pyathena/arrow/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,21 +216,6 @@ def _fetch(self) -> None:
processed_rows = list(zip(*columns, strict=False))
self._rows.extend(processed_rows)

@override
def fetchone(
self,
) -> tuple[Any | None, ...] | dict[Any, Any | None] | None:
if not self._rows:
self._fetch()
if not self._rows:
return None
row = self._rows.popleft()
# As _count_rows() does, inline on the per-row path.
rownumber = self._rownumber
if rownumber is not None:
self._rownumber = rownumber + 1
return row

def _open_csv(self, location: str) -> Any:
"""Open the CSV result file for ``pyarrow.csv.read_csv()``.

Expand Down
32 changes: 17 additions & 15 deletions pyathena/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,14 +459,18 @@ def _get_query_results(self, max_results: int, next_token: str | None = None) ->
return cast(dict[str, Any], response)

def _fetch(self) -> None:
"""Fetch the next page of rows into the result set.
"""Fetch the next rows, if any, into the buffer.

The default result set fetches the next page; it fetches nothing when no
page remains, as after ``close()``.

Raises:
ProgrammingError: If there is no next page.
ProgrammingError: If the next page is requested and the query ID is

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review (relayed), static only: FINDINGS

Reviewer: Codex CLI 0.162.1 (codex exec -s read-only), model gpt-6-astra, session 01a14031-fbf3-7883-941a-4fbeed65dd05. Scope: git diff 06224494788043a4887afcb112a96c94bbcb02f1..0100221287e8329c273967a3b1923c2ce9dce287 on a detached snapshot at 0100221. The prompt left out the PR description and the prior findings, and allowed no edits, builds, tests, GitHub, or network. The snapshot was unchanged afterwards.

Coverage: base, dict, and asyncio result sets (including the AthenaAioDictResultSet MRO) and all three fetch methods; the Arrow/S3FS refill; the pandas/Polars bulk overrides; the sync, futures-based, and aio cursor callers; request counts, failure accounting, rownumber, close and concurrent close, docstrings, and tests.

Findings:

  1. P2, pre-existing: pyathena/aio/result_set.py:158 and pyathena/result_set.py:475. If close() completes during a page request and the response carries another NextToken, the response restores that token. The cleared metadata keeps rows out of the buffer, but the next fetch requests again and raises ProgrammingError for the missing query ID. The reviewer confirmed that the base commit has the same defect.
    → Deferred as pre-existing and outside Share the buffer refill between fetchone() and the bulk fetches of the result sets #1193. The old fetchone() hit the same request through its _next_token check. A follow-up issue is proposed to the maintainer.
  2. P3, introduced: the Raises: ProgrammingError text on _fetch() / _async_fetch() said a closed result set raises, but close(); _fetch() returns without error because the token is cleared.
    → Verified and repaired in 6cd9b59 (reply below).

The reviewer found no other functional regression and judged that the tests exercise page/batch refills, exhaustion, request counts, and partial-consumption accounting.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repair of finding 2: 6cd9b59 (docstrings only, base and aio).

Raises: ProgrammingError now reads "If the next page is requested and the query ID is missing, the query has not succeeded, or the result set is closed."

  • Round-one perspective: no code changed. just lint passed, and tests/pyathena/test_result_set.py plus tests/pyathena/aio/test_result_set.py passed (27).
  • Round-two perspective: the claim matches the code. The error comes only from _build_get_query_results_request() or the closed check in _get_query_results() / _async_get_query_results(), and both run only when a _next_token remains.
  • Independent follow-up: Codex (gpt-6-astra, codex exec -s read-only, session 01a14038-971d-7491-896b-0f610f70ec86) reviewed git diff 0100221287e8329c273967a3b1923c2ce9dce287..6cd9b5962dcc93ac668eff417e137641aef5e0c4 on a detached snapshot and returned CLEAN: the repair resolves the finding, describes both methods accurately, and adds nothing new. This was a static review.

missing, the query has not succeeded, or the result set is closed.
OperationalError: If the request fails.
"""
if not self._next_token:
raise ProgrammingError("NextToken is none or empty.")
return
response = self._get_query_results(self._arraysize, self._next_token)
rows, self._next_token = self._parse_result_rows(response)
self._process_rows(rows)
Expand All @@ -490,10 +494,10 @@ def _pre_fetch(self) -> None:
def fetchone(
self,
) -> tuple[Any | None, ...] | dict[Any, Any | None] | None:
if not self._rows and self._next_token:
self._fetch()
if not self._rows:
return None
self._fetch()
if not self._rows:
return None
row = self._rows.popleft()
# As _count_rows() does, inline on the per-row path.
rownumber = self._rownumber
Expand Down Expand Up @@ -536,9 +540,9 @@ def fetchall(
def _take_rows(self, size: int | None) -> list[tuple[Any | None, ...] | dict[Any, Any | None]]:
"""Take the next rows and count them in ``rownumber``.

The buffered rows are taken in bulk. When the buffer is empty, ``fetchone()``
takes the next row, so that it fetches the next rows into the buffer as the
result set does.
The buffered rows are taken in bulk, and ``_fetch()`` refills the buffer when
it is empty. A subclass whose ``fetchone()`` does not use the buffer overrides

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round two (claims, compatibility, operations): FINDINGS, repaired in the PR description

Scope: git diff 06224494788043a4887afcb112a96c94bbcb02f1..0100221287e8329c273967a3b1923c2ce9dce287, full claim audit of the PR body, the commit messages, and the changed docstrings.

Claims checked:

  • "Rows, rownumber, and GetQueryResults requests stay the same": checked by the stubbed request counts in the base/aio across-pages tests, by the Arrow/S3FS refill tests, and by 74 live fetch tests at 63646b5. The source changes since then are docstrings only.
  • "No other PyAthena result set has a fetchone() that ignores the buffer": a grep of def fetchone under pyathena/ finds only base, aio, pandas, and Polars on result sets. Pandas and Polars both override _take_rows().
  • _fetch() docstrings: no page remains after close() because close() clears _next_token (pyathena/result_set.py:880). The "closed" ProgrammingError comes from _get_query_results() / _async_get_query_results().
  • No docs, comments, or tests still mention the removed "NextToken is none or empty." error or the old route through fetchone().

Existing callers: dbt-athena (dbt-labs/dbt-adapters, connections_legacy.py:157) builds AthenaResultSet directly and doesn't override fetchone() or call _fetch(). Its connections.py uses its own result set class.

AWS operator: no change to retries or request counts. An exhausted or closed result set now calls _fetch(), which returns before any request.

Corrections to the PR description:

  1. Added a 4.2.0 release-note candidate. Released 4.1.0 still looped fetchone() in fetchmany()/fetchall(), so a custom subclass that overrides only fetchone() must now also override _take_rows().
  2. "approved option 1 in Share the buffer refill between fetchone() and the bulk fetches of the result sets #1193" now reads "chose option 1". The decision was made with the maintainer, not recorded on the issue.
  3. Updated the TEST section: the tested commit, 354 unit tests after the round-one test removals, the commit the live tests ran at, and the benchmark environment.

Limits: the full just test pyathena / sqla / sqla-async suites run in the AWS CI once the PR is Ready. The micro-benchmark used stubbed pages on a laptop, not AWS traffic.

this method too.

Args:
size: The maximum number of rows, or None for all remaining rows.
Expand All @@ -548,13 +552,11 @@ def _take_rows(self, size: int | None) -> list[tuple[Any | None, ...] | dict[Any
"""
rows: list[tuple[Any | None, ...] | dict[Any, Any | None]] = []
while size is None or len(rows) < size:
if self._rows:
self._take_buffered_rows(rows, size)
else:
row = self.fetchone()
if row is None:
if not self._rows:
self._fetch()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round one (implementation behavior): FINDINGS, repaired

Scope: git diff 06224494788043a4887afcb112a96c94bbcb02f1..63646b5b78e2f7cea6565c7b5b49a923f614fd3c, full pass over all 8 files. It covered the base/aio _fetch(), fetchone(), and _take_rows(); the removed Arrow/S3FS fetchone(); every AthenaResultSet subclass (dict, aio dict through the MRO, pandas/Polars overrides); and the cursors that call these methods. It also ran /code-review (high) and /simplify (reuse, simplification, efficiency, altitude).

Verified unchanged: the rows, rownumber, and the GetQueryResults request count. A page that comes back empty still ends the fetch after one request, as fetchone() did before. After close(), _next_token is None and the buffer is empty, so no request is made. Arrow and S3FS _fetch() already returned without rows at the end, so they inherit the base fetchone() unchanged. A micro-benchmark showed no measurable change.

Repairs in 0100221:

  1. _fetch() / _async_fetch() docstrings: "fetches nothing when … the result set is closed" holds only when close() comes before the call, and "another thread" is the wrong model for asyncio. Both now say "when no page remains, as after close()" and "or the result set is closed", the wording _get_query_results() uses.
  2. Removed test_fetch_without_next_page (sync/aio). test_fetchone_across_pages covers the same path through the public API: its fourth fetchone() reaches _fetch() with no token, and call_count == 2 is asserted. Also dropped the duplicate stream.closed assertion in the S3FS refill test, which test_reader_closes_at_end_of_results already checks.

Deferred with reasons:

  • A subclass that overrides only fetchone() now needs _take_rows() too. This is the agreed private contract of Share the buffer refill between fetchone() and the bulk fetches of the result sets #1193 and is documented on _take_rows(); see round two for the release-note question.
  • Rows taken before a failed refill are counted and lost with the exception. This predates the PR: released 4.1.0 looped fetchone() with the same result, and the sync test is from Take buffered rows in bulk in fetchmany() and fetchall() #1187.
  • An empty refill (an empty GetQueryResults page with a NextToken, or an empty Arrow batch) ends the fetch. This is also pre-existing: the old fetchone() returned None there too.
  • The pandas/Polars fetchone()/_take_rows() duplicates predate the PR. Sharing them needs a class restructuring, which is out of scope.

if not self._rows:
break
rows.append(row)
self._take_buffered_rows(rows, size)
return rows

def _take_buffered_rows(
Expand Down
20 changes: 0 additions & 20 deletions pyathena/s3fs/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,26 +253,6 @@ def _fetch(self) -> None:
self._rows.append(converted_row)
rows_fetched += 1

@override
def fetchone(
self,
) -> tuple[Any | None, ...] | dict[Any, Any | None] | None:
"""Fetch the next row of the result set.

Returns:
A tuple representing the next row, or None if no more rows.
"""
if not self._rows:
self._fetch()
if not self._rows:
return None
row = self._rows.popleft()
# As _count_rows() does, inline on the per-row path.
rownumber = self._rownumber
if rownumber is not None:
self._rownumber = rownumber + 1
return row

@override
def close(self) -> None:
"""Close the result set and release resources."""
Expand Down
30 changes: 28 additions & 2 deletions tests/pyathena/aio/test_result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from pyathena.aio.result_set import AthenaAioDictResultSet, AthenaAioResultSet
from pyathena.converter import DefaultTypeConverter
from pyathena.error import OperationalError
from pyathena.model import AthenaQueryExecution
from pyathena.util import RetryConfig
from tests.pyathena.util import get_query_results_responses
Expand All @@ -20,8 +21,9 @@ async def _create_result_set(result_set_class, pages=(("1", "2"),)):

Args:
result_set_class: The asyncio result set class to create.
pages: The values of an integer column ``a``, one tuple per page. The first
page is fetched at creation, the others on demand.
pages: The values of an integer column ``a``, one tuple per page, or an
exception that the request for the page raises. The first page is fetched
at creation, the others on demand.

Returns:
The result set.
Expand Down Expand Up @@ -90,3 +92,27 @@ async def test_fetchmany_size_not_integer(self):
with pytest.raises(TypeError):
await result_set.fetchmany(1.5)
assert await result_set.fetchall() == [(1,), (2,)]

async def test_fetchone_across_pages(self):
result_set = await _create_result_set(AthenaAioResultSet, [("1", "2"), ("3",)])
assert [await result_set.fetchone() for _ in range(4)] == [(1,), (2,), (3,), None]
assert result_set.rownumber == 3
assert result_set.connection.client.get_query_results.call_count == 2

@pytest.mark.parametrize(("method", "args"), [("fetchmany", (3,)), ("fetchall", ())])
async def test_fetch_counts_rows_taken_before_error(self, method, args):
"""The rows taken before a failed page request are counted in rownumber."""
result_set = await _create_result_set(
AthenaAioResultSet, [("1", "2"), RuntimeError("failed")]
)
with pytest.raises(OperationalError):
await getattr(result_set, method)(*args)
assert result_set.rownumber == 2

async def test_fetch_after_close(self):
result_set = await _create_result_set(AthenaAioResultSet, [("1", "2"), ("3",)])
result_set.close()
assert await result_set.fetchone() is None
assert await result_set.fetchmany() == []
assert await result_set.fetchall() == []
assert result_set.rownumber is None
29 changes: 29 additions & 0 deletions tests/pyathena/arrow/test_result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,35 @@ def test_fetch_after_close(self):
assert result_set.fetchmany() == []
assert result_set.fetchall() == []

def test_fetch_across_batches(self):
"""The buffer is refilled one record batch at a time.

No AWS calls; the first page request and the result table are mocked.
"""
with (
patch.object(AthenaArrowResultSet, "_pre_fetch"),
patch.object(AthenaArrowResultSet, "_create_s3_file_system"),
patch.object(
AthenaArrowResultSet, "_as_arrow", return_value=pa.table({"a": [1, 2, 3]})
),
):
result_set = AthenaArrowResultSet(
connection=MagicMock(),
converter=DefaultArrowTypeConverter(),
query_execution=MagicMock(
state=AthenaQueryExecution.STATE_SUCCEEDED,
output_location="s3://bucket/query.csv",
),
arraysize=1,
retry_config=RetryConfig(),
)
assert result_set.fetchmany(2) == [(1,), (2,)]
assert result_set.fetchone() == (3,)
assert result_set.fetchmany() == []
assert result_set.fetchall() == []
assert result_set.fetchone() is None
assert result_set.rownumber == 3

def test_read_csv_timestamps_with_the_same_name(self):
"""Timestamp columns are converted by position, also with names that repeat.

Expand Down
11 changes: 11 additions & 0 deletions tests/pyathena/s3fs/test_result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,17 @@ def test_reader_closes_at_end_of_results(self):
assert result_set.fetchone() is None
result_set.close()

def test_fetch_across_batches(self):
"""The buffer is refilled one batch of arraysize rows at a time."""
result_set, _ = _result_set("null_col,empty_col,value_col\n,,a\n,,b\n,,c\n", None)
assert result_set.fetchmany(2) == [(None, None, "a"), (None, None, "b")]
assert result_set.fetchone() == (None, None, "c")
assert result_set.fetchmany() == []
assert result_set.fetchall() == []
assert result_set.fetchone() is None
assert result_set.rownumber == 3
result_set.close()

def test_reader_failure_closes_file(self):
"""A failure to read the header row closes the file."""
data = "null_col,empty_col,value_col\n"
Expand Down
30 changes: 4 additions & 26 deletions tests/pyathena/test_result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,27 +54,6 @@ def _fetching_result_set(*pages):
)


class _FetchoneResultSet(AthenaResultSet):
"""A result set whose ``fetchone()`` takes rows from an iterator, not the buffer."""

def __init__(self, rows):
"""Initialize the result set with its rows, skipping the base initialization.

Args:
rows: The rows that ``fetchone()`` returns.
"""
self._rows = collections.deque()
self._rownumber = 0
self._row_iter = iter(rows)

@override
def fetchone(self):
row = next(self._row_iter, None)
if row is not None:
self._rownumber += 1
return row


class _ClosingDeque(collections.deque):
"""A buffer that closes its result set when it is read, as another thread may."""

Expand Down Expand Up @@ -158,12 +137,11 @@ def test_fetch_with_close_during_take(self, method, args, expected):
assert getattr(result_set, method)(*args) == expected
assert result_set.rownumber is None

def test_fetch_with_fetchone_override(self):
"""A subclass whose fetchone() does not use the buffer has its rows fetched."""
result_set = _FetchoneResultSet([(1,), (2,), (3,)])
assert result_set.fetchmany(2) == [(1,), (2,)]
assert result_set.fetchall() == [(3,)]
def test_fetchone_across_pages(self):
result_set = _fetching_result_set(("1", "2"), ("3",))
assert [result_set.fetchone() for _ in range(4)] == [(1,), (2,), (3,), None]
assert result_set.rownumber == 3
assert result_set.connection.client.get_query_results.call_count == 2

def test_fetch_all_rows_skips_column_labels_only_on_first_page(self):
"""A later page can start with a data row equal to the column labels.
Expand Down
Loading