-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Python: discard pending State on failed/cancelled superstep (#7859) #8306
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
base: main
Are you sure you want to change the base?
Changes from all commits
6f7973d
7cacb8a
0dad1f9
ad6a77e
8dcbb9e
3bff1df
07c4334
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 |
|---|---|---|
|
|
@@ -81,6 +81,9 @@ def __init__( | |
| self._iteration = 0 | ||
| self._max_iterations = max_iterations | ||
| self._state = state | ||
| # When True, Workflow.run must reject successors even if the ResponseStream | ||
| # weakref is already gone (cleanup still in progress after a drop/cancel). | ||
| self._blocking_reuse_until_cleanup = False | ||
|
|
||
| # Checkpointing related attributes | ||
| self._previous_checkpoint_id: CheckpointID | None = None | ||
|
|
@@ -128,51 +131,83 @@ async def run_until_convergence(self) -> AsyncGenerator[WorkflowEvent, None]: | |
| # Run iteration concurrently with live event streaming: we poll | ||
| # for new events while the iteration coroutine progresses. | ||
| iteration_task = asyncio.create_task(self._run_iteration()) | ||
| # Track commit so cancel/abort cleanup spans the whole superstep | ||
| # (polling → await iteration → drain → commit), not only the poll loop (#7859). | ||
| committed = False | ||
| # Defer failure events until after discard so dropping the ResponseStream | ||
| # cannot race a successor commit of stale pending writes (#7859). | ||
| deferred_failure_events: list[WorkflowEvent] = [] | ||
| try: | ||
| self._blocking_reuse_until_cleanup = True | ||
| while not iteration_task.done(): | ||
| try: | ||
| # Wait briefly for any new event; timeout allows progress checks | ||
| event = await asyncio.wait_for(self._ctx.next_event(), timeout=0.05) | ||
| yield event | ||
| if event.type == "executor_failed": | ||
| deferred_failure_events.append(event) | ||
| else: | ||
| yield event | ||
| except asyncio.TimeoutError: | ||
| # Periodically continue to let iteration advance | ||
| continue | ||
| except asyncio.CancelledError: | ||
| # Propagate cancellation to the iteration task to avoid orphaned work | ||
| iteration_task.cancel() | ||
| with contextlib.suppress(asyncio.CancelledError): | ||
| await iteration_task | ||
| raise | ||
|
|
||
| # Propagate errors from iteration, but first surface any pending events | ||
| try: | ||
| await iteration_task | ||
| except Exception: | ||
| # Make sure failure-related events (like ExecutorFailedEvent) are surfaced | ||
| if await self._ctx.has_events(): | ||
| for event in await self._ctx.drain_events(): | ||
| # Propagate errors from iteration, but first surface any pending events | ||
| try: | ||
| await iteration_task | ||
| except Exception: | ||
| # Discard staged writes immediately — before any await/yield — so a | ||
| # streaming consumer that stops after executor_failed cannot leave | ||
| # pending state for a later run to commit (#7859). | ||
| self._state.discard() | ||
| for event in deferred_failure_events: | ||
| yield event | ||
| raise | ||
| self._iteration += 1 | ||
| deferred_failure_events.clear() | ||
| if await self._ctx.has_events(): | ||
| for event in await self._ctx.drain_events(): | ||
| yield event | ||
| raise | ||
|
|
||
| # Drain any straggler events emitted at tail end | ||
| if await self._ctx.has_events(): | ||
| for event in await self._ctx.drain_events(): | ||
| for event in deferred_failure_events: | ||
| yield event | ||
| deferred_failure_events.clear() | ||
|
|
||
| logger.info(f"Completed superstep {self._iteration}") | ||
|
|
||
| # Commit pending state changes at superstep boundary | ||
| self._state.commit() | ||
| self._iteration += 1 | ||
|
|
||
| # Create checkpoint after each superstep iteration | ||
| await self.create_checkpoint_if_enabled() | ||
|
|
||
| yield WorkflowEvent.superstep_completed(iteration=self._iteration) | ||
| # Drain any straggler events emitted at tail end | ||
| if await self._ctx.has_events(): | ||
| for event in await self._ctx.drain_events(): | ||
| yield event | ||
|
|
||
| # Check for convergence: no more messages to process | ||
| if not await self._ctx.has_messages(): | ||
| break | ||
| logger.info(f"Completed superstep {self._iteration}") | ||
|
|
||
| # Commit pending state changes at superstep boundary | ||
| self._state.commit() | ||
| committed = True | ||
|
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. This flag only records that the superstep payload was committed; checkpoint preparation below can still stage new pending state before its own commit. If checkpoint preparation is cancelled or fails after staging executor/edge state, the |
||
|
|
||
| # Create checkpoint after each superstep iteration | ||
| await self.create_checkpoint_if_enabled() | ||
|
|
||
| yield WorkflowEvent.superstep_completed(iteration=self._iteration) | ||
|
|
||
| # Check for convergence: no more messages to process | ||
| if not await self._ctx.has_messages(): | ||
| break | ||
| except BaseException: | ||
| # Cancel during poll/drain, or an iteration task that ends cancelled, | ||
| # must still abandon staged writes before the commit boundary (#7859). | ||
| # Await cleanup with broad suppression so a raising executor ``finally`` | ||
| # cannot skip discard below. | ||
| if not iteration_task.done(): | ||
| iteration_task.cancel() | ||
| with contextlib.suppress(BaseException): | ||
| await iteration_task | ||
| raise | ||
| finally: | ||
| # Always discard on abort — including when iteration-task await above | ||
| # raised from executor cleanup — so staged writes cannot leak (#7859). | ||
| if not committed: | ||
| self._state.discard() | ||
|
ktz03 marked this conversation as resolved.
|
||
| self._blocking_reuse_until_cleanup = False | ||
|
|
||
| logger.info(f"Workflow completed after {self._iteration} supersteps") | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -392,6 +392,9 @@ def __init__( | |
| # ever iterating, the weakref dereferences to ``None`` once Python collects it, | ||
| # so a subsequent ``run()`` is allowed. | ||
| self._active_run: weakref.ref[ResponseStream[WorkflowEvent, WorkflowRunResult]] | None = None | ||
| # Strong hold for non-streaming exclusive entry points (e.g. cancel_pending_requests) | ||
| # that mutate the same runner/state without installing a ResponseStream weakref. | ||
| self._exclusive_run_hold: bool = False | ||
|
|
||
| @property | ||
| def status(self) -> WorkflowRunState: | ||
|
|
@@ -1307,6 +1310,14 @@ async def cancel_pending_requests( | |
| if not all(isinstance(request_id, str) and request_id for request_id in selected_ids): | ||
| raise ValueError("Pending workflow request IDs must be non-empty strings.") | ||
|
|
||
| # Share the same active/cleanup lock as ``run()`` so a cancellation continuation | ||
| # cannot start during ResponseStream cleanup (or while another run holds the lock) | ||
| # and commit pending State before the abandoned run discards it (#7859). | ||
| if self._is_run_active(): | ||
| raise WorkflowException( | ||
| "Workflow is already running; concurrent runs are not allowed on the same instance." | ||
| ) | ||
|
|
||
| async def apply_cancellations() -> None: | ||
| cancelled_events = await self._runner.context.cancel_request_info_events(selected_ids) | ||
| for request_id, request_event in cancelled_events.items(): | ||
|
|
@@ -1321,6 +1332,7 @@ async def apply_cancellations() -> None: | |
| ) | ||
| await executor._cancel_pending_request(request_id, context) # pyright: ignore[reportPrivateUsage] | ||
|
|
||
| self._exclusive_run_hold = True | ||
|
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. This hold needs to be released if setup below fails. |
||
| if checkpoint_storage is not None: | ||
| self._runner.context.set_runtime_checkpoint_storage(checkpoint_storage) | ||
| runtime_tools = normalize_tools(tools) if tools is not None else None | ||
|
|
@@ -1341,6 +1353,7 @@ async def apply_cancellations() -> None: | |
| continue | ||
| events.append(event) | ||
| finally: | ||
| self._exclusive_run_hold = False | ||
| if checkpoint_storage is not None: | ||
| self._runner.context.clear_runtime_checkpoint_storage() | ||
| self._runner.context.clear_runtime_tools() | ||
|
|
@@ -1395,5 +1408,11 @@ def _is_run_active(self) -> bool: | |
| Returns: | ||
| True if a run is active, False otherwise. | ||
| """ | ||
| if self._exclusive_run_hold: | ||
| return True | ||
| # Runner cleanup can outlive a dropped ResponseStream (weakref cleared on GC). | ||
| # Keep the instance reserved until pending State is discarded (#7859). | ||
| if getattr(self._runner, "_blocking_reuse_until_cleanup", False): | ||
|
ktz03 marked this conversation as resolved.
|
||
| return True | ||
| existing_stream = self._active_run() if self._active_run is not None else None | ||
| return existing_stream is not None | ||
Uh oh!
There was an error while loading. Please reload this page.