Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
5 changes: 5 additions & 0 deletions temporalio/client/_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,13 +309,17 @@ class ActivityExecutionDescription(ActivityExecution):
long_poll_token: bytes | None
"""Token for follow-on long-poll requests. None if the activity is complete."""

raw_callbacks: Sequence[temporalio.api.activity.v1.CallbackInfo]
"""Underlying protobuf callbacks"""

@classmethod
async def _from_execution_info(
cls,
info: temporalio.api.activity.v1.ActivityExecutionInfo,
long_poll_token: bytes | None,
namespace: str,
data_converter: temporalio.converter.DataConverter,
callbacks: Sequence[temporalio.api.activity.v1.CallbackInfo],
) -> Self:
"""Create from raw proto activity execution info."""
# Decode heartbeat details if present
Expand Down Expand Up @@ -409,6 +413,7 @@ async def _from_execution_info(
typed_search_attributes=temporalio.converter.decode_typed_search_attributes(
info.search_attributes
),
raw_callbacks=callbacks,
)


Expand Down
49 changes: 47 additions & 2 deletions temporalio/client/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import temporalio.exceptions
import temporalio.nexus
import temporalio.nexus._operation_context
import temporalio.nexus._token
from temporalio.activity import ActivityCancellationDetails
from temporalio.converter import (
ActivitySerializationContext,
Expand Down Expand Up @@ -246,7 +247,7 @@ async def _build_start_workflow_execution_request(
# inside a Nexus operation handler must forward the inbound Nexus task links
# explicitly so the started callee's WorkflowExecutionStarted event links back to
# the caller.
if not temporalio.nexus._operation_context._in_nexus_backing_workflow_start_context():
if not temporalio.nexus._operation_context._in_nexus_backing_start_context():
req.links.extend(nexus_ctx._get_request_links())

return req
Expand Down Expand Up @@ -277,7 +278,7 @@ async def _build_signal_with_start_workflow_execution_request(
# If this signal-with-start is issued from inside a Nexus operation handler (but not the
# nexus-backing workflow), forward the inbound Nexus task links so both the callee's
# WorkflowExecutionStarted and WorkflowExecutionSignaled events link back to the caller.
if not temporalio.nexus._operation_context._in_nexus_backing_workflow_start_context():
if not temporalio.nexus._operation_context._in_nexus_backing_start_context():
nexus_ctx = (
temporalio.nexus._operation_context._try_start_operation_context()
)
Expand Down Expand Up @@ -587,6 +588,11 @@ async def start_activity(self, input: StartActivityInput) -> ActivityHandle[Any]
input.id, input.activity_type, run_id=details.run_id
)
raise

nexus_ctx = temporalio.nexus._operation_context._try_start_operation_context()
if nexus_ctx is not None:
nexus_ctx._add_response_link(resp.link)
Comment thread
VegetarianOrc marked this conversation as resolved.
Outdated

return ActivityHandle(
self._client,
input.id,
Expand Down Expand Up @@ -670,6 +676,44 @@ async def _build_start_activity_execution_request(
# Set priority
req.priority.CopyFrom(input.priority._to_proto())

nexus_ctx = temporalio.nexus._operation_context._try_start_operation_context()

request_links: list[temporalio.api.common.v1.Link] = []
if nexus_ctx is not None:
req.on_conflict_options.attach_request_id = True
req.on_conflict_options.attach_completion_callbacks = True
req.on_conflict_options.attach_links = True

# Add all Nexus links if we're in a Nexus context, backing or otherwise
request_links = nexus_ctx._get_request_links()

# Add request ID and callbacks only if we're in a backing Nexus context
if (
nexus_ctx is not None
and temporalio.nexus._operation_context._in_nexus_backing_start_context()
):
req.request_id = nexus_ctx.nexus_context.request_id
Comment thread
VegetarianOrc marked this conversation as resolved.
Outdated
callbacks = nexus_ctx._get_callbacks(
temporalio.nexus._token.OperationToken(
type=temporalio.nexus._token.OperationTokenType.ACTIVITY,
namespace=self._client.namespace,
activity_id=input.id,
).encode()
)
req.completion_callbacks.extend(
temporalio.api.common.v1.Callback(
nexus=temporalio.api.common.v1.Callback.Nexus(
url=callback.url,
header=callback.headers,
),
links=request_links,
)
for callback in callbacks
)

# Links are duplicated on request for compatibility with older server versions.
req.links.extend(request_links)
Comment thread
VegetarianOrc marked this conversation as resolved.
Outdated

return req

async def cancel_activity(self, input: CancelActivityInput) -> None:
Expand Down Expand Up @@ -733,6 +777,7 @@ async def describe_activity(
is_local=False,
)
),
callbacks=resp.callbacks,
)

def list_activities(
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 (
CancelActivityOptions,
CancelUpdateWorkflowOptions,
CancelWorkflowRunOptions,
TemporalOperationHandler,
Expand All @@ -34,6 +35,7 @@

__all__ = (
"workflow_run_operation",
"CancelActivityOptions",
"CancelWorkflowRunOptions",
"CancelUpdateWorkflowOptions",
"Info",
Expand Down
50 changes: 46 additions & 4 deletions temporalio/nexus/_link_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
r"^/namespaces/(?P<namespace>[^/]+)/nexus-operations/(?P<operation_id>[^/]+)/(?P<run_id>[^/]*)/details$"
)

_ACTIVITY_LINK_URL_PATH_REGEX = re.compile(
r"^/namespaces/(?P<namespace>[^/]+)/activities/(?P<activity_id>[^/]+)/(?P<run_id>[^/]+)/details$"
)

_WORKFLOW_LINK_URL_PATH_REGEX = re.compile(
r"^/namespaces/(?P<namespace>[^/]+)/workflows/(?P<workflow_id>[^/]+)/(?P<run_id>[^/]+)(?P<history>/history)?$"
)
Expand All @@ -32,6 +36,7 @@ class _LinkType(str, Enum):
WORKFLOW_EVENT = temporalio.api.common.v1.Link.WorkflowEvent.DESCRIPTOR.full_name
WORKFLOW = temporalio.api.common.v1.Link.Workflow.DESCRIPTOR.full_name
NEXUS_OPERATION = temporalio.api.common.v1.Link.NexusOperation.DESCRIPTOR.full_name
ACTIVITY = temporalio.api.common.v1.Link.Activity.DESCRIPTOR.full_name


LINK_EVENT_ID_PARAM_NAME = "eventID"
Expand Down Expand Up @@ -88,6 +93,9 @@ def nexus_link_to_temporal_link(
case _LinkType.NEXUS_OPERATION:
return nexus_link_to_nexus_operation_link(nexus_link)

case _LinkType.ACTIVITY:
return nexus_link_to_activity_link(nexus_link)


def temporal_link_to_nexus_link(
temporal_link: temporalio.api.common.v1.Link,
Expand All @@ -106,10 +114,11 @@ def temporal_link_to_nexus_link(
case "nexus_operation":
return nexus_operation_to_nexus_link(temporal_link.nexus_operation)

case "activity" | "batch_job":
raise NotImplementedError(
"only workflow_event and nexus operation links are supported"
)
case "activity":
return activity_link_to_nexus_link(temporal_link.activity)

case "batch_job":
raise NotImplementedError("batch_job links are not supported")

case None:
logger.warning("Invalid Temporal link: missing variant")
Expand Down Expand Up @@ -190,6 +199,17 @@ def nexus_operation_to_nexus_link(
)


def activity_link_to_nexus_link(
activity: temporalio.api.common.v1.Link.Activity,
) -> nexusrpc.Link:
"""Convert an Activity link into a nexusrpc link."""
namespace = urllib.parse.quote(activity.namespace, safe="")
activity_id = urllib.parse.quote(activity.activity_id, safe="")
run_id = urllib.parse.quote(activity.run_id, safe="")
path = f"/namespaces/{namespace}/activities/{activity_id}/{run_id}/details"
return nexusrpc.Link(url=_temporal_nexus_url(path), type=_LinkType.ACTIVITY.value)


def _workflow_nexus_url(
namespace: str,
workflow_id: str,
Expand Down Expand Up @@ -334,6 +354,28 @@ def nexus_link_to_nexus_operation_link(
return temporalio.api.common.v1.Link(nexus_operation=nexus_op_link)


def nexus_link_to_activity_link(
nexus_link: nexusrpc.Link,
) -> temporalio.api.common.v1.Link | None:
"""Convert a Nexus Activity link into a Temporal Activity link."""
match = _ACTIVITY_LINK_URL_PATH_REGEX.match(
urllib.parse.urlparse(nexus_link.url).path
)
if not match:
logger.warning(
f"Invalid Nexus link: {nexus_link}. Expected path to match {_ACTIVITY_LINK_URL_PATH_REGEX.pattern}"
)
return None
groups = match.groupdict()
return temporalio.api.common.v1.Link(
activity=temporalio.api.common.v1.Link.Activity(
namespace=urllib.parse.unquote(groups["namespace"]),
activity_id=urllib.parse.unquote(groups["activity_id"]),
run_id=urllib.parse.unquote(groups["run_id"]),
)
)


def _event_reference_to_query_params(
event_ref: temporalio.api.common.v1.Link.WorkflowEvent.EventReference,
) -> str:
Expand Down
49 changes: 24 additions & 25 deletions temporalio/nexus/_operation_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
from ._link_conversion import (
nexus_link_to_temporal_link,
temporal_link_to_nexus_link,
workflow_event_to_nexus_link,
workflow_execution_started_event_link_from_workflow_handle,
)
from ._token import OperationToken, OperationTokenType, WorkflowHandle
Expand All @@ -66,12 +65,12 @@
ContextVar("temporal-cancel-operation-context")
)

# A Nexus start handler might start zero or more workflows as usual using a Temporal client. In
# addition, it may start one "nexus-backing" workflow, using
# WorkflowRunOperationContext.start_workflow. This context is active while the latter is being done.
# A Nexus start handler might start zero or more async Temporal actions as usual using a Temporal client. In
# addition, it may start one "nexus-backing" async Temporal action, using
# WorkflowRunOperationContext.start_workflow or methods from TemporalNexusClient. This context is active while the latter is being done.
# It is thus a narrower context than _temporal_start_operation_context.
_temporal_nexus_backing_workflow_start_context: ContextVar[bool] = ContextVar(
"temporal-nexus-backing-workflow-start-context"
_temporal_nexus_backing_start_context: ContextVar[bool] = ContextVar(
"temporal-nexus-backing-start-context"
)


Expand Down Expand Up @@ -170,21 +169,21 @@ def _try_temporal_context() -> (


def _try_start_operation_context() -> _TemporalStartOperationContext | None: # pyright: ignore[reportUnusedFunction]
"""The Nexus start-operation context if a handler is currently running, else None."""
"""Return the active Nexus start-operation context, if any."""
return _temporal_start_operation_context.get(None)


@contextmanager
def _nexus_backing_workflow_start_context() -> Generator[None]:
token = _temporal_nexus_backing_workflow_start_context.set(True)
def _nexus_backing_start_context() -> Generator[None]:
token = _temporal_nexus_backing_start_context.set(True)
try:
yield
finally:
_temporal_nexus_backing_workflow_start_context.reset(token)
_temporal_nexus_backing_start_context.reset(token)


def _in_nexus_backing_workflow_start_context() -> bool: # type:ignore[reportUnusedClass]
return _temporal_nexus_backing_workflow_start_context.get(False)
def _in_nexus_backing_start_context() -> bool: # type:ignore[reportUnusedClass]
return _temporal_nexus_backing_start_context.get(False)


_OperationCtxT = TypeVar("_OperationCtxT", bound=OperationContext)
Expand Down Expand Up @@ -254,11 +253,11 @@ def _get_request_links(self) -> list[temporalio.api.common.v1.Link]:
``links`` field so the callee's history event links back to whatever scheduled this
Nexus operation.
"""
event_links: list[temporalio.api.common.v1.Link] = []
links: list[temporalio.api.common.v1.Link] = []
for inbound_link in self.nexus_context.inbound_links:
if link := nexus_link_to_temporal_link(inbound_link):
event_links.append(link)
return event_links
links.append(link)
return links

def _add_start_workflow_response_link(
self, workflow_handle: temporalio.client.WorkflowHandle[Any, Any]
Expand Down Expand Up @@ -302,17 +301,17 @@ def _add_response_link(self, link: temporalio.api.common.v1.Link | None) -> None
"""Append a response link returned by an RPC the operation handler issued.

``link`` is the ``common.v1.Link`` returned on a signal, signal-with-start, or start
response (or ``None`` against a server that did not return one). When present and of the
``workflow_event`` variant, it is converted to a Nexus link and added to the operation's
outbound links so the caller workflow's Nexus history event links to the callee event.
response (or ``None`` against a server that did not return one). When present, it is
converted to a Nexus link and added to the operation's outbound links.

This is only safe to call from the single thread/task that runs the operation handler.
"""
if link is None or not link.HasField("workflow_event"):
return
self.nexus_context.outbound_links.append(
workflow_event_to_nexus_link(link.workflow_event)
)
if link is not None:
try:
if response_link := temporal_link_to_nexus_link(link):
self.nexus_context.outbound_links.append(response_link)
except Exception as e:
logger.warning(f"Failed to create Nexus link from Temporal link: {e}")


class WorkflowRunOperationContext(StartOperationContext):
Expand Down Expand Up @@ -668,7 +667,7 @@ async def _start_nexus_backing_workflow(
priority: temporalio.common.Priority = temporalio.common.Priority.default,
versioning_override: temporalio.common.VersioningOverride | None = None,
) -> WorkflowHandle[ReturnType]:
# We must pass nexus_completion_callbacks, workflow_event_links, and request_id,
# We must pass nexus_completion_callbacks, links, and request_id,
# but these are deliberately not exposed in overloads, hence the type-check
# violation.

Expand All @@ -677,7 +676,7 @@ async def _start_nexus_backing_workflow(
# namespace to deliver the result to the caller namespace when the workflow reaches a
# terminal state) and inbound links to the caller workflow (attached to history events of
# the workflow started in the handler namespace, and displayed in the UI).
with _nexus_backing_workflow_start_context():
with _nexus_backing_start_context():
token = OperationToken(
type=OperationTokenType.WORKFLOW,
namespace=temporal_context.client.namespace,
Expand Down
Loading
Loading