Skip to content

Commit 2777500

Browse files
authored
feat(extstore): Add support for Nexus task handling (#1676)
1 parent 63eadc7 commit 2777500

7 files changed

Lines changed: 437 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ to include examples, links to docs, or any other relevant information.
5252
- Added the experimental `Worker` `patch_activation_callback` option, allowing workers
5353
to decide whether a first non-replay `workflow.patched` call should activate a patch
5454
during rolling deployments.
55+
- Added external storage support to Nexus task handling.
5556

5657
### Changed
5758

scripts/gen_payload_visitor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
sys.path.insert(0, str(base_dir))
1313

1414
from temporalio.api.common.v1.message_pb2 import Payload, Payloads, SearchAttributes
15+
from temporalio.bridge.proto.nexus import NexusTaskCompletion
1516
from temporalio.bridge.proto.workflow_activation.workflow_activation_pb2 import (
1617
WorkflowActivation,
1718
)
@@ -425,9 +426,12 @@ def walk(self, desc: Descriptor) -> bool:
425426
def write_bridge_visitors() -> None:
426427
out_path = base_dir / "temporalio" / "bridge" / "_visitor.py"
427428

429+
# Build root descriptors: WorkflowActivation, WorkflowActivationCompletion,
430+
# NexusTaskCompletion, and the system Nexus operation roots.
428431
roots: list[Descriptor] = [
429432
WorkflowActivation.DESCRIPTOR,
430433
WorkflowActivationCompletion.DESCRIPTOR,
434+
NexusTaskCompletion.DESCRIPTOR,
431435
] + discover_system_nexus_roots()
432436

433437
code = VisitorGenerator().generate(roots)

temporalio/bridge/_visitor.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,58 @@ async def _visit_coresdk_workflow_completion_WorkflowActivationCompletion(
548548
elif o.HasField("failed"):
549549
await self._visit_coresdk_workflow_completion_Failure(fs, o.failed)
550550

551+
async def _visit_temporal_api_nexus_v1_StartOperationResponse_Sync(
552+
self, fs: VisitorFunctions, o: Any
553+
):
554+
if o.HasField("payload"):
555+
await self._visit_temporal_api_common_v1_Payload(fs, o.payload)
556+
557+
async def _visit_temporal_api_nexus_v1_Failure(self, fs: VisitorFunctions, o: Any):
558+
if o.HasField("cause"):
559+
await self._visit_temporal_api_nexus_v1_Failure(fs, o.cause)
560+
561+
async def _visit_temporal_api_nexus_v1_UnsuccessfulOperationError(
562+
self, fs: VisitorFunctions, o: Any
563+
):
564+
if o.HasField("failure"):
565+
await self._visit_temporal_api_nexus_v1_Failure(fs, o.failure)
566+
567+
async def _visit_temporal_api_nexus_v1_StartOperationResponse(
568+
self, fs: VisitorFunctions, o: Any
569+
):
570+
if o.HasField("sync_success"):
571+
await self._visit_temporal_api_nexus_v1_StartOperationResponse_Sync(
572+
fs, o.sync_success
573+
)
574+
elif o.HasField("operation_error"):
575+
await self._visit_temporal_api_nexus_v1_UnsuccessfulOperationError(
576+
fs, o.operation_error
577+
)
578+
elif o.HasField("failure"):
579+
await self._visit_temporal_api_failure_v1_Failure(fs, o.failure)
580+
581+
async def _visit_temporal_api_nexus_v1_Response(self, fs: VisitorFunctions, o: Any):
582+
if o.HasField("start_operation"):
583+
await self._visit_temporal_api_nexus_v1_StartOperationResponse(
584+
fs, o.start_operation
585+
)
586+
587+
async def _visit_temporal_api_nexus_v1_HandlerError(
588+
self, fs: VisitorFunctions, o: Any
589+
):
590+
if o.HasField("failure"):
591+
await self._visit_temporal_api_nexus_v1_Failure(fs, o.failure)
592+
593+
async def _visit_coresdk_nexus_NexusTaskCompletion(
594+
self, fs: VisitorFunctions, o: Any
595+
):
596+
if o.HasField("completed"):
597+
await self._visit_temporal_api_nexus_v1_Response(fs, o.completed)
598+
elif o.HasField("error"):
599+
await self._visit_temporal_api_nexus_v1_HandlerError(fs, o.error)
600+
elif o.HasField("failure"):
601+
await self._visit_temporal_api_failure_v1_Failure(fs, o.failure)
602+
551603
async def _visit_temporal_api_common_v1_Header(self, fs: VisitorFunctions, o: Any):
552604
for v in o.fields.values():
553605
await self._visit_temporal_api_common_v1_Payload(fs, v)

temporalio/converter/_data_converter.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
import temporalio.api.common.v1
1414
import temporalio.api.failure.v1
1515
import temporalio.common
16-
from temporalio.api.sdk.v1.external_storage_pb2 import ExternalStorageReference
1716
from temporalio.converter._extstore import (
1817
_REFERENCE_ENCODING,
18+
_REFERENCE_MESSAGE_TYPE,
1919
ExternalStorage,
2020
StorageDriverStoreContext,
2121
)
@@ -35,8 +35,6 @@
3535
WithSerializationContext,
3636
)
3737

38-
_REFERENCE_MESSAGE_TYPE = ExternalStorageReference.DESCRIPTOR.full_name.encode()
39-
4038

4139
def _is_reference_payload(p: temporalio.api.common.v1.Payload) -> bool:
4240
"""Return True if *p* is an external-storage reference payload."""

temporalio/converter/_extstore.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
_T = TypeVar("_T")
2828

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

3132

3233
@dataclass
@@ -455,8 +456,6 @@ async def _store_payload_sequence(
455456

456457
def _decode_reference(self, payload: Payload) -> ExternalStorageReference | None:
457458
"""Decode an external storage reference from a payload."""
458-
if len(payload.external_payloads) == 0:
459-
return None
460459
encoding = payload.metadata.get("encoding", b"")
461460
if encoding == _REFERENCE_ENCODING:
462461
legacy = self._legacy_claim_converter.from_payload(
@@ -468,6 +467,11 @@ def _decode_reference(self, payload: Payload) -> ExternalStorageReference | None
468467
driver_name=legacy.driver_name,
469468
claim_data=legacy.driver_claim.claim_data,
470469
)
470+
if not (
471+
encoding == b"json/protobuf"
472+
and payload.metadata.get("messageType") == _REFERENCE_MESSAGE_TYPE
473+
):
474+
return None
471475
ref = self._claim_converter.from_payload(payload, ExternalStorageReference)
472476
return ref if isinstance(ref, ExternalStorageReference) else None
473477

temporalio/worker/_nexus.py

Lines changed: 103 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import concurrent.futures
77
import contextvars
88
import threading
9-
from collections.abc import Callable, Mapping, Sequence
9+
from collections.abc import Awaitable, Callable, Mapping, Sequence
1010
from dataclasses import dataclass
1111
from datetime import datetime, timezone
1212
from functools import reduce
@@ -30,6 +30,8 @@
3030
import temporalio.common
3131
import temporalio.converter
3232
import temporalio.nexus
33+
from temporalio.bridge._visitor import PayloadVisitor
34+
from temporalio.bridge._visitor_functions import PayloadSequence, VisitorFunctions
3335
from temporalio.bridge.worker import PollShutdownError
3436
from temporalio.exceptions import (
3537
ApplicationError,
@@ -216,6 +218,19 @@ async def _complete_task(
216218
):
217219
await asyncio.shield(self._bridge_worker().complete_nexus_task(completion))
218220

221+
async def _encode_completion(
222+
self, completion: temporalio.bridge.proto.nexus.NexusTaskCompletion
223+
) -> None:
224+
"""Apply the payload codec then external storage to the completion's payloads."""
225+
dc = self._data_converter
226+
await PayloadVisitor(skip_search_attributes=True, skip_headers=True).visit(
227+
_PayloadTransformVisitor(dc._encode_payload_sequence), completion
228+
)
229+
await PayloadVisitor(skip_search_attributes=True).visit(
230+
_PayloadTransformVisitor(dc._external_store_payload_sequence),
231+
completion,
232+
)
233+
219234
# TODO(nexus-preview): stack trace pruning. See sdk-typescript NexusHandler.execute
220235
# "Any call up to this function and including this one will be trimmed out of stack traces.""
221236

@@ -260,6 +275,14 @@ async def _handle_cancel_operation_task(
260275
try:
261276
try:
262277
await self._handler.cancel_operation(ctx, request.operation_token)
278+
completion = temporalio.bridge.proto.nexus.NexusTaskCompletion(
279+
task_token=task_token,
280+
completed=temporalio.api.nexus.v1.Response(
281+
cancel_operation=temporalio.api.nexus.v1.CancelOperationResponse()
282+
),
283+
)
284+
# No-op but keeps the cancel covered if it ever carries a payload.
285+
await self._encode_completion(completion)
263286
except asyncio.CancelledError:
264287
completion = temporalio.bridge.proto.nexus.NexusTaskCompletion(
265288
task_token=task_token,
@@ -271,16 +294,12 @@ async def _handle_cancel_operation_task(
271294
completion = temporalio.bridge.proto.nexus.NexusTaskCompletion(
272295
task_token=task_token,
273296
)
274-
await self._data_converter.encode_failure(
275-
handler_error, completion.failure
276-
)
277-
else:
278-
completion = temporalio.bridge.proto.nexus.NexusTaskCompletion(
279-
task_token=task_token,
280-
completed=temporalio.api.nexus.v1.Response(
281-
cancel_operation=temporalio.api.nexus.v1.CancelOperationResponse()
282-
),
297+
self._data_converter.failure_converter.to_failure(
298+
handler_error,
299+
self._data_converter.payload_converter,
300+
completion.failure,
283301
)
302+
await self._encode_completion(completion)
284303
await self._complete_task(completion)
285304
except Exception:
286305
logger.exception("Failed to send Nexus task completion")
@@ -315,6 +334,13 @@ async def _handle_start_operation_task(
315334
request_deadline,
316335
endpoint,
317336
)
337+
completion = temporalio.bridge.proto.nexus.NexusTaskCompletion(
338+
task_token=task_token,
339+
completed=temporalio.api.nexus.v1.Response(
340+
start_operation=start_response
341+
),
342+
)
343+
await self._encode_completion(completion)
318344
except asyncio.CancelledError:
319345
completion = temporalio.bridge.proto.nexus.NexusTaskCompletion(
320346
task_token=task_token,
@@ -326,19 +352,15 @@ async def _handle_start_operation_task(
326352
task_token=task_token,
327353
)
328354
handler_error = _exception_to_handler_error(err)
329-
await self._data_converter.encode_failure(
330-
handler_error, completion.failure
355+
self._data_converter.failure_converter.to_failure(
356+
handler_error,
357+
self._data_converter.payload_converter,
358+
completion.failure,
331359
)
332360

333361
if isinstance(err, concurrent.futures.BrokenExecutor):
334362
self._fail_worker_exception_queue.put_nowait(err)
335-
else:
336-
completion = temporalio.bridge.proto.nexus.NexusTaskCompletion(
337-
task_token=task_token,
338-
completed=temporalio.api.nexus.v1.Response(
339-
start_operation=start_response
340-
),
341-
)
363+
await self._encode_completion(completion)
342364

343365
await self._complete_task(completion)
344366
except Exception:
@@ -417,7 +439,9 @@ async def _start_operation(
417439
)
418440
)
419441
elif isinstance(result, nexusrpc.handler.StartOperationResultSync):
420-
[payload] = await self._data_converter.encode([result.value])
442+
[payload] = self._data_converter.payload_converter.to_payloads(
443+
[result.value]
444+
)
421445
return temporalio.api.nexus.v1.StartOperationResponse(
422446
sync_success=temporalio.api.nexus.v1.StartOperationResponse.Sync(
423447
payload=payload,
@@ -446,10 +470,41 @@ async def _start_operation(
446470
) from err.__cause__
447471
except FailureError as new_err:
448472
response = temporalio.api.nexus.v1.StartOperationResponse()
449-
await self._data_converter.encode_failure(new_err, response.failure)
473+
self._data_converter.failure_converter.to_failure(
474+
new_err,
475+
self._data_converter.payload_converter,
476+
response.failure,
477+
)
450478
return response
451479

452480

481+
class _PayloadTransformVisitor(VisitorFunctions):
482+
"""Adapts a payload-sequence transform for use with :class:`PayloadVisitor`."""
483+
484+
def __init__(
485+
self,
486+
f: Callable[
487+
[Sequence[temporalio.api.common.v1.Payload]],
488+
Awaitable[list[temporalio.api.common.v1.Payload]],
489+
],
490+
) -> None:
491+
self._f = f
492+
493+
async def visit_payload(self, payload: temporalio.api.common.v1.Payload) -> None:
494+
new_payload = (await self._f([payload]))[0]
495+
if new_payload is not payload:
496+
payload.CopyFrom(new_payload)
497+
498+
async def visit_payloads(self, payloads: PayloadSequence) -> None:
499+
if len(payloads) == 0:
500+
return
501+
new_payloads = await self._f(payloads)
502+
if new_payloads is payloads:
503+
return
504+
del payloads[:]
505+
payloads.extend(new_payloads)
506+
507+
453508
@dataclass
454509
class _DummyPayloadSerializer:
455510
data_converter: temporalio.converter.DataConverter
@@ -465,18 +520,35 @@ async def deserialize(
465520
content: nexusrpc.Content, # type:ignore[reportUnusedParameter]
466521
as_type: type[Any] | None = None,
467522
) -> Any:
468-
payload = self.payload
469-
if self.data_converter.payload_codec:
470-
try:
471-
[payload] = await self.data_converter.payload_codec.decode([payload])
472-
except Exception as err:
473-
raise nexusrpc.HandlerError(
474-
"Payload codec failed to decode Nexus operation input",
475-
type=nexusrpc.HandlerErrorType.INTERNAL,
476-
) from err
523+
dc = self.data_converter
524+
# The visitor mutates in place, so work on a copy to leave the request
525+
# payload untouched.
526+
payload = temporalio.api.common.v1.Payload()
527+
payload.CopyFrom(self.payload)
528+
try:
529+
await PayloadVisitor(skip_search_attributes=True).visit(
530+
_PayloadTransformVisitor(dc._external_retrieve_payload_sequence),
531+
payload,
532+
)
533+
except Exception as err:
534+
raise nexusrpc.HandlerError(
535+
"Failed to retrieve Nexus operation input from external storage",
536+
type=nexusrpc.HandlerErrorType.INTERNAL,
537+
retryable_override=True,
538+
) from err
539+
540+
try:
541+
await PayloadVisitor(skip_search_attributes=True, skip_headers=True).visit(
542+
_PayloadTransformVisitor(dc._decode_payload_sequence), payload
543+
)
544+
except Exception as err:
545+
raise nexusrpc.HandlerError(
546+
"Payload codec failed to decode Nexus operation input",
547+
type=nexusrpc.HandlerErrorType.INTERNAL,
548+
) from err
477549

478550
try:
479-
[input] = self.data_converter.payload_converter.from_payloads(
551+
[input] = dc.payload_converter.from_payloads(
480552
[payload],
481553
type_hints=[as_type] if as_type else None,
482554
)

0 commit comments

Comments
 (0)