Skip to content

Commit 29e0c51

Browse files
authored
NEXUS-484: Support UpdateWorkflow as a Nexus Operation (#1631)
* NEXUS-484: Add support for workflow update Nexus operations * address comments, cleanup run_id, extend tests * address comments 2, fix docstrings, clarify signatures and errors
1 parent 0fe762f commit 29e0c51

10 files changed

Lines changed: 650 additions & 5 deletions

File tree

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 Workflow Updates exposed as Nexus Operations. 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: 9 additions & 1 deletion
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,10 @@ async def _start_update(
955956
result_type: type | None = None,
956957
rpc_metadata: Mapping[str, str | bytes] = {},
957958
rpc_timeout: timedelta | None = None,
959+
# The following options are for Workflow Updates exposed as Nexus Operations. Experimental and unstable
960+
callbacks: Sequence[Callback] | None = None,
961+
links: Sequence[temporalio.api.common.v1.Link] | None = None,
962+
request_id: str | None = None,
958963
) -> WorkflowUpdateHandle[Any]:
959964
if wait_for_stage == WorkflowUpdateStage.ADMITTED:
960965
raise ValueError("ADMITTED wait stage not supported")
@@ -967,7 +972,7 @@ async def _start_update(
967972
StartWorkflowUpdateInput(
968973
id=self._id,
969974
run_id=self._run_id,
970-
first_execution_run_id=self.first_execution_run_id,
975+
first_execution_run_id=self._first_execution_run_id,
971976
update_id=id,
972977
update=update_name,
973978
args=temporalio.common._arg_or_args(arg, args),
@@ -976,6 +981,9 @@ async def _start_update(
976981
rpc_metadata=rpc_metadata,
977982
rpc_timeout=rpc_timeout,
978983
wait_for_stage=wait_for_stage,
984+
callbacks=callbacks,
985+
links=links,
986+
request_id=request_id,
979987
)
980988
)
981989

temporalio/nexus/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
wait_for_worker_shutdown_sync,
2626
)
2727
from ._operation_handlers import (
28+
CancelUpdateWorkflowOptions,
2829
CancelWorkflowRunOptions,
2930
TemporalOperationHandler,
3031
)
@@ -34,6 +35,7 @@
3435
__all__ = (
3536
"workflow_run_operation",
3637
"CancelWorkflowRunOptions",
38+
"CancelUpdateWorkflowOptions",
3739
"Info",
3840
"LoggerAdapter",
3941
"NexusCallback",

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_operation_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+
update_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 = update_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(
747+
workflow_id, run_id=run_id, first_execution_run_id=first_execution_run_id
748+
)
749+
return await workflow_handle._start_update(
750+
update,
751+
arg,
752+
args=args,
753+
wait_for_stage=temporalio.client.WorkflowUpdateStage.ACCEPTED, # hardcoded as nexus only supports async updates
754+
id=update_id,
755+
result_type=result_type,
756+
rpc_metadata=rpc_metadata,
757+
rpc_timeout=rpc_timeout,
758+
callbacks=temporal_context._get_callbacks(token),
759+
links=temporal_context._get_request_links(),
760+
request_id=temporal_context.nexus_context.request_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+
async def cancel_workflow_update(
238+
self,
239+
ctx: TemporalCancelOperationContext, # pyright: ignore[reportUnusedParameter]
240+
options: CancelUpdateWorkflowOptions, # pyright: ignore[reportUnusedParameter]
241+
) -> None:
242+
"""Cancels the Workflow Update triggered by the Nexus operation. There is no native way to cancel an accepted Update, so cancellation depends on the Update handler itself.
243+
Override this method and coordinate with the specific Update handler to trigger a cancellation.
244+
245+
246+
.. warning::
247+
This API is experimental and unstable.
248+
"""
249+
raise HandlerError(
250+
"""
251+
There is no native way to cancel an accepted Update, so cancellation depends on the Update handler itself.
252+
Override this method and coordinate with the specific Update handler to trigger a cancellation.
253+
""",
254+
type=HandlerErrorType.NOT_IMPLEMENTED,
255+
)

temporalio/nexus/_temporal_client.py

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@
1515
overload,
1616
)
1717

18-
from nexusrpc import HandlerError, HandlerErrorType
18+
from nexusrpc import HandlerError, HandlerErrorType, OperationError, OperationErrorState
1919
from nexusrpc.handler import StartOperationResultAsync, StartOperationResultSync
2020
from typing_extensions import Self
2121

2222
import temporalio.common
2323
from temporalio.nexus._operation_context import (
2424
_start_nexus_backing_workflow,
25+
_start_nexus_operation_workflow_update,
2526
_TemporalStartOperationContext,
2627
)
2728
from temporalio.types import (
@@ -33,8 +34,11 @@
3334
SelfType,
3435
)
3536

37+
from ._token import OperationToken, OperationTokenType
38+
3639
if TYPE_CHECKING:
3740
import temporalio.client
41+
import temporalio.workflow
3842

3943

4044
_ResultT = TypeVar("_ResultT")
@@ -279,6 +283,91 @@ async def start_workflow(
279283
"""
280284
...
281285

286+
# Overload for no-param update
287+
@overload
288+
async def start_workflow_update(
289+
self,
290+
workflow_id: str,
291+
update: temporalio.workflow.UpdateMethodMultiParam[[Any], ReturnType],
292+
*,
293+
update_id: str | None = None,
294+
rpc_metadata: Mapping[str, str | bytes] = {},
295+
rpc_timeout: timedelta | None = None,
296+
run_id: str | None = None,
297+
first_execution_run_id: str | None = None,
298+
) -> TemporalOperationResult[ReturnType]: ...
299+
300+
# Overload for single-param update
301+
@overload
302+
async def start_workflow_update(
303+
self,
304+
workflow_id: str,
305+
update: temporalio.workflow.UpdateMethodMultiParam[
306+
[Any, ParamType], ReturnType
307+
],
308+
arg: ParamType,
309+
*,
310+
update_id: str | None = None,
311+
rpc_metadata: Mapping[str, str | bytes] = {},
312+
rpc_timeout: timedelta | None = None,
313+
run_id: str | None = None,
314+
first_execution_run_id: str | None = None,
315+
) -> TemporalOperationResult[ReturnType]: ...
316+
317+
# Overload for multi-param update
318+
@overload
319+
async def start_workflow_update(
320+
self,
321+
workflow_id: str,
322+
update: temporalio.workflow.UpdateMethodMultiParam[MultiParamSpec, ReturnType],
323+
*,
324+
args: MultiParamSpec.args, # type: ignore
325+
update_id: str | None = None,
326+
rpc_metadata: Mapping[str, str | bytes] = {},
327+
rpc_timeout: timedelta | None = None,
328+
run_id: str | None = None,
329+
first_execution_run_id: str | None = None,
330+
) -> TemporalOperationResult[ReturnType]: ...
331+
332+
# Overload for string-name update
333+
@overload
334+
async def start_workflow_update(
335+
self,
336+
workflow_id: str,
337+
update: str,
338+
arg: Any = temporalio.common._arg_unset,
339+
*,
340+
args: Sequence[Any] = [],
341+
update_id: str | None = None,
342+
result_type: type[ReturnType] | None = None,
343+
rpc_metadata: Mapping[str, str | bytes] = {},
344+
rpc_timeout: timedelta | None = None,
345+
run_id: str | None = None,
346+
first_execution_run_id: str | None = None,
347+
) -> TemporalOperationResult[ReturnType]: ...
348+
349+
@abstractmethod
350+
async def start_workflow_update(
351+
self,
352+
workflow_id: str,
353+
update: str | Callable,
354+
arg: Any = temporalio.common._arg_unset,
355+
*,
356+
args: Sequence[Any] = [],
357+
update_id: str | None = None,
358+
result_type: type | None = None,
359+
rpc_metadata: Mapping[str, str | bytes] = {},
360+
rpc_timeout: timedelta | None = None,
361+
run_id: str | None = None,
362+
first_execution_run_id: str | None = None,
363+
) -> TemporalOperationResult[Any]:
364+
"""Start a Workflow Update-backed Nexus Operation.
365+
366+
.. warning::
367+
This API is experimental and unstable.
368+
"""
369+
...
370+
282371

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

379468
return TemporalOperationResult.async_token(wf_handle.to_token())
469+
470+
async def start_workflow_update(
471+
self,
472+
workflow_id: str,
473+
update: str | Callable,
474+
arg: Any = temporalio.common._arg_unset,
475+
*,
476+
args: Sequence[Any] = [],
477+
update_id: str | None = None,
478+
result_type: type | None = None,
479+
rpc_metadata: Mapping[str, str | bytes] = {},
480+
rpc_timeout: timedelta | None = None,
481+
run_id: str | None = None,
482+
first_execution_run_id: str | None = None,
483+
) -> TemporalOperationResult[Any]:
484+
"""Start a Workflow Update-backed Nexus Operation."""
485+
if not self._temporal_context.nexus_context.callback_url:
486+
raise HandlerError(
487+
"callback URL is required for a workflow update Nexus operation",
488+
type=HandlerErrorType.BAD_REQUEST,
489+
)
490+
with self._reserve_async_start():
491+
update_handle = await _start_nexus_operation_workflow_update(
492+
temporal_context=self._temporal_context,
493+
workflow_id=workflow_id,
494+
update=update,
495+
arg=arg,
496+
args=args,
497+
update_id=update_id,
498+
result_type=result_type,
499+
rpc_metadata=rpc_metadata,
500+
rpc_timeout=rpc_timeout,
501+
run_id=run_id,
502+
first_execution_run_id=first_execution_run_id,
503+
)
504+
# If the update has already completed, return the result synchronously
505+
if update_handle._known_outcome is not None:
506+
try:
507+
result = await update_handle.result()
508+
except temporalio.client.WorkflowUpdateFailedError as err:
509+
raise OperationError(
510+
str(err), state=OperationErrorState.FAILED
511+
) from err
512+
return TemporalOperationResult.sync(result)
513+
token = OperationToken(
514+
type=OperationTokenType.UPDATE_WORKFLOW,
515+
namespace=update_handle._client.namespace,
516+
workflow_id=update_handle.workflow_id,
517+
update_id=update_handle.id,
518+
run_id=update_handle.workflow_run_id,
519+
).encode()
520+
return TemporalOperationResult.async_token(token)

0 commit comments

Comments
 (0)