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
35 changes: 19 additions & 16 deletions pyathena/arrow/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,24 +197,27 @@ def converters(self) -> dict[str, Callable[[str | None], Any | None]]:

@override
def _fetch(self) -> None:
try:
rows = next(self._batches)
except StopIteration:
"""Buffer the rows of the next record batch that has any.

A table can hold empty record batches between others, such as one of
an empty result file, which would otherwise end the fetches early.
"""
rows = next((batch for batch in self._batches if batch.num_rows), None)

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 1 (behavior, failure paths, simplicity, tests). Base b2d7aa24b00a204e9e150952c708e3bb1126f9e2, head 9809e3312b116d50e0cb98d177b848e32de99c8f. Inputs: my pass and /code-review high on the earlier head 6b8612c3 (7 findings). Result: FINDINGS, resolved by narrowing.

  • The /code-review findings on the rows returned to the buffer (an interrupt between taking and counting rows, a concurrent close()) and on the token reordering (Arrow/S3FS not covered, a deterministic conversion error repeating its request, a test checking only the private token) were verified from the source. The maintainer chose, after a consultation with Codex and claude-fable-5-1 that disagreed (unspecified vs. retryable), to leave the behavior after a fetch error unspecified here and to decide one contract for all result sets in Define the behavior of result set fetches after an error #1225; both changes are removed (58ced3e, 9809e33).
  • The finding that test comments cited an unrelated issue (Free failed part bodies without the GC and abort an async copy's upload created during loop shutdown #1186) is resolved: the remaining test cites none.
  • The empty-batch fix: _batches is an iterator, so the generator expression resumes after the batches it consumed; close() replaces it with an empty iterator. The test asserts that the table has an empty batch before each case (pyarrow's to_batches() yields none after the last rows, so leading, middle, and consecutive cases are covered) and fails on master in all 12 cases.

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 Codex result). Codex CLI 0.162.1, model gpt-6-astra, codex exec -s read-only, session 01a14709-346e-7690-9844-1f0d8d2bbe66. Static review of a clean detached snapshot of 9809e3312b116d50e0cb98d177b848e32de99c8f against base b2d7aa24b00a204e9e150952c708e3bb1126f9e2, prompt without PR text or prior findings; no edits, builds, tests, or network. Covered: "Arrow fetch methods and iteration, including empty batches, exhaustion, conversion, close(), rownumber, and arraysize. Base, pandas, Polars, aio, and both async Arrow cursor paths; very large and invalid fetch sizes. Regression tests, helper replacement, and changed comments/docstrings." Result: CLEAN — "The 12 new cases explicitly check that empty batches exist and would expose the original early stop. Their setup has no timing or randomness dependency. The clamp relocation preserves size validation and keeps islice() arguments within bounds." Snapshot unchanged afterwards.

if rows is None:
return
# Read the columns and their converters by position; to_pydict() and the
# converters property keep one column per name.
columns = [column.to_pylist() for column in rows.columns]
description = self.description if self.description else []
converters = [self._converter.get(d[1]) for d in description]
if any(convert is not _to_default for convert in converters):
processed_rows = [
tuple(convert(v) for convert, v in zip(converters, row, strict=False))
for row in zip(*columns, strict=False)
]
else:
# Read the columns and their converters by position; to_pydict() and the
# converters property keep one column per name.
columns = [column.to_pylist() for column in rows.columns]
description = self.description if self.description else []
converters = [self._converter.get(d[1]) for d in description]
if any(convert is not _to_default for convert in converters):
processed_rows = [
tuple(convert(v) for convert, v in zip(converters, row, strict=False))
for row in zip(*columns, strict=False)
]
else:
processed_rows = list(zip(*columns, strict=False))
self._rows.extend(processed_rows)
processed_rows = list(zip(*columns, strict=False))
self._rows.extend(processed_rows)

def _open_csv(self, location: str) -> Any:
"""Open the CSV result file for ``pyarrow.csv.read_csv()``.
Expand Down
6 changes: 4 additions & 2 deletions pyathena/pandas/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import csv
import importlib
import logging
import sys
import threading
from collections import abc
from collections.abc import Callable, Iterable, Iterator
Expand Down Expand Up @@ -992,8 +993,9 @@ def _take_rows(self, size: int | None) -> list[tuple[Any | None, ...] | dict[Any
"""
rows: list[tuple[Any | None, ...] | dict[Any, Any | None]] = []
try:
# extend() keeps the rows that it appended before an exception.
rows.extend(islice(self._row_iter, size))
# extend() keeps the rows that it appended before an exception, and
# islice() takes no size above sys.maxsize.

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 2 (claims, callers, docs, evidence). Base b2d7aa24b00a204e9e150952c708e3bb1126f9e2, head 9809e3312b116d50e0cb98d177b848e32de99c8f. Result: CLEAN.

  • Claim that only the islice-based result sets need the clamp: the base _take_buffered_rows() calls islice() only when the limit is below the buffer length, and the aio fetchmany() never clamped; the existing test_fetchmany_size_above_maxsize of the base, pandas, and Polars result sets pass.
  • Claim that the Arrow defect is older than Take buffered rows in bulk in fetchmany() and fetchall() #1187: the parent of Take buffered rows in bulk in fetchmany() and fetchall() #1187 also returned None from fetchone() at an empty batch and built the bulk fetches on it.
  • Callers: ArrowCursor, AsyncArrowCursor, and AioArrowCursor use AthenaArrowResultSet; no API changes. No documentation describes the fetch of empty batches. Not run: the AWS cursor suites, which run when the PR is Ready.

rows.extend(islice(self._row_iter, None if size is None else min(size, sys.maxsize)))
finally:
if rows:
self._count_rows(len(rows))
Expand Down
6 changes: 4 additions & 2 deletions pyathena/polars/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import csv
import importlib.util
import logging
import sys
import threading
from collections import abc
from collections.abc import Callable, Iterable, Iterator
Expand Down Expand Up @@ -647,8 +648,9 @@ def _take_rows(self, size: int | None) -> list[tuple[Any | None, ...] | dict[Any
"""
rows: list[tuple[Any | None, ...] | dict[Any, Any | None]] = []
try:
# extend() keeps the rows that it appended before an exception.
rows.extend(islice(self._row_iter, size))
# extend() keeps the rows that it appended before an exception, and
# islice() takes no size above sys.maxsize.
rows.extend(islice(self._row_iter, None if size is None else min(size, sys.maxsize)))
finally:
if rows:
self._count_rows(len(rows))
Expand Down
4 changes: 1 addition & 3 deletions pyathena/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import collections
import logging
import operator
import sys
from datetime import datetime
from itertools import islice
from typing import (
Expand Down Expand Up @@ -529,8 +528,7 @@ def fetchmany(
"""
if not size or size <= 0:
size = self._arraysize
# islice() in the subclasses' _take_rows() takes no size above sys.maxsize.
return self._take_rows(min(operator.index(size), sys.maxsize))
return self._take_rows(operator.index(size))

@override
def fetchall(
Expand Down
47 changes: 47 additions & 0 deletions tests/pyathena/arrow/test_result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,53 @@ def test_fetch_across_batches(self):
assert result_set.fetchone() is None
assert result_set.rownumber == 3

@pytest.mark.parametrize("method", ["fetchone", "fetchmany", "fetchall", "iter"])
@pytest.mark.parametrize(
("chunks", "expected"),
[
([[1, 2, 3], [], [4]], [1, 2, 3, 4]),
([[], [1, 2]], [1, 2]),
([[1], [], [], [2]], [1, 2]),
],
ids=["middle", "leading", "consecutive"],
)
def test_fetch_across_empty_batches(self, method, chunks, expected):
"""Empty record batches before or between others do not end the fetches.

A table read from result files that include an empty one has such
batches; the fetches used to stop at the first one. to_batches() yields
no empty batch after the last rows. No AWS calls; the first page
request and the result table are mocked.
"""
table = pa.concat_tables([pa.table({"a": pa.array(chunk, pa.int64())}) for chunk in chunks])
with (
patch.object(AthenaArrowResultSet, "_pre_fetch"),
patch.object(AthenaArrowResultSet, "_create_s3_file_system"),
patch.object(AthenaArrowResultSet, "_as_arrow", return_value=table),
):
result_set = AthenaArrowResultSet(
connection=MagicMock(),
converter=DefaultArrowTypeConverter(),
query_execution=MagicMock(
state=AthenaQueryExecution.STATE_SUCCEEDED,
output_location="s3://bucket/query.csv",
),
arraysize=2,
retry_config=RetryConfig(),
)
assert 0 in [batch.num_rows for batch in table.to_batches(2)]
if method == "fetchone":
rows = list(iter(result_set.fetchone, None))
elif method == "fetchmany":
rows = result_set.fetchmany(10)
elif method == "fetchall":
rows = result_set.fetchall()
else:
rows = list(result_set)
assert rows == [(value,) for value in expected]
assert result_set.rownumber == len(expected)
assert result_set.fetchone() is None

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

Expand Down
9 changes: 1 addition & 8 deletions tests/pyathena/test_result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,6 @@
from tests.pyathena.util import close_during_next_request, get_query_results_responses


def _page(values, next_token=None):
response = {"ResultSet": {"Rows": [{"Data": [{"VarCharValue": v}]} for v in values]}}
if next_token:
response["NextToken"] = next_token
return response


def _row(*values):
return {"Data": [{} if v is None else {"VarCharValue": v} for v in values]}

Expand Down Expand Up @@ -180,7 +173,7 @@ def test_fetch_all_rows_skips_column_labels_only_on_first_page(self):
result_set._process_metadata(
{"ResultSet": {"ResultSetMetadata": {"ColumnInfo": [{"Name": "a", "Type": "varchar"}]}}}
)
pages = [_page(["a", "1"], "token"), _page(["a", "2"])]
pages = get_query_results_responses(["a", "1"], ["a", "2"])
with patch.object(result_set, "_get_query_results", side_effect=pages):
assert result_set._fetch_all_rows() == [("1",), ("a",), ("2",)]

Expand Down
Loading