Skip to content
Merged
32 changes: 32 additions & 0 deletions docs/aio.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,38 @@ async with await aio_connect(s3_staging_dir="s3://YOUR_S3_BUCKET/path/to/",
await cursor.cancel()
```

(aio-task-cancellation)=

### Task cancellation

With `kill_on_interrupt` enabled, which is the default, cancelling the task while `execute()` waits for the query
requests cancellation of the query, waits until it reaches a terminal state, and then raises `asyncio.CancelledError`.
Cancellation is a best-effort request, so the query can still end as `SUCCEEDED` or `FAILED`.
The `query_id` property keeps the ID of the cancelled query.
If the cancellation request fails, `asyncio.CancelledError` is raised with the error as its cause.
Cancelling the task while `execute()` is still starting the query first waits for the start request to finish,
and then cancels the query it started in the same way.
If the task is cancelled before `execute()` begins the request, the request is never sent.
Cancelling the task again during the cancellation request or these waits raises `asyncio.CancelledError` immediately, and the query can keep running.
With `kill_on_interrupt=False`, `asyncio.CancelledError` is raised immediately, and a query that has already started keeps running.

With `kill_on_interrupt` enabled, a timeout from `asyncio.wait_for()` while `execute()` starts or waits for the query therefore cancels it and raises `asyncio.TimeoutError`.
If the timeout expires while `execute()` is still looking up a cached result, no query is started and `query_id` is `None`.

```python
import asyncio

from pyathena import aio_connect

async with await aio_connect(s3_staging_dir="s3://YOUR_S3_BUCKET/path/to/",
region_name="us-west-2") as conn:
async with conn.cursor() as cursor:
try:
await asyncio.wait_for(cursor.execute("SELECT * FROM many_rows"), timeout=60)
except asyncio.TimeoutError:
print(f"Query timed out: {cursor.query_id}")
```

(aio-dict-cursor)=

## AioDictCursor
Expand Down
1 change: 1 addition & 0 deletions docs/spark.md
Original file line number Diff line number Diff line change
Expand Up @@ -512,4 +512,5 @@ With `kill_on_interrupt` enabled, which is the default, cancelling the task whil
requests cancellation of the calculation, waits until it reaches a terminal state, and then raises `asyncio.CancelledError`.
Cancelling the task while `execute()` is still starting the calculation first waits for the start request to finish,
and then cancels the calculation it started in the same way.
If the task is cancelled before `execute()` begins the request, the request is never sent.
Cancelling the task again during this wait raises `asyncio.CancelledError` at once without cancelling the calculation.
34 changes: 34 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,40 @@ The `on_start_query_execution` callback is supported by the following cursor typ
Note: `AsyncCursor` and its variants do not support this callback as they already
return the query ID immediately through their different execution model.

## Query cancellation on interrupt

With `kill_on_interrupt` enabled, which is the default, a `KeyboardInterrupt` while `execute()` waits for the query
requests cancellation, waits until the query reaches a terminal state, and then propagates.
Cancellation is a best-effort request, so the query can still end as `SUCCEEDED` or `FAILED`.

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, and operations. Result: FINDINGS (PR description only, repaired)

Scope: c01c56f73c7dbf973fe73b52b6093f0a32952321..2de91e768196dc061251f7609ae0d9b9be2905cd, all claims in the PR description, commit message, _poll() docstrings, docs/usage.md, and docs/aio.md.

Claims checked:

  • Best-effort stop can still end SUCCEEDED: measured with one SELECT 1 on the CI account. StopQueryExecution on an already SUCCEEDED query returns HTTP 200 and the state stays SUCCEEDED. So the race re-raises the interrupt without a cause, and this sentence and the _poll() docstrings hold.
  • "If the cancellation request fails, ... as its cause": covered by the failure[cancel] tests on both bases.
  • kill_on_interrupt=False keeps the query running: no stop request is made (test_execute_without_kill_on_interrupt).
  • Timeouts: the commit message claim holds. On the original code, asyncio.wait_for() surfaces OperationalError (the new timeout test fails this way), and CPython's Timeout.__aexit__ converts only CancelledError into TimeoutError.
  • Existing callers: dbt-athena's current connection manager uses boto3 directly, and its legacy PyAthena cursor overrides _poll() (dbt-athena/src/dbt/adapters/athena/connections_legacy.py:167), so it is unaffected. The kill_on_interrupt parameter descriptions in pyathena/connection.py:228 and the cursor docstrings remain accurate.

Findings, repaired in the PR description:

  1. The WHY claimed that TaskGroup could not handle the cancellation. A TaskGroup still raises its ExceptionGroup when a sibling fails, so this is narrowed to the verified asyncio.wait_for() effect.
  2. The description omitted the executemany() consequence: it now stops at the interrupted execution, where an interrupted execution that ended SUCCEEDED used to let the loop continue. This is added, along with the dbt-athena compatibility note and the live stop measurement.

Deferred (pre-existing, out of scope): the thread-pool Async* cursor docstrings (e.g. pyathena/arrow/async_cursor.py:91) say kill_on_interrupt cancels on keyboard interrupt, but their polling runs in executor threads, which never receive KeyboardInterrupt. This PR does not change that path.

The `query_id` property keeps the ID of the interrupted query.
If the cancellation request fails, the `KeyboardInterrupt` propagates with the error as its cause.

A `KeyboardInterrupt` while `execute()` is still starting the query first waits for the
[StartQueryExecution](https://docs.aws.amazon.com/athena/latest/APIReference/API_StartQueryExecution.html)
request to finish, and then cancels the query it started in the same way.
The `query_id` property returns that query's ID.
If `execute()` has not begun the request when the interrupt is handled, the request is never sent.
`AsyncCursor` and its variants also stop a query whose start is interrupted in `execute()`,
but they have no `query_id` property, so the ID of that query is not available.
They wait for queries on worker threads, which do not receive `KeyboardInterrupt`.

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 (expanded scope): claims, callers, and operations. Result: CLEAN

Scope: 659676c07e2c09397b7cbc5740cc10bfe6fe41cb..d66c6994034894313f6b68f23f9f5d421ac13632, the rewritten PR description, docstrings, and docs/usage.md / docs/aio.md.

Claims checked:

  • No SQL token generation needed: botocore's Athena model marks StartQueryExecution.ClientRequestToken as idempotencyToken (auto-generated per call) and StartCalculationExecution's not. RetryConfig defaults to THROTTLING_ERROR_CODES, which do not start a query.
  • "AsyncCursor and its variants also stop a query whose start is interrupted": pyathena/async_cursor.py:225 and the pandas/arrow/polars/s3fs async cursors call self._execute() on the caller's thread. Polling runs in self._executor.
  • "If the request has not been sent yet ..., it is never sent": this is the sync abandon path, covered by the Cancel a Spark calculation interrupted while it is being started #861 tests test_calculate_interrupted_before_request_is_sent[False/True], which now exercise the shared helper.
  • Real signal: a SIGINT via os.kill while Cursor.execute() was blocked in a mocked StartQueryExecution produced one start request, a stop with the returned ID, query_id set, and KeyboardInterrupt without a cause. Checked on 3.13.1 and 3.10.16.
  • Operational: one short-lived daemon thread per StartQueryExecution with kill_on_interrupt, and no extra AWS calls on the normal path. The start still goes through retry_api_call with the same config.
  • Evidence scope: local results are offline only (3.13.1 and 3.10.16). The AWS suites were not run locally for this revision. An accidental local run of all of tests/pyathena with --noconftest sent some queries from tests that call connect() directly; its missing-schema failures are not used as evidence, and the PR description says so.

No corrections were needed beyond the round 1 repair.


A second `KeyboardInterrupt` during the cancellation request or these waits propagates immediately, and the query can keep running.
With `kill_on_interrupt=False`, the `KeyboardInterrupt` propagates immediately, and a query that has already started keeps running.

```python
from pyathena import connect

cursor = connect(s3_staging_dir="s3://YOUR_S3_BUCKET/path/to/",
region_name="us-west-2").cursor()
try:
cursor.execute("SELECT * FROM many_rows")
except KeyboardInterrupt:
print(f"Query {cursor.query_id} was interrupted")
raise
```

For the native asyncio cursors, see {ref}`aio-task-cancellation`.

## Query polling callback

PyAthena provides an `on_poll` callback that is invoked once per poll iteration with the
Expand Down
138 changes: 114 additions & 24 deletions pyathena/aio/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import asyncio
import logging
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Coroutine
from typing import Any, NoReturn, TypeVar, cast

from botocore.exceptions import BotoCoreError, ClientError
Expand Down Expand Up @@ -64,6 +64,8 @@ async def _execute( # type: ignore[override]
The query execution ID.

Raises:
asyncio.CancelledError: If the task is cancelled while starting the
query; see ``_start_execution()``.
ProgrammingError: If the formatter rejects the query or its parameters.
DatabaseError: If the ``StartQueryExecution`` request fails.
"""
Expand All @@ -88,18 +90,89 @@ async def _execute( # type: ignore[override]
cache_expiration_time=options.cache_expiration_time,
)
if query_id is None:
query_id = await self._start_execution(lambda: self._start_query_execution(request))
return query_id

@override
async def _start_query_execution(self, request: dict[str, Any]) -> str: # type: ignore[override]
"""Send a ``StartQueryExecution`` request.

Args:
request: The request parameters.

Returns:
The query execution ID.

Raises:
DatabaseError: If the request fails.
"""
try:
response = await async_retry_api_call(
self._connection.client.start_query_execution,
config=self._retry_config,
logger=_logger,
**request,
)
except Exception as e:
_logger.exception("Failed to execute query.")
raise DatabaseError(*e.args) from e
return cast(str, response.get("QueryExecutionId"))

@override

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.

Rebase onto 39f22dc (#917 @override) and independent follow-up (relayed).

The series ecb19c15..2f4dcc43ec34ad63294925d75850eef7578eed05 was rebased to 39f22dc1..a32a9930c46943c2572f94627946088a1b145d45. The only conflict was the @override that #917 put on _get_query_execution. Following #917's convention, the new asyncio overrides _start_query_execution, _start_execution, and _cancel_and_wait also got @override, keeping their # type: ignore[override]. just lint passes. mypy --warn-unused-ignores reports only the three existing pyathena/sqlalchemy/ ignores. Offline SQL (27) and Spark (114) tests were rerun with the same results. AWS CI on the pre-rebase head 2f4dcc4 passed (run 36894523702).

Codex CLI, reported model gpt-6-astra, reasoning effort max (as reported), session 01a0f867-80a7-7a02-85f6-a436a679bf45. Scope: git range-diff ecb19c15..2f4dcc43 39f22dc1..a32a9930, read-only snapshot, static review. The snapshot and the PR worktree were unchanged afterwards.

CLEAN — no actionable findings.

Covered surfaces:

  • All eight commits via the requested range-diff. The only substantive rebase adjustments are three @override additions: _start_query_execution, _start_execution, and _cancel_and_wait.
  • All four named AioBaseCursor methods and the affected AioSparkCursor methods. Their markers target existing base methods and follow Mark the asyncio overrides of sync methods with @override #917’s decorator/type-ignore convention.
  • Mark the asyncio overrides of sync methods with @override #917’s imports, decorators, type-ignore removals, and pyathena.util.override shim. All are preserved, including in both overlapping asyncio files.
  • Behavior preservation: method bodies are unchanged from the reviewed head; the shim returns functions unchanged. The series’ remaining source files, documentation, and tests are byte-identical.

Static review only; no builds, tests, writes, or network access. HEAD remains a32a9930, and the worktree is clean.

async def _start_execution( # type: ignore[override]
self, start: Callable[[], Coroutine[Any, Any, str]]
) -> str:
"""Send a start request so that task cancellation stops the execution it starts.

With ``kill_on_interrupt`` enabled, the request runs in a task shielded
from task cancellation. On cancellation, the request is abandoned if that
task has not begun it by then; it is never sent, and the cancellation
propagates. Otherwise the cursor waits for the request to finish, records
the execution ID with ``_set_interrupted_execution_id()``, requests
cancellation with ``_cancel_and_wait()``, and re-raises
``asyncio.CancelledError``. Another cancellation during that wait
propagates at once.

Args:
start: Returns a coroutine that sends the start request and returns the
execution ID.

Returns:
The execution ID.

Raises:
asyncio.CancelledError: If the task is cancelled while starting. A
failure to start, cancel, or wait for the execution becomes its
``__cause__``.
DatabaseError: If the request fails.
"""
if not self._kill_on_interrupt:
return await start()

caller = asyncio.current_task()
cancel_requests = caller.cancelling() if caller else 0

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 of the repairs (rounds 1 and 2): range 4480d3cc7d885ccfa1f1eff3b2e3366271482bf4..fdb6c2e0938d30d8a3dde843b0b85a6feb3a815c. Result: FINDINGS (docs wording, repaired in fdb6c2e)

Round 1, behavior:

  • The cancellation count is taken on entry, so a cancellation that was already pending, or swallowed without uncancel(), does not abandon a later start.
  • asyncio.wait_for() on 3.11 runs the coroutine in its own task. The timeout cancels that task, which is the caller here. The start-phase timeout tests pass on 3.11.11.
  • A second cancellation while the handler awaits a start task that has not run cancels that task before its first step, so start() is never called and no coroutine is left un-awaited.
  • If the start task has begun, the path is unchanged: wait, record, stop, re-raise.
  • AioSparkCursor has the identical change.
  • Checks: SQL 27 passed, Spark 114 passed, 3.11.11 140 passed. The window, timeout, and cancelled-while-starting tests passed 30 of 30 repeated runs. Both window tests fail on 4480d3c.

Round 2, claims:

  • The new doc sentence said a request "not sent yet when the cancellation is handled" is never sent. The code abandons it only if the start task, or the sync helper thread, has not begun the request. A begun request whose HTTP call has not left yet is still sent.
  • The sync sentence in docs/usage.md had the same imprecision.
  • All three now say the request is never sent if the interrupt or cancellation comes before execute() begins it.


async def run() -> str | None:
# Begin the request only if the caller has not been cancelled since.
if caller and caller.cancelling() > cancel_requests:
return None
return await start()

task = asyncio.ensure_future(run())
try:
return cast(str, await asyncio.shield(task))
except asyncio.CancelledError as cancellation:
try:
response = await async_retry_api_call(
self._connection.client.start_query_execution,
config=self._retry_config,
logger=_logger,
**request,
)
query_id = response.get("QueryExecutionId")
execution_id = await task
if execution_id is None:
# The task did not begin the request, so it was never sent.
raise cancellation
_logger.warning("Query canceled by user.")
self._set_interrupted_execution_id(execution_id)
await self._cancel_and_wait(execution_id)
except Exception as e:
_logger.exception("Failed to execute query.")
raise DatabaseError(*e.args) from e
return query_id
raise cancellation from e
raise

@override
async def _get_query_execution(self, query_id: str) -> AthenaQueryExecution: # type: ignore[override]
Expand Down Expand Up @@ -154,10 +227,12 @@ async def _poll_until_terminal(self, query_id: str) -> AthenaQueryExecution: #

@override
async def _poll(self, query_id: str) -> AthenaQueryExecution: # type: ignore[override]
"""Wait for a query execution to finish.
"""Wait for a query execution to reach a terminal state.

On ``asyncio.CancelledError`` with ``kill_on_interrupt`` enabled, stops the
query and returns its final execution instead of re-raising.
On task cancellation with ``kill_on_interrupt`` enabled, requests
cancellation with ``_cancel_and_wait()`` and re-raises
``asyncio.CancelledError``. Cancellation is a best-effort request, so the
query can still end as ``SUCCEEDED`` or ``FAILED`` instead of ``CANCELLED``.

Args:
query_id: The query execution ID.
Expand All @@ -166,19 +241,34 @@ async def _poll(self, query_id: str) -> AthenaQueryExecution: # type: ignore[ov
The query execution in a terminal state.

Raises:
asyncio.CancelledError: If cancelled and ``kill_on_interrupt`` is disabled.
OperationalError: If a status or stop request fails.
asyncio.CancelledError: If the task is cancelled while waiting. A failure
to cancel or wait for the query becomes its ``__cause__``.
OperationalError: If a status request fails.
"""
try:
query_execution = await self._poll_until_terminal(query_id)
except asyncio.CancelledError:
if self._kill_on_interrupt:
_logger.warning("Query canceled by user.")
await self._cancel(query_id)
query_execution = await self._poll_until_terminal(query_id)
else:
return await self._poll_until_terminal(query_id)
except asyncio.CancelledError as cancellation:
if not self._kill_on_interrupt:
raise
return query_execution
_logger.warning("Query canceled by user.")
try:
await self._cancel_and_wait(query_id)
except Exception as e:
raise cancellation from e
raise

@override
async def _cancel_and_wait(self, query_id: str) -> None: # type: ignore[override]
"""Request cancellation of a query and wait for a terminal state.

Args:
query_id: The query execution ID.

Raises:
OperationalError: If the cancellation or a status request fails.
"""
await self._cancel(query_id)
await self._poll_until_terminal(query_id)

@override
async def _cancel(self, query_id: str) -> None: # type: ignore[override]
Expand Down
32 changes: 25 additions & 7 deletions pyathena/aio/spark/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,10 @@ async def _calculate( # type: ignore[override]
retried request returns the calculation an earlier attempt started instead
of starting another one.

With ``kill_on_interrupt`` enabled, the request is shielded from task
cancellation. On cancellation, waits for the request to finish, requests
With ``kill_on_interrupt`` enabled, the request runs in a task shielded
from task cancellation. On cancellation, the request is abandoned if that
task has not begun it by then; it is never sent, and the cancellation
propagates. Otherwise the cursor waits for the request to finish, requests
cancellation of the calculation it started, waits for a terminal state,
stores the calculation ID and execution on the cursor, and re-raises
``asyncio.CancelledError``. Another cancellation during that wait
Expand Down Expand Up @@ -137,14 +139,27 @@ async def _calculate( # type: ignore[override]
if not self._kill_on_interrupt:
return await self._start_calculation_execution(request)

start = asyncio.ensure_future(self._start_calculation_execution(request))
caller = asyncio.current_task()
cancel_requests = caller.cancelling() if caller else 0

async def run() -> str | None:
# Begin the request only if the caller has not been cancelled since.
if caller and caller.cancelling() > cancel_requests:
return None
return await self._start_calculation_execution(request)

start = asyncio.ensure_future(run())
try:
return await asyncio.shield(start)
return cast(str, await asyncio.shield(start))
except asyncio.CancelledError as cancellation:
_logger.warning("Query canceled by user.")
try:
self._calculation_id = await start
await self._cancel_and_wait(self._calculation_id)
calculation_id = await start
if calculation_id is None:
# The task did not begin the request, so it was never sent.
raise cancellation
_logger.warning("Query canceled by user.")
self._calculation_id = calculation_id
await self._cancel_and_wait(calculation_id)
except Exception as e:
raise cancellation from e
raise
Expand Down Expand Up @@ -340,6 +355,9 @@ async def execute(
Returns:
Self reference for method chaining.
"""
# A failure below must not leave the previous calculation on the cursor.

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 gpt-6-sol): 3. [P2] pre-existing

Anchored here because the line is outside the diff: pyathena/aio/spark/cursor.py:136.

"Native asyncio Spark already has the same shielded-start window: cancellation before its scheduled start task runs can still initiate a calculation. This path was unchanged by the reviewed commit."

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.

Disposition: fixed in 1ae69b4 (pre-existing since #861; the maintainer chose to fix it in this PR). AioSparkCursor._calculate() gets the same Task.cancelling() check as AioBaseCursor._start_execution(). The class does not inherit AioBaseCursor, and the maintainer chose not to add module-level helpers, so the check is written in both places. New test tests/pyathena/aio/spark/test_cursor.py::test_execute_cancelled_before_request_is_sent: no StartCalculationExecution, no stop, calculation_id is None. It fails on 4480d3c. All #861 asyncio Spark tests still pass. docs/spark.md gets the matching sentence.

self._calculation_id = None
self._calculation_execution = None
self._calculation_id = await self._calculate(
session_id=session_id if session_id else self._session_id,
code_block=operation,
Expand Down
Loading
Loading