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
57 changes: 47 additions & 10 deletions pyathena/pandas/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
import logging
import operator
import sys
import threading
from collections import abc
from collections.abc import Callable, Iterable, Iterator
from concurrent.futures import ThreadPoolExecutor
from contextlib import ExitStack
from contextlib import ExitStack, contextmanager
from functools import partial
from io import BufferedReader, BytesIO, IOBase, StringIO, TextIOWrapper
from itertools import chain, islice
Expand Down Expand Up @@ -336,6 +337,31 @@ def __init__(
self._reader = reader
self._trunc_date = trunc_date
self._csv_stream = csv_stream
self._lock = threading.Lock()
self._readers = 0
self._close_requested = False

@contextmanager
def _read(self) -> Iterator[TextFileReader | Iterator[DataFrame]]:

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: behavior and implementation (FINDINGS, repaired)

  • Initial pass: 301ae52f6ff9be1db33be61f16282a8edb3066ff..6c60ea60a67d1213bee3ffdb4805c87eebd66909, then a rebase onto 38dc8f860ece434b36779f0da0291d505f4e42a1 (Return PolarsCursor rows as tuples without a dictionary per row聽#1177 import conflicts only). Current head: 0c5268f1.
  • Covered: PandasDataFrameIterator and PolarsDataFrameIterator read and close paths (__next__, get_chunk, itertuples/iterrows/as_pandas/as_polars through __next__); both result sets' close(); the aio fetch (asyncio.to_thread) and close (close_in_thread) paths in pyathena/aio/common.py; the new tests.
  • Method: a manual pass, /code-review (high), and /simplify (reuse, simplification, efficiency, altitude). A Codex read-only consultation covered the design choice between a deferred close and a lock held around each read.
  • Findings and repairs are in the threads on the related lines. Every self._reader access now goes through _read() or close(). _read() runs once per chunk, not per row: about 1 碌s plus two uncontended lock round trips.

"""Track a chunk read and run a close that was requested during it.

Yields:
The reader to read the chunk from.
"""
with self._lock:
self._readers += 1
reader = iter(()) if self._close_requested else self._reader

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 finding (repaired in b8be349): in the initial head, a deferred close() left self._reader in place. A read that started after close() had returned still received the live generator, and raised ValueError: generator already executing while another thread was reading it. For a TextFileReader, it would read the same parser concurrently. _read() now yields iter(()) once a close has been requested; the same change is in pyathena/polars/result_set.py:116. The tests now call next() during the deferred close and expect StopIteration.

try:
yield reader
finally:
with self._lock:
self._readers -= 1
close = self._close_requested and not self._readers
if close:
try:
self.close()
except Exception:
_logger.exception("Failed to close the chunk reader after a read.")

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 finding (repaired in b8be349): the deferred close runs in the reading thread's finally. An error from reader.close() or csv_stream.close() replaced the chunk that the read had already returned, and no caller of close() waited for that error. The error is now logged; the same change is in pyathena/polars/result_set.py:127. test_close_during_read[...-close_error1] covers it.


@override
def __next__(self) -> DataFrame:
Expand All @@ -348,7 +374,8 @@ def __next__(self) -> DataFrame:
StopIteration: When no more chunks are available.
"""
try:
df = next(self._reader)
with self._read() as reader:
df = next(reader)
return self._trunc_date(df)
except BaseException:
self.close()
Expand All @@ -368,17 +395,27 @@ def __exit__(self, exc_type, exc_value, traceback) -> None:
self.close()

def close(self) -> None:
"""Close the iterator and release resources."""
"""Close the iterator and release resources.

If another thread is reading a chunk, the read returns that chunk, and the

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, callers, and evidence (FINDINGS, repaired in the PR description)

Full pass over 38dc8f860ece434b36779f0da0291d505f4e42a1..0c5268f1bcb72c11c02854c73935f13fad7e4b13.

Claims checked:

  • Generator readers. Pandas chunked UNLOAD uses _iter_parquet_chunks(). Polars chunked CSV and UNLOAD use _iter_csv_chunks() and _iter_parquet_chunks(), selected in _create_dataframe_iterator(). Pandas chunked CSV uses a TextFileReader with the CSV stream.
  • The ValueError and partial close. Reproduced on 301ae52f with the issue's script. Both result sets' close() call super().close() before self._df_iter.close().
  • The aio path. Traced in code, not reproduced against Athena. Fetches use asyncio.to_thread(result_set.fetchone/fetchmany/fetchall) (pyathena/aio/common.py), and __aexit__ uses close_in_thread(self.close).
  • The rejected lock design. Cursor close() skips a result set that is already closed (pyathena/result_set.py:1264). fetchall() takes rows continuously through islice(self._row_iter, size). Python documents no fairness for threading.Lock.
  • The docstrings. They state the deferred close, empty reads after it, and the logged error. The tests assert each of these.

Callers:

  • With no read in progress, close() behaves as before.
  • Readers that are not generators (iter([df])) are unaffected.
  • The docs have no statement on closing during a read (searched docs/).

Finding: the initial PR description did not mention that reads after a deferred close get no chunks, or that deferred close errors are logged. It also reported tests from before the round-one repairs. The description is updated.

reader and the CSV stream are closed when it completes. Reads that start after the
close get no chunks, and an error from the deferred close is logged.
"""
from pandas.io.parsers import TextFileReader

reader = self._reader
self._reader = iter(())
with self._lock:
if self._readers:

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 reasoned deferrals

  • The count is incremented before the try in _read(). An asynchronous exception in that window leaves the count stuck. The window is a lock release and the try setup, as in any with lock: followed by try:, so no change.
  • For a chunked CSV TextFileReader, close now waits for the running chunk instead of closing the stream under it. pandas' C tokenizer can run without the GIL, so closing it concurrently is unsafe. The cost is bounded by one chunk.
  • AthenaPolarsResultSet.close() clears the query execution before the iterator is closed. That ordering is the same on 301ae52f, so a generator still in its first read can observe it either way. It is pre-existing and not changed here.
  • A lock held around each read, with a blocking close(), was rejected. Python locks are not fair, so fetchall() can take the lock again for the next chunk before the closer. An interrupted wait in close() would also leave the reader unclosed after the result set is already marked closed (pyathena/result_set.py:1264).
  • The _read()/close() protocol is duplicated in the two iterator classes. Sharing it needs a new shared helper or base, which is a design change for the maintainer to approve.

self._close_requested = True
return
self._close_requested = False
reader, self._reader = self._reader, iter(())
csv_stream, self._csv_stream = self._csv_stream, None
try:
if isinstance(reader, (TextFileReader, GeneratorType)):
reader.close()
finally:
if self._csv_stream is not None:
self._csv_stream.close()
if csv_stream is not None:
csv_stream.close()

def iterrows(self) -> Iterator[tuple[int, dict[str, Any]]]:
"""Iterate over rows as (index, row_dict) tuples.
Expand Down Expand Up @@ -465,9 +502,9 @@ def get_chunk(self, size: int | None = None) -> DataFrame:
from pandas.io.parsers import TextFileReader

try:
if isinstance(self._reader, TextFileReader):
return self._trunc_date(self._reader.get_chunk(size))
return self._trunc_date(next(self._reader))
with self._read() as reader:
df = reader.get_chunk(size) if isinstance(reader, TextFileReader) else next(reader)
return self._trunc_date(df)
except BaseException:
self.close()
raise
Expand Down
45 changes: 41 additions & 4 deletions pyathena/polars/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
import logging
import operator
import sys
import threading
from collections import abc
from collections.abc import Callable, Iterable, Iterator
from contextlib import contextmanager
from io import BytesIO, StringIO
from itertools import islice, repeat
from multiprocessing import cpu_count
Expand Down Expand Up @@ -98,6 +100,31 @@ def __init__(
self._reader = reader
self._converters = converters
self._column_names = column_names
self._lock = threading.Lock()
self._readers = 0
self._close_requested = False

@contextmanager
def _read(self) -> Iterator[Iterator[pl.DataFrame]]:
"""Track a chunk read and run a close that was requested during it.

Yields:
The reader to read the chunk from.
"""
with self._lock:
self._readers += 1
reader = iter(()) if self._close_requested else self._reader

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 result from Codex)

  • Reviewer: Codex CLI (codex exec -s read-only), a different model from the author. Session 01a13c89-9d67-72d0-a83b-80aea2fb93d5. Static review only: no tests or builds were run.
  • Scope: 38dc8f860ece434b36779f0da0291d505f4e42a1..0c5268f1bcb72c11c02854c73935f13fad7e4b13. The source was a detached snapshot at the head. The prompt contained no PR number, description, commit messages, or prior findings. The snapshot and the PR worktree were unchanged afterwards.
  • Covered: all four changed files, read/close interleavings including free-threaded Python, CSV and UNLOAD resource ownership, exception paths, sync and asyncio callers, fetch semantics, compatibility, tests, and conventions.
  • Verdict: FINDINGS. There is one pre-existing gap and no regression introduced by the diff.
    • P2, pre-existing (pyathena/polars/result_set.py:1064, :933): AthenaPolarsResultSet.close() clears the query execution in super().close(). If the first chunk read is still in _is_csv_readable()'s head_object when the cursor closes, the generator then sees output_location is None and raises ProgrammingError instead of returning its chunk. The race exists at the merge-base.
    • The reviewer found that the tests catch the original failure and assert the returned data, the eventual close, exhaustion afterwards, and the logged close error. Their synthetic generators bypass the Polars generator's own preamble, and no test exercises an active pandas TextFileReader.
  • Author verification: confirmed in the code. _iter_csv_chunks() checks self.output_location after _is_csv_readable(), and close() calls super().close() first; the same ordering is on 301ae52f. Scope decision pending with the maintainer.

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.

Scope decision (maintainer): the pre-existing P2 is split into #1188 and not fixed in this PR. The test-coverage limits (synthetic generators; no active TextFileReader test) stay as recorded in the PR description. No change to the reviewed diff, so no follow-up review is needed.

try:
yield reader
finally:
with self._lock:
self._readers -= 1
close = self._close_requested and not self._readers
if close:
try:
self.close()
except Exception:
_logger.exception("Failed to close the chunk reader after a read.")

@override
def __next__(self) -> pl.DataFrame:
Expand All @@ -110,7 +137,8 @@ def __next__(self) -> pl.DataFrame:
StopIteration: When no more chunks are available.
"""
try:
return next(self._reader)
with self._read() as reader:
return next(reader)
except StopIteration:
self.close()
raise
Expand All @@ -129,9 +157,18 @@ def __exit__(self, exc_type, exc_value, traceback) -> None:
self.close()

def close(self) -> None:
"""Close the iterator and release resources."""
reader = self._reader
self._reader = iter(())
"""Close the iterator and release resources.

If another thread is reading a chunk, the read returns that chunk, and the
reader is closed when it completes. Reads that start after the
close get no chunks, and an error from the deferred close is logged.
"""
with self._lock:
if self._readers:
self._close_requested = True
return
self._close_requested = False
reader, self._reader = self._reader, iter(())
if isinstance(reader, GeneratorType):
reader.close()

Expand Down
62 changes: 62 additions & 0 deletions tests/pyathena/pandas/test_result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# SPDX-License-Identifier: MIT

import csv
import inspect
import io
import threading
from collections import defaultdict
Expand Down Expand Up @@ -140,6 +141,46 @@ def test_as_pandas_single_dataframe(self):

assert df_iter.as_pandas() is df

@pytest.mark.parametrize(
"read",
[PandasDataFrameIterator.__next__, PandasDataFrameIterator.get_chunk],
ids=["next", "get_chunk"],
)
@pytest.mark.parametrize("close_error", [None, RuntimeError("close failed")])
def test_close_during_read(self, caplog, read, close_error):

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 test findings (repaired)

"""A close during a read in another thread is deferred until the read completes."""
reading, release = threading.Event(), threading.Event()

def chunks():
try:
yield pd.DataFrame({"n": [1]})
reading.set()
release.wait(10)
yield pd.DataFrame({"n": [2]})
yield pd.DataFrame({"n": [3]})
finally:
if close_error:
raise close_error

csv_stream = io.StringIO()
reader = chunks()
df_iter = PandasDataFrameIterator(reader, _no_trunc_date, csv_stream)
next(df_iter)
with ThreadPoolExecutor(1) as executor:
future = executor.submit(read, df_iter)
assert reading.wait(10)
df_iter.close()
with pytest.raises(StopIteration):
next(df_iter)
assert inspect.getgeneratorstate(reader) == inspect.GEN_RUNNING
assert not csv_stream.closed
release.set()
assert future.result(10)["n"].tolist() == [2]
assert inspect.getgeneratorstate(reader) == inspect.GEN_CLOSED
assert ("Failed to close the chunk reader" in caplog.text) == bool(close_error)
assert csv_stream.closed
assert list(df_iter) == []


_FS = sentinel.pyathena_fs
_USER_FS = sentinel.user_fs
Expand Down Expand Up @@ -281,6 +322,27 @@ def __next__(self):
assert future.result(10) == [(0,), (1,), (2,), (3,)]
assert result_set.rownumber is None

def test_close_during_fetchall_generator(self):
"""A close() during a fetch from a generator, such as a chunked UNLOAD, does not raise."""
reading, release = threading.Event(), threading.Event()

def chunks():
yield pd.DataFrame({"n": [0, 1]})
reading.set()
release.wait(10)
yield pd.DataFrame({"n": [2, 3]}, index=[2, 3])
yield pd.DataFrame({"n": [4]}, index=[4])

reader = chunks()
result_set = _fetching_result_set(reader, arraysize=3)
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(result_set.fetchall)
assert reading.wait(10)
result_set.close()
release.set()
assert future.result(10) == [(0,), (1,), (2,), (3,)]
assert inspect.getgeneratorstate(reader) == inspect.GEN_CLOSED

def test_fetchmany_size_above_maxsize(self):
result_set = _fetching_result_set(pd.DataFrame({"n": [1, 2]}), arraysize=3)
assert result_set.fetchmany(10**100) == [(1,), (2,)]
Expand Down
55 changes: 54 additions & 1 deletion tests/pyathena/polars/test_result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#
# SPDX-License-Identifier: MIT

import inspect
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, date, datetime, time, timedelta
Expand Down Expand Up @@ -161,7 +162,6 @@ def test_close_during_fetchall(self):
requested = threading.Event()
closed = threading.Event()

# Not a generator, which close() cannot close while it runs (#1172).
class Chunks:
def __init__(self):
self._chunks = iter([pl.DataFrame({"n": [0, 1]}), pl.DataFrame({"n": [2, 3]})])
Expand All @@ -187,6 +187,27 @@ def __next__(self):
assert future.result(10) == [(0,), (1,), (2,), (3,)]
assert result_set.rownumber is None

def test_close_during_fetchall_generator(self):

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 evidence

  • Local unit tests at 0c5268f1bcb72c11c02854c73935f13fad7e4b13: tests/pyathena/pandas/test_result_set.py tests/pyathena/polars/test_result_set.py: 295 passed, 10 skipped.
  • The new tests fail on the reverted source with the issue's ValueError.
  • The AWS run of tests/pyathena/pandas tests/pyathena/polars tests/pyathena/aio/pandas tests/pyathena/aio/polars at the pre-rebase head 6c60ea60 passed: 728 passed, 10 skipped. The run at the current head, serialized with -n 1 because another session's AWS run overlaps, will be recorded in this thread.
  • Static only: aio cancellation against Athena, and TextFileReader deferral.

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.

AWS run at 0c5268f1: uv run --env-file .env pytest -q -p no:cacheprovider -n 4 tests/pyathena/pandas tests/pyathena/polars tests/pyathena/aio/pandas tests/pyathena/aio/polars: 778 passed, 10 skipped. The serialized -n 1 run was stopped at 18% after the overlapping run ended, and replaced by this run.

"""A close() during a fetch from a generator, such as a chunked UNLOAD, does not raise."""
reading, release = threading.Event(), threading.Event()

def chunks():
yield pl.DataFrame({"n": [0, 1]})
reading.set()
release.wait(10)
yield pl.DataFrame({"n": [2, 3]})
yield pl.DataFrame({"n": [4]})

reader = chunks()
result_set = _fetching_result_set(reader, ["n"], arraysize=3)
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(result_set.fetchall)
assert reading.wait(10)
result_set.close()
release.set()
assert future.result(10) == [(0,), (1,), (2,), (3,)]
assert inspect.getgeneratorstate(reader) == inspect.GEN_CLOSED

def test_fetchmany_size_above_maxsize(self):
"""A size above sys.maxsize returns the remaining rows."""
result_set = _fetching_result_set([pl.DataFrame({"n": [1, 2]})], ["n"], arraysize=3)
Expand Down Expand Up @@ -629,3 +650,35 @@ def test_close_stops_iteration(self, reader):
df_iter = PolarsDataFrameIterator(reader, {}, ["a"])
df_iter.close()
assert list(df_iter) == []

@pytest.mark.parametrize("close_error", [None, RuntimeError("close failed")])
def test_close_during_read(self, caplog, close_error):
"""A close during a read in another thread is deferred until the read completes."""
reading, release = threading.Event(), threading.Event()

def chunks():
try:
yield pl.DataFrame({"a": [1]})
reading.set()
release.wait(10)
yield pl.DataFrame({"a": [2]})
yield pl.DataFrame({"a": [3]})
finally:
if close_error:
raise close_error

reader = chunks()
df_iter = PolarsDataFrameIterator(reader, {}, ["a"])
next(df_iter)
with ThreadPoolExecutor(1) as executor:
future = executor.submit(next, df_iter)
assert reading.wait(10)
df_iter.close()
with pytest.raises(StopIteration):
next(df_iter)
assert inspect.getgeneratorstate(reader) == inspect.GEN_RUNNING
release.set()
assert future.result(10)["a"].to_list() == [2]
assert inspect.getgeneratorstate(reader) == inspect.GEN_CLOSED
assert ("Failed to close the chunk reader" in caplog.text) == bool(close_error)
assert list(df_iter) == []
Loading