From b94877c6cfd85dd5a9dd2a6d6763ddcbb6b64093 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 2 Oct 2024 07:35:34 -0400 Subject: [PATCH 01/20] message_handler_waiting_compensation_cleanup --- .../README.md | 71 ++++++++++ .../__init__.py | 27 ++++ .../activities.py | 8 ++ .../starter.py | 84 ++++++++++++ .../worker.py | 40 ++++++ .../workflows.py | 121 ++++++++++++++++++ 6 files changed, 351 insertions(+) create mode 100644 message_passing/message_handler_waiting_compensation_cleanup/README.md create mode 100644 message_passing/message_handler_waiting_compensation_cleanup/__init__.py create mode 100644 message_passing/message_handler_waiting_compensation_cleanup/activities.py create mode 100644 message_passing/message_handler_waiting_compensation_cleanup/starter.py create mode 100644 message_passing/message_handler_waiting_compensation_cleanup/worker.py create mode 100644 message_passing/message_handler_waiting_compensation_cleanup/workflows.py diff --git a/message_passing/message_handler_waiting_compensation_cleanup/README.md b/message_passing/message_handler_waiting_compensation_cleanup/README.md new file mode 100644 index 00000000..0bbb1a97 --- /dev/null +++ b/message_passing/message_handler_waiting_compensation_cleanup/README.md @@ -0,0 +1,71 @@ +# Waiting for message handlers, and performing compensation and cleanup in message handlers + +This sample demonstrates the following recommended practices: + +1. Ensuring that all signal and update handlers are finished before a successful + workflow return, and on workflow failure, cancellation, and continue-as-new. +2. Performing necessary compensation/cleanup in an update handler when the + workflow is cancelled, fails, or continues-as-new. + + +To run, open two terminals and `cd` to this directory in them. + +Run the worker in one terminal: + + poetry run python worker.py + +And run the workflow-starter code in the other terminal: + + poetry run python starter.py + + +Here's the output you'll see, along with some explanation: + +``` +workflow exit type: success + update action on premature workflow exit: continue + 👇 [Caller gets a successful update response because main workflow method waits for handlers to finish] + 🟢 caller received update result + 🟢 caller received workflow result + update action on premature workflow exit: abort_with_compensation + 👇 [Same as above: the workflow is successful for action-on-premature exit is irrelevant] + 🟢 caller received update result + 🟢 caller received workflow result + + +workflow exit type: failure + update action on premature workflow exit: continue + 👇 [update does not abort and main workflow method waits for handlers to finish => caller gets successful update result prior to workflow failure] + 🟢 caller received update result + 🔴 caught exception while waiting for workflow result: Workflow execution failed: deliberately failing workflow + update action on premature workflow exit: abort_with_compensation + 👇 [update aborts, compensates and raises => caller gets failed update result] + 🔴 caught exception while waiting for update result: Workflow update failed: The update failed because the workflow run exited: deliberately failing workflow + 🔴 caught exception while waiting for workflow result: Workflow execution failed: deliberately failing workflow + + +workflow exit type: cancellation + update action on premature workflow exit: continue + 👇 [update does not abort and main workflow method waits for handlers to finish => caller gets successful update result prior to workflow cancellation] + 🟢 caller received update result + 🔴 caught exception while waiting for workflow result: Workflow execution failed: Workflow cancelled + update action on premature workflow exit: abort_with_compensation + 👇 [update aborts, compensates and raises => caller gets failed update result] + 🔴 caught exception while waiting for update result: Workflow update failed: The update failed because the workflow run exited: + 🔴 caught exception while waiting for workflow result: Workflow execution failed: Workflow cancelled + + +workflow exit type: continue_as_new + update action on premature workflow exit: continue + 👇 [update does not abort and main workflow method waits for handlers to finish => caller gets successful update result prior to continue-as-new] + 🟢 caller received update result + 👇 [a second update is sent to the post-CAN run, which run succeeds, hence update succeeds] + 🟢 caller received update result + 🟢 caller received workflow result + update action on premature workflow exit: abort_with_compensation + 👇 [update aborts, compensates and raises => caller gets failed update result] + 🔴 caught exception while waiting for update result: update "50cd58dc-2db7-4a70-9204-bf5922203203" not found: + 👇 [a second update is sent to the post-CAN run, which run succeeds, hence update succeeds] + 🟢 caller received update result + 🟢 caller received workflow result +``` \ No newline at end of file diff --git a/message_passing/message_handler_waiting_compensation_cleanup/__init__.py b/message_passing/message_handler_waiting_compensation_cleanup/__init__.py new file mode 100644 index 00000000..b255147e --- /dev/null +++ b/message_passing/message_handler_waiting_compensation_cleanup/__init__.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from enum import StrEnum + +TASK_QUEUE = "my-task-queue" +WORKFLOW_ID = "my-workflow-id" + + +class WorkflowExitType(StrEnum): + SUCCESS = "success" + FAILURE = "failure" + CONTINUE_AS_NEW = "continue_as_new" + CANCELLATION = "cancellation" + + +@dataclass +class WorkflowInput: + exit_type: WorkflowExitType + + +class OnWorkflowExitAction(StrEnum): + CONTINUE = "continue" + ABORT_WITH_COMPENSATION = "abort_with_compensation" + + +@dataclass +class UpdateInput: + on_premature_workflow_exit: OnWorkflowExitAction diff --git a/message_passing/message_handler_waiting_compensation_cleanup/activities.py b/message_passing/message_handler_waiting_compensation_cleanup/activities.py new file mode 100644 index 00000000..1a4a16d7 --- /dev/null +++ b/message_passing/message_handler_waiting_compensation_cleanup/activities.py @@ -0,0 +1,8 @@ +import asyncio + +from temporalio import activity + + +@activity.defn +async def my_activity(): + await asyncio.sleep(1) diff --git a/message_passing/message_handler_waiting_compensation_cleanup/starter.py b/message_passing/message_handler_waiting_compensation_cleanup/starter.py new file mode 100644 index 00000000..e436881c --- /dev/null +++ b/message_passing/message_handler_waiting_compensation_cleanup/starter.py @@ -0,0 +1,84 @@ +import asyncio +from contextlib import contextmanager + +from temporalio import client, common + +from message_passing.message_handler_waiting_compensation_cleanup import ( + TASK_QUEUE, + WORKFLOW_ID, + OnWorkflowExitAction, + UpdateInput, + WorkflowExitType, + WorkflowInput, +) +from message_passing.message_handler_waiting_compensation_cleanup.workflows import ( + MyWorkflow, +) + + +async def starter(exit_type: WorkflowExitType, update_action: OnWorkflowExitAction): + cl = await client.Client.connect("localhost:7233") + wf_handle = await cl.start_workflow( + MyWorkflow.run, + WorkflowInput(exit_type=exit_type), + id=WORKFLOW_ID, + task_queue=TASK_QUEUE, + id_reuse_policy=common.WorkflowIDReusePolicy.TERMINATE_IF_RUNNING, + ) + await _check_run(wf_handle, exit_type, update_action) + + +async def _check_run( + wf_handle: client.WorkflowHandle, + exit_type: WorkflowExitType, + update_action: OnWorkflowExitAction, +): + with catch("starting update"): + up_handle = await wf_handle.start_update( + MyWorkflow.my_update, + UpdateInput(on_premature_workflow_exit=update_action), + wait_for_stage=client.WorkflowUpdateStage.ACCEPTED, + ) + + if exit_type == WorkflowExitType.CANCELLATION: + await wf_handle.cancel() + + with catch("waiting for update result"): + await up_handle.result() + print(" 🟢 caller received update result") + + if exit_type == WorkflowExitType.CONTINUE_AS_NEW: + await _check_run(wf_handle, WorkflowExitType.SUCCESS, update_action) + else: + with catch("waiting for workflow result"): + await wf_handle.result() + print(" 🟢 caller received workflow result") + + +@contextmanager +def catch(operation: str): + try: + yield + except Exception as e: + cause = getattr(e, "cause", None) + print(f" 🔴 caught exception while {operation}: {e}: {cause or ''}") + + +async def main(): + for exit_type in [ + WorkflowExitType.SUCCESS, + WorkflowExitType.FAILURE, + WorkflowExitType.CANCELLATION, + WorkflowExitType.CONTINUE_AS_NEW, + ]: + print(f"\n\nworkflow exit type: {exit_type}") + for update_action in [ + OnWorkflowExitAction.CONTINUE, + OnWorkflowExitAction.ABORT_WITH_COMPENSATION, + ]: + print(f" update action on premature workflow exit: {update_action}") + await starter(exit_type, update_action) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/message_passing/message_handler_waiting_compensation_cleanup/worker.py b/message_passing/message_handler_waiting_compensation_cleanup/worker.py new file mode 100644 index 00000000..c53cc441 --- /dev/null +++ b/message_passing/message_handler_waiting_compensation_cleanup/worker.py @@ -0,0 +1,40 @@ +import asyncio +import logging + +from temporalio.client import Client +from temporalio.worker import Worker + +from message_passing.message_handler_waiting_compensation_cleanup import TASK_QUEUE +from message_passing.message_handler_waiting_compensation_cleanup.activities import ( + my_activity, +) +from message_passing.message_handler_waiting_compensation_cleanup.workflows import ( + MyWorkflow, +) + +interrupt_event = asyncio.Event() + + +async def main(): + logging.basicConfig(level=logging.INFO) + + client = await Client.connect("localhost:7233") + + async with Worker( + client, + task_queue=TASK_QUEUE, + workflows=[MyWorkflow], + activities=[my_activity], + ): + logging.info("Worker started, ctrl+c to exit") + await interrupt_event.wait() + logging.info("Shutting down") + + +if __name__ == "__main__": + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(main()) + except KeyboardInterrupt: + interrupt_event.set() + loop.run_until_complete(loop.shutdown_asyncgens()) diff --git a/message_passing/message_handler_waiting_compensation_cleanup/workflows.py b/message_passing/message_handler_waiting_compensation_cleanup/workflows.py new file mode 100644 index 00000000..0adafbc9 --- /dev/null +++ b/message_passing/message_handler_waiting_compensation_cleanup/workflows.py @@ -0,0 +1,121 @@ +import asyncio +from datetime import timedelta + +from temporalio import exceptions, workflow + +from message_passing.message_handler_waiting_compensation_cleanup import ( + OnWorkflowExitAction, + UpdateInput, + WorkflowExitType, + WorkflowInput, +) +from message_passing.message_handler_waiting_compensation_cleanup.activities import ( + my_activity, +) + + +@workflow.defn +class MyWorkflow: + """ + This Workflow upholds the following recommended practices: + + 1. The main workflow method ensures that all signal and update handlers are + finished before a successful return, and on failure, cancellation, and + continue-as-new. + 2. The update handler performs any necessary compensation/cleanup when the + workflow is cancelled, fails, or continues-as-new. + """ + + def __init__(self) -> None: + self.workflow_exit_exception: asyncio.Future[BaseException] = asyncio.Future() + + @workflow.run + async def run(self, input: WorkflowInput) -> str: + try: + # 👉 Use this `try...except` style, instead of waiting for message + # handlers to finish in a `finally` block. The reason is that other + # exception types will cause a Workflow Task failure, in which case + # we do *not* want to wait for message handlers to finish. + result = await self._run(input) + await workflow.wait_condition(workflow.all_handlers_finished) + return result + except ( + asyncio.CancelledError, + workflow.ContinueAsNewError, + exceptions.FailureError, + ) as exc: + self.workflow_exit_exception.set_result(exc) + await workflow.wait_condition(workflow.all_handlers_finished) + raise exc + + @workflow.update + async def my_update(self, input: UpdateInput) -> str: + """ + This update handler demonstrates how to handle the situation where the + main Workflow method exits prematurely. In that case we perform + compensation/cleanup, and fail the Update. The Update caller will get a + WorkflowUpdateFailedError. + """ + # Coroutines must be wrapped in tasks in order to use workflow.wait. + update_task = asyncio.Task(self._my_update()) + # 👉 Always use `workflow.wait` instead of `asyncio.wait` in Workflow + # code: asyncio's version is non-deterministic. + first_completed, _ = await workflow.wait( + [update_task, self.workflow_exit_exception], + return_when=asyncio.FIRST_COMPLETED, + ) + # 👉 It's possible that the update completed and the workflow exited + # prematurely in the same tick of the event loop. If the Update has + # completed, return the Update result to the caller, whether or not the + # Workflow is exiting. + if ( + update_task in first_completed + or input.on_premature_workflow_exit == OnWorkflowExitAction.CONTINUE + ): + return await update_task + else: + await self._my_update_compensation_and_cleanup() + raise exceptions.ApplicationError( + f"The update failed because the workflow run exited: {await self.workflow_exit_exception}" + ) + + async def _my_update(self) -> str: + """ + This handler calls a slow activity, so + + (1) In the case where the workflow finishes successfully, the worker + would get an UnfinishedUpdateHandlersWarning (TMPRL1102) if the main + workflow task didn't wait for it to finish. + + (2) In the other cases (failure, cancellation, and continue-as-new), the + premature workflow exit will occur before the update is finished. + """ + # Ignore: implementation detail specific to this sample + self._update_started = True + + await workflow.execute_activity( + my_activity, start_to_close_timeout=timedelta(seconds=10) + ) + return "update-result" + + async def _my_update_compensation_and_cleanup(self): + workflow.logger.info( + "performing update handler compensation and cleanup operations" + ) + + async def _run(self, input: WorkflowInput) -> str: + # Ignore this method unless you are interested in the implementation + # details of this sample. + + # Wait until handlers started, so that we are demonstrating that we wait for them to finish. + await workflow.wait_condition(lambda: getattr(self, "_update_started", False)) + if input.exit_type == WorkflowExitType.SUCCESS: + return "workflow-result" + elif input.exit_type == WorkflowExitType.CONTINUE_AS_NEW: + workflow.continue_as_new(WorkflowInput(exit_type=WorkflowExitType.SUCCESS)) + elif input.exit_type == WorkflowExitType.FAILURE: + raise exceptions.ApplicationError("deliberately failing workflow") + elif input.exit_type == WorkflowExitType.CANCELLATION: + # Block forever; the starter will send a workflow cancellation request. + await asyncio.Future() + raise AssertionError("unreachable") From 65a04b791fcaf0bd8545cae22cf9d4febaebb331 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 2 Oct 2024 13:23:33 -0400 Subject: [PATCH 02/20] 3.8 compat --- .../__init__.py | 18 +++++++++--------- .../workflows.py | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/message_passing/message_handler_waiting_compensation_cleanup/__init__.py b/message_passing/message_handler_waiting_compensation_cleanup/__init__.py index b255147e..a27233c6 100644 --- a/message_passing/message_handler_waiting_compensation_cleanup/__init__.py +++ b/message_passing/message_handler_waiting_compensation_cleanup/__init__.py @@ -1,15 +1,15 @@ from dataclasses import dataclass -from enum import StrEnum +from enum import IntEnum TASK_QUEUE = "my-task-queue" WORKFLOW_ID = "my-workflow-id" -class WorkflowExitType(StrEnum): - SUCCESS = "success" - FAILURE = "failure" - CONTINUE_AS_NEW = "continue_as_new" - CANCELLATION = "cancellation" +class WorkflowExitType(IntEnum): + SUCCESS = 0 + FAILURE = 1 + CONTINUE_AS_NEW = 2 + CANCELLATION = 3 @dataclass @@ -17,9 +17,9 @@ class WorkflowInput: exit_type: WorkflowExitType -class OnWorkflowExitAction(StrEnum): - CONTINUE = "continue" - ABORT_WITH_COMPENSATION = "abort_with_compensation" +class OnWorkflowExitAction(IntEnum): + CONTINUE = 0 + ABORT_WITH_COMPENSATION = 1 @dataclass diff --git a/message_passing/message_handler_waiting_compensation_cleanup/workflows.py b/message_passing/message_handler_waiting_compensation_cleanup/workflows.py index 0adafbc9..aac04b01 100644 --- a/message_passing/message_handler_waiting_compensation_cleanup/workflows.py +++ b/message_passing/message_handler_waiting_compensation_cleanup/workflows.py @@ -60,7 +60,7 @@ async def my_update(self, input: UpdateInput) -> str: update_task = asyncio.Task(self._my_update()) # 👉 Always use `workflow.wait` instead of `asyncio.wait` in Workflow # code: asyncio's version is non-deterministic. - first_completed, _ = await workflow.wait( + first_completed, _ = await workflow.wait( # type: ignore [update_task, self.workflow_exit_exception], return_when=asyncio.FIRST_COMPLETED, ) From de618f934a548e912f4cc874e753658318d71c6a Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 3 Oct 2024 12:10:01 -0400 Subject: [PATCH 03/20] Improvements from code review --- .../__init__.py | 4 +- .../starter.py | 40 +++++++++---------- .../workflows.py | 7 +++- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/message_passing/message_handler_waiting_compensation_cleanup/__init__.py b/message_passing/message_handler_waiting_compensation_cleanup/__init__.py index a27233c6..b3a49b4f 100644 --- a/message_passing/message_handler_waiting_compensation_cleanup/__init__.py +++ b/message_passing/message_handler_waiting_compensation_cleanup/__init__.py @@ -8,8 +8,8 @@ class WorkflowExitType(IntEnum): SUCCESS = 0 FAILURE = 1 - CONTINUE_AS_NEW = 2 - CANCELLATION = 3 + CANCELLATION = 2 + CONTINUE_AS_NEW = 3 @dataclass diff --git a/message_passing/message_handler_waiting_compensation_cleanup/starter.py b/message_passing/message_handler_waiting_compensation_cleanup/starter.py index e436881c..ebf48033 100644 --- a/message_passing/message_handler_waiting_compensation_cleanup/starter.py +++ b/message_passing/message_handler_waiting_compensation_cleanup/starter.py @@ -1,7 +1,6 @@ import asyncio -from contextlib import contextmanager -from temporalio import client, common +from temporalio import client from message_passing.message_handler_waiting_compensation_cleanup import ( TASK_QUEUE, @@ -23,7 +22,6 @@ async def starter(exit_type: WorkflowExitType, update_action: OnWorkflowExitActi WorkflowInput(exit_type=exit_type), id=WORKFLOW_ID, task_queue=TASK_QUEUE, - id_reuse_policy=common.WorkflowIDReusePolicy.TERMINATE_IF_RUNNING, ) await _check_run(wf_handle, exit_type, update_action) @@ -33,45 +31,43 @@ async def _check_run( exit_type: WorkflowExitType, update_action: OnWorkflowExitAction, ): - with catch("starting update"): + try: up_handle = await wf_handle.start_update( MyWorkflow.my_update, UpdateInput(on_premature_workflow_exit=update_action), wait_for_stage=client.WorkflowUpdateStage.ACCEPTED, ) + except Exception as e: + print( + f" 🔴 caught exception while starting update: {e}: {e.__cause__ or ''}" + ) if exit_type == WorkflowExitType.CANCELLATION: await wf_handle.cancel() - with catch("waiting for update result"): + try: await up_handle.result() print(" 🟢 caller received update result") + except Exception as e: + print( + f" 🔴 caught exception while waiting for update result: {e}: {e.__cause__ or ''}" + ) if exit_type == WorkflowExitType.CONTINUE_AS_NEW: await _check_run(wf_handle, WorkflowExitType.SUCCESS, update_action) else: - with catch("waiting for workflow result"): + try: await wf_handle.result() print(" 🟢 caller received workflow result") - - -@contextmanager -def catch(operation: str): - try: - yield - except Exception as e: - cause = getattr(e, "cause", None) - print(f" 🔴 caught exception while {operation}: {e}: {cause or ''}") + except Exception as e: + print( + f" 🔴 caught exception while waiting for workflow result: {e}: {e.__cause__ or ''}" + ) async def main(): - for exit_type in [ - WorkflowExitType.SUCCESS, - WorkflowExitType.FAILURE, - WorkflowExitType.CANCELLATION, - WorkflowExitType.CONTINUE_AS_NEW, - ]: - print(f"\n\nworkflow exit type: {exit_type}") + for exit_type in WorkflowExitType: + print(f"\n\nworkflow exit type: {exit_type.name}") for update_action in [ OnWorkflowExitAction.CONTINUE, OnWorkflowExitAction.ABORT_WITH_COMPENSATION, diff --git a/message_passing/message_handler_waiting_compensation_cleanup/workflows.py b/message_passing/message_handler_waiting_compensation_cleanup/workflows.py index aac04b01..528575bb 100644 --- a/message_passing/message_handler_waiting_compensation_cleanup/workflows.py +++ b/message_passing/message_handler_waiting_compensation_cleanup/workflows.py @@ -36,6 +36,9 @@ async def run(self, input: WorkflowInput) -> str: # handlers to finish in a `finally` block. The reason is that other # exception types will cause a Workflow Task failure, in which case # we do *not* want to wait for message handlers to finish. + + # self._run would contain your actual workflow business logic. In + # this sample, its actual implementation contains nothing relevant. result = await self._run(input) await workflow.wait_condition(workflow.all_handlers_finished) return result @@ -57,7 +60,7 @@ async def my_update(self, input: UpdateInput) -> str: WorkflowUpdateFailedError. """ # Coroutines must be wrapped in tasks in order to use workflow.wait. - update_task = asyncio.Task(self._my_update()) + update_task = asyncio.create_task(self._my_update()) # 👉 Always use `workflow.wait` instead of `asyncio.wait` in Workflow # code: asyncio's version is non-deterministic. first_completed, _ = await workflow.wait( # type: ignore @@ -100,7 +103,7 @@ async def _my_update(self) -> str: async def _my_update_compensation_and_cleanup(self): workflow.logger.info( - "performing update handler compensation and cleanup operations" + "Performing update handler compensation and cleanup operations" ) async def _run(self, input: WorkflowInput) -> str: From 51e61a05d9e73d609153a7bc5ee1e17299d9fa01 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 3 Oct 2024 19:39:05 -0400 Subject: [PATCH 04/20] Rewrite / cleanup --- .../activities.py | 8 - .../workflows.py | 124 -------------- .../README.md | 0 .../__init__.py | 10 -- .../activities.py | 13 ++ .../starter.py | 41 ++--- .../worker.py | 18 +- .../workflows.py | 160 ++++++++++++++++++ .../workflow_test.py | 82 +++++++++ 9 files changed, 284 insertions(+), 172 deletions(-) delete mode 100644 message_passing/message_handler_waiting_compensation_cleanup/activities.py delete mode 100644 message_passing/message_handler_waiting_compensation_cleanup/workflows.py rename message_passing/{message_handler_waiting_compensation_cleanup => waiting_for_handlers_and_compensation}/README.md (100%) rename message_passing/{message_handler_waiting_compensation_cleanup => waiting_for_handlers_and_compensation}/__init__.py (63%) create mode 100644 message_passing/waiting_for_handlers_and_compensation/activities.py rename message_passing/{message_handler_waiting_compensation_cleanup => waiting_for_handlers_and_compensation}/starter.py (56%) rename message_passing/{message_handler_waiting_compensation_cleanup => waiting_for_handlers_and_compensation}/worker.py (53%) create mode 100644 message_passing/waiting_for_handlers_and_compensation/workflows.py create mode 100644 tests/message_passing/waiting_for_handlers_and_compensation/workflow_test.py diff --git a/message_passing/message_handler_waiting_compensation_cleanup/activities.py b/message_passing/message_handler_waiting_compensation_cleanup/activities.py deleted file mode 100644 index 1a4a16d7..00000000 --- a/message_passing/message_handler_waiting_compensation_cleanup/activities.py +++ /dev/null @@ -1,8 +0,0 @@ -import asyncio - -from temporalio import activity - - -@activity.defn -async def my_activity(): - await asyncio.sleep(1) diff --git a/message_passing/message_handler_waiting_compensation_cleanup/workflows.py b/message_passing/message_handler_waiting_compensation_cleanup/workflows.py deleted file mode 100644 index 528575bb..00000000 --- a/message_passing/message_handler_waiting_compensation_cleanup/workflows.py +++ /dev/null @@ -1,124 +0,0 @@ -import asyncio -from datetime import timedelta - -from temporalio import exceptions, workflow - -from message_passing.message_handler_waiting_compensation_cleanup import ( - OnWorkflowExitAction, - UpdateInput, - WorkflowExitType, - WorkflowInput, -) -from message_passing.message_handler_waiting_compensation_cleanup.activities import ( - my_activity, -) - - -@workflow.defn -class MyWorkflow: - """ - This Workflow upholds the following recommended practices: - - 1. The main workflow method ensures that all signal and update handlers are - finished before a successful return, and on failure, cancellation, and - continue-as-new. - 2. The update handler performs any necessary compensation/cleanup when the - workflow is cancelled, fails, or continues-as-new. - """ - - def __init__(self) -> None: - self.workflow_exit_exception: asyncio.Future[BaseException] = asyncio.Future() - - @workflow.run - async def run(self, input: WorkflowInput) -> str: - try: - # 👉 Use this `try...except` style, instead of waiting for message - # handlers to finish in a `finally` block. The reason is that other - # exception types will cause a Workflow Task failure, in which case - # we do *not* want to wait for message handlers to finish. - - # self._run would contain your actual workflow business logic. In - # this sample, its actual implementation contains nothing relevant. - result = await self._run(input) - await workflow.wait_condition(workflow.all_handlers_finished) - return result - except ( - asyncio.CancelledError, - workflow.ContinueAsNewError, - exceptions.FailureError, - ) as exc: - self.workflow_exit_exception.set_result(exc) - await workflow.wait_condition(workflow.all_handlers_finished) - raise exc - - @workflow.update - async def my_update(self, input: UpdateInput) -> str: - """ - This update handler demonstrates how to handle the situation where the - main Workflow method exits prematurely. In that case we perform - compensation/cleanup, and fail the Update. The Update caller will get a - WorkflowUpdateFailedError. - """ - # Coroutines must be wrapped in tasks in order to use workflow.wait. - update_task = asyncio.create_task(self._my_update()) - # 👉 Always use `workflow.wait` instead of `asyncio.wait` in Workflow - # code: asyncio's version is non-deterministic. - first_completed, _ = await workflow.wait( # type: ignore - [update_task, self.workflow_exit_exception], - return_when=asyncio.FIRST_COMPLETED, - ) - # 👉 It's possible that the update completed and the workflow exited - # prematurely in the same tick of the event loop. If the Update has - # completed, return the Update result to the caller, whether or not the - # Workflow is exiting. - if ( - update_task in first_completed - or input.on_premature_workflow_exit == OnWorkflowExitAction.CONTINUE - ): - return await update_task - else: - await self._my_update_compensation_and_cleanup() - raise exceptions.ApplicationError( - f"The update failed because the workflow run exited: {await self.workflow_exit_exception}" - ) - - async def _my_update(self) -> str: - """ - This handler calls a slow activity, so - - (1) In the case where the workflow finishes successfully, the worker - would get an UnfinishedUpdateHandlersWarning (TMPRL1102) if the main - workflow task didn't wait for it to finish. - - (2) In the other cases (failure, cancellation, and continue-as-new), the - premature workflow exit will occur before the update is finished. - """ - # Ignore: implementation detail specific to this sample - self._update_started = True - - await workflow.execute_activity( - my_activity, start_to_close_timeout=timedelta(seconds=10) - ) - return "update-result" - - async def _my_update_compensation_and_cleanup(self): - workflow.logger.info( - "Performing update handler compensation and cleanup operations" - ) - - async def _run(self, input: WorkflowInput) -> str: - # Ignore this method unless you are interested in the implementation - # details of this sample. - - # Wait until handlers started, so that we are demonstrating that we wait for them to finish. - await workflow.wait_condition(lambda: getattr(self, "_update_started", False)) - if input.exit_type == WorkflowExitType.SUCCESS: - return "workflow-result" - elif input.exit_type == WorkflowExitType.CONTINUE_AS_NEW: - workflow.continue_as_new(WorkflowInput(exit_type=WorkflowExitType.SUCCESS)) - elif input.exit_type == WorkflowExitType.FAILURE: - raise exceptions.ApplicationError("deliberately failing workflow") - elif input.exit_type == WorkflowExitType.CANCELLATION: - # Block forever; the starter will send a workflow cancellation request. - await asyncio.Future() - raise AssertionError("unreachable") diff --git a/message_passing/message_handler_waiting_compensation_cleanup/README.md b/message_passing/waiting_for_handlers_and_compensation/README.md similarity index 100% rename from message_passing/message_handler_waiting_compensation_cleanup/README.md rename to message_passing/waiting_for_handlers_and_compensation/README.md diff --git a/message_passing/message_handler_waiting_compensation_cleanup/__init__.py b/message_passing/waiting_for_handlers_and_compensation/__init__.py similarity index 63% rename from message_passing/message_handler_waiting_compensation_cleanup/__init__.py rename to message_passing/waiting_for_handlers_and_compensation/__init__.py index b3a49b4f..35eb78c2 100644 --- a/message_passing/message_handler_waiting_compensation_cleanup/__init__.py +++ b/message_passing/waiting_for_handlers_and_compensation/__init__.py @@ -15,13 +15,3 @@ class WorkflowExitType(IntEnum): @dataclass class WorkflowInput: exit_type: WorkflowExitType - - -class OnWorkflowExitAction(IntEnum): - CONTINUE = 0 - ABORT_WITH_COMPENSATION = 1 - - -@dataclass -class UpdateInput: - on_premature_workflow_exit: OnWorkflowExitAction diff --git a/message_passing/waiting_for_handlers_and_compensation/activities.py b/message_passing/waiting_for_handlers_and_compensation/activities.py new file mode 100644 index 00000000..cec6372f --- /dev/null +++ b/message_passing/waiting_for_handlers_and_compensation/activities.py @@ -0,0 +1,13 @@ +import asyncio + +from temporalio import activity + + +@activity.defn +async def activity_executed_by_update_handler(): + await asyncio.sleep(1) + + +@activity.defn +async def activity_executed_by_update_handler_to_perform_compensation(): + await asyncio.sleep(1) diff --git a/message_passing/message_handler_waiting_compensation_cleanup/starter.py b/message_passing/waiting_for_handlers_and_compensation/starter.py similarity index 56% rename from message_passing/message_handler_waiting_compensation_cleanup/starter.py rename to message_passing/waiting_for_handlers_and_compensation/starter.py index ebf48033..757f3571 100644 --- a/message_passing/message_handler_waiting_compensation_cleanup/starter.py +++ b/message_passing/waiting_for_handlers_and_compensation/starter.py @@ -1,46 +1,41 @@ import asyncio -from temporalio import client +from temporalio import client, common -from message_passing.message_handler_waiting_compensation_cleanup import ( +from message_passing.waiting_for_handlers_and_compensation import ( TASK_QUEUE, WORKFLOW_ID, - OnWorkflowExitAction, - UpdateInput, WorkflowExitType, WorkflowInput, ) -from message_passing.message_handler_waiting_compensation_cleanup.workflows import ( - MyWorkflow, +from message_passing.waiting_for_handlers_and_compensation.workflows import ( + WaitingForHandlersAndCompensationWorkflow, ) -async def starter(exit_type: WorkflowExitType, update_action: OnWorkflowExitAction): +async def starter(exit_type: WorkflowExitType): cl = await client.Client.connect("localhost:7233") wf_handle = await cl.start_workflow( - MyWorkflow.run, + WaitingForHandlersAndCompensationWorkflow.run, WorkflowInput(exit_type=exit_type), id=WORKFLOW_ID, task_queue=TASK_QUEUE, + id_conflict_policy=common.WorkflowIDConflictPolicy.TERMINATE_EXISTING, ) - await _check_run(wf_handle, exit_type, update_action) + await _check_run(wf_handle, exit_type) async def _check_run( wf_handle: client.WorkflowHandle, exit_type: WorkflowExitType, - update_action: OnWorkflowExitAction, ): try: up_handle = await wf_handle.start_update( - MyWorkflow.my_update, - UpdateInput(on_premature_workflow_exit=update_action), + WaitingForHandlersAndCompensationWorkflow.my_update, wait_for_stage=client.WorkflowUpdateStage.ACCEPTED, ) except Exception as e: - print( - f" 🔴 caught exception while starting update: {e}: {e.__cause__ or ''}" - ) + print(f" 🔴 caught exception while starting update: {e}: {e.__cause__ or ''}") if exit_type == WorkflowExitType.CANCELLATION: await wf_handle.cancel() @@ -54,7 +49,7 @@ async def _check_run( ) if exit_type == WorkflowExitType.CONTINUE_AS_NEW: - await _check_run(wf_handle, WorkflowExitType.SUCCESS, update_action) + await _check_run(wf_handle, WorkflowExitType.SUCCESS) else: try: await wf_handle.result() @@ -66,14 +61,14 @@ async def _check_run( async def main(): - for exit_type in WorkflowExitType: + for exit_type in [ + WorkflowExitType.SUCCESS, + WorkflowExitType.FAILURE, + WorkflowExitType.CANCELLATION, + WorkflowExitType.CONTINUE_AS_NEW, + ]: print(f"\n\nworkflow exit type: {exit_type.name}") - for update_action in [ - OnWorkflowExitAction.CONTINUE, - OnWorkflowExitAction.ABORT_WITH_COMPENSATION, - ]: - print(f" update action on premature workflow exit: {update_action}") - await starter(exit_type, update_action) + await starter(exit_type) if __name__ == "__main__": diff --git a/message_passing/message_handler_waiting_compensation_cleanup/worker.py b/message_passing/waiting_for_handlers_and_compensation/worker.py similarity index 53% rename from message_passing/message_handler_waiting_compensation_cleanup/worker.py rename to message_passing/waiting_for_handlers_and_compensation/worker.py index c53cc441..64020693 100644 --- a/message_passing/message_handler_waiting_compensation_cleanup/worker.py +++ b/message_passing/waiting_for_handlers_and_compensation/worker.py @@ -4,12 +4,13 @@ from temporalio.client import Client from temporalio.worker import Worker -from message_passing.message_handler_waiting_compensation_cleanup import TASK_QUEUE -from message_passing.message_handler_waiting_compensation_cleanup.activities import ( - my_activity, +from message_passing.waiting_for_handlers_and_compensation import TASK_QUEUE +from message_passing.waiting_for_handlers_and_compensation.activities import ( + activity_executed_by_update_handler, + activity_executed_by_update_handler_to_perform_compensation, ) -from message_passing.message_handler_waiting_compensation_cleanup.workflows import ( - MyWorkflow, +from message_passing.waiting_for_handlers_and_compensation.workflows import ( + WaitingForHandlersAndCompensationWorkflow, ) interrupt_event = asyncio.Event() @@ -23,8 +24,11 @@ async def main(): async with Worker( client, task_queue=TASK_QUEUE, - workflows=[MyWorkflow], - activities=[my_activity], + workflows=[WaitingForHandlersAndCompensationWorkflow], + activities=[ + activity_executed_by_update_handler, + activity_executed_by_update_handler_to_perform_compensation, + ], ): logging.info("Worker started, ctrl+c to exit") await interrupt_event.wait() diff --git a/message_passing/waiting_for_handlers_and_compensation/workflows.py b/message_passing/waiting_for_handlers_and_compensation/workflows.py new file mode 100644 index 00000000..8b37989b --- /dev/null +++ b/message_passing/waiting_for_handlers_and_compensation/workflows.py @@ -0,0 +1,160 @@ +import asyncio +from datetime import timedelta +from typing import cast + +from temporalio import exceptions, workflow + +from message_passing.waiting_for_handlers_and_compensation import ( + WorkflowExitType, + WorkflowInput, +) +from message_passing.waiting_for_handlers_and_compensation.activities import ( + activity_executed_by_update_handler, + activity_executed_by_update_handler_to_perform_compensation, +) + + +@workflow.defn +class WaitingForHandlersAndCompensationWorkflow: + """ + This Workflow demonstrates how to wait for message handlers to finish and + perform compensation/cleanup: + + 1. It ensures that all signal and update handlers have finished before a + successful return, and on failure, cancellation, and continue-as-new. + 2. The update handler performs any necessary compensation/cleanup when the + workflow is cancelled, fails, or continues-as-new. + """ + + def __init__(self) -> None: + # 👉 If the workflow exits prematurely, this future will be completed + # with the associated exception as its value. Message handlers can then + # "race" this future against a task performing the message handler's own + # application logic; if this future completes before the message handler + # task then the handler should abort and perform compensation. + self.workflow_exit = asyncio.Future() + + @workflow.run + async def run(self, input: WorkflowInput) -> str: + try: + # 👉 Use this `try...except` style, instead of waiting for message + # handlers to finish in a `finally` block. The reason is that some + # exception types cause a workflow task failure as opposed to + # workflow exit, in which case we do *not* want to wait for message + # handlers to finish. + + # 👉 self._run contains your actual application logic. This is + # implemented in a separate method in order to separate + # "platform-level" concerns (waiting for handlers to finish and + # ensuring that compensation is performed when appropriate) from + # application logic. In this sample, its actual implementation is + # below but contains nothing relevant. + result = await self._run(input) + self.workflow_exit.set_result(None) + await workflow.wait_condition(workflow.all_handlers_finished) + return result + # 👉 Catch BaseException since asyncio.CancelledError does not inherit + # from Exception. + except BaseException as e: + if is_workflow_exit_exception(e): + self.workflow_exit.set_exception(e) + await workflow.wait_condition(workflow.all_handlers_finished) + raise + + @workflow.update + async def my_update(self) -> str: + """ + An update handler that handles exceptions in itself and in the main + workflow method. + + It ensures that: + - Compensation/cleanup is always performed when appropriate + - The update caller gets the update result, or WorkflowUpdateFailedError + """ + # 👉 As with the main workflow method, the update application logic is + # implemented in a separate method in order to separate "platform-level" + # error-handling and compensation concerns from application logic. Note + # that coroutines must be wrapped in tasks in order to use + # workflow.wait. + update_task = asyncio.create_task(self._my_update()) + + # 👉 "Race" the workflow_exit future against the handler's own application + # logic. Always use `workflow.wait` instead of `asyncio.wait` in + # Workflow code: asyncio's version is non-deterministic. + await workflow.wait( + [update_task, self.workflow_exit], return_when=asyncio.FIRST_EXCEPTION + ) + try: + if update_task.done(): + # 👉 The update has finished (whether successfully or not). + # Regardless of whether the main workflow method is about to + # exit or not, the update caller should receive a response + # informing them of the outcome of the update. So return the + # result, or raise the exception that caused the update handler + # to exit. + return await update_task + else: + # 👉 The main workflow method exited prematurely due to an + # error, and this happened before the update finished. Fail the + # update with the workflow exception as cause. + raise exceptions.ApplicationError( + "The update failed because the workflow run exited" + ) from cast(BaseException, self.workflow_exit.exception()) + # 👉 Catch BaseException since asyncio.CancelledError does not inherit + # from Exception. + except BaseException as e: + if is_workflow_exit_exception(e): + try: + await self.my_update_compensation() + except BaseException as e: + raise exceptions.ApplicationError( + "Update compensation failed" + ) from e + raise + + async def my_update_compensation(self): + await workflow.execute_activity( + activity_executed_by_update_handler_to_perform_compensation, + start_to_close_timeout=timedelta(seconds=10), + ) + + # The following two methods are placeholders for the actual application + # logic that you would perform in your main workflow method or update + # handler. Their implementation can be ignored. + + async def _my_update(self) -> str: + # Ignore this method unless you are interested in the implementation + # details of this sample. + self._update_started = True + await workflow.execute_activity( + activity_executed_by_update_handler, + start_to_close_timeout=timedelta(seconds=10), + ) + return "update-result" + + async def _run(self, input: WorkflowInput) -> str: + # Ignore this method unless you are interested in the implementation + # details of this sample. + + # Wait until handlers have started, so that we are demonstrating that we + # wait for them to finish. + await workflow.wait_condition(lambda: getattr(self, "_update_started", False)) + if input.exit_type == WorkflowExitType.SUCCESS: + return "workflow-result" + elif input.exit_type == WorkflowExitType.CONTINUE_AS_NEW: + workflow.continue_as_new(WorkflowInput(exit_type=WorkflowExitType.SUCCESS)) + elif input.exit_type == WorkflowExitType.FAILURE: + raise exceptions.ApplicationError("deliberately failing workflow") + elif input.exit_type == WorkflowExitType.CANCELLATION: + # Block forever; the starter will send a workflow cancellation request. + await asyncio.Future() + raise AssertionError("unreachable") + + +def is_workflow_exit_exception(e: BaseException) -> bool: + # 👉 If you have set additional failure_exception_types you should also + # check for these here. + return isinstance( + e, + (asyncio.CancelledError, workflow.ContinueAsNewError, exceptions.FailureError), + ) diff --git a/tests/message_passing/waiting_for_handlers_and_compensation/workflow_test.py b/tests/message_passing/waiting_for_handlers_and_compensation/workflow_test.py new file mode 100644 index 00000000..db92dbe9 --- /dev/null +++ b/tests/message_passing/waiting_for_handlers_and_compensation/workflow_test.py @@ -0,0 +1,82 @@ +import uuid + +import pytest +from temporalio.client import Client, WorkflowHandle, WorkflowUpdateStage +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +from message_passing.waiting_for_handlers_and_compensation import ( + WorkflowExitType, + WorkflowInput, +) +from message_passing.waiting_for_handlers_and_compensation.starter import ( + TASK_QUEUE, +) +from message_passing.waiting_for_handlers_and_compensation.workflows import ( + WaitingForHandlersAndCompensationWorkflow, +) + + +async def test_waiting_for_handlers_and_compensation( + client: Client, env: WorkflowEnvironment +): + if env.supports_time_skipping: + pytest.skip( + "Java test server: https://github.com/temporalio/sdk-java/issues/1903" + ) + async with Worker( + client, + task_queue=TASK_QUEUE, + workflows=[WaitingForHandlersAndCompensationWorkflow], + ): + await starter( + WorkflowExitType.SUCCESS, + client, + ) + + +async def starter(exit_type: WorkflowExitType, cl: Client): + wf_handle = await cl.start_workflow( + WaitingForHandlersAndCompensationWorkflow.run, + WorkflowInput(exit_type=exit_type), + id=str(uuid.uuid4()), + task_queue=TASK_QUEUE, + ) + await _check_run(wf_handle, exit_type) + + +async def _check_run( + wf_handle: WorkflowHandle, + exit_type: WorkflowExitType, +): + try: + up_handle = await wf_handle.start_update( + WaitingForHandlersAndCompensationWorkflow.my_update, + wait_for_stage=WorkflowUpdateStage.ACCEPTED, + ) + except Exception as e: + print( + f" 🔴 caught exception while starting update: {e}: {e.__cause__ or ''}" + ) + + if exit_type == WorkflowExitType.CANCELLATION: + await wf_handle.cancel() + + try: + await up_handle.result() + print(" 🟢 caller received update result") + except Exception as e: + print( + f" 🔴 caught exception while waiting for update result: {e}: {e.__cause__ or ''}" + ) + + if exit_type == WorkflowExitType.CONTINUE_AS_NEW: + await _check_run(wf_handle, WorkflowExitType.SUCCESS) + else: + try: + await wf_handle.result() + print(" 🟢 caller received workflow result") + except Exception as e: + print( + f" 🔴 caught exception while waiting for workflow result: {e}: {e.__cause__ or ''}" + ) From 5e93468688acdb0f08ccec629661eab7739688f1 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Fri, 4 Oct 2024 11:49:13 -0400 Subject: [PATCH 05/20] Test --- .../workflows.py | 10 +- .../workflow_test.py | 114 ++++++++++-------- 2 files changed, 72 insertions(+), 52 deletions(-) diff --git a/message_passing/waiting_for_handlers_and_compensation/workflows.py b/message_passing/waiting_for_handlers_and_compensation/workflows.py index 8b37989b..379554c8 100644 --- a/message_passing/waiting_for_handlers_and_compensation/workflows.py +++ b/message_passing/waiting_for_handlers_and_compensation/workflows.py @@ -33,6 +33,7 @@ def __init__(self) -> None: # application logic; if this future completes before the message handler # task then the handler should abort and perform compensation. self.workflow_exit = asyncio.Future() + self._update_compensation_done = False @workflow.run async def run(self, input: WorkflowInput) -> str: @@ -64,8 +65,8 @@ async def run(self, input: WorkflowInput) -> str: @workflow.update async def my_update(self) -> str: """ - An update handler that handles exceptions in itself and in the main - workflow method. + An update handler that handles exceptions raised in its own execution + and in that of the main workflow method. It ensures that: - Compensation/cleanup is always performed when appropriate @@ -117,6 +118,11 @@ async def my_update_compensation(self): activity_executed_by_update_handler_to_perform_compensation, start_to_close_timeout=timedelta(seconds=10), ) + self._update_compensation_done = True + + @workflow.query + def update_compensation_done(self) -> bool: + return self._update_compensation_done # The following two methods are placeholders for the actual application # logic that you would perform in your main workflow method or update diff --git a/tests/message_passing/waiting_for_handlers_and_compensation/workflow_test.py b/tests/message_passing/waiting_for_handlers_and_compensation/workflow_test.py index db92dbe9..1dfe624e 100644 --- a/tests/message_passing/waiting_for_handlers_and_compensation/workflow_test.py +++ b/tests/message_passing/waiting_for_handlers_and_compensation/workflow_test.py @@ -1,82 +1,96 @@ import uuid +from enum import Enum import pytest -from temporalio.client import Client, WorkflowHandle, WorkflowUpdateStage +from temporalio import client, worker from temporalio.testing import WorkflowEnvironment -from temporalio.worker import Worker from message_passing.waiting_for_handlers_and_compensation import ( WorkflowExitType, WorkflowInput, ) -from message_passing.waiting_for_handlers_and_compensation.starter import ( - TASK_QUEUE, +from message_passing.waiting_for_handlers_and_compensation.activities import ( + activity_executed_by_update_handler, + activity_executed_by_update_handler_to_perform_compensation, ) +from message_passing.waiting_for_handlers_and_compensation.starter import TASK_QUEUE from message_passing.waiting_for_handlers_and_compensation.workflows import ( WaitingForHandlersAndCompensationWorkflow, ) +class UpdateExpect(Enum): + SUCCESS = "success" + FAILURE = "failure" + + +class WorkflowExpect(Enum): + SUCCESS = "success" + FAILURE = "failure" + + +@pytest.mark.parametrize( + ["exit_type_name", "update_expect", "workflow_expect"], + [ + (WorkflowExitType.SUCCESS.name, UpdateExpect.SUCCESS, WorkflowExpect.SUCCESS), + (WorkflowExitType.FAILURE.name, UpdateExpect.FAILURE, WorkflowExpect.FAILURE), + ( + WorkflowExitType.CANCELLATION.name, + UpdateExpect.FAILURE, + WorkflowExpect.FAILURE, + ), + ], +) async def test_waiting_for_handlers_and_compensation( - client: Client, env: WorkflowEnvironment + env: WorkflowEnvironment, + exit_type_name: str, + update_expect: UpdateExpect, + workflow_expect: WorkflowExpect, ): + [exit_type] = [t for t in WorkflowExitType if t.name == exit_type_name] if env.supports_time_skipping: pytest.skip( "Java test server: https://github.com/temporalio/sdk-java/issues/1903" ) - async with Worker( - client, + async with worker.Worker( + env.client, task_queue=TASK_QUEUE, workflows=[WaitingForHandlersAndCompensationWorkflow], + activities=[ + activity_executed_by_update_handler, + activity_executed_by_update_handler_to_perform_compensation, + ], ): - await starter( - WorkflowExitType.SUCCESS, - client, + wf_handle = await env.client.start_workflow( + WaitingForHandlersAndCompensationWorkflow.run, + WorkflowInput(exit_type=exit_type), + id=str(uuid.uuid4()), + task_queue=TASK_QUEUE, ) - - -async def starter(exit_type: WorkflowExitType, cl: Client): - wf_handle = await cl.start_workflow( - WaitingForHandlersAndCompensationWorkflow.run, - WorkflowInput(exit_type=exit_type), - id=str(uuid.uuid4()), - task_queue=TASK_QUEUE, - ) - await _check_run(wf_handle, exit_type) - - -async def _check_run( - wf_handle: WorkflowHandle, - exit_type: WorkflowExitType, -): - try: up_handle = await wf_handle.start_update( WaitingForHandlersAndCompensationWorkflow.my_update, - wait_for_stage=WorkflowUpdateStage.ACCEPTED, - ) - except Exception as e: - print( - f" 🔴 caught exception while starting update: {e}: {e.__cause__ or ''}" + wait_for_stage=client.WorkflowUpdateStage.ACCEPTED, ) - if exit_type == WorkflowExitType.CANCELLATION: - await wf_handle.cancel() + if exit_type == WorkflowExitType.CANCELLATION: + await wf_handle.cancel() - try: - await up_handle.result() - print(" 🟢 caller received update result") - except Exception as e: - print( - f" 🔴 caught exception while waiting for update result: {e}: {e.__cause__ or ''}" - ) + if update_expect == UpdateExpect.SUCCESS: + await up_handle.result() + assert not ( + await wf_handle.query( + WaitingForHandlersAndCompensationWorkflow.update_compensation_done + ) + ) + else: + with pytest.raises(client.WorkflowUpdateFailedError): + await up_handle.result() + assert await wf_handle.query( + WaitingForHandlersAndCompensationWorkflow.update_compensation_done + ) - if exit_type == WorkflowExitType.CONTINUE_AS_NEW: - await _check_run(wf_handle, WorkflowExitType.SUCCESS) - else: - try: + if workflow_expect == WorkflowExpect.SUCCESS: await wf_handle.result() - print(" 🟢 caller received workflow result") - except Exception as e: - print( - f" 🔴 caught exception while waiting for workflow result: {e}: {e.__cause__ or ''}" - ) + else: + with pytest.raises(client.WorkflowFailureError): + await wf_handle.result() From d5f082d31c1e7b80c2f21abd24b3787accf1b339 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Fri, 4 Oct 2024 11:59:02 -0400 Subject: [PATCH 06/20] Satisfy mypy --- .../waiting_for_handlers_and_compensation/workflows.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/message_passing/waiting_for_handlers_and_compensation/workflows.py b/message_passing/waiting_for_handlers_and_compensation/workflows.py index 379554c8..c552a3c0 100644 --- a/message_passing/waiting_for_handlers_and_compensation/workflows.py +++ b/message_passing/waiting_for_handlers_and_compensation/workflows.py @@ -32,7 +32,7 @@ def __init__(self) -> None: # "race" this future against a task performing the message handler's own # application logic; if this future completes before the message handler # task then the handler should abort and perform compensation. - self.workflow_exit = asyncio.Future() + self.workflow_exit: asyncio.Future[None] = asyncio.Future() self._update_compensation_done = False @workflow.run @@ -82,7 +82,7 @@ async def my_update(self) -> str: # 👉 "Race" the workflow_exit future against the handler's own application # logic. Always use `workflow.wait` instead of `asyncio.wait` in # Workflow code: asyncio's version is non-deterministic. - await workflow.wait( + await workflow.wait( # type: ignore [update_task, self.workflow_exit], return_when=asyncio.FIRST_EXCEPTION ) try: From c08c0199338970488f842a94f6948b34e6a00847 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Fri, 4 Oct 2024 12:02:29 -0400 Subject: [PATCH 07/20] Stop using ad-hoc attributes --- .../waiting_for_handlers_and_compensation/workflows.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/message_passing/waiting_for_handlers_and_compensation/workflows.py b/message_passing/waiting_for_handlers_and_compensation/workflows.py index c552a3c0..57fd6774 100644 --- a/message_passing/waiting_for_handlers_and_compensation/workflows.py +++ b/message_passing/waiting_for_handlers_and_compensation/workflows.py @@ -33,6 +33,10 @@ def __init__(self) -> None: # application logic; if this future completes before the message handler # task then the handler should abort and perform compensation. self.workflow_exit: asyncio.Future[None] = asyncio.Future() + + # The following two attributes are implementation detail of this sample + # and can be ignored + self._update_started = False self._update_compensation_done = False @workflow.run @@ -144,7 +148,7 @@ async def _run(self, input: WorkflowInput) -> str: # Wait until handlers have started, so that we are demonstrating that we # wait for them to finish. - await workflow.wait_condition(lambda: getattr(self, "_update_started", False)) + await workflow.wait_condition(lambda: self._update_started) if input.exit_type == WorkflowExitType.SUCCESS: return "workflow-result" elif input.exit_type == WorkflowExitType.CONTINUE_AS_NEW: From 47b8aff4fbd0964c12e279ccadd79642eced9225 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Fri, 4 Oct 2024 12:08:18 -0400 Subject: [PATCH 08/20] Remove Continue-As-New from the sample --- .../README.md | 11 +++++----- .../__init__.py | 1 - .../starter.py | 22 +++++++++---------- .../workflows.py | 11 +++------- 4 files changed, 19 insertions(+), 26 deletions(-) diff --git a/message_passing/waiting_for_handlers_and_compensation/README.md b/message_passing/waiting_for_handlers_and_compensation/README.md index 0bbb1a97..dfbac6e7 100644 --- a/message_passing/waiting_for_handlers_and_compensation/README.md +++ b/message_passing/waiting_for_handlers_and_compensation/README.md @@ -1,11 +1,12 @@ # Waiting for message handlers, and performing compensation and cleanup in message handlers -This sample demonstrates the following recommended practices: +This sample demonstrates how to do the following: + +1. Ensure that all update/signal handlers are finished before a successful + workflow return, and on workflow cancellation and failure. +2. Perform compensation/cleanup in an update handler when the workflow is + cancelled or fails. -1. Ensuring that all signal and update handlers are finished before a successful - workflow return, and on workflow failure, cancellation, and continue-as-new. -2. Performing necessary compensation/cleanup in an update handler when the - workflow is cancelled, fails, or continues-as-new. To run, open two terminals and `cd` to this directory in them. diff --git a/message_passing/waiting_for_handlers_and_compensation/__init__.py b/message_passing/waiting_for_handlers_and_compensation/__init__.py index 35eb78c2..d537520f 100644 --- a/message_passing/waiting_for_handlers_and_compensation/__init__.py +++ b/message_passing/waiting_for_handlers_and_compensation/__init__.py @@ -9,7 +9,6 @@ class WorkflowExitType(IntEnum): SUCCESS = 0 FAILURE = 1 CANCELLATION = 2 - CONTINUE_AS_NEW = 3 @dataclass diff --git a/message_passing/waiting_for_handlers_and_compensation/starter.py b/message_passing/waiting_for_handlers_and_compensation/starter.py index 757f3571..fd57c4a5 100644 --- a/message_passing/waiting_for_handlers_and_compensation/starter.py +++ b/message_passing/waiting_for_handlers_and_compensation/starter.py @@ -35,7 +35,9 @@ async def _check_run( wait_for_stage=client.WorkflowUpdateStage.ACCEPTED, ) except Exception as e: - print(f" 🔴 caught exception while starting update: {e}: {e.__cause__ or ''}") + print( + f" 🔴 caught exception while starting update: {e}: {e.__cause__ or ''}" + ) if exit_type == WorkflowExitType.CANCELLATION: await wf_handle.cancel() @@ -48,16 +50,13 @@ async def _check_run( f" 🔴 caught exception while waiting for update result: {e}: {e.__cause__ or ''}" ) - if exit_type == WorkflowExitType.CONTINUE_AS_NEW: - await _check_run(wf_handle, WorkflowExitType.SUCCESS) - else: - try: - await wf_handle.result() - print(" 🟢 caller received workflow result") - except Exception as e: - print( - f" 🔴 caught exception while waiting for workflow result: {e}: {e.__cause__ or ''}" - ) + try: + await wf_handle.result() + print(" 🟢 caller received workflow result") + except Exception as e: + print( + f" 🔴 caught exception while waiting for workflow result: {e}: {e.__cause__ or ''}" + ) async def main(): @@ -65,7 +64,6 @@ async def main(): WorkflowExitType.SUCCESS, WorkflowExitType.FAILURE, WorkflowExitType.CANCELLATION, - WorkflowExitType.CONTINUE_AS_NEW, ]: print(f"\n\nworkflow exit type: {exit_type.name}") await starter(exit_type) diff --git a/message_passing/waiting_for_handlers_and_compensation/workflows.py b/message_passing/waiting_for_handlers_and_compensation/workflows.py index 57fd6774..b157ed9d 100644 --- a/message_passing/waiting_for_handlers_and_compensation/workflows.py +++ b/message_passing/waiting_for_handlers_and_compensation/workflows.py @@ -21,9 +21,9 @@ class WaitingForHandlersAndCompensationWorkflow: perform compensation/cleanup: 1. It ensures that all signal and update handlers have finished before a - successful return, and on failure, cancellation, and continue-as-new. + successful return, and on failure and cancellation. 2. The update handler performs any necessary compensation/cleanup when the - workflow is cancelled, fails, or continues-as-new. + workflow is cancelled or fails. """ def __init__(self) -> None: @@ -151,8 +151,6 @@ async def _run(self, input: WorkflowInput) -> str: await workflow.wait_condition(lambda: self._update_started) if input.exit_type == WorkflowExitType.SUCCESS: return "workflow-result" - elif input.exit_type == WorkflowExitType.CONTINUE_AS_NEW: - workflow.continue_as_new(WorkflowInput(exit_type=WorkflowExitType.SUCCESS)) elif input.exit_type == WorkflowExitType.FAILURE: raise exceptions.ApplicationError("deliberately failing workflow") elif input.exit_type == WorkflowExitType.CANCELLATION: @@ -164,7 +162,4 @@ async def _run(self, input: WorkflowInput) -> str: def is_workflow_exit_exception(e: BaseException) -> bool: # 👉 If you have set additional failure_exception_types you should also # check for these here. - return isinstance( - e, - (asyncio.CancelledError, workflow.ContinueAsNewError, exceptions.FailureError), - ) + return isinstance(e, (asyncio.CancelledError, exceptions.FailureError)) From e16540100778df139d0877c4ee520dbd1554a925 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Fri, 4 Oct 2024 12:40:31 -0400 Subject: [PATCH 09/20] Add workflow compensation --- .../activities.py | 5 +++++ .../worker.py | 2 ++ .../workflows.py | 21 ++++++++++++++++--- .../workflow_test.py | 10 +++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/message_passing/waiting_for_handlers_and_compensation/activities.py b/message_passing/waiting_for_handlers_and_compensation/activities.py index cec6372f..36c5a5cb 100644 --- a/message_passing/waiting_for_handlers_and_compensation/activities.py +++ b/message_passing/waiting_for_handlers_and_compensation/activities.py @@ -3,6 +3,11 @@ from temporalio import activity +@activity.defn +async def activity_executed_to_perform_workflow_compensation(): + await asyncio.sleep(1) + + @activity.defn async def activity_executed_by_update_handler(): await asyncio.sleep(1) diff --git a/message_passing/waiting_for_handlers_and_compensation/worker.py b/message_passing/waiting_for_handlers_and_compensation/worker.py index 64020693..7daf768f 100644 --- a/message_passing/waiting_for_handlers_and_compensation/worker.py +++ b/message_passing/waiting_for_handlers_and_compensation/worker.py @@ -8,6 +8,7 @@ from message_passing.waiting_for_handlers_and_compensation.activities import ( activity_executed_by_update_handler, activity_executed_by_update_handler_to_perform_compensation, + activity_executed_to_perform_workflow_compensation, ) from message_passing.waiting_for_handlers_and_compensation.workflows import ( WaitingForHandlersAndCompensationWorkflow, @@ -28,6 +29,7 @@ async def main(): activities=[ activity_executed_by_update_handler, activity_executed_by_update_handler_to_perform_compensation, + activity_executed_to_perform_workflow_compensation, ], ): logging.info("Worker started, ctrl+c to exit") diff --git a/message_passing/waiting_for_handlers_and_compensation/workflows.py b/message_passing/waiting_for_handlers_and_compensation/workflows.py index b157ed9d..0851e5c8 100644 --- a/message_passing/waiting_for_handlers_and_compensation/workflows.py +++ b/message_passing/waiting_for_handlers_and_compensation/workflows.py @@ -11,6 +11,7 @@ from message_passing.waiting_for_handlers_and_compensation.activities import ( activity_executed_by_update_handler, activity_executed_by_update_handler_to_perform_compensation, + activity_executed_to_perform_workflow_compensation, ) @@ -38,6 +39,7 @@ def __init__(self) -> None: # and can be ignored self._update_started = False self._update_compensation_done = False + self._workflow_compensation_done = False @workflow.run async def run(self, input: WorkflowInput) -> str: @@ -64,8 +66,17 @@ async def run(self, input: WorkflowInput) -> str: if is_workflow_exit_exception(e): self.workflow_exit.set_exception(e) await workflow.wait_condition(workflow.all_handlers_finished) + await self.workflow_compensation() + self._workflow_compensation_done = True raise + async def workflow_compensation(self): + await workflow.execute_activity( + activity_executed_to_perform_workflow_compensation, + start_to_close_timeout=timedelta(seconds=10), + ) + self._update_compensation_done = True + @workflow.update async def my_update(self) -> str: """ @@ -124,13 +135,17 @@ async def my_update_compensation(self): ) self._update_compensation_done = True + @workflow.query + def workflow_compensation_done(self) -> bool: + return self._workflow_compensation_done + @workflow.query def update_compensation_done(self) -> bool: return self._update_compensation_done - # The following two methods are placeholders for the actual application - # logic that you would perform in your main workflow method or update - # handler. Their implementation can be ignored. + # The following methods are placeholders for the actual application logic + # that you would perform in your main workflow method or update handler. + # Their implementation can be ignored. async def _my_update(self) -> str: # Ignore this method unless you are interested in the implementation diff --git a/tests/message_passing/waiting_for_handlers_and_compensation/workflow_test.py b/tests/message_passing/waiting_for_handlers_and_compensation/workflow_test.py index 1dfe624e..2b10d396 100644 --- a/tests/message_passing/waiting_for_handlers_and_compensation/workflow_test.py +++ b/tests/message_passing/waiting_for_handlers_and_compensation/workflow_test.py @@ -12,6 +12,7 @@ from message_passing.waiting_for_handlers_and_compensation.activities import ( activity_executed_by_update_handler, activity_executed_by_update_handler_to_perform_compensation, + activity_executed_to_perform_workflow_compensation, ) from message_passing.waiting_for_handlers_and_compensation.starter import TASK_QUEUE from message_passing.waiting_for_handlers_and_compensation.workflows import ( @@ -59,6 +60,7 @@ async def test_waiting_for_handlers_and_compensation( activities=[ activity_executed_by_update_handler, activity_executed_by_update_handler_to_perform_compensation, + activity_executed_to_perform_workflow_compensation, ], ): wf_handle = await env.client.start_workflow( @@ -91,6 +93,14 @@ async def test_waiting_for_handlers_and_compensation( if workflow_expect == WorkflowExpect.SUCCESS: await wf_handle.result() + assert not ( + await wf_handle.query( + WaitingForHandlersAndCompensationWorkflow.workflow_compensation_done + ) + ) else: with pytest.raises(client.WorkflowFailureError): await wf_handle.result() + assert await wf_handle.query( + WaitingForHandlersAndCompensationWorkflow.workflow_compensation_done + ) From 0dbdd0d48310263d25d46bd1cb095ee7514873ce Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Fri, 4 Oct 2024 13:12:12 -0400 Subject: [PATCH 10/20] Update output in README now that there is no "CONTINUE" case --- .../README.md | 45 +++---------------- 1 file changed, 6 insertions(+), 39 deletions(-) diff --git a/message_passing/waiting_for_handlers_and_compensation/README.md b/message_passing/waiting_for_handlers_and_compensation/README.md index dfbac6e7..08a265bd 100644 --- a/message_passing/waiting_for_handlers_and_compensation/README.md +++ b/message_passing/waiting_for_handlers_and_compensation/README.md @@ -20,53 +20,20 @@ And run the workflow-starter code in the other terminal: poetry run python starter.py -Here's the output you'll see, along with some explanation: +Here's the output you'll see: ``` -workflow exit type: success - update action on premature workflow exit: continue - 👇 [Caller gets a successful update response because main workflow method waits for handlers to finish] - 🟢 caller received update result - 🟢 caller received workflow result - update action on premature workflow exit: abort_with_compensation - 👇 [Same as above: the workflow is successful for action-on-premature exit is irrelevant] +workflow exit type: SUCCESS 🟢 caller received update result 🟢 caller received workflow result -workflow exit type: failure - update action on premature workflow exit: continue - 👇 [update does not abort and main workflow method waits for handlers to finish => caller gets successful update result prior to workflow failure] - 🟢 caller received update result - 🔴 caught exception while waiting for workflow result: Workflow execution failed: deliberately failing workflow - update action on premature workflow exit: abort_with_compensation - 👇 [update aborts, compensates and raises => caller gets failed update result] - 🔴 caught exception while waiting for update result: Workflow update failed: The update failed because the workflow run exited: deliberately failing workflow +workflow exit type: FAILURE + 🔴 caught exception while waiting for update result: Workflow update failed: The update failed because the workflow run exited 🔴 caught exception while waiting for workflow result: Workflow execution failed: deliberately failing workflow -workflow exit type: cancellation - update action on premature workflow exit: continue - 👇 [update does not abort and main workflow method waits for handlers to finish => caller gets successful update result prior to workflow cancellation] - 🟢 caller received update result - 🔴 caught exception while waiting for workflow result: Workflow execution failed: Workflow cancelled - update action on premature workflow exit: abort_with_compensation - 👇 [update aborts, compensates and raises => caller gets failed update result] - 🔴 caught exception while waiting for update result: Workflow update failed: The update failed because the workflow run exited: +workflow exit type: CANCELLATION + 🔴 caught exception while waiting for update result: Workflow update failed: The update failed because the workflow run exited 🔴 caught exception while waiting for workflow result: Workflow execution failed: Workflow cancelled - - -workflow exit type: continue_as_new - update action on premature workflow exit: continue - 👇 [update does not abort and main workflow method waits for handlers to finish => caller gets successful update result prior to continue-as-new] - 🟢 caller received update result - 👇 [a second update is sent to the post-CAN run, which run succeeds, hence update succeeds] - 🟢 caller received update result - 🟢 caller received workflow result - update action on premature workflow exit: abort_with_compensation - 👇 [update aborts, compensates and raises => caller gets failed update result] - 🔴 caught exception while waiting for update result: update "50cd58dc-2db7-4a70-9204-bf5922203203" not found: - 👇 [a second update is sent to the post-CAN run, which run succeeds, hence update succeeds] - 🟢 caller received update result - 🟢 caller received workflow result ``` \ No newline at end of file From 499d4b65454827a410e7ca463386ac70acf64591 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 7 Oct 2024 14:22:45 -0400 Subject: [PATCH 11/20] black --- .../waiting_for_handlers_and_compensation/starter.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/message_passing/waiting_for_handlers_and_compensation/starter.py b/message_passing/waiting_for_handlers_and_compensation/starter.py index fd57c4a5..812bee5f 100644 --- a/message_passing/waiting_for_handlers_and_compensation/starter.py +++ b/message_passing/waiting_for_handlers_and_compensation/starter.py @@ -35,9 +35,7 @@ async def _check_run( wait_for_stage=client.WorkflowUpdateStage.ACCEPTED, ) except Exception as e: - print( - f" 🔴 caught exception while starting update: {e}: {e.__cause__ or ''}" - ) + print(f" 🔴 caught exception while starting update: {e}: {e.__cause__ or ''}") if exit_type == WorkflowExitType.CANCELLATION: await wf_handle.cancel() From 0883e23d411c2d117b7ba3aa70d83257b7990d16 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 14 Oct 2024 06:40:59 -0400 Subject: [PATCH 12/20] split --- .../waiting_for_handlers/README.md | 39 ++++ .../waiting_for_handlers/__init__.py | 16 ++ .../waiting_for_handlers/activities.py | 18 ++ .../waiting_for_handlers/starter.py | 71 +++++++ .../waiting_for_handlers/worker.py | 46 +++++ .../waiting_for_handlers/workflows.py | 180 ++++++++++++++++++ .../waiting_for_handlers/workflow_test.py | 106 +++++++++++ 7 files changed, 476 insertions(+) create mode 100644 message_passing/waiting_for_handlers/README.md create mode 100644 message_passing/waiting_for_handlers/__init__.py create mode 100644 message_passing/waiting_for_handlers/activities.py create mode 100644 message_passing/waiting_for_handlers/starter.py create mode 100644 message_passing/waiting_for_handlers/worker.py create mode 100644 message_passing/waiting_for_handlers/workflows.py create mode 100644 tests/message_passing/waiting_for_handlers/workflow_test.py diff --git a/message_passing/waiting_for_handlers/README.md b/message_passing/waiting_for_handlers/README.md new file mode 100644 index 00000000..08a265bd --- /dev/null +++ b/message_passing/waiting_for_handlers/README.md @@ -0,0 +1,39 @@ +# Waiting for message handlers, and performing compensation and cleanup in message handlers + +This sample demonstrates how to do the following: + +1. Ensure that all update/signal handlers are finished before a successful + workflow return, and on workflow cancellation and failure. +2. Perform compensation/cleanup in an update handler when the workflow is + cancelled or fails. + + + +To run, open two terminals and `cd` to this directory in them. + +Run the worker in one terminal: + + poetry run python worker.py + +And run the workflow-starter code in the other terminal: + + poetry run python starter.py + + +Here's the output you'll see: + +``` +workflow exit type: SUCCESS + 🟢 caller received update result + 🟢 caller received workflow result + + +workflow exit type: FAILURE + 🔴 caught exception while waiting for update result: Workflow update failed: The update failed because the workflow run exited + 🔴 caught exception while waiting for workflow result: Workflow execution failed: deliberately failing workflow + + +workflow exit type: CANCELLATION + 🔴 caught exception while waiting for update result: Workflow update failed: The update failed because the workflow run exited + 🔴 caught exception while waiting for workflow result: Workflow execution failed: Workflow cancelled +``` \ No newline at end of file diff --git a/message_passing/waiting_for_handlers/__init__.py b/message_passing/waiting_for_handlers/__init__.py new file mode 100644 index 00000000..d537520f --- /dev/null +++ b/message_passing/waiting_for_handlers/__init__.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from enum import IntEnum + +TASK_QUEUE = "my-task-queue" +WORKFLOW_ID = "my-workflow-id" + + +class WorkflowExitType(IntEnum): + SUCCESS = 0 + FAILURE = 1 + CANCELLATION = 2 + + +@dataclass +class WorkflowInput: + exit_type: WorkflowExitType diff --git a/message_passing/waiting_for_handlers/activities.py b/message_passing/waiting_for_handlers/activities.py new file mode 100644 index 00000000..36c5a5cb --- /dev/null +++ b/message_passing/waiting_for_handlers/activities.py @@ -0,0 +1,18 @@ +import asyncio + +from temporalio import activity + + +@activity.defn +async def activity_executed_to_perform_workflow_compensation(): + await asyncio.sleep(1) + + +@activity.defn +async def activity_executed_by_update_handler(): + await asyncio.sleep(1) + + +@activity.defn +async def activity_executed_by_update_handler_to_perform_compensation(): + await asyncio.sleep(1) diff --git a/message_passing/waiting_for_handlers/starter.py b/message_passing/waiting_for_handlers/starter.py new file mode 100644 index 00000000..812bee5f --- /dev/null +++ b/message_passing/waiting_for_handlers/starter.py @@ -0,0 +1,71 @@ +import asyncio + +from temporalio import client, common + +from message_passing.waiting_for_handlers_and_compensation import ( + TASK_QUEUE, + WORKFLOW_ID, + WorkflowExitType, + WorkflowInput, +) +from message_passing.waiting_for_handlers_and_compensation.workflows import ( + WaitingForHandlersAndCompensationWorkflow, +) + + +async def starter(exit_type: WorkflowExitType): + cl = await client.Client.connect("localhost:7233") + wf_handle = await cl.start_workflow( + WaitingForHandlersAndCompensationWorkflow.run, + WorkflowInput(exit_type=exit_type), + id=WORKFLOW_ID, + task_queue=TASK_QUEUE, + id_conflict_policy=common.WorkflowIDConflictPolicy.TERMINATE_EXISTING, + ) + await _check_run(wf_handle, exit_type) + + +async def _check_run( + wf_handle: client.WorkflowHandle, + exit_type: WorkflowExitType, +): + try: + up_handle = await wf_handle.start_update( + WaitingForHandlersAndCompensationWorkflow.my_update, + wait_for_stage=client.WorkflowUpdateStage.ACCEPTED, + ) + except Exception as e: + print(f" 🔴 caught exception while starting update: {e}: {e.__cause__ or ''}") + + if exit_type == WorkflowExitType.CANCELLATION: + await wf_handle.cancel() + + try: + await up_handle.result() + print(" 🟢 caller received update result") + except Exception as e: + print( + f" 🔴 caught exception while waiting for update result: {e}: {e.__cause__ or ''}" + ) + + try: + await wf_handle.result() + print(" 🟢 caller received workflow result") + except Exception as e: + print( + f" 🔴 caught exception while waiting for workflow result: {e}: {e.__cause__ or ''}" + ) + + +async def main(): + for exit_type in [ + WorkflowExitType.SUCCESS, + WorkflowExitType.FAILURE, + WorkflowExitType.CANCELLATION, + ]: + print(f"\n\nworkflow exit type: {exit_type.name}") + await starter(exit_type) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/message_passing/waiting_for_handlers/worker.py b/message_passing/waiting_for_handlers/worker.py new file mode 100644 index 00000000..7daf768f --- /dev/null +++ b/message_passing/waiting_for_handlers/worker.py @@ -0,0 +1,46 @@ +import asyncio +import logging + +from temporalio.client import Client +from temporalio.worker import Worker + +from message_passing.waiting_for_handlers_and_compensation import TASK_QUEUE +from message_passing.waiting_for_handlers_and_compensation.activities import ( + activity_executed_by_update_handler, + activity_executed_by_update_handler_to_perform_compensation, + activity_executed_to_perform_workflow_compensation, +) +from message_passing.waiting_for_handlers_and_compensation.workflows import ( + WaitingForHandlersAndCompensationWorkflow, +) + +interrupt_event = asyncio.Event() + + +async def main(): + logging.basicConfig(level=logging.INFO) + + client = await Client.connect("localhost:7233") + + async with Worker( + client, + task_queue=TASK_QUEUE, + workflows=[WaitingForHandlersAndCompensationWorkflow], + activities=[ + activity_executed_by_update_handler, + activity_executed_by_update_handler_to_perform_compensation, + activity_executed_to_perform_workflow_compensation, + ], + ): + logging.info("Worker started, ctrl+c to exit") + await interrupt_event.wait() + logging.info("Shutting down") + + +if __name__ == "__main__": + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(main()) + except KeyboardInterrupt: + interrupt_event.set() + loop.run_until_complete(loop.shutdown_asyncgens()) diff --git a/message_passing/waiting_for_handlers/workflows.py b/message_passing/waiting_for_handlers/workflows.py new file mode 100644 index 00000000..0851e5c8 --- /dev/null +++ b/message_passing/waiting_for_handlers/workflows.py @@ -0,0 +1,180 @@ +import asyncio +from datetime import timedelta +from typing import cast + +from temporalio import exceptions, workflow + +from message_passing.waiting_for_handlers_and_compensation import ( + WorkflowExitType, + WorkflowInput, +) +from message_passing.waiting_for_handlers_and_compensation.activities import ( + activity_executed_by_update_handler, + activity_executed_by_update_handler_to_perform_compensation, + activity_executed_to_perform_workflow_compensation, +) + + +@workflow.defn +class WaitingForHandlersAndCompensationWorkflow: + """ + This Workflow demonstrates how to wait for message handlers to finish and + perform compensation/cleanup: + + 1. It ensures that all signal and update handlers have finished before a + successful return, and on failure and cancellation. + 2. The update handler performs any necessary compensation/cleanup when the + workflow is cancelled or fails. + """ + + def __init__(self) -> None: + # 👉 If the workflow exits prematurely, this future will be completed + # with the associated exception as its value. Message handlers can then + # "race" this future against a task performing the message handler's own + # application logic; if this future completes before the message handler + # task then the handler should abort and perform compensation. + self.workflow_exit: asyncio.Future[None] = asyncio.Future() + + # The following two attributes are implementation detail of this sample + # and can be ignored + self._update_started = False + self._update_compensation_done = False + self._workflow_compensation_done = False + + @workflow.run + async def run(self, input: WorkflowInput) -> str: + try: + # 👉 Use this `try...except` style, instead of waiting for message + # handlers to finish in a `finally` block. The reason is that some + # exception types cause a workflow task failure as opposed to + # workflow exit, in which case we do *not* want to wait for message + # handlers to finish. + + # 👉 self._run contains your actual application logic. This is + # implemented in a separate method in order to separate + # "platform-level" concerns (waiting for handlers to finish and + # ensuring that compensation is performed when appropriate) from + # application logic. In this sample, its actual implementation is + # below but contains nothing relevant. + result = await self._run(input) + self.workflow_exit.set_result(None) + await workflow.wait_condition(workflow.all_handlers_finished) + return result + # 👉 Catch BaseException since asyncio.CancelledError does not inherit + # from Exception. + except BaseException as e: + if is_workflow_exit_exception(e): + self.workflow_exit.set_exception(e) + await workflow.wait_condition(workflow.all_handlers_finished) + await self.workflow_compensation() + self._workflow_compensation_done = True + raise + + async def workflow_compensation(self): + await workflow.execute_activity( + activity_executed_to_perform_workflow_compensation, + start_to_close_timeout=timedelta(seconds=10), + ) + self._update_compensation_done = True + + @workflow.update + async def my_update(self) -> str: + """ + An update handler that handles exceptions raised in its own execution + and in that of the main workflow method. + + It ensures that: + - Compensation/cleanup is always performed when appropriate + - The update caller gets the update result, or WorkflowUpdateFailedError + """ + # 👉 As with the main workflow method, the update application logic is + # implemented in a separate method in order to separate "platform-level" + # error-handling and compensation concerns from application logic. Note + # that coroutines must be wrapped in tasks in order to use + # workflow.wait. + update_task = asyncio.create_task(self._my_update()) + + # 👉 "Race" the workflow_exit future against the handler's own application + # logic. Always use `workflow.wait` instead of `asyncio.wait` in + # Workflow code: asyncio's version is non-deterministic. + await workflow.wait( # type: ignore + [update_task, self.workflow_exit], return_when=asyncio.FIRST_EXCEPTION + ) + try: + if update_task.done(): + # 👉 The update has finished (whether successfully or not). + # Regardless of whether the main workflow method is about to + # exit or not, the update caller should receive a response + # informing them of the outcome of the update. So return the + # result, or raise the exception that caused the update handler + # to exit. + return await update_task + else: + # 👉 The main workflow method exited prematurely due to an + # error, and this happened before the update finished. Fail the + # update with the workflow exception as cause. + raise exceptions.ApplicationError( + "The update failed because the workflow run exited" + ) from cast(BaseException, self.workflow_exit.exception()) + # 👉 Catch BaseException since asyncio.CancelledError does not inherit + # from Exception. + except BaseException as e: + if is_workflow_exit_exception(e): + try: + await self.my_update_compensation() + except BaseException as e: + raise exceptions.ApplicationError( + "Update compensation failed" + ) from e + raise + + async def my_update_compensation(self): + await workflow.execute_activity( + activity_executed_by_update_handler_to_perform_compensation, + start_to_close_timeout=timedelta(seconds=10), + ) + self._update_compensation_done = True + + @workflow.query + def workflow_compensation_done(self) -> bool: + return self._workflow_compensation_done + + @workflow.query + def update_compensation_done(self) -> bool: + return self._update_compensation_done + + # The following methods are placeholders for the actual application logic + # that you would perform in your main workflow method or update handler. + # Their implementation can be ignored. + + async def _my_update(self) -> str: + # Ignore this method unless you are interested in the implementation + # details of this sample. + self._update_started = True + await workflow.execute_activity( + activity_executed_by_update_handler, + start_to_close_timeout=timedelta(seconds=10), + ) + return "update-result" + + async def _run(self, input: WorkflowInput) -> str: + # Ignore this method unless you are interested in the implementation + # details of this sample. + + # Wait until handlers have started, so that we are demonstrating that we + # wait for them to finish. + await workflow.wait_condition(lambda: self._update_started) + if input.exit_type == WorkflowExitType.SUCCESS: + return "workflow-result" + elif input.exit_type == WorkflowExitType.FAILURE: + raise exceptions.ApplicationError("deliberately failing workflow") + elif input.exit_type == WorkflowExitType.CANCELLATION: + # Block forever; the starter will send a workflow cancellation request. + await asyncio.Future() + raise AssertionError("unreachable") + + +def is_workflow_exit_exception(e: BaseException) -> bool: + # 👉 If you have set additional failure_exception_types you should also + # check for these here. + return isinstance(e, (asyncio.CancelledError, exceptions.FailureError)) diff --git a/tests/message_passing/waiting_for_handlers/workflow_test.py b/tests/message_passing/waiting_for_handlers/workflow_test.py new file mode 100644 index 00000000..2b10d396 --- /dev/null +++ b/tests/message_passing/waiting_for_handlers/workflow_test.py @@ -0,0 +1,106 @@ +import uuid +from enum import Enum + +import pytest +from temporalio import client, worker +from temporalio.testing import WorkflowEnvironment + +from message_passing.waiting_for_handlers_and_compensation import ( + WorkflowExitType, + WorkflowInput, +) +from message_passing.waiting_for_handlers_and_compensation.activities import ( + activity_executed_by_update_handler, + activity_executed_by_update_handler_to_perform_compensation, + activity_executed_to_perform_workflow_compensation, +) +from message_passing.waiting_for_handlers_and_compensation.starter import TASK_QUEUE +from message_passing.waiting_for_handlers_and_compensation.workflows import ( + WaitingForHandlersAndCompensationWorkflow, +) + + +class UpdateExpect(Enum): + SUCCESS = "success" + FAILURE = "failure" + + +class WorkflowExpect(Enum): + SUCCESS = "success" + FAILURE = "failure" + + +@pytest.mark.parametrize( + ["exit_type_name", "update_expect", "workflow_expect"], + [ + (WorkflowExitType.SUCCESS.name, UpdateExpect.SUCCESS, WorkflowExpect.SUCCESS), + (WorkflowExitType.FAILURE.name, UpdateExpect.FAILURE, WorkflowExpect.FAILURE), + ( + WorkflowExitType.CANCELLATION.name, + UpdateExpect.FAILURE, + WorkflowExpect.FAILURE, + ), + ], +) +async def test_waiting_for_handlers_and_compensation( + env: WorkflowEnvironment, + exit_type_name: str, + update_expect: UpdateExpect, + workflow_expect: WorkflowExpect, +): + [exit_type] = [t for t in WorkflowExitType if t.name == exit_type_name] + if env.supports_time_skipping: + pytest.skip( + "Java test server: https://github.com/temporalio/sdk-java/issues/1903" + ) + async with worker.Worker( + env.client, + task_queue=TASK_QUEUE, + workflows=[WaitingForHandlersAndCompensationWorkflow], + activities=[ + activity_executed_by_update_handler, + activity_executed_by_update_handler_to_perform_compensation, + activity_executed_to_perform_workflow_compensation, + ], + ): + wf_handle = await env.client.start_workflow( + WaitingForHandlersAndCompensationWorkflow.run, + WorkflowInput(exit_type=exit_type), + id=str(uuid.uuid4()), + task_queue=TASK_QUEUE, + ) + up_handle = await wf_handle.start_update( + WaitingForHandlersAndCompensationWorkflow.my_update, + wait_for_stage=client.WorkflowUpdateStage.ACCEPTED, + ) + + if exit_type == WorkflowExitType.CANCELLATION: + await wf_handle.cancel() + + if update_expect == UpdateExpect.SUCCESS: + await up_handle.result() + assert not ( + await wf_handle.query( + WaitingForHandlersAndCompensationWorkflow.update_compensation_done + ) + ) + else: + with pytest.raises(client.WorkflowUpdateFailedError): + await up_handle.result() + assert await wf_handle.query( + WaitingForHandlersAndCompensationWorkflow.update_compensation_done + ) + + if workflow_expect == WorkflowExpect.SUCCESS: + await wf_handle.result() + assert not ( + await wf_handle.query( + WaitingForHandlersAndCompensationWorkflow.workflow_compensation_done + ) + ) + else: + with pytest.raises(client.WorkflowFailureError): + await wf_handle.result() + assert await wf_handle.query( + WaitingForHandlersAndCompensationWorkflow.workflow_compensation_done + ) From b6060f08b391c7c2f8012086a0b9e3ad80d4e633 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 14 Oct 2024 07:11:00 -0400 Subject: [PATCH 13/20] Use WorkflowResult dataclass --- .../waiting_for_handlers_and_compensation/__init__.py | 5 +++++ .../waiting_for_handlers_and_compensation/workflows.py | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/message_passing/waiting_for_handlers_and_compensation/__init__.py b/message_passing/waiting_for_handlers_and_compensation/__init__.py index d537520f..1274f9bf 100644 --- a/message_passing/waiting_for_handlers_and_compensation/__init__.py +++ b/message_passing/waiting_for_handlers_and_compensation/__init__.py @@ -14,3 +14,8 @@ class WorkflowExitType(IntEnum): @dataclass class WorkflowInput: exit_type: WorkflowExitType + + +@dataclass +class WorkflowResult: + data: str diff --git a/message_passing/waiting_for_handlers_and_compensation/workflows.py b/message_passing/waiting_for_handlers_and_compensation/workflows.py index 0851e5c8..607ddff6 100644 --- a/message_passing/waiting_for_handlers_and_compensation/workflows.py +++ b/message_passing/waiting_for_handlers_and_compensation/workflows.py @@ -7,6 +7,7 @@ from message_passing.waiting_for_handlers_and_compensation import ( WorkflowExitType, WorkflowInput, + WorkflowResult, ) from message_passing.waiting_for_handlers_and_compensation.activities import ( activity_executed_by_update_handler, @@ -42,7 +43,7 @@ def __init__(self) -> None: self._workflow_compensation_done = False @workflow.run - async def run(self, input: WorkflowInput) -> str: + async def run(self, input: WorkflowInput) -> WorkflowResult: try: # 👉 Use this `try...except` style, instead of waiting for message # handlers to finish in a `finally` block. The reason is that some @@ -157,7 +158,7 @@ async def _my_update(self) -> str: ) return "update-result" - async def _run(self, input: WorkflowInput) -> str: + async def _run(self, input: WorkflowInput) -> WorkflowResult: # Ignore this method unless you are interested in the implementation # details of this sample. @@ -165,7 +166,7 @@ async def _run(self, input: WorkflowInput) -> str: # wait for them to finish. await workflow.wait_condition(lambda: self._update_started) if input.exit_type == WorkflowExitType.SUCCESS: - return "workflow-result" + return WorkflowResult(data="workflow-result") elif input.exit_type == WorkflowExitType.FAILURE: raise exceptions.ApplicationError("deliberately failing workflow") elif input.exit_type == WorkflowExitType.CANCELLATION: From 270830ddd147c69440e01860cc0efe4a0c1cb4b7 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 14 Oct 2024 07:13:46 -0400 Subject: [PATCH 14/20] Diverge --- .../waiting_for_handlers/README.md | 20 ++- .../waiting_for_handlers/__init__.py | 5 + .../waiting_for_handlers/activities.py | 10 -- .../waiting_for_handlers/starter.py | 16 +- .../waiting_for_handlers/worker.py | 14 +- .../waiting_for_handlers/workflows.py | 167 +++++------------- .../workflows.py | 47 +++-- .../waiting_for_handlers/workflow_test.py | 45 ++--- 8 files changed, 104 insertions(+), 220 deletions(-) diff --git a/message_passing/waiting_for_handlers/README.md b/message_passing/waiting_for_handlers/README.md index 08a265bd..0667cff3 100644 --- a/message_passing/waiting_for_handlers/README.md +++ b/message_passing/waiting_for_handlers/README.md @@ -1,12 +1,15 @@ -# Waiting for message handlers, and performing compensation and cleanup in message handlers +# Waiting for message handlers in message handlers -This sample demonstrates how to do the following: +This workflow demonstrates how to wait for signal and update handlers to +finish in the following circumstances: -1. Ensure that all update/signal handlers are finished before a successful - workflow return, and on workflow cancellation and failure. -2. Perform compensation/cleanup in an update handler when the workflow is - cancelled or fails. +- Before a successful return +- On failure +- On cancellation +Your workflow can also exit via Continue-As-New. In that case you would +usually wait for the handlers to finish immediately before the call to +continue_as_new(); that's not illustrated in this sample. To run, open two terminals and `cd` to this directory in them. @@ -29,11 +32,10 @@ workflow exit type: SUCCESS workflow exit type: FAILURE - 🔴 caught exception while waiting for update result: Workflow update failed: The update failed because the workflow run exited + 🟢 caller received update result 🔴 caught exception while waiting for workflow result: Workflow execution failed: deliberately failing workflow workflow exit type: CANCELLATION - 🔴 caught exception while waiting for update result: Workflow update failed: The update failed because the workflow run exited - 🔴 caught exception while waiting for workflow result: Workflow execution failed: Workflow cancelled + 🟢 caller received update result ``` \ No newline at end of file diff --git a/message_passing/waiting_for_handlers/__init__.py b/message_passing/waiting_for_handlers/__init__.py index d537520f..1274f9bf 100644 --- a/message_passing/waiting_for_handlers/__init__.py +++ b/message_passing/waiting_for_handlers/__init__.py @@ -14,3 +14,8 @@ class WorkflowExitType(IntEnum): @dataclass class WorkflowInput: exit_type: WorkflowExitType + + +@dataclass +class WorkflowResult: + data: str diff --git a/message_passing/waiting_for_handlers/activities.py b/message_passing/waiting_for_handlers/activities.py index 36c5a5cb..610db849 100644 --- a/message_passing/waiting_for_handlers/activities.py +++ b/message_passing/waiting_for_handlers/activities.py @@ -3,16 +3,6 @@ from temporalio import activity -@activity.defn -async def activity_executed_to_perform_workflow_compensation(): - await asyncio.sleep(1) - - @activity.defn async def activity_executed_by_update_handler(): await asyncio.sleep(1) - - -@activity.defn -async def activity_executed_by_update_handler_to_perform_compensation(): - await asyncio.sleep(1) diff --git a/message_passing/waiting_for_handlers/starter.py b/message_passing/waiting_for_handlers/starter.py index 812bee5f..13f4efc2 100644 --- a/message_passing/waiting_for_handlers/starter.py +++ b/message_passing/waiting_for_handlers/starter.py @@ -2,21 +2,21 @@ from temporalio import client, common -from message_passing.waiting_for_handlers_and_compensation import ( +from message_passing.waiting_for_handlers import ( TASK_QUEUE, WORKFLOW_ID, WorkflowExitType, WorkflowInput, ) -from message_passing.waiting_for_handlers_and_compensation.workflows import ( - WaitingForHandlersAndCompensationWorkflow, +from message_passing.waiting_for_handlers.workflows import ( + WaitingForHandlersWorkflow, ) async def starter(exit_type: WorkflowExitType): cl = await client.Client.connect("localhost:7233") wf_handle = await cl.start_workflow( - WaitingForHandlersAndCompensationWorkflow.run, + WaitingForHandlersWorkflow.run, WorkflowInput(exit_type=exit_type), id=WORKFLOW_ID, task_queue=TASK_QUEUE, @@ -31,11 +31,13 @@ async def _check_run( ): try: up_handle = await wf_handle.start_update( - WaitingForHandlersAndCompensationWorkflow.my_update, + WaitingForHandlersWorkflow.my_update, wait_for_stage=client.WorkflowUpdateStage.ACCEPTED, ) except Exception as e: - print(f" 🔴 caught exception while starting update: {e}: {e.__cause__ or ''}") + print( + f" 🔴 caught exception while starting update: {e}: {e.__cause__ or ''}" + ) if exit_type == WorkflowExitType.CANCELLATION: await wf_handle.cancel() @@ -51,7 +53,7 @@ async def _check_run( try: await wf_handle.result() print(" 🟢 caller received workflow result") - except Exception as e: + except BaseException as e: print( f" 🔴 caught exception while waiting for workflow result: {e}: {e.__cause__ or ''}" ) diff --git a/message_passing/waiting_for_handlers/worker.py b/message_passing/waiting_for_handlers/worker.py index 7daf768f..bfad6197 100644 --- a/message_passing/waiting_for_handlers/worker.py +++ b/message_passing/waiting_for_handlers/worker.py @@ -4,14 +4,12 @@ from temporalio.client import Client from temporalio.worker import Worker -from message_passing.waiting_for_handlers_and_compensation import TASK_QUEUE -from message_passing.waiting_for_handlers_and_compensation.activities import ( +from message_passing.waiting_for_handlers import TASK_QUEUE +from message_passing.waiting_for_handlers.activities import ( activity_executed_by_update_handler, - activity_executed_by_update_handler_to_perform_compensation, - activity_executed_to_perform_workflow_compensation, ) -from message_passing.waiting_for_handlers_and_compensation.workflows import ( - WaitingForHandlersAndCompensationWorkflow, +from message_passing.waiting_for_handlers.workflows import ( + WaitingForHandlersWorkflow, ) interrupt_event = asyncio.Event() @@ -25,11 +23,9 @@ async def main(): async with Worker( client, task_queue=TASK_QUEUE, - workflows=[WaitingForHandlersAndCompensationWorkflow], + workflows=[WaitingForHandlersWorkflow], activities=[ activity_executed_by_update_handler, - activity_executed_by_update_handler_to_perform_compensation, - activity_executed_to_perform_workflow_compensation, ], ): logging.info("Worker started, ctrl+c to exit") diff --git a/message_passing/waiting_for_handlers/workflows.py b/message_passing/waiting_for_handlers/workflows.py index 0851e5c8..b4ed2991 100644 --- a/message_passing/waiting_for_handlers/workflows.py +++ b/message_passing/waiting_for_handlers/workflows.py @@ -1,155 +1,73 @@ import asyncio from datetime import timedelta -from typing import cast from temporalio import exceptions, workflow -from message_passing.waiting_for_handlers_and_compensation import ( +from message_passing.waiting_for_handlers import ( WorkflowExitType, WorkflowInput, + WorkflowResult, ) -from message_passing.waiting_for_handlers_and_compensation.activities import ( +from message_passing.waiting_for_handlers.activities import ( activity_executed_by_update_handler, - activity_executed_by_update_handler_to_perform_compensation, - activity_executed_to_perform_workflow_compensation, ) -@workflow.defn -class WaitingForHandlersAndCompensationWorkflow: +def is_workflow_exit_exception(e: BaseException) -> bool: """ - This Workflow demonstrates how to wait for message handlers to finish and - perform compensation/cleanup: + True if the exception is of a type that will cause the workflow to exit. - 1. It ensures that all signal and update handlers have finished before a - successful return, and on failure and cancellation. - 2. The update handler performs any necessary compensation/cleanup when the - workflow is cancelled or fails. + This is as opposed to exceptions that cause a workflow task failure, which + are retried automatically by Temporal. """ + # 👉 If you have set additional failure_exception_types you should also + # check for these here. + return isinstance(e, (asyncio.CancelledError, exceptions.FailureError)) - def __init__(self) -> None: - # 👉 If the workflow exits prematurely, this future will be completed - # with the associated exception as its value. Message handlers can then - # "race" this future against a task performing the message handler's own - # application logic; if this future completes before the message handler - # task then the handler should abort and perform compensation. - self.workflow_exit: asyncio.Future[None] = asyncio.Future() - - # The following two attributes are implementation detail of this sample - # and can be ignored - self._update_started = False - self._update_compensation_done = False - self._workflow_compensation_done = False +@workflow.defn +class WaitingForHandlersWorkflow: @workflow.run - async def run(self, input: WorkflowInput) -> str: + async def run(self, input: WorkflowInput) -> WorkflowResult: + """ + This workflow.run method demonstrates a pattern that can be used to wait for signal and + update handlers to finish in the following circumstances: + + - On successful workflow return + - On workflow cancellation + - On workflow failure + + Your workflow can also exit via Continue-As-New. In that case you would usually wait for + the handlers to finish immediately before the call to continue_as_new(); that's not + illustrated in this sample. + + If you additionally need to perform cleanup or compensation on workflow failure or + cancellation, see the message_passing/waiting_for_handlers_and_compensation sample. + """ try: # 👉 Use this `try...except` style, instead of waiting for message # handlers to finish in a `finally` block. The reason is that some # exception types cause a workflow task failure as opposed to # workflow exit, in which case we do *not* want to wait for message # handlers to finish. - - # 👉 self._run contains your actual application logic. This is - # implemented in a separate method in order to separate - # "platform-level" concerns (waiting for handlers to finish and - # ensuring that compensation is performed when appropriate) from - # application logic. In this sample, its actual implementation is - # below but contains nothing relevant. - result = await self._run(input) - self.workflow_exit.set_result(None) + result = await self._my_workflow_application_logic(input) await workflow.wait_condition(workflow.all_handlers_finished) return result # 👉 Catch BaseException since asyncio.CancelledError does not inherit # from Exception. except BaseException as e: if is_workflow_exit_exception(e): - self.workflow_exit.set_exception(e) await workflow.wait_condition(workflow.all_handlers_finished) - await self.workflow_compensation() - self._workflow_compensation_done = True raise - async def workflow_compensation(self): - await workflow.execute_activity( - activity_executed_to_perform_workflow_compensation, - start_to_close_timeout=timedelta(seconds=10), - ) - self._update_compensation_done = True + # Methods below this point can be ignored unless you are interested in + # the implementation details of this sample. + + def __init__(self) -> None: + self._update_started = False @workflow.update async def my_update(self) -> str: - """ - An update handler that handles exceptions raised in its own execution - and in that of the main workflow method. - - It ensures that: - - Compensation/cleanup is always performed when appropriate - - The update caller gets the update result, or WorkflowUpdateFailedError - """ - # 👉 As with the main workflow method, the update application logic is - # implemented in a separate method in order to separate "platform-level" - # error-handling and compensation concerns from application logic. Note - # that coroutines must be wrapped in tasks in order to use - # workflow.wait. - update_task = asyncio.create_task(self._my_update()) - - # 👉 "Race" the workflow_exit future against the handler's own application - # logic. Always use `workflow.wait` instead of `asyncio.wait` in - # Workflow code: asyncio's version is non-deterministic. - await workflow.wait( # type: ignore - [update_task, self.workflow_exit], return_when=asyncio.FIRST_EXCEPTION - ) - try: - if update_task.done(): - # 👉 The update has finished (whether successfully or not). - # Regardless of whether the main workflow method is about to - # exit or not, the update caller should receive a response - # informing them of the outcome of the update. So return the - # result, or raise the exception that caused the update handler - # to exit. - return await update_task - else: - # 👉 The main workflow method exited prematurely due to an - # error, and this happened before the update finished. Fail the - # update with the workflow exception as cause. - raise exceptions.ApplicationError( - "The update failed because the workflow run exited" - ) from cast(BaseException, self.workflow_exit.exception()) - # 👉 Catch BaseException since asyncio.CancelledError does not inherit - # from Exception. - except BaseException as e: - if is_workflow_exit_exception(e): - try: - await self.my_update_compensation() - except BaseException as e: - raise exceptions.ApplicationError( - "Update compensation failed" - ) from e - raise - - async def my_update_compensation(self): - await workflow.execute_activity( - activity_executed_by_update_handler_to_perform_compensation, - start_to_close_timeout=timedelta(seconds=10), - ) - self._update_compensation_done = True - - @workflow.query - def workflow_compensation_done(self) -> bool: - return self._workflow_compensation_done - - @workflow.query - def update_compensation_done(self) -> bool: - return self._update_compensation_done - - # The following methods are placeholders for the actual application logic - # that you would perform in your main workflow method or update handler. - # Their implementation can be ignored. - - async def _my_update(self) -> str: - # Ignore this method unless you are interested in the implementation - # details of this sample. self._update_started = True await workflow.execute_activity( activity_executed_by_update_handler, @@ -157,24 +75,21 @@ async def _my_update(self) -> str: ) return "update-result" - async def _run(self, input: WorkflowInput) -> str: - # Ignore this method unless you are interested in the implementation - # details of this sample. + async def _my_workflow_application_logic( + self, input: WorkflowInput + ) -> WorkflowResult: + # The main workflow logic is implemented in a separate method in order + # to separate "platform-level" concerns (waiting for handlers to finish + # and error handling) from application logic. # Wait until handlers have started, so that we are demonstrating that we # wait for them to finish. await workflow.wait_condition(lambda: self._update_started) if input.exit_type == WorkflowExitType.SUCCESS: - return "workflow-result" + return WorkflowResult(data="workflow-result") elif input.exit_type == WorkflowExitType.FAILURE: raise exceptions.ApplicationError("deliberately failing workflow") elif input.exit_type == WorkflowExitType.CANCELLATION: # Block forever; the starter will send a workflow cancellation request. await asyncio.Future() raise AssertionError("unreachable") - - -def is_workflow_exit_exception(e: BaseException) -> bool: - # 👉 If you have set additional failure_exception_types you should also - # check for these here. - return isinstance(e, (asyncio.CancelledError, exceptions.FailureError)) diff --git a/message_passing/waiting_for_handlers_and_compensation/workflows.py b/message_passing/waiting_for_handlers_and_compensation/workflows.py index 607ddff6..79fce970 100644 --- a/message_passing/waiting_for_handlers_and_compensation/workflows.py +++ b/message_passing/waiting_for_handlers_and_compensation/workflows.py @@ -26,6 +26,9 @@ class WaitingForHandlersAndCompensationWorkflow: successful return, and on failure and cancellation. 2. The update handler performs any necessary compensation/cleanup when the workflow is cancelled or fails. + + If all you need to do is wait for handlers, without performing cleanup or compensation, + then see the simpler sample message_passing/waiting_for_handlers. """ def __init__(self) -> None: @@ -51,13 +54,11 @@ async def run(self, input: WorkflowInput) -> WorkflowResult: # workflow exit, in which case we do *not* want to wait for message # handlers to finish. - # 👉 self._run contains your actual application logic. This is - # implemented in a separate method in order to separate - # "platform-level" concerns (waiting for handlers to finish and - # ensuring that compensation is performed when appropriate) from - # application logic. In this sample, its actual implementation is - # below but contains nothing relevant. - result = await self._run(input) + # 👉 The actual workflow application logic is implemented in a + # separate method in order to separate "platform-level" concerns + # (waiting for handlers to finish and ensuring that compensation is + # performed when appropriate) from application logic. + result = await self._my_workflow_application_logic(input) self.workflow_exit.set_result(None) await workflow.wait_condition(workflow.all_handlers_finished) return result @@ -88,16 +89,12 @@ async def my_update(self) -> str: - Compensation/cleanup is always performed when appropriate - The update caller gets the update result, or WorkflowUpdateFailedError """ - # 👉 As with the main workflow method, the update application logic is - # implemented in a separate method in order to separate "platform-level" - # error-handling and compensation concerns from application logic. Note - # that coroutines must be wrapped in tasks in order to use - # workflow.wait. - update_task = asyncio.create_task(self._my_update()) - - # 👉 "Race" the workflow_exit future against the handler's own application - # logic. Always use `workflow.wait` instead of `asyncio.wait` in - # Workflow code: asyncio's version is non-deterministic. + # 👉 "Race" the workflow_exit future against the handler's own + # application logic. Always use `workflow.wait` instead of + # `asyncio.wait` in Workflow code: asyncio's version is + # non-deterministic. (Note that coroutines must be wrapped in tasks in + # order to use workflow.wait.) + update_task = asyncio.create_task(self._my_update_application_logic()) await workflow.wait( # type: ignore [update_task, self.workflow_exit], return_when=asyncio.FIRST_EXCEPTION ) @@ -144,13 +141,12 @@ def workflow_compensation_done(self) -> bool: def update_compensation_done(self) -> bool: return self._update_compensation_done - # The following methods are placeholders for the actual application logic + # Methods below this point are placeholders for the actual application logic # that you would perform in your main workflow method or update handler. - # Their implementation can be ignored. + # They can be ignored unless you are interested in the implementation + # details of this sample. - async def _my_update(self) -> str: - # Ignore this method unless you are interested in the implementation - # details of this sample. + async def _my_update_application_logic(self) -> str: self._update_started = True await workflow.execute_activity( activity_executed_by_update_handler, @@ -158,10 +154,9 @@ async def _my_update(self) -> str: ) return "update-result" - async def _run(self, input: WorkflowInput) -> WorkflowResult: - # Ignore this method unless you are interested in the implementation - # details of this sample. - + async def _my_workflow_application_logic( + self, input: WorkflowInput + ) -> WorkflowResult: # Wait until handlers have started, so that we are demonstrating that we # wait for them to finish. await workflow.wait_condition(lambda: self._update_started) diff --git a/tests/message_passing/waiting_for_handlers/workflow_test.py b/tests/message_passing/waiting_for_handlers/workflow_test.py index 2b10d396..af5b2403 100644 --- a/tests/message_passing/waiting_for_handlers/workflow_test.py +++ b/tests/message_passing/waiting_for_handlers/workflow_test.py @@ -1,22 +1,19 @@ -import uuid from enum import Enum import pytest from temporalio import client, worker from temporalio.testing import WorkflowEnvironment -from message_passing.waiting_for_handlers_and_compensation import ( +from message_passing.waiting_for_handlers import ( WorkflowExitType, WorkflowInput, ) -from message_passing.waiting_for_handlers_and_compensation.activities import ( +from message_passing.waiting_for_handlers.activities import ( activity_executed_by_update_handler, - activity_executed_by_update_handler_to_perform_compensation, - activity_executed_to_perform_workflow_compensation, ) -from message_passing.waiting_for_handlers_and_compensation.starter import TASK_QUEUE -from message_passing.waiting_for_handlers_and_compensation.workflows import ( - WaitingForHandlersAndCompensationWorkflow, +from message_passing.waiting_for_handlers.starter import TASK_QUEUE +from message_passing.waiting_for_handlers.workflows import ( + WaitingForHandlersWorkflow, ) @@ -34,15 +31,15 @@ class WorkflowExpect(Enum): ["exit_type_name", "update_expect", "workflow_expect"], [ (WorkflowExitType.SUCCESS.name, UpdateExpect.SUCCESS, WorkflowExpect.SUCCESS), - (WorkflowExitType.FAILURE.name, UpdateExpect.FAILURE, WorkflowExpect.FAILURE), + (WorkflowExitType.FAILURE.name, UpdateExpect.SUCCESS, WorkflowExpect.FAILURE), ( WorkflowExitType.CANCELLATION.name, - UpdateExpect.FAILURE, + UpdateExpect.SUCCESS, WorkflowExpect.FAILURE, ), ], ) -async def test_waiting_for_handlers_and_compensation( +async def test_waiting_for_handlers( env: WorkflowEnvironment, exit_type_name: str, update_expect: UpdateExpect, @@ -56,21 +53,19 @@ async def test_waiting_for_handlers_and_compensation( async with worker.Worker( env.client, task_queue=TASK_QUEUE, - workflows=[WaitingForHandlersAndCompensationWorkflow], + workflows=[WaitingForHandlersWorkflow], activities=[ activity_executed_by_update_handler, - activity_executed_by_update_handler_to_perform_compensation, - activity_executed_to_perform_workflow_compensation, ], ): wf_handle = await env.client.start_workflow( - WaitingForHandlersAndCompensationWorkflow.run, + WaitingForHandlersWorkflow.run, WorkflowInput(exit_type=exit_type), - id=str(uuid.uuid4()), + id="waiting-for-handlers-test", task_queue=TASK_QUEUE, ) up_handle = await wf_handle.start_update( - WaitingForHandlersAndCompensationWorkflow.my_update, + WaitingForHandlersWorkflow.my_update, wait_for_stage=client.WorkflowUpdateStage.ACCEPTED, ) @@ -79,28 +74,12 @@ async def test_waiting_for_handlers_and_compensation( if update_expect == UpdateExpect.SUCCESS: await up_handle.result() - assert not ( - await wf_handle.query( - WaitingForHandlersAndCompensationWorkflow.update_compensation_done - ) - ) else: with pytest.raises(client.WorkflowUpdateFailedError): await up_handle.result() - assert await wf_handle.query( - WaitingForHandlersAndCompensationWorkflow.update_compensation_done - ) if workflow_expect == WorkflowExpect.SUCCESS: await wf_handle.result() - assert not ( - await wf_handle.query( - WaitingForHandlersAndCompensationWorkflow.workflow_compensation_done - ) - ) else: with pytest.raises(client.WorkflowFailureError): await wf_handle.result() - assert await wf_handle.query( - WaitingForHandlersAndCompensationWorkflow.workflow_compensation_done - ) From d35ad5df72fb37c257aba291b120d03d826f65dc Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 23 Jan 2025 18:01:23 -0500 Subject: [PATCH 15/20] Cleanup --- .../waiting_for_handlers_and_compensation/workflows.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/message_passing/waiting_for_handlers_and_compensation/workflows.py b/message_passing/waiting_for_handlers_and_compensation/workflows.py index 79fce970..450dff2c 100644 --- a/message_passing/waiting_for_handlers_and_compensation/workflows.py +++ b/message_passing/waiting_for_handlers_and_compensation/workflows.py @@ -39,8 +39,8 @@ def __init__(self) -> None: # task then the handler should abort and perform compensation. self.workflow_exit: asyncio.Future[None] = asyncio.Future() - # The following two attributes are implementation detail of this sample - # and can be ignored + # The following attributes are implementation detail of this sample and can be + # ignored self._update_started = False self._update_compensation_done = False self._workflow_compensation_done = False From 088315afe4ef8231ccb31d5f5a71663a95b8f4b8 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 28 Jan 2025 08:11:02 -0500 Subject: [PATCH 16/20] Edit title --- message_passing/waiting_for_handlers/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/message_passing/waiting_for_handlers/README.md b/message_passing/waiting_for_handlers/README.md index 0667cff3..27fa5d7d 100644 --- a/message_passing/waiting_for_handlers/README.md +++ b/message_passing/waiting_for_handlers/README.md @@ -1,4 +1,4 @@ -# Waiting for message handlers in message handlers +# Waiting for message handlers This workflow demonstrates how to wait for signal and update handlers to finish in the following circumstances: From e8f39d99cc6deacd6e0c4e0983ec9edb49f65488 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 28 Jan 2025 08:11:55 -0500 Subject: [PATCH 17/20] blacken --- message_passing/waiting_for_handlers/starter.py | 4 +--- pydantic_converter/converter.py | 8 +++++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/message_passing/waiting_for_handlers/starter.py b/message_passing/waiting_for_handlers/starter.py index 13f4efc2..b12aaa00 100644 --- a/message_passing/waiting_for_handlers/starter.py +++ b/message_passing/waiting_for_handlers/starter.py @@ -35,9 +35,7 @@ async def _check_run( wait_for_stage=client.WorkflowUpdateStage.ACCEPTED, ) except Exception as e: - print( - f" 🔴 caught exception while starting update: {e}: {e.__cause__ or ''}" - ) + print(f" 🔴 caught exception while starting update: {e}: {e.__cause__ or ''}") if exit_type == WorkflowExitType.CANCELLATION: await wf_handle.cancel() diff --git a/pydantic_converter/converter.py b/pydantic_converter/converter.py index a3c1cee6..81997e81 100644 --- a/pydantic_converter/converter.py +++ b/pydantic_converter/converter.py @@ -42,9 +42,11 @@ class PydanticPayloadConverter(CompositePayloadConverter): def __init__(self) -> None: super().__init__( *( - c - if not isinstance(c, JSONPlainPayloadConverter) - else PydanticJSONPayloadConverter() + ( + c + if not isinstance(c, JSONPlainPayloadConverter) + else PydanticJSONPayloadConverter() + ) for c in DefaultPayloadConverter.default_encoding_payload_converters ) ) From 1ca9796872cd4e2ef71522aaef113d29909d607d Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 28 Jan 2025 08:18:06 -0500 Subject: [PATCH 18/20] isort --- message_passing/waiting_for_handlers/starter.py | 4 +--- message_passing/waiting_for_handlers/worker.py | 4 +--- .../waiting_for_handlers/workflow_test.py | 9 ++------- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/message_passing/waiting_for_handlers/starter.py b/message_passing/waiting_for_handlers/starter.py index b12aaa00..908095c5 100644 --- a/message_passing/waiting_for_handlers/starter.py +++ b/message_passing/waiting_for_handlers/starter.py @@ -8,9 +8,7 @@ WorkflowExitType, WorkflowInput, ) -from message_passing.waiting_for_handlers.workflows import ( - WaitingForHandlersWorkflow, -) +from message_passing.waiting_for_handlers.workflows import WaitingForHandlersWorkflow async def starter(exit_type: WorkflowExitType): diff --git a/message_passing/waiting_for_handlers/worker.py b/message_passing/waiting_for_handlers/worker.py index bfad6197..9eea60a3 100644 --- a/message_passing/waiting_for_handlers/worker.py +++ b/message_passing/waiting_for_handlers/worker.py @@ -8,9 +8,7 @@ from message_passing.waiting_for_handlers.activities import ( activity_executed_by_update_handler, ) -from message_passing.waiting_for_handlers.workflows import ( - WaitingForHandlersWorkflow, -) +from message_passing.waiting_for_handlers.workflows import WaitingForHandlersWorkflow interrupt_event = asyncio.Event() diff --git a/tests/message_passing/waiting_for_handlers/workflow_test.py b/tests/message_passing/waiting_for_handlers/workflow_test.py index af5b2403..e6d200e3 100644 --- a/tests/message_passing/waiting_for_handlers/workflow_test.py +++ b/tests/message_passing/waiting_for_handlers/workflow_test.py @@ -4,17 +4,12 @@ from temporalio import client, worker from temporalio.testing import WorkflowEnvironment -from message_passing.waiting_for_handlers import ( - WorkflowExitType, - WorkflowInput, -) +from message_passing.waiting_for_handlers import WorkflowExitType, WorkflowInput from message_passing.waiting_for_handlers.activities import ( activity_executed_by_update_handler, ) from message_passing.waiting_for_handlers.starter import TASK_QUEUE -from message_passing.waiting_for_handlers.workflows import ( - WaitingForHandlersWorkflow, -) +from message_passing.waiting_for_handlers.workflows import WaitingForHandlersWorkflow class UpdateExpect(Enum): From d5ff404c15f51970e07edc0fddc37928a092c456 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 28 Jan 2025 08:29:23 -0500 Subject: [PATCH 19/20] Rename tests --- .../{workflow_test.py => waiting_for_handlers_test.py} | 0 ...flow_test.py => waiting_for_handlers_and_compensation_test.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tests/message_passing/waiting_for_handlers/{workflow_test.py => waiting_for_handlers_test.py} (100%) rename tests/message_passing/waiting_for_handlers_and_compensation/{workflow_test.py => waiting_for_handlers_and_compensation_test.py} (100%) diff --git a/tests/message_passing/waiting_for_handlers/workflow_test.py b/tests/message_passing/waiting_for_handlers/waiting_for_handlers_test.py similarity index 100% rename from tests/message_passing/waiting_for_handlers/workflow_test.py rename to tests/message_passing/waiting_for_handlers/waiting_for_handlers_test.py diff --git a/tests/message_passing/waiting_for_handlers_and_compensation/workflow_test.py b/tests/message_passing/waiting_for_handlers_and_compensation/waiting_for_handlers_and_compensation_test.py similarity index 100% rename from tests/message_passing/waiting_for_handlers_and_compensation/workflow_test.py rename to tests/message_passing/waiting_for_handlers_and_compensation/waiting_for_handlers_and_compensation_test.py From 9a381ed2a813e0ae7a967669d5ae5a5809b9ddab Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 28 Jan 2025 09:35:33 -0500 Subject: [PATCH 20/20] Add a note communicating that this is more advanced --- message_passing/waiting_for_handlers_and_compensation/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/message_passing/waiting_for_handlers_and_compensation/README.md b/message_passing/waiting_for_handlers_and_compensation/README.md index 08a265bd..47df3b63 100644 --- a/message_passing/waiting_for_handlers_and_compensation/README.md +++ b/message_passing/waiting_for_handlers_and_compensation/README.md @@ -7,7 +7,7 @@ This sample demonstrates how to do the following: 2. Perform compensation/cleanup in an update handler when the workflow is cancelled or fails. - +For a simpler sample showing how to do (1) without (2), see [safe_message_handlers](../safe_message_handlers/README.md). To run, open two terminals and `cd` to this directory in them.