Repository navigation
feat: Consistently Handle FatalDecisionError #200
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
Open
natemort
wants to merge
1
commit into
cadence-workflow:main
Choose a base branch
from
natemort:fatal_decision
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+945
−135
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,23 +1,60 @@ | ||
| import inspect | ||
| import traceback | ||
| from asyncio import AbstractEventLoop, Handle, TimerHandle, futures, tasks, Future, Task | ||
| from contextvars import Context | ||
| import weakref | ||
| from asyncio import ( | ||
| AbstractEventLoop, | ||
| CancelledError, | ||
| Handle, | ||
| TimerHandle, | ||
| futures, | ||
| tasks, | ||
| Future, | ||
| Task, | ||
| ) | ||
| from contextvars import Context, copy_context | ||
| import logging | ||
| import collections | ||
| import asyncio.events as events | ||
| import threading | ||
| from typing import Callable, Any, TypeVar, Coroutine, Awaitable, Generator | ||
| from typing import Callable, Any, TypeVar, Coroutine, Awaitable, Generator, NoReturn | ||
| from typing_extensions import Unpack, TypeVarTuple | ||
|
|
||
| from cadence._internal.workflow.waiter import Waiter | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class FatalDecisionError(Exception): | ||
| class FatalDecisionError(BaseException): | ||
| """Fails the decision task rather than the workflow. | ||
|
|
||
| Extends BaseException so that ``except Exception`` in workflow code doesn't | ||
| swallow it. | ||
| """ | ||
|
|
||
| def __init__(self, *args) -> None: | ||
| super().__init__(*args) | ||
|
|
||
|
|
||
| class DecisionAbortedError(FatalDecisionError): | ||
| """Raised in workflow code once the decision has failed or the loop is closing. | ||
|
|
||
| Its ``__cause__`` is the original failure, if any. The original is never raised | ||
| inside workflow code, so its traceback and context are reported unchanged. | ||
| """ | ||
|
|
||
|
|
||
| def fail_decision(error: FatalDecisionError) -> NoReturn: | ||
| """Mark the running decision as failed and raise error. | ||
|
|
||
| The DeterministicEventLoop stops running workflow code and re-raises error, | ||
| so the decision task fails even if workflow code catches it. | ||
| """ | ||
| loop = events._get_running_loop() | ||
| if isinstance(loop, DeterministicEventLoop): | ||
| loop.fail_decision(error) | ||
| raise error | ||
|
|
||
|
|
||
| _Ts = TypeVarTuple("_Ts") | ||
| _T = TypeVar("_T") | ||
|
|
||
|
|
@@ -37,15 +74,45 @@ def __init__(self) -> None: | |
| self._waiters: list[Waiter] = [] | ||
| self._stopping: bool = False | ||
| self._closed: bool = False | ||
| self._decision_failure: FatalDecisionError | None = None | ||
| # Insertion-ordered so close() unwinds tasks deterministically. | ||
| self._tasks: weakref.WeakValueDictionary[int, _DeterministicTask[Any]] = ( | ||
| weakref.WeakValueDictionary() | ||
| ) | ||
| self._task_seq: int = 0 | ||
|
|
||
| def run_until_yield(self): | ||
| """Run until stop() is called.""" | ||
| """Run until there is no ready work. | ||
|
|
||
| Raises the FatalDecisionError passed to fail_decision(), if any. | ||
| """ | ||
| self._run_forever_setup() | ||
| try: | ||
| while self._ready: | ||
| while self._ready and self._decision_failure is None: | ||
| self._run_once() | ||
| finally: | ||
| self._run_forever_cleanup() | ||
| if self._decision_failure is not None: | ||
| raise self._decision_failure | ||
|
|
||
| def fail_decision(self, error: FatalDecisionError) -> None: | ||
| """Stop running workflow code and fail the decision with error.""" | ||
| if self._decision_failure is None: | ||
| self._decision_failure = error | ||
|
|
||
| def abort_if_decision_failed(self) -> None: | ||
|
Member
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. Do we really need this level of fail-fast? User really has to catch BaseException and have high CPU workflow code to break the SDK. It does make the code a little too complicated. But I'm open to keep it but needs some documentation |
||
| """Raise a DecisionAbortedError in workflow code if the decision has failed | ||
| or the loop is closed.""" | ||
| if self._closed or self._decision_failure is not None: | ||
| raise self._abort_error() | ||
|
|
||
| def _abort_error(self) -> DecisionAbortedError: | ||
| failure = self._decision_failure | ||
| if failure is None: | ||
| return DecisionAbortedError("workflow destroyed") | ||
| error = DecisionAbortedError("decision already failed") | ||
| error.__cause__ = failure | ||
| return error | ||
|
|
||
| # Event Loop APIs | ||
| def call_soon( | ||
|
|
@@ -78,10 +145,12 @@ def run_forever(self) -> None: | |
| try: | ||
| while True: | ||
| self._run_once() | ||
| if self._stopping: | ||
| if self._stopping or self._decision_failure is not None: | ||
| break | ||
| finally: | ||
| self._run_forever_cleanup() | ||
| if self._decision_failure is not None: | ||
| raise self._decision_failure | ||
|
|
||
| def run_until_complete( | ||
| self, future: Generator[Any, None, _T] | Awaitable[_T] | ||
|
|
@@ -126,19 +195,23 @@ def create_task( | |
|
|
||
| Return a task object. | ||
| """ | ||
| self._check_closed() | ||
| if self._closed or self._decision_failure is not None: | ||
| # The loop won't run again, so the task could never start. | ||
| if inspect.iscoroutine(coro): | ||
| coro.close() | ||
| raise self._abort_error() | ||
|
|
||
| # NOTE: eager_start is not supported for deterministic event loop | ||
| if kwargs.get("eager_start", False): | ||
| if kwargs.pop("eager_start", False): | ||
| if inspect.iscoroutine(coro): | ||
| coro.close() | ||
| raise RuntimeError( | ||
| "eager_start in create_task is not supported for deterministic event loop" | ||
| ) | ||
|
|
||
| task = tasks.Task(coro, loop=self, **kwargs) | ||
| # We intentionally destroy pending tasks when shutting down the event loop. | ||
| # If our asyncio implementation supports it, disable the logs | ||
| if hasattr(task, "_log_destroy_pending"): | ||
| setattr(task, "_log_destroy_pending", False) | ||
| task = _DeterministicTask(coro, loop=self, **kwargs) | ||
|
gitar-bot[bot] marked this conversation as resolved.
|
||
| self._tasks[self._task_seq] = task | ||
| self._task_seq += 1 | ||
| return task | ||
|
|
||
| def create_future(self) -> Future[Any]: | ||
|
|
@@ -158,14 +231,19 @@ def _run_once(self) -> None: | |
| if handle._cancelled: | ||
| continue | ||
| handle._run() | ||
| if self._decision_failure is not None: | ||
| return | ||
|
|
||
| # Poll waiters; only stop early if settling one schedules new work, | ||
| # so remaining waiters are not skipped. | ||
| i = 0 | ||
| while i < len(self._waiters): | ||
| w = self._waiters[i] | ||
| ready_before = len(self._ready) | ||
| if w.poll(): | ||
| settled = w.poll() | ||
| if self._decision_failure is not None: | ||
| return | ||
| if settled: | ||
| del self._waiters[i] | ||
| if len(self._ready) > ready_before: | ||
| return | ||
|
|
@@ -175,6 +253,9 @@ def _run_once(self) -> None: | |
| def _run_forever_setup(self) -> None: | ||
| self._check_closed() | ||
| self._check_running() | ||
| self._set_running() | ||
|
|
||
| def _set_running(self) -> None: | ||
| self._thread_id = threading.get_ident() | ||
| events._set_running_loop(self) | ||
|
|
||
|
|
@@ -188,7 +269,7 @@ def stop(self) -> None: | |
|
|
||
| def _check_closed(self) -> None: | ||
| if self._closed: | ||
| raise RuntimeError("Event loop is closed") | ||
| raise self._abort_error() | ||
|
|
||
| def _check_running(self) -> None: | ||
| if self.is_running(): | ||
|
|
@@ -202,18 +283,34 @@ def is_running(self) -> bool: | |
| return self._thread_id is not None | ||
|
|
||
| def close(self) -> None: | ||
| """Close the event loop. | ||
| The event loop must not be running. | ||
| """Close the loop and unwind every pending coroutine. | ||
|
|
||
| The event loop must not be running. Coroutines are unwound directly rather | ||
| than by running the loop, so each finally block runs once and no other | ||
| workflow code is scheduled. Once closed, interacting with the loop raises | ||
| DecisionAbortedError. | ||
| """ | ||
| if self.is_running(): | ||
| raise RuntimeError("Cannot close a running event loop") | ||
| if self._closed: | ||
| return | ||
| if self._debug: | ||
| logger.debug("Close %r", self) | ||
| self._check_running() | ||
| self._closed = True | ||
| self._ready.clear() | ||
| self._waiters.clear() | ||
| # Mark the loop as running so get_running_loop() works in finally blocks. | ||
| self._set_running() | ||
| try: | ||
| # create_task() refuses new tasks once the loop is closed, so finally | ||
| # blocks can't add to this snapshot. | ||
| for task in reversed(list(self._tasks.values())): | ||
| task.destroy(self._abort_error) | ||
| finally: | ||
| self._run_forever_cleanup() | ||
| # Callbacks scheduled while unwinding are dropped on purpose: every | ||
| # task they would wake has already been unwound. | ||
| self._ready.clear() | ||
| self._waiters.clear() | ||
|
|
||
| def is_closed(self) -> bool: | ||
| """Returns True if the event loop was closed.""" | ||
|
|
@@ -547,6 +644,78 @@ async def connect_accepted_socket( | |
| ) | ||
|
|
||
|
|
||
| # Bounds how long workflow code that keeps catching the error can delay close(). | ||
| _MAX_DESTROY_THROWS = 100 | ||
|
|
||
|
|
||
| class _DeterministicTask(Task[_T]): | ||
| """A Task that keeps its Context so close() can unwind it in that Context.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| coro: Generator[Any, None, _T] | Coroutine[Any, Any, _T], | ||
| *, | ||
| loop: AbstractEventLoop, | ||
| name: str | None = None, | ||
| context: Context | None = None, | ||
| ) -> None: | ||
| if context is None: | ||
| context = copy_context() | ||
| super().__init__(coro, loop=loop, name=name, context=context) | ||
| self._destroy_context = context | ||
| # close() unwinds coroutines directly, so tasks are never marked done. | ||
| # Disable the "Task was destroyed but it is pending!" log for them. | ||
| if hasattr(self, "_log_destroy_pending"): | ||
| setattr(self, "_log_destroy_pending", False) | ||
|
|
||
| def destroy(self, abort_error: Callable[[], DecisionAbortedError]) -> None: | ||
| if self.done(): | ||
| if isinstance(getattr(self, "_exception", None), FatalDecisionError): | ||
| # Already surfaced as the decision failure, don't log it again | ||
| # as "Task exception was never retrieved". Other exceptions are | ||
| # left alone so unretrieved user errors are still reported. | ||
| self._log_traceback = False | ||
| return | ||
| coro = self.get_coro() | ||
| if not inspect.iscoroutine(coro): | ||
| return | ||
| state = inspect.getcoroutinestate(coro) | ||
| if state == inspect.CORO_CREATED: | ||
| # Never started: closing runs no workflow code. | ||
| coro.close() | ||
| return | ||
| if state != inspect.CORO_SUSPENDED: | ||
| return | ||
| # Each async context manager whose __aexit__ awaits (e.g. a TaskGroup waiting on | ||
| # its children) yields once while handling the error, so throw again at each new | ||
| # await point until the coroutine finishes. close() can't do this: awaiting while | ||
| # handling GeneratorExit fails and leaves the coroutine for the garbage collector. | ||
| for _ in range(_MAX_DESTROY_THROWS): | ||
| try: | ||
| self._destroy_context.run(coro.throw, abort_error()) | ||
| except (StopIteration, FatalDecisionError): | ||
| return | ||
| except BaseExceptionGroup as e: | ||
| if e.split(FatalDecisionError)[1] is not None: | ||
| logger.warning( | ||
| "Workflow code raised while being destroyed", exc_info=True | ||
| ) | ||
| return | ||
| except (Exception, CancelledError): | ||
| logger.warning( | ||
| "Workflow code raised while being destroyed", exc_info=True | ||
| ) | ||
| return | ||
| try: | ||
| self._destroy_context.run(coro.close) | ||
| except (Exception, CancelledError): | ||
| logger.error( | ||
| "Workflow coroutine could not be destroyed, it will be finalized by " | ||
| "the garbage collector", | ||
| exc_info=True, | ||
| ) | ||
|
|
||
|
|
||
| def _run_until_complete_cb(fut: Future[Any]) -> None: | ||
| if not fut.cancelled(): | ||
| exc = fut.exception() | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
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.
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.
nit: use public API asyncio.get_running_loop but need to handle RuntimeError if not found. In this case, maybe instead raise FatalDecision("bug: run fail_decision not in deterministic eventloop") from