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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ to include examples, links to docs, or any other relevant information.
- Added the experimental `Worker` `patch_activation_callback` option, allowing workers
to decide whether a first non-replay `workflow.patched` call should activate a patch
during rolling deployments.
- Added external storage support to Nexus task handling.

### Changed

Expand Down
4 changes: 3 additions & 1 deletion scripts/gen_payload_visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
sys.path.insert(0, str(base_dir))

from temporalio.api.common.v1.message_pb2 import Payload, Payloads, SearchAttributes
from temporalio.bridge.proto.nexus import NexusTaskCompletion
from temporalio.bridge.proto.workflow_activation.workflow_activation_pb2 import (
WorkflowActivation,
)
Expand Down Expand Up @@ -426,10 +427,11 @@ def write_bridge_visitors() -> None:
out_path = base_dir / "temporalio" / "bridge" / "_visitor.py"

# Build root descriptors: WorkflowActivation, WorkflowActivationCompletion,
# and all messages from selected API modules
# NexusTaskCompletion, and all messages from selected API modules
roots: list[Descriptor] = [
WorkflowActivation.DESCRIPTOR,
WorkflowActivationCompletion.DESCRIPTOR,
NexusTaskCompletion.DESCRIPTOR,
]

code = VisitorGenerator().generate(roots)
Expand Down
52 changes: 52 additions & 0 deletions temporalio/bridge/_visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,3 +549,55 @@ async def _visit_coresdk_workflow_completion_WorkflowActivationCompletion(
await self._visit_coresdk_workflow_completion_Success(fs, o.successful)
elif o.HasField("failed"):
await self._visit_coresdk_workflow_completion_Failure(fs, o.failed)

async def _visit_temporal_api_nexus_v1_StartOperationResponse_Sync(
self, fs: VisitorFunctions, o: Any
):
if o.HasField("payload"):
await self._visit_temporal_api_common_v1_Payload(fs, o.payload)

async def _visit_temporal_api_nexus_v1_Failure(self, fs: VisitorFunctions, o: Any):
if o.HasField("cause"):
await self._visit_temporal_api_nexus_v1_Failure(fs, o.cause)

async def _visit_temporal_api_nexus_v1_UnsuccessfulOperationError(
self, fs: VisitorFunctions, o: Any
):
if o.HasField("failure"):
await self._visit_temporal_api_nexus_v1_Failure(fs, o.failure)

async def _visit_temporal_api_nexus_v1_StartOperationResponse(
self, fs: VisitorFunctions, o: Any
):
if o.HasField("sync_success"):
await self._visit_temporal_api_nexus_v1_StartOperationResponse_Sync(
fs, o.sync_success
)
elif o.HasField("operation_error"):
await self._visit_temporal_api_nexus_v1_UnsuccessfulOperationError(
fs, o.operation_error
)
elif o.HasField("failure"):
await self._visit_temporal_api_failure_v1_Failure(fs, o.failure)

async def _visit_temporal_api_nexus_v1_Response(self, fs: VisitorFunctions, o: Any):
if o.HasField("start_operation"):
await self._visit_temporal_api_nexus_v1_StartOperationResponse(
fs, o.start_operation
)

async def _visit_temporal_api_nexus_v1_HandlerError(
self, fs: VisitorFunctions, o: Any
):
if o.HasField("failure"):
await self._visit_temporal_api_nexus_v1_Failure(fs, o.failure)

async def _visit_coresdk_nexus_NexusTaskCompletion(
self, fs: VisitorFunctions, o: Any
):
if o.HasField("completed"):
await self._visit_temporal_api_nexus_v1_Response(fs, o.completed)
elif o.HasField("error"):
await self._visit_temporal_api_nexus_v1_HandlerError(fs, o.error)
elif o.HasField("failure"):
await self._visit_temporal_api_failure_v1_Failure(fs, o.failure)
4 changes: 1 addition & 3 deletions temporalio/converter/_data_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
import temporalio.api.common.v1
import temporalio.api.failure.v1
import temporalio.common
from temporalio.api.sdk.v1.external_storage_pb2 import ExternalStorageReference
from temporalio.converter._extstore import (
_REFERENCE_ENCODING,
_REFERENCE_MESSAGE_TYPE,
ExternalStorage,
StorageDriverStoreContext,
)
Expand All @@ -35,8 +35,6 @@
WithSerializationContext,
)

_REFERENCE_MESSAGE_TYPE = ExternalStorageReference.DESCRIPTOR.full_name.encode()


def _is_reference_payload(p: temporalio.api.common.v1.Payload) -> bool:
"""Return True if *p* is an external-storage reference payload."""
Expand Down
8 changes: 6 additions & 2 deletions temporalio/converter/_extstore.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
_T = TypeVar("_T")

_REFERENCE_ENCODING = b"json/external-storage-reference"
_REFERENCE_MESSAGE_TYPE = ExternalStorageReference.DESCRIPTOR.full_name.encode()


@dataclass
Expand Down Expand Up @@ -455,8 +456,6 @@ async def _store_payload_sequence(

def _decode_reference(self, payload: Payload) -> ExternalStorageReference | None:
"""Decode an external storage reference from a payload."""
if len(payload.external_payloads) == 0:

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.

The Nexus task transport drops the external_payloads field. Regardless, it shouldn't be checking for this field anyway and should only rely on the metadata.

return None
encoding = payload.metadata.get("encoding", b"")
if encoding == _REFERENCE_ENCODING:
legacy = self._legacy_claim_converter.from_payload(
Expand All @@ -468,6 +467,11 @@ def _decode_reference(self, payload: Payload) -> ExternalStorageReference | None
driver_name=legacy.driver_name,
claim_data=legacy.driver_claim.claim_data,
)
if not (
encoding == b"json/protobuf"
and payload.metadata.get("messageType") == _REFERENCE_MESSAGE_TYPE
):
return None
ref = self._claim_converter.from_payload(payload, ExternalStorageReference)
return ref if isinstance(ref, ExternalStorageReference) else None

Expand Down
134 changes: 103 additions & 31 deletions temporalio/worker/_nexus.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import concurrent.futures
import contextvars
import threading
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import reduce
Expand All @@ -30,6 +30,8 @@
import temporalio.common
import temporalio.converter
import temporalio.nexus
from temporalio.bridge._visitor import PayloadVisitor
from temporalio.bridge._visitor_functions import PayloadSequence, VisitorFunctions
from temporalio.bridge.worker import PollShutdownError
from temporalio.exceptions import (
ApplicationError,
Expand Down Expand Up @@ -216,6 +218,19 @@ async def _complete_task(
):
await asyncio.shield(self._bridge_worker().complete_nexus_task(completion))

async def _encode_completion(
self, completion: temporalio.bridge.proto.nexus.NexusTaskCompletion
) -> None:
"""Apply the payload codec then external storage to the completion's payloads."""
dc = self._data_converter
await PayloadVisitor(skip_search_attributes=True, skip_headers=True).visit(
_PayloadTransformVisitor(dc._encode_payload_sequence), completion
)
await PayloadVisitor(skip_search_attributes=True).visit(
_PayloadTransformVisitor(dc._external_store_payload_sequence),
completion,
)

# TODO(nexus-preview): stack trace pruning. See sdk-typescript NexusHandler.execute
# "Any call up to this function and including this one will be trimmed out of stack traces.""

Expand Down Expand Up @@ -260,6 +275,14 @@ async def _handle_cancel_operation_task(
try:
try:
await self._handler.cancel_operation(ctx, request.operation_token)
completion = temporalio.bridge.proto.nexus.NexusTaskCompletion(
task_token=task_token,
completed=temporalio.api.nexus.v1.Response(
cancel_operation=temporalio.api.nexus.v1.CancelOperationResponse()
),
)
# No-op but keeps the cancel covered if it ever carries a payload.
await self._encode_completion(completion)
except asyncio.CancelledError:
completion = temporalio.bridge.proto.nexus.NexusTaskCompletion(
task_token=task_token,
Expand All @@ -271,16 +294,12 @@ async def _handle_cancel_operation_task(
completion = temporalio.bridge.proto.nexus.NexusTaskCompletion(
task_token=task_token,
)
await self._data_converter.encode_failure(
handler_error, completion.failure
)
else:
completion = temporalio.bridge.proto.nexus.NexusTaskCompletion(
task_token=task_token,
completed=temporalio.api.nexus.v1.Response(
cancel_operation=temporalio.api.nexus.v1.CancelOperationResponse()
),
self._data_converter.failure_converter.to_failure(
handler_error,
self._data_converter.payload_converter,
completion.failure,
)
await self._encode_completion(completion)
await self._complete_task(completion)
except Exception:
logger.exception("Failed to send Nexus task completion")
Expand Down Expand Up @@ -315,6 +334,13 @@ async def _handle_start_operation_task(
request_deadline,
endpoint,
)
completion = temporalio.bridge.proto.nexus.NexusTaskCompletion(
task_token=task_token,
completed=temporalio.api.nexus.v1.Response(
start_operation=start_response
),
)
await self._encode_completion(completion)
except asyncio.CancelledError:
completion = temporalio.bridge.proto.nexus.NexusTaskCompletion(
task_token=task_token,
Expand All @@ -326,19 +352,15 @@ async def _handle_start_operation_task(
task_token=task_token,
)
handler_error = _exception_to_handler_error(err)
await self._data_converter.encode_failure(
handler_error, completion.failure
self._data_converter.failure_converter.to_failure(
handler_error,
self._data_converter.payload_converter,
completion.failure,
)

if isinstance(err, concurrent.futures.BrokenExecutor):
self._fail_worker_exception_queue.put_nowait(err)
else:
completion = temporalio.bridge.proto.nexus.NexusTaskCompletion(
task_token=task_token,
completed=temporalio.api.nexus.v1.Response(
start_operation=start_response
),
)
await self._encode_completion(completion)

await self._complete_task(completion)
except Exception:
Expand Down Expand Up @@ -417,7 +439,9 @@ async def _start_operation(
)
)
elif isinstance(result, nexusrpc.handler.StartOperationResultSync):
[payload] = await self._data_converter.encode([result.value])
[payload] = self._data_converter.payload_converter.to_payloads(
[result.value]
)
return temporalio.api.nexus.v1.StartOperationResponse(
sync_success=temporalio.api.nexus.v1.StartOperationResponse.Sync(
payload=payload,
Expand Down Expand Up @@ -446,10 +470,41 @@ async def _start_operation(
) from err.__cause__
except FailureError as new_err:
response = temporalio.api.nexus.v1.StartOperationResponse()
await self._data_converter.encode_failure(new_err, response.failure)
self._data_converter.failure_converter.to_failure(
new_err,
self._data_converter.payload_converter,
response.failure,
)
return response


class _PayloadTransformVisitor(VisitorFunctions):
"""Adapts a payload-sequence transform for use with :class:`PayloadVisitor`."""

def __init__(
self,
f: Callable[
[Sequence[temporalio.api.common.v1.Payload]],
Awaitable[list[temporalio.api.common.v1.Payload]],
],
) -> None:
self._f = f

async def visit_payload(self, payload: temporalio.api.common.v1.Payload) -> None:
new_payload = (await self._f([payload]))[0]
if new_payload is not payload:
payload.CopyFrom(new_payload)

async def visit_payloads(self, payloads: PayloadSequence) -> None:
if len(payloads) == 0:
return
new_payloads = await self._f(payloads)
if new_payloads is payloads:
return
del payloads[:]
payloads.extend(new_payloads)


@dataclass
class _DummyPayloadSerializer:
data_converter: temporalio.converter.DataConverter
Expand All @@ -465,18 +520,35 @@ async def deserialize(
content: nexusrpc.Content, # type:ignore[reportUnusedParameter]
as_type: type[Any] | None = None,
) -> Any:
payload = self.payload
if self.data_converter.payload_codec:
try:
[payload] = await self.data_converter.payload_codec.decode([payload])
except Exception as err:
raise nexusrpc.HandlerError(
"Payload codec failed to decode Nexus operation input",
type=nexusrpc.HandlerErrorType.INTERNAL,
) from err
dc = self.data_converter
# The visitor mutates in place, so work on a copy to leave the request
# payload untouched.
payload = temporalio.api.common.v1.Payload()
payload.CopyFrom(self.payload)
try:
await PayloadVisitor(skip_search_attributes=True).visit(
_PayloadTransformVisitor(dc._external_retrieve_payload_sequence),
payload,
)
except Exception as err:
raise nexusrpc.HandlerError(
"Failed to retrieve Nexus operation input from external storage",
type=nexusrpc.HandlerErrorType.INTERNAL,
retryable_override=True,
) from err

try:
await PayloadVisitor(skip_search_attributes=True, skip_headers=True).visit(
_PayloadTransformVisitor(dc._decode_payload_sequence), payload
)
except Exception as err:
raise nexusrpc.HandlerError(
"Payload codec failed to decode Nexus operation input",
type=nexusrpc.HandlerErrorType.INTERNAL,
) from err

try:
[input] = self.data_converter.payload_converter.from_payloads(
[input] = dc.payload_converter.from_payloads(
[payload],
type_hints=[as_type] if as_type else None,
)
Expand Down
Loading
Loading