Repository navigation
Break result sets after failed or cancelled fetches and drop the cross-thread guards - #1238
Merged
Merged
Conversation
laughingman7743
commented
Oct 17, 2026
| return cast(_ClosableT, future.result()) | ||
|
|
||
|
|
||
| async def run_in_thread_or_close( |
Member
Author
There was a problem hiding this comment.
Self-review round one (behavior and implementation)
- Base:
59169c5e137a0b453e8bd441ddbdd4b028df4f94; initial head:d1dcf0ce61a0bd51d41eacfd1464914874844cdc; repairs throughef1b3a094(94c45e02,54f9750a,ef1b3a09). - Covered: base/aio/pandas/Polars/Arrow/S3FS fetch paths (
fetchone/fetchmany/fetchall, iteration), pandas/Polars chunk iterators (__next__,get_chunk,itertuples,iter_chunks),WithAsyncFetchfetches and cancellation,retry_api_call/async_retry_api_call,create_in_thread, removed guards, tests. Ran/code-review(high) and/simplifyin addition to the manual pass. - Result: FINDINGS, repaired.
retry_api_call(stop_when=...)had no caller after the guards were removed (pyathena/util.py), so it was removed. It is unreleased: it is not in v4.1.0.asyncio.shield()let a cancelled fetch that was still queued run in full (pyathena/aio/common.py). The close of the detached result set relied on the event loop: inline on the loop thread after an executor shutdown, and never after the loop closed. The fix is the newrun_in_thread_or_close(), which skips a queued function and closes in the fetch's thread or a new thread, ascreate_in_thread()does. It awaits the future directly, becauseasyncio.wait()/shield()cost about 20 µs per call (measured).- The pandas/Polars
_take_rowsstopped counting the rows taken before an exception, unlike the base. Restored. - Docstrings: the Polars
__next__section order,AioS3FSCursor, andWithAsyncFetch. Docs: the invalid-argument exception and the release note for cross-threadcancel(). - Simplify: one failure message, a shared
assert_fetches_fail, a singlerunningflag,create_in_threadusing the logging close, the pandas Parquet chunk reader readingself._data_manifestagain, and_count_rows.
- Measured: sync
fetchoneover 1M buffered rows takes 133 ns/row on master and on this branch. The asyncio fetch helper costs about the same asrun_in_thread(). - Deferred:
- The guard is inlined in 8 fetch methods. The base
fetchoneis a per-row path that the code deliberately inlines. - A shared iterator mixin would restructure classes, which needs approval.
- Reverting the Polars reader split from Give the chunk readers the state that close() clears when they are created #1190 would change when the CSV size check runs. It is a follow-up candidate.
- A busy-executor test fixture would refactor an existing test pattern.
- The guard is inlined in 8 fetch methods. The base
| An invalid argument, such as a `fetchmany()` size that is not an integer, raises its error without that effect. | ||
| Execute the query again to read its result. | ||
|
|
||
| As `pyathena.threadsafety = 2` declares, threads can share connections, but not cursors. |
Member
Author
There was a problem hiding this comment.
Self-review round two (claims, compatibility, operations)
- Base:
59169c5e137a0b453e8bd441ddbdd4b028df4f94; head:a2d2fc005789ff771f0e84a4e0154dd45a861520. - Claims checked:
threadsafety = 2(pyathena/__init__.py:30). PEP 249 level 2 lets threads share connections, not cursors.- Validation errors do not break the result set:
operator.index(size)runs before the check, in sync and aio. AsyncCursor.cancel(query_id)exists (pyathena/async_cursor.py:304).- The removed guards and
stop_when(Defer closing a chunk reader while another thread reads it #1182, Take buffered rows in bulk in fetchmany() and fetchall() #1187, Give the chunk readers the state that close() clears when they are created #1190, Discard the page of a request that close() interrupts #1197, Stop the retries of closed result sets and cancelled asyncio calls #1223) are in no tag, andgit show v4.1.0:pyathena/util.pyhas nostop_when. The public signatures therefore match v4.1.0.
- Corrections:
docs/pandas.md,docs/polars.md,docs/arrow.md, anddocs/s3fs.mdsaid the asyncio cursors useasyncio.to_thread()for creation and fetches. That was already inaccurate for creation (create_in_thread) and is now inaccurate for fetches.docs/aio.mdnow says what a cancellation does to a fetch that has not started.- The PR body now covers the
stop_whenremoval, the partial Give the chunk readers the state that close() clears when they are created #1190 revert, the cross-threadcancel()release note, and dropping the earlier draft release-note entries.
- Existing callers:
- SQLAlchemy async adapters close the cursor after a failed fetch. After a cancelled fetch the cursor has no result set, so that close does nothing, and the result set is closed by the helper.
- No constructor or keyword changes.
- AWS: no new requests.
- A cancellation still stops the retries of a running fetch.
- A
close()from another thread no longer stopsGetQueryResultsretries. That use is unsupported.
- Evidence at
ef1b3a09(code);a2d2fc00changes only docs:- Affected unit/integration files: 548 passed, 10 skipped.
- aio cursor integration tests (
tests/pyathena/aio/{pandas,polars,arrow,s3fs}/test_cursor.py,tests/pyathena/aio/test_cursor.py): 139 passed. just lintpassed. markdownlint: 0 errors.just docs buildsucceeded.- The full suite and SQLAlchemy suites run in AWS CI at Ready.
- Result: FINDINGS (docs/PR claims), corrected.
- Open:
docs/spark.md:280still showsthreading.Timer(60, cursor.cancel)on the same cursor. Spark has no start callback, so this waits for the maintainer's decision.
laughingman7743
commented
Oct 17, 2026
| row = next(self._row_iter, None) | ||
| if row is not None: | ||
| self._count_rows(1) | ||
| if self._fetch_failed: |
Member
Author
There was a problem hiding this comment.
Independent review (relayed), round one
- Reviewer: Codex CLI (
codex exec -s read-only), modelgpt-6-astra, reasoning effortmax, session01a14923-03ff-7cf3-8386-30021435220b. A static review: no tests, builds, edits, or network access. - Scope:
59169c5e137a0b453e8bd441ddbdd4b028df4f94..a2d2fc005789ff771f0e84a4e0154dd45a861520, on a detached snapshot that stayed unchanged. The prompt held the diff and the intended contract, with no PR text or prior findings. - Covered: base/dict, asyncio, Arrow, S3FS, pandas, and Polars fetch paths, argument validation, chunk iteration, and cleanup;
run_in_thread_or_close,create_in_thread, cancellation handoffs, executor shutdown, and retry propagation; sync cursors,AsyncCursorfutures, SQLAlchemy adapters, retry-helper callers, tests, and docs. - Result: FINDINGS (4).
- P2: after
iter_chunks()fails reading the next chunk, rows already buffered in the row iterator from an earlier chunk are still returned before the fetch raises (pandas/result_set.py:977,polars/result_set.py:626). Rejected as intended. Those rows come from a chunk that was read successfully, in order, with nothing skipped or duplicated. This matches the existing contract thattest_fetch_after_chunks_run_outtests: the rows of a chunk that a fetch started stay. The next fetch that needs a new chunk raisesProgrammingError. - P2: the
ParquetChunkReaderprefetch executor (arrow/reader.py:197) does not copy context, so a cancellation does not stop the retries of a prefetchGetObject, and the cleanup waits for them. Deferred as pre-existing. The read-ahead/prefetch pools were deliberately left out of the stop scopes in Cancelled AioS3FileSystem operations keep retrying in executor threads #1203/Stop the S3 retries of cancelled calls and of failed requests' siblings #1231. Before this PR, a cancelled fetch stopped no retries at all. - P2: the pandas/Polars
fetchone()called_count_rows(1)outside the exception handler, so an interrupt there left fetching enabled. Fixed inb8b5ebad: the count moved inside the handler, andtest_fetchone_interrupted_while_countingwas added for both. - P3:
test_run_in_thread_or_close_cancelled_after_functionrelied on a 100 ms sleep. Fixed inb8b5ebad: it now waits for the call to return through the next job of a single-worker executor. 5 consecutive runs passed.
- P2: after
- Validation after repair:
just lintpassed. Affected test files: 550 passed, 10 skipped.
Member
Author
There was a problem hiding this comment.
Repair follow-up for b8b5ebad
- Self-review of the repair:
- Round one (behavior): the pandas/Polars
fetchone()now count inside the handler, as the base does. The newtest_fetchone_interrupted_while_countingcovers both backends. - Round two (claims): no docs, PR text, or public contracts change.
- Round one (behavior): the pandas/Polars
- Independent follow-up (relayed):
- Reviewer: Codex CLI
codex exec -s read-only, modelgpt-6-astra, reasoning effortmax, session01a1492e-b9ac-76b2-ba82-1d59cdb53539. A static review. - Scope:
a2d2fc005789ff771f0e84a4e0154dd45a861520..b8b5ebad56a1161f80886fe7822372640678ffa6on the detached snapshot. - Result: CLEAN. The interrupt tests distinguish base from head. The cancellation test reaches the new-thread close deterministically, with no deadlock or executor leak.
- Codex noted that the cancellation test passes on both revisions, since its repair only removes the timing dependency.
- Reviewer: Codex CLI
laughingman7743
marked this pull request as ready for review
October 17, 2026 09:29
laughingman7743
marked this pull request as draft
October 17, 2026 09:42
laughingman7743
commented
Oct 17, 2026
| with pytest.raises(StopIteration): | ||
| chunks.get_chunk() | ||
| assert pandas_cursor.fetchone() is None | ||
| # GH-1225: reads and fetches after the failed read raise. |
Member
Author
There was a problem hiding this comment.
AWS CI failure and repair (06adf235)
- Failure: AWS CI run 39021324039, job
test / run (3.15)atb8b5ebad. 4 failed, 4367 passed. All 4 failures wereTestPandasCursor.test_binary_csv_stream_close_on_read_error[{next,get_chunk}-{c,python}], which expectedStopIterationandfetchone() is Noneafter a failed chunk read. That is the behavior Define the behavior of result set fetches after an error #1225 replaced; the newProgrammingErrorwas raised as designed.test-sqlaandtest-sqla-asyncpassed. - Repair: the test now expects
ProgrammingErrorfromnext(),get_chunk(), andfetchone()after the failed read. Locallyuv run --env-file .env pytest -n 2 tests/pyathena/pandas/test_cursor.py -k binary_csv_stream_closegave 20 passed.just lintpassed. - Self-review: round one, the assertion matches the iterator message and the shared-iterator path of
fetchone(). Round two, it is a test-only change with no claim changes, and no other cursor test still asserts the old post-failure behavior; the full CI run showed no other failures. - Independent follow-up (relayed): Codex CLI
codex exec -s read-only, modelgpt-6-astra, effortmax, session01a14940-30c3-7a62-8288-2ee4b7d4edec, rangeb8b5ebad56a1161f80886fe7822372640678ffa6..06adf235f6341a33104c439debfeef619442b5f9. A static review. Result: CLEAN. The remainingStopIteration/None/[]expectations in the file cover normal closure or exhaustion only.
laughingman7743
marked this pull request as ready for review
October 17, 2026 09:48
This was referenced Oct 17, 2026
After a fetch raises, including for an interrupt or a value that fails to convert, later fetches raise ProgrammingError without reading further rows (#1225 option (c)). Before, the pandas and Polars result sets returned no more rows, and the others continued after dropped or skipped rows. The pandas and Polars chunk iterators do the same for their reads, so a failed iter_chunks() read also breaks the fetches that share the iterator. The fetches of AioPandasCursor, AioArrowCursor, AioPolarsCursor, and AioS3FSCursor stop their retries when cancelled and detach the result set, which is closed in a thread once the fetch has ended in its thread. A later fetch therefore raises instead of running alongside the leftover thread, and leaving async with no longer closes the result set during that fetch. PyAthena declares threadsafety = 2, so using one cursor or result set from several threads, including close() and cancel() from another thread, is unsupported. The code that only served that case is removed: the deferred chunk reader close of the pandas and Polars iterators, the is_closed checks after a page request, the buffer replacement and IndexError handling of the bulk take, and the stop of the GetQueryResults retries on close(). The documentation states the limitation, and the cancellation example that called cancel() from a timer thread is removed. Closes #1206 Closes #1225 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The result sets were its only callers, to stop their retries on a close() from another thread, which is no longer supported. The stop scopes of stop_retries_on() remain for asyncio cancellations. Refs #1206 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add pyathena.aio.util.run_in_thread_or_close(), which the asyncio fetches use instead of their own shielded executor call. As run_in_thread() does, a cancellation skips a fetch that has not started and stops the retries of a running one. The fetch's thread closes the result set when the fetch ends, and otherwise a new thread closes it, as create_in_thread() does, so the close neither runs on the event loop nor waits for a closed loop. The future is awaited directly, since asyncio.shield() and asyncio.wait() add an event loop iteration, about 20 microseconds, to every fetch. Restore the counting of the rows that the pandas and Polars bulk takes took before an exception, as the base and asyncio result sets count them, and correct docstrings and documentation found in review. Refs #1206 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Use one failed-fetch message for all result sets and share the test helper that asserts it. - Track whether the function of run_in_thread_or_close() is running with one flag, and close the objects of create_in_thread() with the same logging helper. - Read the data manifest in the pandas Parquet chunk reader again; the paths parameter only served a close() from another thread. - Update rownumber in _count_rows() directly. Refs #1206 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pandas, Polars, Arrow, and S3FS pages said that their asyncio cursors use asyncio.to_thread() for result set creation and fetches, which they no longer do, and the asyncio page now says what a cancellation does to a fetch that has not started. Refs #1206 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Their fetchone() counted the row it took outside the exception handler, so an interrupt at that point left later fetches enabled; the base result set counts inside it. The test of a cancellation after the function ended now waits for the call to return on a single-worker executor instead of sleeping. Refs #1206 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
laughingman7743
force-pushed
the
fix/1206-result-set-guard
branch
from
October 17, 2026 10:26
06adf23 to
4677cc7
Compare
This was referenced Oct 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
WHAT
fetchone(),fetchmany(), andfetchall()calls raiseProgrammingError("The result set cannot be fetched after a failed fetch.")without reading further rows. This covers request errors, interrupts, values that fail to convert, and asyncio cancellations. Argument validation errors, such as a non-integerfetchmany()size, do not break the result set.iter_chunks()read also breaks the fetch methods that share the iterator.AioPandasCursor,AioArrowCursor,AioPolarsCursor, andAioS3FSCursor. These fetches now run through the newpyathena.aio.util.run_in_thread_or_close().ProgrammingError("No result set.").create_in_thread(), the close needs neither the event loop nor its executor.create_in_thread()now uses the same logging close.threadsafety = 2, so code that served only concurrent use of one cursor or result set is removed:is_closedchecks after a page requestIndexErrorhandling in the bulk takestop_whenparameter ofretry_api_call()andasync_retry_api_call(), and its use to stopGetQueryResultsretries onclose()pathsparameter of the pandas Parquet chunk reader and the stated reasons. The Polars chunk readers keep the state they take at creation.docs/usage.md: a failed fetch makes later fetches raise, except for invalid arguments. Calling one cursor or its result set from several threads, includingclose()andcancel(), is unsupported. The callback text points to stopping a query by its ID. The example that cancelled a query from a timer thread is removed.docs/aio.md,docs/pandas.md,docs/polars.md,docs/arrow.md, anddocs/s3fs.md: how the asyncio cursors run fetches in threads and what a cancellation does.Release note (breaking):
ProgrammingError. Before, the pandas and Polars result sets returned no more rows, and the other result sets continued after skipped or dropped rows.threadsafety = 2declares. This includescancel()from a timer thread, which an earlier documentation example showed, andclose()during a fetch.WHY
Closes #1206. Closes #1225.
#1225 decided option (c): a failed fetch breaks the result set. #1206 dropped the proposed operation guard because concurrent use of a cursor is unsupported under
threadsafety = 2. See #1206 (comment) and the contract update on #1205: #1205 (comment).The asyncio cursors created the one concurrent close themselves. A fetch that keeps running in its thread after a cancellation was closed when the task left
async withor calledexecute()again (#1172). The cursor now drops that result set instead, and the result set is closed after the fetch ends.TEST
Tested commit: 06adf23.
just lint: passed. markdownlint on the changed docs: 0 errors.uv run --env-file .env pytest -n 2 tests/pyathena/test_result_set.py tests/pyathena/aio/test_result_set.py tests/pyathena/aio/test_common.py tests/pyathena/pandas/test_result_set.py tests/pyathena/polars/test_result_set.py tests/pyathena/aio/test_util.py tests/pyathena/test_util.py tests/pyathena/arrow/test_result_set.py tests/pyathena/s3fs/test_result_set.py: 550 passed, 10 skipped.KeyboardInterrupt, conversion error) for the base and asyncio result sets.AthenaAioResultSetfetch.iter_chunks()reads breaking the pandas and Polars fetches.WithAsyncFetchfetches: retries stopped, result set dropped and closed off the event loop after the fetch ends.run_in_thread_or_close()cancelled before, during, and after its function.stop_whenwere removed.fetchone()over 1,000,000 buffered rows: 133 ns per row on both master and this branch.fetchone()through the thread helper: about the same asrun_in_thread(). Anasyncio.wait()orasyncio.shield()variant added about 20 µs per call and was not used.test_fetchone_interrupted_while_counting).tests/pyathena/pandas/test_cursor.py -k binary_csv_stream_close(AWS): 20 passed. The test now expectsProgrammingErrorafter a failed chunk read; an earlier AWS CI run failed on its old expectation.tests/pyathena/aio/{pandas,polars,arrow,s3fs}/test_cursor.py,tests/pyathena/aio/test_cursor.py): 139 passed.🤖 Generated with Claude Code