Repository navigation
Stop the retries of closed result sets and cancelled asyncio calls #1223
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e5e7416
1c5debe
4b584ef
78f6555
2764110
dfbf5d8
090655c
6fd3711
24b263e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,13 +10,16 @@ | |
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import contextlib | ||
| import contextvars | ||
| import logging | ||
| import threading | ||
| from collections.abc import Callable | ||
| from typing import Any, Protocol, TypeVar, cast | ||
|
|
||
| from pyathena.util import RetryConfig, retry_api_call | ||
| from pyathena.util import RetryConfig, retry_api_call, stop_retries_on, unstoppable_retries | ||
|
|
||
| T = TypeVar("T") | ||
|
|
||
|
|
||
| class _Closable(Protocol): | ||
|
|
@@ -26,33 +29,82 @@ def close(self) -> Any: ... | |
| _ClosableT = TypeVar("_ClosableT", bound=_Closable) | ||
|
|
||
|
|
||
| async def run_in_thread(func: Callable[..., T], /, *args: Any, **kwargs: Any) -> T: | ||
| """Run a blocking function in the default executor, stopping its retries on cancellation. | ||
|
|
||
| Like ``asyncio.to_thread()``, the function runs in the event loop's default | ||
| executor with a copy of the caller's context, and a cancellation of the | ||
| caller skips a function that has not started. A cancellation also stops | ||
| the retries of the AWS API calls that the running function makes (see | ||
| :func:`~pyathena.util.stop_retries_on`). A request that has been sent | ||
| still runs to its end, and the function returns or raises in its | ||
| thread, where nobody awaits it. | ||
|
|
||
| Args: | ||
| func: The blocking function. | ||
| *args: Positional arguments for ``func``. | ||
| **kwargs: Keyword arguments for ``func``. | ||
|
|
||
| Returns: | ||
| The return value of ``func``. | ||
| """ | ||
| stop = threading.Event() | ||
|
|
||
| def call() -> T: | ||
| """Run the function in a scope that the cancellation stops.""" | ||
| with stop_retries_on(stop): | ||
| return func(*args, **kwargs) | ||
|
|
||
| future = asyncio.get_running_loop().run_in_executor(None, contextvars.copy_context().run, call) | ||
| try: | ||
| return await future | ||
| except asyncio.CancelledError: | ||
| stop.set() | ||
| raise | ||
|
|
||
|
|
||
| async def async_retry_api_call( | ||
| func: Callable[..., Any], | ||
| config: RetryConfig, | ||
| logger: logging.Logger | None = None, | ||
| *args: Any, | ||
| stop_on: Callable[[BaseException], bool] | None = None, | ||
| stop_when: Callable[[], bool] | None = None, | ||
| stoppable: bool = True, | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Independent review (relayed Codex result) Reviewer: OpenAI Codex CLI 0.162.1 (model gpt-6-astra, reasoning effort max), Initial review:
Follow-up review of the repair Self-review of the repair (both perspectives, narrow; |
||
| **kwargs: Any, | ||
| ) -> Any: | ||
| """Execute a function with retry logic in a thread to avoid blocking the event loop. | ||
|
|
||
| Wraps ``retry_api_call`` with ``asyncio.to_thread()`` so that blocking | ||
| boto3 calls do not block the asyncio event loop. | ||
| Runs ``retry_api_call`` with :func:`run_in_thread` so that blocking boto3 | ||
| calls do not block the asyncio event loop. Cancelling the caller makes no | ||
| further attempt and ends the backoff wait, unless ``stoppable`` is false. | ||
|
|
||
| Args: | ||
| func: The AWS API function to call. | ||
| config: RetryConfig instance specifying retry behavior. | ||
| logger: Optional logger for retry attempt logging. | ||
| *args: Positional arguments to pass to ``retry_api_call``. | ||
| stop_on: Passed to ``retry_api_call``. | ||
| stop_when: Passed to ``retry_api_call``. | ||
| stoppable: Whether a cancellation of the caller stops the retries. A | ||
| request that cleans up, such as one that stops a query or | ||
| terminates a session, passes False: its retries then run to their | ||
| end in the thread (see :func:`~pyathena.util.unstoppable_retries`), | ||
| as with ``asyncio.to_thread()``. | ||
| **kwargs: Keyword arguments to pass to the function. | ||
|
|
||
| Returns: | ||
| The result of the successful function call. | ||
| """ | ||
| return await asyncio.to_thread( | ||
| retry_api_call, func, config, logger, *args, stop_on=stop_on, **kwargs | ||
| ) | ||
|
|
||
| def call() -> Any: | ||
| """Make the call, ignoring the stop scopes unless it is stoppable.""" | ||
| with contextlib.nullcontext() if stoppable else unstoppable_retries(): | ||
| return retry_api_call( | ||
| func, config, logger, *args, stop_on=stop_on, stop_when=stop_when, **kwargs | ||
| ) | ||
|
|
||
| return await (run_in_thread(call) if stoppable else asyncio.to_thread(call)) | ||
|
|
||
|
|
||
| async def close_in_thread(close: Callable[[], Any]) -> None: | ||
|
|
@@ -76,7 +128,8 @@ async def create_in_thread( | |
|
|
||
| Like ``asyncio.to_thread()``, the constructor runs in the event loop's | ||
| default executor with the caller's context. If the caller is cancelled, a | ||
| construction that has not started is skipped, an object constructed after | ||
| construction that has not started is skipped, the retries of a running | ||
| one are stopped as by :func:`run_in_thread`, an object constructed after | ||
| the cancellation is closed by the constructing thread, and an object | ||
| constructed just before it is closed in a new thread: nothing else can | ||
| close it, and a close by the garbage collector could block the event | ||
|
|
@@ -96,14 +149,22 @@ async def create_in_thread( | |
| constructed: list[_ClosableT] = [] | ||
|
|
||
| def construct() -> _ClosableT | None: | ||
| """Construct the object in a scope that the cancellation stops. | ||
|
|
||
| Returns: | ||
| The object, or None if the caller was cancelled, after which an | ||
| object constructed here is closed here. | ||
| """ | ||
| if cancelled.is_set(): | ||
| return None | ||
| obj = factory(*args, **kwargs) | ||
| with stop_retries_on(cancelled): | ||
| obj = factory(*args, **kwargs) | ||
| with lock: | ||
| if not cancelled.is_set(): | ||
| constructed.append(obj) | ||
| return obj | ||
| obj.close() | ||
| with unstoppable_retries(): | ||
| obj.close() | ||
| return None | ||
|
|
||
| future = asyncio.get_running_loop().run_in_executor( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Codex finding 1 (P2): second cancellation stops the start's retries. Verified. Decision: the behavior is kept and the claim corrected — before, the thread kept retrying after a second cancellation and could start a query that nobody stopped. This line now states that a first cancellation keeps the retries and a second one stops them;
test_execute_cancelled_again_while_start_is_retriedcovers it. Follow-up review: CLEAN.