Repository navigation
Defer closing a chunk reader while another thread reads it - #1182
laughingman7743 wants to merge 5 commits into
Conversation
PandasDataFrameIterator and PolarsDataFrameIterator closed their chunk reader directly, so closing a cursor while another thread read the next chunk raised "ValueError: generator already executing" for a generator reader and left the result set partly closed. AioPandasCursor and AioPolarsCursor do this themselves when a cancelled fetch keeps reading in its thread and the cursor is closed. The iterators now count the reads in progress. A close during a read records the request, and the reading thread closes the reader, and the CSV stream for pandas, when its read completes. Fixes #1172 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A read that starts after a close was deferred now gets an empty reader instead of the reader that another thread is still reading. An error from the deferred close, which no caller waits for, is logged instead of replacing the chunk that the reading thread has read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also test that closing a pandas result set during a fetch from a generator reader does not raise and closes the generator once the fetch completes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fb4e113 to
0c5268f
Compare
| self._close_requested = False | ||
|
|
||
| @contextmanager | ||
| def _read(self) -> Iterator[TextFileReader | Iterator[DataFrame]]: |
There was a problem hiding this comment.
Self-review round one: behavior and implementation (FINDINGS, repaired)
- Initial pass:
301ae52f6ff9be1db33be61f16282a8edb3066ff..6c60ea60a67d1213bee3ffdb4805c87eebd66909, then a rebase onto38dc8f860ece434b36779f0da0291d505f4e42a1(Return PolarsCursor rows as tuples without a dictionary per row #1177 import conflicts only). Current head:0c5268f1. - Covered:
PandasDataFrameIteratorandPolarsDataFrameIteratorread and close paths (__next__,get_chunk,itertuples/iterrows/as_pandas/as_polarsthrough__next__); both result sets'close(); the aio fetch (asyncio.to_thread) and close (close_in_thread) paths inpyathena/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._readeraccess now goes through_read()orclose()._read()runs once per chunk, not per row: about 1 µs plus two uncontended lock round trips.
| """ | ||
| with self._lock: | ||
| self._readers += 1 | ||
| reader = iter(()) if self._close_requested else self._reader |
There was a problem hiding this comment.
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: | ||
| self.close() | ||
| except Exception: | ||
| _logger.exception("Failed to close the chunk reader after a read.") |
There was a problem hiding this comment.
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.
| reader = self._reader | ||
| self._reader = iter(()) | ||
| with self._lock: | ||
| if self._readers: |
There was a problem hiding this comment.
Self-review round one reasoned deferrals
- The count is incremented before the
tryin_read(). An asynchronous exception in that window leaves the count stuck. The window is a lock release and thetrysetup, as in anywith lock:followed bytry:, 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 on301ae52f, 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, sofetchall()can take the lock again for the next chunk before the closer. An interrupted wait inclose()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.
| ids=["next", "get_chunk"], | ||
| ) | ||
| @pytest.mark.parametrize("close_error", [None, RuntimeError("close failed")]) | ||
| def test_close_during_read(self, caplog, read, close_error): |
There was a problem hiding this comment.
Self-review round one test findings (repaired)
- f966192: the method under test is passed as a parameter instead of being looked up by name.
- 3daaf64, 0c5268f: the tests check the generator state (
GEN_RUNNINGduring the deferred close,GEN_CLOSEDafter it) instead of a separate Event. Newtest_close_during_fetchall_generatortests in both result set classes drive the Closing a PandasCursor or PolarsCursor result set while another thread reads the next chunk raises ValueError #1172 path throughresult_set.close()duringfetchall(). - 0c5268f: a comment in the Polars
test_close_during_fetchall, which said a generator cannot be closed while it runs (Closing a PandasCursor or PolarsCursor result set while another thread reads the next chunk raises ValueError #1172), is removed. - With the source changes reverted, every new test fails with
ValueError: generator already executing. - Not covered:
TextFileReaderdeferral has no deterministic test, because the reader buffers 256 KiB per read.
| """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 |
There was a problem hiding this comment.
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 aTextFileReaderwith the CSV stream. - The
ValueErrorand partial close. Reproduced on301ae52fwith the issue's script. Both result sets'close()callsuper().close()beforeself._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__usesclose_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 throughislice(self._row_iter, size). Python documents no fairness forthreading.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.
| assert future.result(10) == [(0,), (1,), (2,), (3,)] | ||
| assert result_set.rownumber is None | ||
|
|
||
| def test_close_during_fetchall_generator(self): |
There was a problem hiding this comment.
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/polarsat the pre-rebase head6c60ea60passed: 728 passed, 10 skipped. The run at the current head, serialized with-n 1because another session's AWS run overlaps, will be recorded in this thread. - Static only: aio cancellation against Athena, and
TextFileReaderdeferral.
There was a problem hiding this comment.
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.
| """ | ||
| with self._lock: | ||
| self._readers += 1 | ||
| reader = iter(()) if self._close_requested else self._reader |
There was a problem hiding this comment.
Independent review (relayed result from Codex)
- Reviewer: Codex CLI (
codex exec -s read-only), a different model from the author. Session01a13c89-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 insuper().close(). If the first chunk read is still in_is_csv_readable()'shead_objectwhen the cursor closes, the generator then seesoutput_location is Noneand raisesProgrammingErrorinstead 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.
- P2, pre-existing (
- Author verification: confirmed in the code.
_iter_csv_chunks()checksself.output_locationafter_is_csv_readable(), andclose()callssuper().close()first; the same ordering is on301ae52f. Scope decision pending with the maintainer.
There was a problem hiding this comment.
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.
WHAT
PandasDataFrameIteratorandPolarsDataFrameIteratorno longer close their chunk reader while another thread is reading a chunk from it._read()), covering__next__()and, for pandas,get_chunk().close()during a read records the request and returns at once. The read returns the chunk it was reading, and the reading thread then closes the reader, and for pandas the CSV stream, when its read completes.WHY
Fixes #1172.
Closing a PandasCursor or PolarsCursor result set while another thread read the next chunk from a generator reader (chunked UNLOAD for PandasCursor; chunked CSV and UNLOAD for PolarsCursor) raised
ValueError: generator already executing.AthenaPandasResultSet.close()andAthenaPolarsResultSet.close()stopped at that point, after the base class had cleared the query execution, and the error propagated out of the cursor'sclose()or__aexit__().AioPandasCursor and AioPolarsCursor do this themselves: a fetch runs in
asyncio.to_thread()and keeps reading after its task is cancelled, while leavingasync withcloses the cursor throughclose_in_thread().For a chunked pandas CSV result, close previously closed the
TextFileReaderand the CSV stream under the running read; that close is now deferred the same way, so the read finishes its chunk before they are closed.A lock held around each read, which would make
close()wait for the read, was considered instead. It does not stop the reading thread from taking the lock again for the next chunk before the closer, and an interrupted wait would leave the reader unclosed.TEST
Tested commit: 0c5268f (rebased onto 38dc8f8).
just lint: passed.uv run --env-file .env pytest -q -p no:cacheprovider tests/pyathena/pandas/test_result_set.py tests/pyathena/polars/test_result_set.py: 295 passed, 10 skipped.TestPandasDataFrameIterator.test_close_during_read(__next__()andget_chunk()) andTestPolarsDataFrameIterator.test_close_during_read: a close during a read in a worker thread does not raise; a read started after it gets no chunk; the generator and the CSV stream stay open until the read completes; the read returns its chunk; the generator is closed afterwards; an error from the deferred close is logged.test_close_during_fetchall_generatorin both result set test classes:close()during afetchall()from a generator does not raise, the fetch returns the rows of the chunk it was reading, and the generator is closed.ValueError: generator already executing.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/polarsat 0c5268f: 778 passed, 10 skipped.next()when the result set closes raisesProgrammingError; pre-existing, found by the independent review).pyathena/aio/common.pyfetch and__aexit__), not reproduced against Athena. The deferral for a chunked CSVTextFileReaderhas no deterministic test, because the reader buffers 256 KiB per read; it uses the same_read()path that the tests cover. The rest ofjust test pyathenaand the SQLAlchemy suites run in the AWS CI when the PR is marked Ready.🤖 Generated with Claude Code