Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions temporalio/client/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,11 @@ async def start_workflow_update(
):
break

# Add response link if its a Nexus operation
nexus_ctx = temporalio.nexus._operation_context._try_start_operation_context()
if nexus_ctx is not None and resp.HasField("link"):
nexus_ctx._add_response_link(resp.link)

# Build the handle. If the user's wait stage is COMPLETED, make sure we
# poll for result.
handle: WorkflowUpdateHandle[Any] = WorkflowUpdateHandle(
Expand Down Expand Up @@ -852,6 +857,23 @@ async def _build_update_workflow_execution_request(
)
),
)
# Only set Nexus fields for StartWorkflowUpdateInput, skip for UpdateWithStartUpdateWorkflowInput
if isinstance(input, StartWorkflowUpdateInput):
if input.request_id:
req.request.request_id = input.request_id
if input.links:
req.request.links.extend(input.links)
if input.callbacks:
req.request.completion_callbacks.extend(
temporalio.api.common.v1.Callback(
nexus=temporalio.api.common.v1.Callback.Nexus(
url=callback.url,
header=callback.headers,
),
links=input.links or [],
)
for callback in input.callbacks
)
if input.args:
req.request.input.args.payloads.extend(
await data_converter.encode(input.args)
Expand Down
4 changes: 4 additions & 0 deletions temporalio/client/_interceptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@ class StartWorkflowUpdateInput:
ret_type: type | None
rpc_metadata: Mapping[str, str | bytes]
rpc_timeout: timedelta | None
# The following options are for Nexus Operation-backed updates. Experimental and unstable
callbacks: Sequence[Callback] | None = None
links: Sequence[temporalio.api.common.v1.Link] | None = None
request_id: str | None = None


@dataclass
Expand Down
10 changes: 9 additions & 1 deletion temporalio/client/_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
ReturnType,
SelfType,
)
from ._callback import Callback
from ._exceptions import (
WorkflowContinuedAsNewError,
WorkflowFailureError,
Expand Down Expand Up @@ -955,6 +956,10 @@ async def _start_update(
result_type: type | None = None,
rpc_metadata: Mapping[str, str | bytes] = {},
rpc_timeout: timedelta | None = None,
# The following options are for Nexus Operation-backed updates. Experimental and unstable
callbacks: Sequence[Callback] | None = None,
links: Sequence[temporalio.api.common.v1.Link] | None = None,
request_id: str | None = None,
) -> WorkflowUpdateHandle[Any]:
if wait_for_stage == WorkflowUpdateStage.ADMITTED:
raise ValueError("ADMITTED wait stage not supported")
Expand All @@ -967,7 +972,7 @@ async def _start_update(
StartWorkflowUpdateInput(
id=self._id,
run_id=self._run_id,
first_execution_run_id=self.first_execution_run_id,
first_execution_run_id=self._first_execution_run_id,
update_id=id,
update=update_name,
args=temporalio.common._arg_or_args(arg, args),
Expand All @@ -976,6 +981,9 @@ async def _start_update(
rpc_metadata=rpc_metadata,
rpc_timeout=rpc_timeout,
wait_for_stage=wait_for_stage,
callbacks=callbacks,
links=links,
request_id=request_id,
)
)

Expand Down
2 changes: 2 additions & 0 deletions temporalio/nexus/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
wait_for_worker_shutdown_sync,
)
from ._operation_handlers import (
CancelUpdateWorkflowOptions,
CancelWorkflowRunOptions,
TemporalOperationHandler,
)
Expand All @@ -34,6 +35,7 @@
__all__ = (
"workflow_run_operation",
"CancelWorkflowRunOptions",
"CancelUpdateWorkflowOptions",
"Info",
"LoggerAdapter",
"NexusCallback",
Expand Down
44 changes: 44 additions & 0 deletions temporalio/nexus/_operation_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -715,3 +715,47 @@ async def _start_nexus_backing_workflow(
)

return WorkflowHandle[ReturnType]._unsafe_from_client_workflow_handle(wf_handle)


async def _start_nexus_backed_workflow_update( # pyright: ignore[reportUnusedFunction]
*,
temporal_context: _TemporalStartOperationContext,
workflow_id: str,
update: str | Callable,
arg: Any = temporalio.common._arg_unset,
args: Sequence[Any] = [],
update_id: str | None = None,
result_type: type | None = None,
rpc_metadata: Mapping[str, str | bytes] = {},
rpc_timeout: timedelta | None = None,
run_id: str | None = None,
first_execution_run_id: str | None = None,
Comment thread
VegetarianOrc marked this conversation as resolved.
) -> temporalio.client.WorkflowUpdateHandle[Any]:
# Default update ID to the Nexus request ID for retry-safety (matches sdk-go).
update_id = update_id or temporal_context.nexus_context.request_id
# This token is different from the actual token returned to the caller
# because return token will have the run_id that is unknowable before
# making the call. If run_id is passed, then it will be the same
token = OperationToken(
type=OperationTokenType.UPDATE_WORKFLOW,
namespace=temporal_context.client.namespace,
workflow_id=workflow_id,
update_id=update_id,
run_id=run_id,
).encode()
workflow_handle = temporal_context.client.get_workflow_handle(
workflow_id, run_id=run_id, first_execution_run_id=first_execution_run_id
)
return await workflow_handle._start_update(
update,
arg,
args=args,
wait_for_stage=temporalio.client.WorkflowUpdateStage.ACCEPTED, # hardcoded as nexus only supports async updates
id=update_id,
result_type=result_type,
rpc_metadata=rpc_metadata,
rpc_timeout=rpc_timeout,
callbacks=temporal_context._get_callbacks(token),
links=temporal_context._get_request_links(),
request_id=temporal_context.nexus_context.request_id,
)
48 changes: 48 additions & 0 deletions temporalio/nexus/_operation_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ class CancelWorkflowRunOptions:
"""The ID of the workflow to cancel."""


@dataclass(frozen=True)
class CancelUpdateWorkflowOptions:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing run ID

"""Options for cancelling the workflow update backing a Nexus operation.

These options are built by :py:class:`TemporalOperationHandler` and passed to
:py:meth:`TemporalOperationHandler.cancel_workflow_update`.

.. warning::
This API is experimental and unstable.
"""

workflow_id: str
"""The ID of the workflow where the update is running."""
update_id: str
"""The ID of the update to cancel."""
run_id: str
"""The workflow runID that accepted the update"""


class TemporalOperationHandler(OperationHandler[InputT, OutputT], ABC):
"""Operation handler for Nexus operations that interact with Temporal.
Implementations override the start_operation method.
Expand Down Expand Up @@ -190,6 +209,15 @@ async def cancel(self, ctx: CancelOperationContext, token: str) -> None:
workflow_id=operation_token.workflow_id
)
await self.cancel_workflow_run(cancel_ctx, options)
case OperationTokenType.UPDATE_WORKFLOW:
assert operation_token.update_id is not None
assert operation_token.run_id is not None
cancel_options = CancelUpdateWorkflowOptions(
workflow_id=operation_token.workflow_id,
update_id=operation_token.update_id,
run_id=operation_token.run_id,
)
await self.cancel_workflow_update(cancel_ctx, cancel_options)

async def cancel_workflow_run(
self,
Expand All @@ -205,3 +233,23 @@ async def cancel_workflow_run(
options.workflow_id
)
await workflow_handle.cancel()

async def cancel_workflow_update(
self,
ctx: TemporalCancelOperationContext, # pyright: ignore[reportUnusedParameter]
options: CancelUpdateWorkflowOptions, # pyright: ignore[reportUnusedParameter]
) -> None:
"""Cancels the workflow update backing the Nexus operation. Cancellation is not natively supported for update-workflow Nexus operations.
Inherit a TemporalOperationHandler and override this method to run cancellable workflow updates.


.. warning::
This API is experimental and unstable.
"""
raise HandlerError(
"""
Cancellation is not natively supported for update-workflow Nexus operations.
Inherit a TemporalOperationHandler and override this method to run cancellable workflow updates.
""",
type=HandlerErrorType.NOT_IMPLEMENTED,
)
143 changes: 142 additions & 1 deletion temporalio/nexus/_temporal_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@
overload,
)

from nexusrpc import HandlerError, HandlerErrorType
from nexusrpc import HandlerError, HandlerErrorType, OperationError, OperationErrorState
from nexusrpc.handler import StartOperationResultAsync, StartOperationResultSync
from typing_extensions import Self

import temporalio.common
from temporalio.nexus._operation_context import (
_start_nexus_backed_workflow_update,
_start_nexus_backing_workflow,
_TemporalStartOperationContext,
)
Expand All @@ -33,8 +34,11 @@
SelfType,
)

from ._token import OperationToken, OperationTokenType

if TYPE_CHECKING:
import temporalio.client
import temporalio.workflow


_ResultT = TypeVar("_ResultT")
Expand Down Expand Up @@ -279,6 +283,91 @@ async def start_workflow(
"""
...

# Overload for no-param update
@overload
async def start_workflow_update(
self,
workflow_id: str,
update: temporalio.workflow.UpdateMethodMultiParam[[Any], ReturnType],
*,
update_id: str | None = None,
rpc_metadata: Mapping[str, str | bytes] = {},
rpc_timeout: timedelta | None = None,
run_id: str | None = None,
first_execution_run_id: str | None = None,
) -> TemporalOperationResult[ReturnType]: ...

# Overload for single-param update
@overload
async def start_workflow_update(
self,
workflow_id: str,
update: temporalio.workflow.UpdateMethodMultiParam[
[Any, ParamType], ReturnType
],
arg: ParamType,
*,
update_id: str | None = None,
rpc_metadata: Mapping[str, str | bytes] = {},
rpc_timeout: timedelta | None = None,
run_id: str | None = None,
first_execution_run_id: str | None = None,
) -> TemporalOperationResult[ReturnType]: ...

# Overload for multi-param update
@overload
async def start_workflow_update(
self,
workflow_id: str,
update: temporalio.workflow.UpdateMethodMultiParam[MultiParamSpec, ReturnType],
*,
args: MultiParamSpec.args, # type: ignore
update_id: str | None = None,
rpc_metadata: Mapping[str, str | bytes] = {},
rpc_timeout: timedelta | None = None,
run_id: str | None = None,
first_execution_run_id: str | None = None,
) -> TemporalOperationResult[ReturnType]: ...

# Overload for string-name update
@overload
async def start_workflow_update(
self,
workflow_id: str,
update: str,
arg: Any = temporalio.common._arg_unset,
*,
args: Sequence[Any] = [],
update_id: str | None = None,
result_type: type[ReturnType] | None = None,
rpc_metadata: Mapping[str, str | bytes] = {},
rpc_timeout: timedelta | None = None,
run_id: str | None = None,
first_execution_run_id: str | None = None,
) -> TemporalOperationResult[ReturnType]: ...

@abstractmethod
async def start_workflow_update(
self,
workflow_id: str,
update: str | Callable,
arg: Any = temporalio.common._arg_unset,
*,
args: Sequence[Any] = [],
update_id: str | None = None,
result_type: type | None = None,
rpc_metadata: Mapping[str, str | bytes] = {},
rpc_timeout: timedelta | None = None,
run_id: str | None = None,
first_execution_run_id: str | None = None,
Comment thread
VegetarianOrc marked this conversation as resolved.
) -> TemporalOperationResult[Any]:
"""Start a workflow update as the backing asynchronous Nexus operation.

.. warning::
This API is experimental and unstable.
"""
...


class _TemporalNexusClient(TemporalNexusClient): # pyright: ignore[reportUnusedClass]
"""Nexus-aware wrapper around a Temporal Client.
Expand Down Expand Up @@ -377,3 +466,55 @@ async def start_workflow(
)

return TemporalOperationResult.async_token(wf_handle.to_token())

async def start_workflow_update(
self,
workflow_id: str,
update: str | Callable,
arg: Any = temporalio.common._arg_unset,
*,
args: Sequence[Any] = [],
update_id: str | None = None,
result_type: type | None = None,
rpc_metadata: Mapping[str, str | bytes] = {},
rpc_timeout: timedelta | None = None,
run_id: str | None = None,
first_execution_run_id: str | None = None,
) -> TemporalOperationResult[Any]:
"""Start a workflow update as the backing asynchronous Nexus operation."""
if not self._temporal_context.nexus_context.callback_url:
raise HandlerError(
"callback URL is required for a workflow update Nexus operation",
type=HandlerErrorType.BAD_REQUEST,
)
with self._reserve_async_start():
update_handle = await _start_nexus_backed_workflow_update(
temporal_context=self._temporal_context,
workflow_id=workflow_id,
update=update,
arg=arg,
args=args,
update_id=update_id,
result_type=result_type,
rpc_metadata=rpc_metadata,
rpc_timeout=rpc_timeout,
run_id=run_id,
first_execution_run_id=first_execution_run_id,
)
# If the update has already completed, return the result synchronously
if update_handle._known_outcome is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this case happens should we clear the flag set in _reserve_async_start() that prevents 2 async operations from being triggered by the client? Thinking through it, it probably makes sense to just always leave the flag set otherwise it could feel a little non-deterministic, but thought I'd pose the question for others to also consider.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with leaving as-is - how it ended - sync/async- isnt strictly related to how it started - and its always starts async because we only support wait for Accepted
Also brought up in this PR discussion - temporalio/sdk-java#2945 (comment) - this is consistent there too

try:
result = await update_handle.result()
except temporalio.client.WorkflowUpdateFailedError as err:
raise OperationError(
str(err), state=OperationErrorState.FAILED
) from err
return TemporalOperationResult.sync(result)
token = OperationToken(
type=OperationTokenType.UPDATE_WORKFLOW,
namespace=update_handle._client.namespace,
workflow_id=update_handle.workflow_id,
update_id=update_handle.id,
run_id=update_handle.workflow_run_id,
).encode()
return TemporalOperationResult.async_token(token)
Loading
Loading