Skip to content

Commit 852e955

Browse files
committed
NEXUS-484: Add support for workflow update Nexus operations
1 parent 1d2cd94 commit 852e955

11 files changed

Lines changed: 530 additions & 7 deletions

temporalio/client/_impl.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,11 @@ async def start_workflow_update(
787787
):
788788
break
789789

790+
# Add response link if its a Nexus operation
791+
nexus_ctx = temporalio.nexus._operation_context._try_start_operation_context()
792+
if nexus_ctx is not None and resp.HasField("link"):
793+
nexus_ctx._add_response_link(resp.link)
794+
790795
# Build the handle. If the user's wait stage is COMPLETED, make sure we
791796
# poll for result.
792797
handle: WorkflowUpdateHandle[Any] = WorkflowUpdateHandle(
@@ -852,6 +857,23 @@ async def _build_update_workflow_execution_request(
852857
)
853858
),
854859
)
860+
# Only set Nexus fields for StartWorkflowUpdateInput, skip for UpdateWithStartUpdateWorkflowInput
861+
if isinstance(input, StartWorkflowUpdateInput):
862+
if input.request_id:
863+
req.request.request_id = input.request_id
864+
if input.links:
865+
req.request.links.extend(input.links)
866+
if input.callbacks:
867+
req.request.completion_callbacks.extend(
868+
temporalio.api.common.v1.Callback(
869+
nexus=temporalio.api.common.v1.Callback.Nexus(
870+
url=callback.url,
871+
header=callback.headers,
872+
),
873+
links=input.links or [],
874+
)
875+
for callback in input.callbacks
876+
)
855877
if input.args:
856878
req.request.input.args.payloads.extend(
857879
await data_converter.encode(input.args)

temporalio/client/_interceptor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,10 @@ class StartWorkflowUpdateInput:
322322
ret_type: type | None
323323
rpc_metadata: Mapping[str, str | bytes]
324324
rpc_timeout: timedelta | None
325+
# The following options are for Nexus Operation-backed updates. Experimental and unstable
326+
callbacks: Sequence[Callback] | None = None
327+
links: Sequence[temporalio.api.common.v1.Link] | None = None
328+
request_id: str | None = None
325329

326330

327331
@dataclass

temporalio/client/_workflow.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
ReturnType,
6060
SelfType,
6161
)
62+
from ._callback import Callback
6263
from ._exceptions import (
6364
WorkflowContinuedAsNewError,
6465
WorkflowFailureError,
@@ -955,6 +956,12 @@ async def _start_update(
955956
result_type: type | None = None,
956957
rpc_metadata: Mapping[str, str | bytes] = {},
957958
rpc_timeout: timedelta | None = None,
959+
run_id: str | None = None,
960+
first_execution_run_id: str | None = None,
961+
# The following options are for Nexus Operation-backed updates. Experimental and unstable
962+
callbacks: Sequence[Callback] | None = None,
963+
links: Sequence[temporalio.api.common.v1.Link] | None = None,
964+
request_id: str | None = None,
958965
) -> WorkflowUpdateHandle[Any]:
959966
if wait_for_stage == WorkflowUpdateStage.ADMITTED:
960967
raise ValueError("ADMITTED wait stage not supported")
@@ -963,11 +970,16 @@ async def _start_update(
963970
temporalio.workflow._UpdateDefinition.get_name_and_result_type(update)
964971
)
965972

973+
if run_id == None:
974+
run_id = self._run_id
975+
if first_execution_run_id == None:
976+
first_execution_run_id = self.first_execution_run_id
977+
966978
return await self._client._impl.start_workflow_update(
967979
StartWorkflowUpdateInput(
968980
id=self._id,
969-
run_id=self._run_id,
970-
first_execution_run_id=self.first_execution_run_id,
981+
run_id=run_id,
982+
first_execution_run_id=first_execution_run_id,
971983
update_id=id,
972984
update=update_name,
973985
args=temporalio.common._arg_or_args(arg, args),
@@ -976,6 +988,9 @@ async def _start_update(
976988
rpc_metadata=rpc_metadata,
977989
rpc_timeout=rpc_timeout,
978990
wait_for_stage=wait_for_stage,
991+
callbacks=callbacks,
992+
links=links,
993+
request_id=request_id,
979994
)
980995
)
981996

temporalio/nexus/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,17 @@
2525
wait_for_worker_shutdown_sync,
2626
)
2727
from ._operation_handlers import (
28+
CancelUpdateWorkflowOptions,
2829
CancelWorkflowRunOptions,
2930
TemporalOperationHandler,
3031
)
3132
from ._temporal_client import TemporalNexusClient, TemporalOperationResult
32-
from ._token import WorkflowHandle
33+
from ._token import UpdateHandle, WorkflowHandle
3334

3435
__all__ = (
3536
"workflow_run_operation",
3637
"CancelWorkflowRunOptions",
38+
"CancelUpdateWorkflowOptions",
3739
"Info",
3840
"LoggerAdapter",
3941
"NexusCallback",
@@ -49,6 +51,7 @@
4951
"wait_for_worker_shutdown",
5052
"wait_for_worker_shutdown_sync",
5153
"WorkflowHandle",
54+
"UpdateHandle",
5255
"TemporalNexusClient",
5356
"TemporalOperationStartHandlerFunc",
5457
"TemporalOperationHandler",

temporalio/nexus/_operation_context.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,3 +715,47 @@ async def _start_nexus_backing_workflow(
715715
)
716716

717717
return WorkflowHandle[ReturnType]._unsafe_from_client_workflow_handle(wf_handle)
718+
719+
720+
async def _start_nexus_backed_workflow_update( # pyright: ignore[reportUnusedFunction]
721+
*,
722+
temporal_context: _TemporalStartOperationContext,
723+
workflow_id: str,
724+
update: str | Callable,
725+
arg: Any = temporalio.common._arg_unset,
726+
args: Sequence[Any] = [],
727+
id: str | None = None,
728+
result_type: type | None = None,
729+
rpc_metadata: Mapping[str, str | bytes] = {},
730+
rpc_timeout: timedelta | None = None,
731+
run_id: str | None = None,
732+
first_execution_run_id: str | None = None,
733+
) -> temporalio.client.WorkflowUpdateHandle[Any]:
734+
# Default update ID to the Nexus request ID for retry-safety (matches sdk-go).
735+
update_id = id or temporal_context.nexus_context.request_id
736+
# This token is different from the actual token returned to the caller
737+
# because return token will have the run_id that is unknowable before
738+
# making the call. If run_id is passed, then it will be the same
739+
token = OperationToken(
740+
type=OperationTokenType.UPDATE_WORKFLOW,
741+
namespace=temporal_context.client.namespace,
742+
workflow_id=workflow_id,
743+
update_id=update_id,
744+
run_id=run_id,
745+
).encode()
746+
workflow_handle = temporal_context.client.get_workflow_handle(workflow_id)
747+
return await workflow_handle._start_update(
748+
update,
749+
arg,
750+
args=args,
751+
wait_for_stage=temporalio.client.WorkflowUpdateStage.ACCEPTED, # hardcoded as nexus only supports async updates
752+
id=update_id,
753+
result_type=result_type,
754+
rpc_metadata=rpc_metadata,
755+
rpc_timeout=rpc_timeout,
756+
callbacks=temporal_context._get_callbacks(token),
757+
links=temporal_context._get_request_links(),
758+
request_id=temporal_context.nexus_context.request_id,
759+
run_id=run_id,
760+
first_execution_run_id=first_execution_run_id,
761+
)

temporalio/nexus/_operation_handlers.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,25 @@ class CancelWorkflowRunOptions:
138138
"""The ID of the workflow to cancel."""
139139

140140

141+
@dataclass(frozen=True)
142+
class CancelUpdateWorkflowOptions:
143+
"""Options for cancelling the workflow update backing a Nexus operation.
144+
145+
These options are built by :py:class:`TemporalOperationHandler` and passed to
146+
:py:meth:`TemporalOperationHandler.cancel_workflow_update`.
147+
148+
.. warning::
149+
This API is experimental and unstable.
150+
"""
151+
152+
workflow_id: str
153+
"""The ID of the workflow where the update is running."""
154+
update_id: str
155+
"""The ID of the update to cancel."""
156+
run_id: str
157+
"""The workflow runID that accepted the update"""
158+
159+
141160
class TemporalOperationHandler(OperationHandler[InputT, OutputT], ABC):
142161
"""Operation handler for Nexus operations that interact with Temporal.
143162
Implementations override the start_operation method.
@@ -190,6 +209,15 @@ async def cancel(self, ctx: CancelOperationContext, token: str) -> None:
190209
workflow_id=operation_token.workflow_id
191210
)
192211
await self.cancel_workflow_run(cancel_ctx, options)
212+
case OperationTokenType.UPDATE_WORKFLOW:
213+
assert operation_token.update_id is not None
214+
assert operation_token.run_id is not None
215+
cancel_options = CancelUpdateWorkflowOptions(
216+
workflow_id=operation_token.workflow_id,
217+
update_id=operation_token.update_id,
218+
run_id=operation_token.run_id,
219+
)
220+
await self.cancel_workflow_update(cancel_ctx, cancel_options)
193221

194222
async def cancel_workflow_run(
195223
self,
@@ -205,3 +233,23 @@ async def cancel_workflow_run(
205233
options.workflow_id
206234
)
207235
await workflow_handle.cancel()
236+
237+
# draft-review: maybe just move it inline, no need for a function just to error out
238+
# check after review in case theres some other way to override/supply custom cancels
239+
async def cancel_workflow_update(
240+
self,
241+
ctx: TemporalCancelOperationContext, # pyright: ignore[reportUnusedParameter]
242+
options: CancelUpdateWorkflowOptions, # pyright: ignore[reportUnusedParameter]
243+
) -> None:
244+
"""Cancels the workflow update backing the Nexus operation.
245+
246+
.. warning::
247+
This API is experimental and unstable.
248+
"""
249+
raise HandlerError(
250+
"""
251+
Cancellation is not natively supported for update-workflow Nexus operations.
252+
Override a TemporalOperationHandler and implement this method to run cancellable workflow updates.
253+
""",
254+
type=HandlerErrorType.NOT_IMPLEMENTED,
255+
)

0 commit comments

Comments
 (0)