-
Notifications
You must be signed in to change notification settings - Fork 114
Cancel a Spark calculation interrupted while it is being started #861
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
b558849
e2968c3
25bd87e
2ca1812
7cb6e58
de2cf80
1466e7b
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 |
|---|---|---|
|
|
@@ -9,14 +9,17 @@ | |
|
|
||
| import contextlib | ||
| import logging | ||
| import threading | ||
| import time | ||
| import uuid | ||
| from abc import ABCMeta, abstractmethod | ||
| from concurrent.futures import Future, wait | ||
| from datetime import datetime | ||
| from typing import Any, cast | ||
|
|
||
| import botocore | ||
|
|
||
| from pyathena import NotSupportedError, OperationalError | ||
| from pyathena import DatabaseError, NotSupportedError, OperationalError | ||
| from pyathena.common import BaseCursor | ||
| from pyathena.model import ( | ||
| AthenaCalculationExecution, | ||
|
|
@@ -28,6 +31,11 @@ | |
|
|
||
| _logger = logging.getLogger(__name__) | ||
|
|
||
| # How often a wait for the start request wakes up to check for Ctrl-C, so that | ||
| # a KeyboardInterrupt is raised promptly where an untimed lock wait cannot be | ||
| # interrupted by signals (Windows before Python 3.14). | ||
| _INTERRUPT_CHECK_INTERVAL = 0.1 | ||
|
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. Repair: rename The series moved from What changed:
Self-review round one (behavior): CLEAN. The rename is behavior-neutral. After the rebase, Self-review round two (claims): CLEAN. The comment's version and platform claim matches the Python docs, and the commit message says the same. Independent follow-up (relayed): CLEAN.
|
||
|
|
||
|
|
||
| class SparkBaseCursor(BaseCursor, metaclass=ABCMeta): | ||
| """Abstract base class for Spark-enabled cursor implementations. | ||
|
|
@@ -340,14 +348,136 @@ def _poll(self, query_id: str) -> AthenaQueryExecution | AthenaCalculationExecut | |
| raise | ||
| _logger.warning("Query canceled by user.") | ||
| try: | ||
| self._cancel(query_id) | ||
| self._calculation_execution = cast( | ||
| AthenaCalculationExecution, self.__poll(query_id) | ||
| ) | ||
| self.__cancel_and_wait(query_id) | ||
| except Exception as e: | ||
| raise interrupt from e | ||
| raise | ||
|
|
||
| def __cancel_and_wait(self, calculation_id: str) -> None: | ||
| """Request cancellation and store the calculation's terminal state. | ||
|
|
||
| Args: | ||
| calculation_id: The calculation execution ID. | ||
|
|
||
| Raises: | ||
| OperationalError: If the cancellation or a status request fails. | ||
| """ | ||
| self._cancel(calculation_id) | ||
| self._calculation_execution = cast(AthenaCalculationExecution, self.__poll(calculation_id)) | ||
|
|
||
| def _calculate( | ||
|
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. Self-review round one — implementation behavior (base Covered: Result: CLEAN (no actionable findings). Checks:
Recorded limitations (not introduced here): a |
||
| self, | ||
| session_id: str, | ||
| code_block: str, | ||
| description: str | None = None, | ||
| client_request_token: str | None = None, | ||
| ) -> str: | ||
| """Start a calculation execution with ``StartCalculationExecution``. | ||
|
|
||
| Without ``client_request_token``, a generated token is sent, so that a | ||
| retried request returns the calculation an earlier attempt started instead | ||
| of starting another one. | ||
|
|
||
| With ``kill_on_interrupt`` enabled, the request runs on a helper thread. | ||
| On ``KeyboardInterrupt``, the cursor first tries to abandon the request. | ||
| This succeeds only if the helper has not begun the request by then; the | ||
| helper then never sends it, and the interrupt 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 the interrupt. Another | ||
| ``KeyboardInterrupt`` during that wait propagates at once. | ||
|
|
||
| Args: | ||
| session_id: The session ID. | ||
| code_block: The code to run. | ||
| description: The calculation description. | ||
| client_request_token: The idempotency token of the request. | ||
|
|
||
| Returns: | ||
| The calculation execution ID. | ||
|
|
||
| Raises: | ||
| KeyboardInterrupt: If interrupted while starting the calculation. A | ||
| failure to start, cancel, or wait for the calculation becomes its | ||
| ``__cause__``. | ||
| DatabaseError: If the request fails. | ||
| """ | ||
| request = self._build_start_calculation_execution_request( | ||
| session_id=session_id, | ||
| code_block=code_block, | ||
| description=description, | ||
| client_request_token=client_request_token or str(uuid.uuid4()), | ||
| ) | ||
| if not self._kill_on_interrupt: | ||
| return self.__start_calculation(request) | ||
|
|
||
| future: Future[str] = Future() | ||
|
|
||
| def start() -> None: | ||
| # Begin the request only if no interrupt has given up on it yet. | ||
| if not future.set_running_or_notify_cancel(): | ||
| return | ||
| try: | ||
| future.set_result(self.__start_calculation(request)) | ||
| except BaseException as e: | ||
| future.set_exception(e) | ||
|
|
||
| try: | ||
| threading.Thread(target=start, name="pyathena-spark-start", daemon=True).start() | ||
|
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 CLI 0.157.0, Coverage reported: the full diff (Spark cursor changes, offline tests, Verdict: FINDINGS (1)
Verified and repaired in
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. Repair self-review of Round one (behavior): CLEAN. Round two (claims): CLEAN. The commit message matches the control flow. The
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 follow-up (relayed): Codex CLI 0.157.0, read-only, Verdict: FINDINGS (2 × P2). Both findings were checked against the code, and neither leaves a calculation running without a stop request:
Repair self-review of
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 follow-ups on the docstring repair (relayed). Codex CLI 0.157.0, read-only,
Repair self-review of
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. Rebase onto
Self-review round one (behavior): CLEAN.
Self-review round two (claims): CLEAN.
Independent follow-up (relayed): CLEAN.
|
||
| return self.__wait_for_start(future) | ||
| except KeyboardInterrupt as interrupt: | ||
| if future.cancel(): | ||
| # The helper has not begun the request and never will. | ||
| raise | ||
| _logger.warning("Query canceled by user.") | ||
| try: | ||
| self._calculation_id = self.__wait_for_start(future) | ||
| self.__cancel_and_wait(self._calculation_id) | ||
| except Exception as e: | ||
| raise interrupt from e | ||
| raise | ||
|
|
||
| def __start_calculation(self, request: dict[str, Any]) -> str: | ||
| """Send a ``StartCalculationExecution`` request. | ||
|
|
||
| Args: | ||
| request: The request parameters. | ||
|
|
||
| Returns: | ||
| The calculation execution ID. | ||
|
|
||
| Raises: | ||
| DatabaseError: If the request fails. | ||
| """ | ||
| try: | ||
| response = retry_api_call( | ||
| self._connection.client.start_calculation_execution, | ||
| config=self._retry_config, | ||
| logger=_logger, | ||
| **request, | ||
| ) | ||
| except Exception as e: | ||
| _logger.exception("Failed to execute calculation.") | ||
| raise DatabaseError(*e.args) from e | ||
| return cast(str, response.get("CalculationExecutionId")) | ||
|
|
||
| @staticmethod | ||
| def __wait_for_start(future: Future[str]) -> str: | ||
| """Wait for the start request on a helper thread to finish. | ||
|
|
||
| Args: | ||
| future: The future of the start request. | ||
|
|
||
| Returns: | ||
| The calculation execution ID. | ||
|
|
||
| Raises: | ||
| DatabaseError: If the request failed. | ||
| """ | ||
| while not future.done(): | ||
| wait((future,), timeout=_INTERRUPT_CHECK_INTERVAL) | ||
| return future.result() | ||
|
|
||
| def _cancel(self, query_id: str) -> None: | ||
| """Stop a calculation execution with ``StopCalculationExecution``. | ||
|
|
||
|
|
||
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.
Self-review round two — claims, callers, operations (head
c0188ced93137cd106a416bd092affaf13c4a552)Result: FINDINGS, repaired in
c0188ceand the PR body.Claims checked:
ClientRequestTokenasidempotencyTokenforStartQueryExecutionbut not forStartCalculationExecution/StartSession— confirmed from the service model.CodeBlock; a different token on a busy session fails withInvalidRequestException; a stop right after the start was ignored in 1 of 6 attempts — from the Spark: an interrupt during StartCalculationExecution leaves the calculation running #841 measurement.Findings and repairs:
client_request_tokenreused with different code silently returns the earlier calculation (measured). Added to this paragraph.Caller compatibility:
_calculate's signature and return are unchanged;execute(client_request_token=...)still sends the caller's token;kill_on_interrupt=Falsekeeps the old control flow. AWS operator: one extra thread perexecute()withkill_on_interrupt; retries of the start request now reuse one token, so they cannot create a second calculation.