Skip to content

Add Temporal Operation Handler#1503

Open
VegetarianOrc wants to merge 12 commits into
mainfrom
amazzeo/temporal-nexus-operation-handler
Open

Add Temporal Operation Handler#1503
VegetarianOrc wants to merge 12 commits into
mainfrom
amazzeo/temporal-nexus-operation-handler

Conversation

@VegetarianOrc
Copy link
Copy Markdown
Contributor

What was changed

Added support for Temporal-backed Nexus operation handlers.

This introduces @temporalio.nexus.temporal_operation, TemporalNexusClient, and TemporalOperationResult, allowing Nexus operation handlers to either return a synchronous result or start a Temporal workflow as the async backing operation. The branch also adds token parsing support for generic operation tokens, cancellation support for Temporal-backed workflow operations, Nexus workflow client overloads for the new operation shape, and focused runtime/type coverage.

Why?

This gives Nexus operation handlers a first-class way to interact with Temporal while preserving Nexus async operation semantics, workflow linking/callback behavior, cancellation handling, and typed workflow-start ergonomics.

Checklist

  1. How was this tested:

    • Existing tests
    • Added type tests
    • Added tests for temporal operation
  2. Any docs updates needed?

Yes. The Nexus docs should cover @temporalio.nexus.temporal_operation, TemporalNexusClient.start_workflow,
TemporalOperationResult.sync, and async workflow-backed operation behavior.

@VegetarianOrc VegetarianOrc force-pushed the amazzeo/temporal-nexus-operation-handler branch from 26c52b8 to b0dd232 Compare May 11, 2026 17:29
@VegetarianOrc VegetarianOrc marked this pull request as ready for review May 12, 2026 21:44
@VegetarianOrc VegetarianOrc requested a review from a team as a code owner May 12, 2026 21:44
Copy link
Copy Markdown

@Evanthx Evanthx left a comment

Choose a reason for hiding this comment

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

Found a few nitpicks!

Comment thread tests/nexus/test_temporal_operation.py Outdated
Comment thread temporalio/nexus/_operation_handlers.py Outdated
Comment thread temporalio/nexus/_temporal_client.py Outdated
Comment thread temporalio/nexus/_token.py Outdated
Comment thread temporalio/nexus/_token.py
Comment thread temporalio/nexus/_temporal_client.py Outdated
Comment thread temporalio/nexus/_temporal_client.py Outdated
@VegetarianOrc VegetarianOrc requested a review from Evanthx May 13, 2026 19:30
Comment thread temporalio/workflow.py Outdated
# Overload for temporal_operation methods
@overload
@abstractmethod
async def start_operation(
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.

Bit surprised to see a change to the workflow code here, what is this needed for?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

In the Python SDK, you can pass use Service Handlers directly instead of just the service contract. Without the overloads here type checking fails when you pass a @temporal_operation to a workflow.NexusClient.

@service_handler()
class EchoServiceHandler:
    @nexus.temporal_operation
    async def echo(
        self,
        _ctx: StartOperationContext,
        client: nexus.TemporalNexusClient,
        input: Input,
    ) -> nexus.TemporalOperationResult[str]:
        return await client.start_workflow(
            EchoWorkflow.run, input, id=f"echo-{input.value}"
        )

@workflow.defn
class EchoWorkflowCaller:
    @workflow.run
    async def run(self, input: Input) -> str:
        client = workflow.create_nexus_client(
            service=EchoServiceHandler,
            endpoint=make_nexus_endpoint_name(input.task_queue),
        )
        
        # Without the additions this fails to match an overload for temporal_operation due to the addition of TemporalNexusClient to the arg list.
        return await client.execute_operation(EchoServiceHandler.echo, input)

This PR updates the type tests to include this.

Comment thread tests/nexus/test_temporal_operation.py Outdated
Comment thread temporalio/nexus/_util.py
for index, (param_type, expected_param_type) in enumerate(
zip(param_types, expected_param_types), start=1
):
if not _is_subclass(expected_param_type, param_type):
Copy link
Copy Markdown
Contributor Author

@VegetarianOrc VegetarianOrc May 14, 2026

Choose a reason for hiding this comment

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

I've swapped expected_param_type and param_type in this check here. Parameters should be contravariant (e.g. you could provide a function that accepts nexusrpc.StartOperationContext) but the previous direction was covariance (e.g. you could provide a function that accepts a child of WorkflowRunOperationContext).

In the covariant case, the type provided at runtime will never fulfill the decorated function's requirement of the more specific type. Type checkers already prevent this, but bug was that the warning below would not trigger at runtime so users that don't type check would not receive the warning.

In the contravariant case, the type provided at runtime will fulfill the decorated function's requirement of the less specific type.

The new tests in test_handler_operation_definitions.py demonstrate both paths and assert that the warning is or is not delivered as appropriate.

Rename the Temporal operation start context to TemporalNexusStartOperationContext and add TemporalNexusCancelOperationContext with access to the worker client.

Make TemporalNexusOperationHandler a public abstract base with overrideable start_operation and cancel_workflow_run hooks, while keeping the decorator-backed implementation private. Update type annotations, exports, and tests, including coverage for custom Temporal operation cancellation.
@VegetarianOrc VegetarianOrc force-pushed the amazzeo/temporal-nexus-operation-handler branch from 57af53b to eb3c560 Compare May 22, 2026 22:44
Comment on lines +170 to +194
class CustomCancelNexusOpHandler(
nexus.TemporalNexusOperationHandler[str, None]
):
@override
async def start_operation(
self,
ctx: nexus.TemporalNexusStartOperationContext,
client: nexus.TemporalNexusClient,
input: str,
) -> nexus.TemporalOperationResult[None]:
result = await client.start_workflow(BlockingWorkflow.run, id=input)
event.set()
return result

@override
async def cancel_workflow_run(
self, ctx: nexus.TemporalNexusCancelOperationContext, workflow_id: str
):
# get a handle to the workflow
handle = ctx.client.get_workflow_handle(workflow_id)

# cancel the workflow
await handle.cancel()

return CustomCancelNexusOpHandler()
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added this test to demonstrate how users can customize cancellation.

await client_workflow_handle.cancel(**kwargs)


class TemporalNexusOperationHandler(OperationHandler[InputT, OutputT], ABC):
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Base class is ABC to allow customization of cancellation and require implementors to override start_operation. The decorator uses the concrete internal implementation _TemporalNexusOperationHandler to invoke the decorated method.

Copy link
Copy Markdown

@Evanthx Evanthx left a comment

Choose a reason for hiding this comment

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

Nothing major, just some nitpicks!

Comment thread temporalio/nexus/_token.py Outdated
namespace = token_details.get("ns")
if not isinstance(namespace, str) or not namespace:
raise TypeError(
f"invalid token: expected namespace to be a non-empty string, got {type(namespace)}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If namespace is an empty string, this is going to be a weird error message - it'll say "expected namespace to be non-empty string, got string".

There is also a comment above in token.py saying "Allow empty string for ns" - is it allowed then? Seems like that comment or this check should change? (I noticed that this time after a prior comment from the last code review!)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call on the error message, I didn't quite realize that! Also good call on the conflicting behavior. I got overzealous with validation here. I've changed the namespace logic to reflect what was previously supported so that should clear up both issues!

"""

value: _ResultT | object = temporalio.common._arg_unset
token: str | None = None
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Just out of pure paranoia ... should you add a check to make sure no one calls TemporalOperationResult(token=None)? (or async_token with a None value?) It looks like that will work, then will fail with a runtime error below in _to_nexus_result if I read this right. Either way, seems worth validating the inputs in the constructor.

Also you have a constructor and then it looks like the desire is that the two class methods call the constructor? In Python, can you hide the constructor from the user to avoid confusion? If we don't want them to call it, then it's nice if it's not there to be called. But that is very much just a nitpick.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You can't truly hide a constructor in Python. You can do some kinda gross things to do it, but IMO it's not worth it.

I have added a __post_init__ implementation to validate that it's been constructed in an expected state to help out with that. Now regardless of how it is constructed, it will assert that it has exactly one of value & token, and if it has a token that it's a non-empty string.

Comment thread temporalio/nexus/_temporal_client.py
Comment thread temporalio/nexus/_operation_handlers.py Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants