From 5b12665520c0fc9e4c30ff47035423156d713c79 Mon Sep 17 00:00:00 2001 From: jmaeagle99 <44687433+jmaeagle99@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:56:20 -0700 Subject: [PATCH 1/7] exp: basic payload handle --- temporalio/bridge/worker.py | 71 +++++++++- temporalio/converter/__init__.py | 2 + temporalio/converter/_data_converter.py | 29 ++++ temporalio/converter/_payload_converter.py | 20 +++ temporalio/converter/_payload_handle.py | 151 +++++++++++++++++++++ temporalio/worker/_workflow.py | 30 ++++ tests/test_payload_handle.py | 143 +++++++++++++++++++ tests/worker/test_payload_handle.py | 122 +++++++++++++++++ 8 files changed, 564 insertions(+), 4 deletions(-) create mode 100644 temporalio/converter/_payload_handle.py create mode 100644 tests/test_payload_handle.py create mode 100644 tests/worker/test_payload_handle.py diff --git a/temporalio/bridge/worker.py b/temporalio/bridge/worker.py index 6554c508c..821fd5b04 100644 --- a/temporalio/bridge/worker.py +++ b/temporalio/bridge/worker.py @@ -20,6 +20,7 @@ import temporalio.bridge.runtime import temporalio.bridge.temporal_sdk_bridge import temporalio.converter +import temporalio.converter._data_converter import temporalio.converter._extstore from temporalio.api.common.v1.message_pb2 import Payload from temporalio.bridge._visitor_functions import PayloadSequence, VisitorFunctions @@ -303,31 +304,93 @@ async def visit_payloads(self, payloads: PayloadSequence) -> None: payloads.extend(new_payloads) +def _skip_payloads( + f: Callable[[Sequence[Payload]], Awaitable[list[Payload]]], + skip: set[bytes], +) -> Callable[[Sequence[Payload]], Awaitable[list[Payload]]]: + """Wrap a transform so matching payloads pass through untransformed. + + Payloads are matched by deterministic serialization rather than object + identity: the payload visitor may hand out fresh wrapper objects for the + same underlying proto (e.g. under the upb implementation), so id() is not + stable, and deterministic serialization also neutralizes metadata-map + ordering. + """ + + def key(payload: Payload) -> bytes: + return payload.SerializeToString(deterministic=True) + + async def wrapped(payloads: Sequence[Payload]) -> list[Payload]: + to_transform = [p for p in payloads if key(p) not in skip] + if len(to_transform) == len(payloads): + return await f(payloads) + if not to_transform: + return list(payloads) + transformed = iter(await f(to_transform)) + return [p if key(p) in skip else next(transformed) for p in payloads] + + return wrapped + + +def _skip_reference_payloads( + f: Callable[[Sequence[Payload]], Awaitable[list[Payload]]], +) -> Callable[[Sequence[Payload]], Awaitable[list[Payload]]]: + """Wrap a transform so external-storage reference payloads pass through. + + References are never codec-encoded (they are created after codec-encode + + store on the way out), so codec-decoding one would be wrong. A reference + only survives to this point when its retrieval was deferred for a handle. + """ + + async def wrapped(payloads: Sequence[Payload]) -> list[Payload]: + is_ref = temporalio.converter._data_converter._is_reference_payload + to_transform = [p for p in payloads if not is_ref(p)] + if len(to_transform) == len(payloads): + return await f(payloads) + if not to_transform: + return list(payloads) + transformed = iter(await f(to_transform)) + return [p if is_ref(p) else next(transformed) for p in payloads] + + return wrapped + + async def decode_activation( activation: temporalio.bridge.proto.workflow_activation.WorkflowActivation, data_converter: temporalio.converter.DataConverter, decode_headers: bool, storage_concurrency_limit: int, + defer_retrieval_payloads: set[bytes] | None = None, ) -> temporalio.converter._extstore.StorageOperationMetrics: """Decode all payloads in the activation. + Args: + defer_retrieval_payloads: deterministic serializations of payloads + (workflow run args annotated as PayloadHandle) whose external-storage + retrieval and codec decode should be skipped so they surface as + forward-only handles inside the workflow. + Returns: Metrics from any external storage retrieval operations that occurred. """ + retrieve = data_converter._external_retrieve_payload_sequence + decode = data_converter._decode_payload_sequence + if defer_retrieval_payloads: + retrieve = _skip_payloads(retrieve, defer_retrieval_payloads) + decode = _skip_reference_payloads(decode) + metrics = temporalio.converter._extstore.StorageOperationMetrics() with metrics.track(): await CommandAwarePayloadVisitor( skip_search_attributes=True, skip_headers=not decode_headers, concurrency_limit=storage_concurrency_limit, - ).visit( - _Visitor(data_converter._external_retrieve_payload_sequence), activation - ) + ).visit(_Visitor(retrieve), activation) await CommandAwarePayloadVisitor( skip_search_attributes=True, skip_headers=not decode_headers, - ).visit(_Visitor(data_converter._decode_payload_sequence), activation) + ).visit(_Visitor(decode), activation) return metrics diff --git a/temporalio/converter/__init__.py b/temporalio/converter/__init__.py index 9192eb704..4cf6177f8 100644 --- a/temporalio/converter/__init__.py +++ b/temporalio/converter/__init__.py @@ -35,6 +35,7 @@ PayloadConverter, value_to_type, ) +from temporalio.converter._payload_handle import PayloadHandle from temporalio.converter._search_attributes import ( decode_search_attributes, decode_typed_search_attributes, @@ -76,6 +77,7 @@ "JSONTypeConverterUnhandled", "PayloadCodec", "PayloadConverter", + "PayloadHandle", "SerializationContext", "WithSerializationContext", "WorkflowSerializationContext", diff --git a/temporalio/converter/_data_converter.py b/temporalio/converter/_data_converter.py index 823d1cc13..c9ac8ad06 100644 --- a/temporalio/converter/_data_converter.py +++ b/temporalio/converter/_data_converter.py @@ -29,6 +29,10 @@ from temporalio.converter._payload_converter import ( PayloadConverter, ) +from temporalio.converter._payload_handle import ( + _bind_data_converter, + _is_payload_handle_hint, +) from temporalio.converter._serialization_context import ( SerializationContext, WithSerializationContext, @@ -128,6 +132,31 @@ async def decode( Returns: Decoded and converted values. """ + # Positions annotated as PayloadHandle defer acquisition: keep their + # opaque payload and skip eager external-storage retrieval + codec + # decode so the produced handle can materialize on demand. The handle + # binds to this (context-applied) converter so materialize() uses the + # correct serialization context and codec. + if type_hints is not None and any( + _is_payload_handle_hint(h) for h in type_hints + ): + payloads = list(payloads) + transform_indexes = [ + i + for i in range(len(payloads)) + if i >= len(type_hints) or not _is_payload_handle_hint(type_hints[i]) + ] + to_transform = [payloads[i] for i in transform_indexes] + if to_transform: + transformed = await self._external_retrieve_payload_sequence( + to_transform + ) + transformed = await self._decode_payload_sequence(transformed) + for i, payload in zip(transform_indexes, transformed): + payloads[i] = payload + with _bind_data_converter(self): + return self.payload_converter.from_payloads(payloads, type_hints) + payloads = await self._external_retrieve_payload_sequence(payloads) payloads = await self._decode_payload_sequence(payloads) return self.payload_converter.from_payloads(payloads, type_hints) diff --git a/temporalio/converter/_payload_converter.py b/temporalio/converter/_payload_converter.py index 8ee85ef72..7a8758b79 100644 --- a/temporalio/converter/_payload_converter.py +++ b/temporalio/converter/_payload_converter.py @@ -45,6 +45,12 @@ if sys.version_info >= (3, 11): from enum import StrEnum # type: ignore[reportUnreachable] +from temporalio.converter._payload_handle import ( + PayloadHandle, + _create_handle, + _is_payload_handle_hint, + _payload_handle_inner_type, +) from temporalio.converter._serialization_context import ( SerializationContext, WithSerializationContext, @@ -261,6 +267,11 @@ def to_payloads( # RawValue should just pass through if isinstance(value, temporalio.common.RawValue): payload = value.payload + # A PayloadHandle re-emits its opaque payload unchanged, so + # forwarding it (e.g. workflow -> activity) neither downloads nor + # re-stores the underlying data. + elif isinstance(value, PayloadHandle): + payload = value._payload else: for converter in self.converters.values(): payload = converter.to_payload(value) @@ -293,6 +304,15 @@ def from_payloads( if type_hint == temporalio.common.RawValue: values.append(temporalio.common.RawValue(payload)) continue + # A PayloadHandle[T] hint defers acquisition: wrap the opaque payload + # regardless of its encoding rather than materializing it now. The + # handle binds to the current boundary converter (via contextvar) if + # one is set, else it is forward-only. + if type_hint is not None and _is_payload_handle_hint(type_hint): + values.append( + _create_handle(payload, _payload_handle_inner_type(type_hint)) + ) + continue encoding = payload.metadata.get("encoding", b"") converter = self.converters.get(encoding) if converter is None: diff --git a/temporalio/converter/_payload_handle.py b/temporalio/converter/_payload_handle.py new file mode 100644 index 000000000..5a5c2ed8a --- /dev/null +++ b/temporalio/converter/_payload_handle.py @@ -0,0 +1,151 @@ +"""Payload handles: lazy, pass-by-reference payload values. + +A :py:class:`PayloadHandle` is used as a parameter or return annotation +(``PayloadHandle[T]``) to defer *acquiring* a value -- external-storage +retrieval, codec decoding, and deserialization -- until it is explicitly +awaited via :py:meth:`PayloadHandle.materialize`. Until then the handle just +carries the opaque, end-of-pipeline payload and can be forwarded (e.g. from a +workflow to an activity) without paying to materialize it. + +This mirrors :py:class:`temporalio.common.RawValue`: the annotation, not any +wire encoding, is what triggers handle behavior, so a handle works on any +already-stored payload in history and is replay-safe. +""" + +from __future__ import annotations + +import contextvars +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import ( + TYPE_CHECKING, + Any, + Generic, + Iterator, + Optional, + cast, + get_args, + get_origin, +) + +import temporalio.api.common.v1 +from temporalio.types import AnyType + +if TYPE_CHECKING: + from temporalio.converter._data_converter import DataConverter + + +# The data converter needed to materialize a handle only exists at the async +# worker/client boundary (never inside the workflow sandbox, where I/O is +# forbidden). Boundary decodes publish it here so handles built during +# conversion can bind to it; the sandbox leaves it unset, yielding forward-only +# handles. +_current_data_converter: contextvars.ContextVar[Optional[DataConverter]] = ( + contextvars.ContextVar("_temporal_payload_handle_data_converter", default=None) +) + + +@contextmanager +def _bind_data_converter(data_converter: DataConverter) -> Iterator[None]: + """Bind the data converter used by handles created within this context.""" + token = _current_data_converter.set(data_converter) + try: + yield + finally: + _current_data_converter.reset(token) + + +@dataclass(frozen=True) +class PayloadHandle(Generic[AnyType]): + """A lazy, immutable, pass-by-reference handle to a payload value. + + Annotate a workflow/activity/signal parameter or return value as + ``PayloadHandle[T]`` to receive one of these instead of the materialized + value. Forward it onward without cost, or call + :py:meth:`materialize` where the value is actually needed. + """ + + # The opaque end-of-pipeline payload (may be an external-storage reference + # or a codec-encoded inline payload). Kept private: the handle is + # backing-agnostic and exposes nothing about how the value is stored. + _payload: temporalio.api.common.v1.Payload + # Inner type ``T`` captured from the annotation, used as the decode hint at + # materialize time. May be None for a bare ``PayloadHandle`` annotation. + _type: Optional[type] = field(default=None, compare=False) + # Set only for handles created at the async boundary; None => forward-only. + _data_converter: Optional[DataConverter] = field( + default=None, compare=False, repr=False + ) + + async def materialize(self) -> AnyType: + """Acquire and return the underlying value. + + Runs the deferred inbound pipeline (external-storage retrieval if + offloaded, codec decoding, then deserialization into the real type + ``T`` captured from the ``PayloadHandle[T]`` annotation). The return type + is that ``T`` -- annotate handles as ``PayloadHandle[T]`` so callers keep + full type information rather than an untyped value. + + Raises: + RuntimeError: if the handle is forward-only (e.g. received inside a + workflow, where acquisition I/O is not permitted), or if it + carries no real type (a bare ``PayloadHandle`` annotation), since + payload conversion needs a concrete type. + """ + data_converter = self._data_converter + if data_converter is None: + raise RuntimeError( + "[TMPRL1106] PayloadHandle is forward-only in this context " + "(such as inside a workflow) and cannot be materialized. Forward " + "it to an activity, or materialize it from client code, instead." + ) + if self._type is None: + raise RuntimeError( + "[TMPRL1106] PayloadHandle has no type to materialize into. " + "Annotate the value as PayloadHandle[T] with a concrete type T." + ) + # Reuse the standard inbound transform (retrieve -> codec-decode) that + # eager decoding would have applied, then deserialize to the real type. + payload = await data_converter._transform_inbound_payload(self._payload) + [value] = data_converter.payload_converter.from_payloads( + [payload], [self._type] + ) + return cast(AnyType, value) + + def __getstate__(self) -> object: + """Pickle support (workflow sandbox caching). + + Excludes the bound data converter so a rehydrated handle is forward-only, + reinforcing that materialization never happens on the sandbox side. + """ + return {"payload": self._payload.SerializeToString(), "type": self._type} + + def __setstate__(self, state: object) -> None: + """Pickle support.""" + if not isinstance(state, dict): + raise TypeError(f"Expected dict state, got {type(state)}") + object.__setattr__( + self, + "_payload", + temporalio.api.common.v1.Payload.FromString(state["payload"]), + ) + object.__setattr__(self, "_type", state.get("type")) + object.__setattr__(self, "_data_converter", None) + + +def _is_payload_handle_hint(hint: Any) -> bool: + """Return True if a type hint is ``PayloadHandle`` or ``PayloadHandle[T]``.""" + return hint is PayloadHandle or get_origin(hint) is PayloadHandle + + +def _payload_handle_inner_type(hint: Any) -> Optional[type]: + """Return ``T`` from ``PayloadHandle[T]``, or None for a bare hint.""" + args = get_args(hint) + return args[0] if args else None + + +def _create_handle( + payload: temporalio.api.common.v1.Payload, inner_type: Optional[type] +) -> PayloadHandle[Any]: + """Build a handle, binding it to the current boundary converter if any.""" + return PayloadHandle(payload, inner_type, _current_data_converter.get()) diff --git a/temporalio/worker/_workflow.py b/temporalio/worker/_workflow.py index b9513068d..7e58d2be1 100644 --- a/temporalio/worker/_workflow.py +++ b/temporalio/worker/_workflow.py @@ -23,6 +23,7 @@ import temporalio.common import temporalio.converter import temporalio.converter._extstore +import temporalio.converter._payload_handle import temporalio.exceptions import temporalio.workflow from temporalio.bridge.worker import PollShutdownError @@ -288,6 +289,34 @@ def run_inline() -> None: loop.call_soon(run_inline) return await future + def _deferred_run_arg_payloads( + self, + init_job: temporalio.bridge.proto.workflow_activation.InitializeWorkflow | None, + ) -> set[bytes] | None: + """Deterministic serializations of run args annotated as PayloadHandle. + + These defer external-storage retrieval so the workflow can forward them + without downloading. This is the prototype's run-args-only plumbing; + other activation positions (activity/child results, signal args) still + retrieve eagerly. A production version would thread this decision + through the payload visitor for every position. + """ + if not init_job: + return None + defn = self._workflows.get(init_job.workflow_type) + if not defn or not defn.arg_types: + return None + arg_types = defn.arg_types + payloads = { + payload.SerializeToString(deterministic=True) + for i, payload in enumerate(init_job.arguments) + if i < len(arg_types) + and temporalio.converter._payload_handle._is_payload_handle_hint( + arg_types[i] + ) + } + return payloads or None + async def _handle_activation( self, act: temporalio.bridge.proto.workflow_activation.WorkflowActivation ) -> None: @@ -369,6 +398,7 @@ async def _handle_activation( data_converter, decode_headers=self._encode_headers, storage_concurrency_limit=self._max_workflow_task_external_storage_concurrency, + defer_retrieval_payloads=self._deferred_run_arg_payloads(init_job), ) if not workflow: assert init_job diff --git a/tests/test_payload_handle.py b/tests/test_payload_handle.py new file mode 100644 index 000000000..f39b9a964 --- /dev/null +++ b/tests/test_payload_handle.py @@ -0,0 +1,143 @@ +"""Unit tests for PayloadHandle (Phase 1 prototype), server-free. + +These exercise the converter-level behavior: a PayloadHandle[T] annotation +defers acquisition (external-storage retrieval, codec decode, deserialization) +until materialize() is awaited, and forwarding a handle re-emits its opaque +payload without downloading. The proof is the driver's retrieve-call count. +""" + +from __future__ import annotations + +import pickle +from collections.abc import Sequence + +import pytest + +from temporalio.api.common.v1 import Payload +from temporalio.converter import ( + DataConverter, + ExternalStorage, + PayloadCodec, + PayloadHandle, +) +from tests.test_extstore import InMemoryTestDriver + +# A value large enough to be worth offloading; threshold=0 offloads everything. +_BIG = "x" * 1000 + + +def _storage_converter( + driver: InMemoryTestDriver, codec: PayloadCodec | None = None +) -> DataConverter: + return DataConverter( + payload_codec=codec, + external_storage=ExternalStorage(drivers=[driver], payload_size_threshold=0), + ) + + +async def test_toplevel_reference_becomes_bound_handle() -> None: + driver = InMemoryTestDriver() + dc = _storage_converter(driver) + + payloads = await dc.encode([_BIG]) + assert driver._store_calls == 1 + + [handle] = await dc.decode(payloads, [PayloadHandle[str]]) + assert isinstance(handle, PayloadHandle) + # No download happened just by receiving the handle. + assert driver._retrieve_calls == 0 + + assert await handle.materialize() == _BIG + assert driver._retrieve_calls == 1 + + +async def test_forward_only_handle_roundtrips_without_download() -> None: + driver = InMemoryTestDriver() + dc = _storage_converter(driver) + [reference] = await dc.encode([_BIG]) + + # Decoding through the bare payload converter (no boundary binding, as in + # the workflow sandbox) yields a forward-only handle. + [handle] = dc.payload_converter.from_payloads([reference], [PayloadHandle[str]]) + assert isinstance(handle, PayloadHandle) + + with pytest.raises(RuntimeError, match="forward-only"): + await handle.materialize() + + # Forwarding re-emits a byte-identical reference payload. + [out] = dc.payload_converter.to_payloads([handle]) + assert out.SerializeToString() == reference.SerializeToString() + assert driver._retrieve_calls == 0 + + +async def test_non_handle_annotation_is_eager() -> None: + driver = InMemoryTestDriver() + dc = _storage_converter(driver) + payloads = await dc.encode([_BIG]) + + # Default behavior is unchanged: a real-type hint materializes eagerly. + [value] = await dc.decode(payloads, [str]) + assert value == _BIG + assert not isinstance(value, PayloadHandle) + assert driver._retrieve_calls == 1 + + +async def test_tmprl1105_preserved_for_non_handle() -> None: + driver = InMemoryTestDriver() + [reference] = await _storage_converter(driver).encode([_BIG]) + + # A reference decoded as a real type without storage still raises, exactly + # as before this feature. + with pytest.raises(RuntimeError, match="TMPRL1105"): + await DataConverter().decode([reference], [str]) + + +class _MarkerCodec(PayloadCodec): + """Reversible codec that prefixes data and counts decode calls.""" + + def __init__(self) -> None: + self.decode_calls = 0 + + async def encode(self, payloads: Sequence[Payload]) -> list[Payload]: + return [ + Payload(metadata=dict(p.metadata), data=b"C" + p.data) for p in payloads + ] + + async def decode(self, payloads: Sequence[Payload]) -> list[Payload]: + self.decode_calls += 1 + out = [] + for p in payloads: + data = p.data[1:] if p.data.startswith(b"C") else p.data + out.append(Payload(metadata=dict(p.metadata), data=data)) + return out + + +async def test_codec_deferred_until_materialize() -> None: + driver = InMemoryTestDriver() + codec = _MarkerCodec() + dc = _storage_converter(driver, codec=codec) + payloads = await dc.encode([_BIG]) + + [handle] = await dc.decode(payloads, [PayloadHandle[str]]) + # The reference is not codec-decoded when the handle is produced. + assert codec.decode_calls == 0 + + assert await handle.materialize() == _BIG + assert codec.decode_calls == 1 + + +async def test_pickled_handle_is_forward_only() -> None: + driver = InMemoryTestDriver() + dc = _storage_converter(driver) + payloads = await dc.encode([_BIG]) + [handle] = await dc.decode(payloads, [PayloadHandle[str]]) + + restored = pickle.loads(pickle.dumps(handle)) + assert isinstance(restored, PayloadHandle) + with pytest.raises(RuntimeError, match="forward-only"): + await restored.materialize() + # The opaque payload survives, so a rehydrated handle can still be forwarded. + # Compare with proto equality: re-parsing may reorder the metadata map, so + # serialized bytes are not a reliable equality check here. + [out] = dc.payload_converter.to_payloads([restored]) + assert out == payloads[0] diff --git a/tests/worker/test_payload_handle.py b/tests/worker/test_payload_handle.py new file mode 100644 index 000000000..068caf4c3 --- /dev/null +++ b/tests/worker/test_payload_handle.py @@ -0,0 +1,122 @@ +"""End-to-end tests for PayloadHandle (Phase 1 prototype). + +Demonstrates the headline behavior: a workflow whose run argument is annotated +``PayloadHandle[T]`` receives a forward-only handle instead of an eagerly +downloaded value, forwards it to an activity without downloading, and the +activity materializes it on demand. The proof is the driver's retrieve count: +0 for pass-through (and on replay), exactly 1 when an activity materializes. +""" + +from __future__ import annotations + +import dataclasses +import uuid +from datetime import timedelta + +import temporalio.converter +from temporalio import activity, workflow +from temporalio.client import Client +from temporalio.converter import ExternalStorage, PayloadHandle +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Replayer +from tests.helpers import new_worker +from tests.test_extstore import InMemoryTestDriver + +# Larger than the offload threshold so the run argument is externalized. +_BIG = "x" * 2000 +_THRESHOLD = 1024 + + +@activity.defn +async def consume_handle(data: PayloadHandle[str]) -> int: + # The activity needs the bytes, so it materializes on demand. + value = await data.materialize() + return len(value) + + +@activity.defn +async def ignore_handle(data: PayloadHandle[str]) -> str: + # Never materializes; the handle is just passed through. + return "ignored" + + +@workflow.defn +class ForwardToConsumeWorkflow: + @workflow.run + async def run(self, data: PayloadHandle[str]) -> int: + # Forward the handle to an activity without materializing it here. + return await workflow.execute_activity( + consume_handle, data, start_to_close_timeout=timedelta(seconds=30) + ) + + +@workflow.defn +class ForwardToIgnoreWorkflow: + @workflow.run + async def run(self, data: PayloadHandle[str]) -> str: + return await workflow.execute_activity( + ignore_handle, data, start_to_close_timeout=timedelta(seconds=30) + ) + + +def _data_converter(driver: InMemoryTestDriver) -> temporalio.converter.DataConverter: + return dataclasses.replace( + temporalio.converter.default(), + external_storage=ExternalStorage( + drivers=[driver], payload_size_threshold=_THRESHOLD + ), + ) + + +async def _client(env: WorkflowEnvironment, driver: InMemoryTestDriver) -> Client: + return await Client.connect( + env.client.service_client.config.target_host, + namespace=env.client.namespace, + data_converter=_data_converter(driver), + ) + + +async def test_activity_materializes_handle_once(env: WorkflowEnvironment) -> None: + driver = InMemoryTestDriver() + client = await _client(env, driver) + async with new_worker( + client, ForwardToConsumeWorkflow, activities=[consume_handle] + ) as worker: + # The caller sends the real value; the workflow opts to receive it as a + # handle, so pass via the loosely-typed args form. + result = await client.execute_workflow( + ForwardToConsumeWorkflow.run, + args=[_BIG], + id=f"wf-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + assert result == len(_BIG) + # Downloaded exactly once, at the point the activity materialized it. + assert driver._retrieve_calls == 1 + + +async def test_workflow_pass_through_no_download(env: WorkflowEnvironment) -> None: + driver = InMemoryTestDriver() + client = await _client(env, driver) + async with new_worker( + client, ForwardToIgnoreWorkflow, activities=[ignore_handle] + ) as worker: + handle = await client.start_workflow( + ForwardToIgnoreWorkflow.run, + args=[_BIG], + id=f"wf-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + assert await handle.result() == "ignored" + + # The workflow forwarded the handle without ever downloading it. + assert driver._retrieve_calls == 0 + + # Replaying the same history must also avoid any download. + history = await handle.fetch_history() + replay_result = await Replayer( + workflows=[ForwardToIgnoreWorkflow], + data_converter=_data_converter(driver), + ).replay_workflow(history, raise_on_replay_failure=True) + assert replay_result is not None + assert driver._retrieve_calls == 0 From b06da9c34fadd433eab2fbda8783d6d05e8b01bb Mon Sep 17 00:00:00 2001 From: jmaeagle99 <44687433+jmaeagle99@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:27:49 -0700 Subject: [PATCH 2/7] exp: return handles from execute apis --- temporalio/bridge/worker.py | 8 +- temporalio/converter/_payload_converter.py | 2 +- temporalio/converter/_payload_handle.py | 13 + temporalio/worker/_workflow.py | 88 +++-- temporalio/worker/_workflow_instance.py | 74 +++- .../worker/workflow_sandbox/_in_sandbox.py | 7 + temporalio/worker/workflow_sandbox/_runner.py | 17 + temporalio/workflow/__init__.py | 6 + temporalio/workflow/_activities.py | 319 ++++++++++++++++++ temporalio/workflow/_workflow_ops.py | 177 ++++++++++ tests/worker/test_payload_handle.py | 118 +++++++ 11 files changed, 799 insertions(+), 30 deletions(-) diff --git a/temporalio/bridge/worker.py b/temporalio/bridge/worker.py index 821fd5b04..dd9ad14c8 100644 --- a/temporalio/bridge/worker.py +++ b/temporalio/bridge/worker.py @@ -373,8 +373,12 @@ async def decode_activation( Returns: Metrics from any external storage retrieval operations that occurred. """ - retrieve = data_converter._external_retrieve_payload_sequence - decode = data_converter._decode_payload_sequence + retrieve: Callable[[Sequence[Payload]], Awaitable[list[Payload]]] = ( + data_converter._external_retrieve_payload_sequence + ) + decode: Callable[[Sequence[Payload]], Awaitable[list[Payload]]] = ( + data_converter._decode_payload_sequence + ) if defer_retrieval_payloads: retrieve = _skip_payloads(retrieve, defer_retrieval_payloads) decode = _skip_reference_payloads(decode) diff --git a/temporalio/converter/_payload_converter.py b/temporalio/converter/_payload_converter.py index 7a8758b79..d3a3f3184 100644 --- a/temporalio/converter/_payload_converter.py +++ b/temporalio/converter/_payload_converter.py @@ -297,7 +297,7 @@ def from_payloads( KeyError: Unknown payload encoding RuntimeError: Error during decode """ - values = [] + values: list[Any] = [] type_hints = type_hints or [] for index, (payload, type_hint) in enumerate(zip_longest(payloads, type_hints)): # Raw value should just wrap diff --git a/temporalio/converter/_payload_handle.py b/temporalio/converter/_payload_handle.py index 5a5c2ed8a..272064fde 100644 --- a/temporalio/converter/_payload_handle.py +++ b/temporalio/converter/_payload_handle.py @@ -144,6 +144,19 @@ def _payload_handle_inner_type(hint: Any) -> Optional[type]: return args[0] if args else None +def _payload_handle_hint(inner_type: Optional[type]) -> Any: + """Build a ``PayloadHandle[inner_type]`` hint (bare if ``inner_type`` is None). + + Used to upgrade a call's result type so an unchanged activity/child result + is consumed as a handle: the declared return type becomes the handle's ``T``. + """ + return ( + PayloadHandle[inner_type] # type: ignore[valid-type] + if inner_type is not None + else PayloadHandle + ) + + def _create_handle( payload: temporalio.api.common.v1.Payload, inner_type: Optional[type] ) -> PayloadHandle[Any]: diff --git a/temporalio/worker/_workflow.py b/temporalio/worker/_workflow.py index 7e58d2be1..45b2ce444 100644 --- a/temporalio/worker/_workflow.py +++ b/temporalio/worker/_workflow.py @@ -14,8 +14,10 @@ from dataclasses import dataclass from datetime import timedelta, timezone from types import TracebackType +from typing import Any import temporalio.api.common.v1 +import temporalio.api.enums.v1.command_type_pb2 import temporalio.bridge.proto.workflow_activation import temporalio.bridge.proto.workflow_completion import temporalio.bridge.runtime @@ -289,33 +291,69 @@ def run_inline() -> None: loop.call_soon(run_inline) return await future - def _deferred_run_arg_payloads( + def _deferred_payloads( self, + act: temporalio.bridge.proto.workflow_activation.WorkflowActivation, init_job: temporalio.bridge.proto.workflow_activation.InitializeWorkflow | None, + workflow: _RunningWorkflow | None, ) -> set[bytes] | None: - """Deterministic serializations of run args annotated as PayloadHandle. - - These defer external-storage retrieval so the workflow can forward them - without downloading. This is the prototype's run-args-only plumbing; - other activation positions (activity/child results, signal args) still - retrieve eagerly. A production version would thread this decision - through the payload visitor for every position. + """Deterministic serializations of payloads whose external-storage + retrieval should be deferred, so the workflow receives forward-only + PayloadHandles it can forward without downloading. + + One unified skip set from two sources: + - Run args annotated ``PayloadHandle`` (from the workflow definition). + - Activity / local-activity / child-workflow results whose call requested + a ``PayloadHandle`` result type (looked up per seq on the running + instance via ``is_result_deferred``). + + Matched by deterministic serialization (see ``_skip_payloads``); this can + over-defer if two positions carry byte-identical offloaded payloads, + which is acceptable for the prototype. """ - if not init_job: - return None - defn = self._workflows.get(init_job.workflow_type) - if not defn or not defn.arg_types: - return None - arg_types = defn.arg_types - payloads = { - payload.SerializeToString(deterministic=True) - for i, payload in enumerate(init_job.arguments) - if i < len(arg_types) - and temporalio.converter._payload_handle._is_payload_handle_hint( - arg_types[i] - ) - } - return payloads or None + is_handle = temporalio.converter._payload_handle._is_payload_handle_hint + deferred: set[bytes] = set() + + # Run args: present on the first activation and again on replay. + if init_job: + defn = self._workflows.get(init_job.workflow_type) + if defn and defn.arg_types: + for i, payload in enumerate(init_job.arguments): + if i < len(defn.arg_types) and is_handle(defn.arg_types[i]): + deferred.add(payload.SerializeToString(deterministic=True)) + + # Results resolved this activation. The instance exists whenever a + # resolve is present (the schedule happened on a prior task). + if workflow is not None: + instance = workflow.instance + command_type = temporalio.api.enums.v1.command_type_pb2.CommandType + result: Any + info: _command_aware_visitor.CommandInfo + for job in act.jobs: + if job.HasField("resolve_activity"): + result = job.resolve_activity.result + info = _command_aware_visitor.CommandInfo( + command_type=command_type.COMMAND_TYPE_SCHEDULE_ACTIVITY_TASK, + command_seq=job.resolve_activity.seq, + ) + elif job.HasField("resolve_child_workflow_execution"): + result = job.resolve_child_workflow_execution.result + info = _command_aware_visitor.CommandInfo( + command_type=command_type.COMMAND_TYPE_START_CHILD_WORKFLOW_EXECUTION, + command_seq=job.resolve_child_workflow_execution.seq, + ) + else: + continue + if ( + result.HasField("completed") + and result.completed.HasField("result") + and instance.is_result_deferred(info) + ): + deferred.add( + result.completed.result.SerializeToString(deterministic=True) + ) + + return deferred or None async def _handle_activation( self, act: temporalio.bridge.proto.workflow_activation.WorkflowActivation @@ -398,7 +436,9 @@ async def _handle_activation( data_converter, decode_headers=self._encode_headers, storage_concurrency_limit=self._max_workflow_task_external_storage_concurrency, - defer_retrieval_payloads=self._deferred_run_arg_payloads(init_job), + defer_retrieval_payloads=self._deferred_payloads( + act, init_job, workflow + ), ) if not workflow: assert init_job diff --git a/temporalio/worker/_workflow_instance.py b/temporalio/worker/_workflow_instance.py index 726ff85e0..d5536e805 100644 --- a/temporalio/worker/_workflow_instance.py +++ b/temporalio/worker/_workflow_instance.py @@ -56,6 +56,7 @@ import temporalio.bridge.proto.workflow_completion import temporalio.common import temporalio.converter +import temporalio.converter._payload_handle import temporalio.exceptions import temporalio.nexus.system import temporalio.workflow @@ -82,6 +83,33 @@ logger = logging.getLogger(__name__) + +def _result_ret_type( + result_type: type | None, declared_ret_type: type | None +) -> type | None: + """Resolve the decode type for a callable activity/child result. + + Normally the callable's declared return type. If the caller opted to consume + the result as a handle (``result_type`` is a ``PayloadHandle`` hint, e.g. via + ``execute_activity_as_handle``), keep an explicit inner type if one was given, + else wrap the declared return type as ``PayloadHandle[declared]``. This lets a + workflow upgrade an unchanged activity's result to a handle. + """ + if ( + result_type is not None + and temporalio.converter._payload_handle._is_payload_handle_hint(result_type) + ): + if ( + temporalio.converter._payload_handle._payload_handle_inner_type(result_type) + is not None + ): + return result_type + return temporalio.converter._payload_handle._payload_handle_hint( + declared_ret_type + ) + return declared_ret_type + + # Set to true to log all cases where we're ignoring things during delete LOG_IGNORE_DURING_DELETE = False @@ -222,6 +250,19 @@ def get_external_store_context( """ raise NotImplementedError + def is_result_deferred( + self, + command_info: _command_aware_visitor.CommandInfo | None, + ) -> bool: + """Whether the resolved result for this command should stay a handle. + + Not abstract: defaults to False (eager retrieval, the prior behavior). + Overridden to return True when the pending activity/child-workflow + requested its result as a ``PayloadHandle``, so the worker can skip + downloading an offloaded result the workflow only forwards. + """ + return False + @abstractmethod def get_info(self) -> temporalio.workflow.Info: """Return the workflow info for this instance.""" @@ -1517,7 +1558,7 @@ def workflow_start_activity( raise ValueError("Cannot invoke dynamic activity explicitly") name = defn.name arg_types = defn.arg_types - ret_type = defn.ret_type + ret_type = _result_ret_type(result_type, defn.ret_type) else: raise TypeError("Activity must be a string or callable") @@ -1581,7 +1622,7 @@ async def workflow_start_child_workflow( raise TypeError("Cannot invoke dynamic workflow explicitly") name = defn.name arg_types = defn.arg_types - ret_type = defn.ret_type + ret_type = _result_ret_type(result_type, defn.ret_type) else: raise TypeError("Workflow must be a string or callable") @@ -1637,7 +1678,7 @@ def workflow_start_local_activity( raise ValueError("Cannot invoke dynamic activity explicitly") name = defn.name arg_types = defn.arg_types - ret_type = defn.ret_type + ret_type = _result_ret_type(result_type, defn.ret_type) else: raise TypeError("Activity must be a string or callable") @@ -2266,6 +2307,33 @@ def _failure_converter_with_context( failure_converter = failure_converter.with_context(context) return failure_converter + def is_result_deferred( + self, + command_info: _command_aware_visitor.CommandInfo | None, + ) -> bool: + if command_info is None: + return False + command_type = temporalio.api.enums.v1.command_type_pb2.CommandType + seq = command_info.command_seq + handle: Any = None + if ( + command_info.command_type + == command_type.COMMAND_TYPE_SCHEDULE_ACTIVITY_TASK + ): + handle = self._pending_activities.get(seq) + elif ( + command_info.command_type + == command_type.COMMAND_TYPE_START_CHILD_WORKFLOW_EXECUTION + ): + handle = self._pending_child_workflows.get(seq) + if handle is None: + return False + ret_type = handle._input.ret_type + return ( + ret_type is not None + and temporalio.converter._payload_handle._is_payload_handle_hint(ret_type) + ) + def get_serialization_context( self, command_info: _command_aware_visitor.CommandInfo | None, diff --git a/temporalio/worker/workflow_sandbox/_in_sandbox.py b/temporalio/worker/workflow_sandbox/_in_sandbox.py index d18374899..4bd2b8230 100644 --- a/temporalio/worker/workflow_sandbox/_in_sandbox.py +++ b/temporalio/worker/workflow_sandbox/_in_sandbox.py @@ -96,3 +96,10 @@ def get_external_store_context( ) -> StorageDriverStoreContext: """Get store context for external storage.""" return self.instance.get_external_store_context(command_info) + + def is_result_deferred( + self, + command_info: _command_aware_visitor.CommandInfo | None, + ) -> bool: + """Whether the command's resolved result should stay a handle.""" + return self.instance.is_result_deferred(command_info) diff --git a/temporalio/worker/workflow_sandbox/_runner.py b/temporalio/worker/workflow_sandbox/_runner.py index 17f473d64..24d946ab8 100644 --- a/temporalio/worker/workflow_sandbox/_runner.py +++ b/temporalio/worker/workflow_sandbox/_runner.py @@ -229,3 +229,20 @@ def get_external_store_context( ) # type: ignore finally: self.importer.restriction_context.is_runtime = False + + def is_result_deferred( + self, + command_info: _command_aware_visitor.CommandInfo | None, + ) -> bool: + # Forward call to the sandboxed instance + self.importer.restriction_context.is_runtime = True + try: + self._run_code( + "with __temporal_importer.applied():\n" + " __temporal_deferred = __temporal_in_sandbox.is_result_deferred(__temporal_command_info)\n", + __temporal_importer=self.importer, + __temporal_command_info=command_info, + ) + return bool(self.globals_and_locals.pop("__temporal_deferred", False)) + finally: + self.importer.restriction_context.is_runtime = False diff --git a/temporalio/workflow/__init__.py b/temporalio/workflow/__init__.py index 3d5a65c77..13b78fa7c 100644 --- a/temporalio/workflow/__init__.py +++ b/temporalio/workflow/__init__.py @@ -38,9 +38,11 @@ LocalActivityConfig, _AsyncioTask, execute_activity, + execute_activity_as_handle, execute_activity_class, execute_activity_method, execute_local_activity, + execute_local_activity_as_handle, execute_local_activity_class, execute_local_activity_method, start_activity, @@ -156,6 +158,7 @@ all_handlers_finished, continue_as_new, execute_child_workflow, + execute_child_workflow_as_handle, get_dynamic_query_handler, get_dynamic_signal_handler, get_dynamic_update_handler, @@ -179,9 +182,11 @@ "ActivityHandle", "LocalActivityConfig", "execute_activity", + "execute_activity_as_handle", "execute_activity_class", "execute_activity_method", "execute_local_activity", + "execute_local_activity_as_handle", "execute_local_activity_class", "execute_local_activity_method", "start_activity", @@ -259,6 +264,7 @@ "all_handlers_finished", "continue_as_new", "execute_child_workflow", + "execute_child_workflow_as_handle", "get_dynamic_query_handler", "get_dynamic_signal_handler", "get_dynamic_update_handler", diff --git a/temporalio/workflow/_activities.py b/temporalio/workflow/_activities.py index ef883c016..9f74c4d01 100644 --- a/temporalio/workflow/_activities.py +++ b/temporalio/workflow/_activities.py @@ -8,6 +8,7 @@ import temporalio.bridge.proto.workflow_commands import temporalio.common +import temporalio.converter from ..types import ( AnyType, @@ -498,6 +499,180 @@ async def execute_activity( ) +# Overload for async no-param activity +@overload +async def execute_activity_as_handle( + activity: CallableAsyncNoParam[ReturnType], + *, + task_queue: str | None = None, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + heartbeat_timeout: timedelta | None = None, + retry_policy: temporalio.common.RetryPolicy | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + versioning_intent: VersioningIntent | None = None, + summary: str | None = None, + priority: temporalio.common.Priority = temporalio.common.Priority.default, +) -> temporalio.converter.PayloadHandle[ReturnType]: ... + + +# Overload for sync no-param activity +@overload +async def execute_activity_as_handle( + activity: CallableSyncNoParam[ReturnType], + *, + task_queue: str | None = None, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + heartbeat_timeout: timedelta | None = None, + retry_policy: temporalio.common.RetryPolicy | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + versioning_intent: VersioningIntent | None = None, + summary: str | None = None, + priority: temporalio.common.Priority = temporalio.common.Priority.default, +) -> temporalio.converter.PayloadHandle[ReturnType]: ... + + +# Overload for async single-param activity +@overload +async def execute_activity_as_handle( + activity: CallableAsyncSingleParam[ParamType, ReturnType], + arg: ParamType, + *, + task_queue: str | None = None, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + heartbeat_timeout: timedelta | None = None, + retry_policy: temporalio.common.RetryPolicy | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + versioning_intent: VersioningIntent | None = None, + summary: str | None = None, + priority: temporalio.common.Priority = temporalio.common.Priority.default, +) -> temporalio.converter.PayloadHandle[ReturnType]: ... + + +# Overload for sync single-param activity +@overload +async def execute_activity_as_handle( + activity: CallableSyncSingleParam[ParamType, ReturnType], + arg: ParamType, + *, + task_queue: str | None = None, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + heartbeat_timeout: timedelta | None = None, + retry_policy: temporalio.common.RetryPolicy | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + versioning_intent: VersioningIntent | None = None, + summary: str | None = None, + priority: temporalio.common.Priority = temporalio.common.Priority.default, +) -> temporalio.converter.PayloadHandle[ReturnType]: ... + + +# Overload for multi-param activity +@overload +async def execute_activity_as_handle( + activity: Callable[..., Awaitable[ReturnType]] | Callable[..., ReturnType], + *, + args: Sequence[Any], + task_queue: str | None = None, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + heartbeat_timeout: timedelta | None = None, + retry_policy: temporalio.common.RetryPolicy | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + versioning_intent: VersioningIntent | None = None, + summary: str | None = None, + priority: temporalio.common.Priority = temporalio.common.Priority.default, +) -> temporalio.converter.PayloadHandle[ReturnType]: ... + + +# Overload for string-name activity +@overload +async def execute_activity_as_handle( + activity: str, + arg: Any = temporalio.common._arg_unset, + *, + args: Sequence[Any] = [], + task_queue: str | None = None, + result_type: type | None = None, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + heartbeat_timeout: timedelta | None = None, + retry_policy: temporalio.common.RetryPolicy | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + versioning_intent: VersioningIntent | None = None, + summary: str | None = None, + priority: temporalio.common.Priority = temporalio.common.Priority.default, +) -> temporalio.converter.PayloadHandle[Any]: ... + + +async def execute_activity_as_handle( + activity: Any, + arg: Any = temporalio.common._arg_unset, + *, + args: Sequence[Any] = [], + task_queue: str | None = None, + result_type: type | None = None, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + heartbeat_timeout: timedelta | None = None, + retry_policy: temporalio.common.RetryPolicy | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + versioning_intent: VersioningIntent | None = None, + summary: str | None = None, + priority: temporalio.common.Priority = temporalio.common.Priority.default, +) -> Any: + """Execute an activity and receive its result as a lazy PayloadHandle. + + Identical to :py:func:`execute_activity` except the activity is left + unchanged and its result is delivered as a + :py:class:`temporalio.converter.PayloadHandle` of the declared return type. + If the result was offloaded to external storage, it is not downloaded into + the workflow; forward the handle to an activity (or materialize it there) to + avoid paying for data the workflow only routes. + + For a string activity name, pass ``result_type=PayloadHandle[T]`` to keep the + materialized type; otherwise the declared return type is used. + """ + handle_result_type = ( + result_type + if result_type is not None + and result_type is not temporalio.converter.PayloadHandle + else temporalio.converter.PayloadHandle + ) + return await _Runtime.current().workflow_start_activity( + activity, + *temporalio.common._arg_or_args(arg, args), + task_queue=task_queue, + result_type=handle_result_type, + schedule_to_close_timeout=schedule_to_close_timeout, + schedule_to_start_timeout=schedule_to_start_timeout, + start_to_close_timeout=start_to_close_timeout, + heartbeat_timeout=heartbeat_timeout, + retry_policy=retry_policy, + cancellation_type=cancellation_type, + activity_id=activity_id, + versioning_intent=versioning_intent, + summary=summary, + priority=priority, + ) + + # Overload for async no-param activity @overload def start_activity_class( @@ -1478,6 +1653,150 @@ async def execute_local_activity( ) +# Overload for async no-param local activity +@overload +async def execute_local_activity_as_handle( + activity: CallableAsyncNoParam[ReturnType], + *, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + retry_policy: temporalio.common.RetryPolicy | None = None, + local_retry_threshold: timedelta | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + summary: str | None = None, +) -> temporalio.converter.PayloadHandle[ReturnType]: ... + + +# Overload for sync no-param local activity +@overload +async def execute_local_activity_as_handle( + activity: CallableSyncNoParam[ReturnType], + *, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + retry_policy: temporalio.common.RetryPolicy | None = None, + local_retry_threshold: timedelta | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + summary: str | None = None, +) -> temporalio.converter.PayloadHandle[ReturnType]: ... + + +# Overload for async single-param local activity +@overload +async def execute_local_activity_as_handle( + activity: CallableAsyncSingleParam[ParamType, ReturnType], + arg: ParamType, + *, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + retry_policy: temporalio.common.RetryPolicy | None = None, + local_retry_threshold: timedelta | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + summary: str | None = None, +) -> temporalio.converter.PayloadHandle[ReturnType]: ... + + +# Overload for sync single-param local activity +@overload +async def execute_local_activity_as_handle( + activity: CallableSyncSingleParam[ParamType, ReturnType], + arg: ParamType, + *, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + retry_policy: temporalio.common.RetryPolicy | None = None, + local_retry_threshold: timedelta | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + summary: str | None = None, +) -> temporalio.converter.PayloadHandle[ReturnType]: ... + + +# Overload for multi-param local activity +@overload +async def execute_local_activity_as_handle( + activity: Callable[..., Awaitable[ReturnType]] | Callable[..., ReturnType], + *, + args: Sequence[Any], + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + retry_policy: temporalio.common.RetryPolicy | None = None, + local_retry_threshold: timedelta | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + summary: str | None = None, +) -> temporalio.converter.PayloadHandle[ReturnType]: ... + + +# Overload for string-name local activity +@overload +async def execute_local_activity_as_handle( + activity: str, + arg: Any = temporalio.common._arg_unset, + *, + args: Sequence[Any] = [], + result_type: type | None = None, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + retry_policy: temporalio.common.RetryPolicy | None = None, + local_retry_threshold: timedelta | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + summary: str | None = None, +) -> temporalio.converter.PayloadHandle[Any]: ... + + +async def execute_local_activity_as_handle( + activity: Any, + arg: Any = temporalio.common._arg_unset, + *, + args: Sequence[Any] = [], + result_type: type | None = None, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + retry_policy: temporalio.common.RetryPolicy | None = None, + local_retry_threshold: timedelta | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + summary: str | None = None, +) -> Any: + """Execute a local activity and receive its result as a lazy PayloadHandle. + + Like :py:func:`execute_local_activity` but the (unchanged) activity's result + is delivered as a :py:class:`temporalio.converter.PayloadHandle` of the + declared return type, avoiding an eager download of an offloaded result. + """ + handle_result_type = ( + result_type + if result_type is not None + and result_type is not temporalio.converter.PayloadHandle + else temporalio.converter.PayloadHandle + ) + return await _Runtime.current().workflow_start_local_activity( + activity, + *temporalio.common._arg_or_args(arg, args), + result_type=handle_result_type, + schedule_to_close_timeout=schedule_to_close_timeout, + schedule_to_start_timeout=schedule_to_start_timeout, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + local_retry_threshold=local_retry_threshold, + cancellation_type=cancellation_type, + activity_id=activity_id, + summary=summary, + ) + + # Overload for async no-param activity @overload def start_local_activity_class( diff --git a/temporalio/workflow/_workflow_ops.py b/temporalio/workflow/_workflow_ops.py index f80ca1bdb..2b20313d6 100644 --- a/temporalio/workflow/_workflow_ops.py +++ b/temporalio/workflow/_workflow_ops.py @@ -7,6 +7,7 @@ import temporalio.bridge.proto.child_workflow import temporalio.common +import temporalio.converter from ..types import ( MethodAsyncNoParam, @@ -548,6 +549,182 @@ async def execute_child_workflow( return await handle +# Overload for no-param child workflow +@overload +async def execute_child_workflow_as_handle( + workflow: MethodAsyncNoParam[SelfType, ReturnType], + *, + id: str | None = None, + task_queue: str | None = None, + cancellation_type: ChildWorkflowCancellationType = ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED, + parent_close_policy: ParentClosePolicy = ParentClosePolicy.TERMINATE, + execution_timeout: timedelta | None = None, + run_timeout: timedelta | None = None, + task_timeout: timedelta | None = None, + id_reuse_policy: temporalio.common.WorkflowIDReusePolicy = temporalio.common.WorkflowIDReusePolicy.ALLOW_DUPLICATE, + retry_policy: temporalio.common.RetryPolicy | None = None, + cron_schedule: str = "", + memo: Mapping[str, Any] | None = None, + search_attributes: None + | ( + temporalio.common.SearchAttributes | temporalio.common.TypedSearchAttributes + ) = None, + versioning_intent: VersioningIntent | None = None, + static_summary: str | None = None, + static_details: str | None = None, + priority: temporalio.common.Priority = temporalio.common.Priority.default, +) -> temporalio.converter.PayloadHandle[ReturnType]: ... + + +# Overload for single-param child workflow +@overload +async def execute_child_workflow_as_handle( + workflow: MethodAsyncSingleParam[SelfType, ParamType, ReturnType], + arg: ParamType, + *, + id: str | None = None, + task_queue: str | None = None, + cancellation_type: ChildWorkflowCancellationType = ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED, + parent_close_policy: ParentClosePolicy = ParentClosePolicy.TERMINATE, + execution_timeout: timedelta | None = None, + run_timeout: timedelta | None = None, + task_timeout: timedelta | None = None, + id_reuse_policy: temporalio.common.WorkflowIDReusePolicy = temporalio.common.WorkflowIDReusePolicy.ALLOW_DUPLICATE, + retry_policy: temporalio.common.RetryPolicy | None = None, + cron_schedule: str = "", + memo: Mapping[str, Any] | None = None, + search_attributes: None + | ( + temporalio.common.SearchAttributes | temporalio.common.TypedSearchAttributes + ) = None, + versioning_intent: VersioningIntent | None = None, + static_summary: str | None = None, + static_details: str | None = None, + priority: temporalio.common.Priority = temporalio.common.Priority.default, +) -> temporalio.converter.PayloadHandle[ReturnType]: ... + + +# Overload for multi-param child workflow +@overload +async def execute_child_workflow_as_handle( + workflow: Callable[Concatenate[SelfType, MultiParamSpec], Awaitable[ReturnType]], + *, + args: Sequence[Any], + id: str | None = None, + task_queue: str | None = None, + cancellation_type: ChildWorkflowCancellationType = ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED, + parent_close_policy: ParentClosePolicy = ParentClosePolicy.TERMINATE, + execution_timeout: timedelta | None = None, + run_timeout: timedelta | None = None, + task_timeout: timedelta | None = None, + id_reuse_policy: temporalio.common.WorkflowIDReusePolicy = temporalio.common.WorkflowIDReusePolicy.ALLOW_DUPLICATE, + retry_policy: temporalio.common.RetryPolicy | None = None, + cron_schedule: str = "", + memo: Mapping[str, Any] | None = None, + search_attributes: None + | ( + temporalio.common.SearchAttributes | temporalio.common.TypedSearchAttributes + ) = None, + versioning_intent: VersioningIntent | None = None, + static_summary: str | None = None, + static_details: str | None = None, + priority: temporalio.common.Priority = temporalio.common.Priority.default, +) -> temporalio.converter.PayloadHandle[ReturnType]: ... + + +# Overload for string-name child workflow +@overload +async def execute_child_workflow_as_handle( + workflow: str, + arg: Any = temporalio.common._arg_unset, + *, + args: Sequence[Any] = [], + id: str | None = None, + task_queue: str | None = None, + result_type: type | None = None, + cancellation_type: ChildWorkflowCancellationType = ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED, + parent_close_policy: ParentClosePolicy = ParentClosePolicy.TERMINATE, + execution_timeout: timedelta | None = None, + run_timeout: timedelta | None = None, + task_timeout: timedelta | None = None, + id_reuse_policy: temporalio.common.WorkflowIDReusePolicy = temporalio.common.WorkflowIDReusePolicy.ALLOW_DUPLICATE, + retry_policy: temporalio.common.RetryPolicy | None = None, + cron_schedule: str = "", + memo: Mapping[str, Any] | None = None, + search_attributes: None + | ( + temporalio.common.SearchAttributes | temporalio.common.TypedSearchAttributes + ) = None, + versioning_intent: VersioningIntent | None = None, + static_summary: str | None = None, + static_details: str | None = None, + priority: temporalio.common.Priority = temporalio.common.Priority.default, +) -> temporalio.converter.PayloadHandle[Any]: ... + + +async def execute_child_workflow_as_handle( + workflow: Any, + arg: Any = temporalio.common._arg_unset, + *, + args: Sequence[Any] = [], + id: str | None = None, + task_queue: str | None = None, + result_type: type | None = None, + cancellation_type: ChildWorkflowCancellationType = ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED, + parent_close_policy: ParentClosePolicy = ParentClosePolicy.TERMINATE, + execution_timeout: timedelta | None = None, + run_timeout: timedelta | None = None, + task_timeout: timedelta | None = None, + id_reuse_policy: temporalio.common.WorkflowIDReusePolicy = temporalio.common.WorkflowIDReusePolicy.ALLOW_DUPLICATE, + retry_policy: temporalio.common.RetryPolicy | None = None, + cron_schedule: str = "", + memo: Mapping[str, Any] | None = None, + search_attributes: None + | ( + temporalio.common.SearchAttributes | temporalio.common.TypedSearchAttributes + ) = None, + versioning_intent: VersioningIntent | None = None, + static_summary: str | None = None, + static_details: str | None = None, + priority: temporalio.common.Priority = temporalio.common.Priority.default, +) -> Any: + """Execute a child workflow and receive its result as a lazy PayloadHandle. + + Like :py:func:`execute_child_workflow` but the (unchanged) child's result is + delivered as a :py:class:`temporalio.converter.PayloadHandle` of the declared + return type, avoiding an eager download of an offloaded result. + """ + temporalio.common._warn_on_deprecated_search_attributes(search_attributes) + handle_result_type = ( + result_type + if result_type is not None + and result_type is not temporalio.converter.PayloadHandle + else temporalio.converter.PayloadHandle + ) + handle = await _Runtime.current().workflow_start_child_workflow( + workflow, + *temporalio.common._arg_or_args(arg, args), + id=id or str(uuid4()), + task_queue=task_queue, + result_type=handle_result_type, + cancellation_type=cancellation_type, + parent_close_policy=parent_close_policy, + execution_timeout=execution_timeout, + run_timeout=run_timeout, + task_timeout=task_timeout, + id_reuse_policy=id_reuse_policy, + retry_policy=retry_policy, + cron_schedule=cron_schedule, + memo=memo, + search_attributes=search_attributes, + versioning_intent=versioning_intent, + static_summary=static_summary, + static_details=static_details, + priority=priority, + ) + return await handle + + class ExternalWorkflowHandle(Generic[SelfType]): """Handle for interacting with an external workflow. diff --git a/tests/worker/test_payload_handle.py b/tests/worker/test_payload_handle.py index 068caf4c3..abf3a9ad2 100644 --- a/tests/worker/test_payload_handle.py +++ b/tests/worker/test_payload_handle.py @@ -59,6 +59,60 @@ async def run(self, data: PayloadHandle[str]) -> str: ) +@activity.defn +async def produce_big() -> str: + # An ordinary activity returning a large value; it is unchanged / unaware of + # handles. The value is offloaded to external storage on completion. + return _BIG + + +@workflow.defn +class ResultAsHandleConsumeWorkflow: + @workflow.run + async def run(self) -> int: + # Upgrade an unchanged activity's result to a handle, then forward it to + # an activity that materializes it. + handle = await workflow.execute_activity_as_handle( + produce_big, start_to_close_timeout=timedelta(seconds=30) + ) + return await workflow.execute_activity( + consume_handle, handle, start_to_close_timeout=timedelta(seconds=30) + ) + + +@workflow.defn +class ResultAsHandlePassThroughWorkflow: + @workflow.run + async def run(self) -> str: + handle = await workflow.execute_activity_as_handle( + produce_big, start_to_close_timeout=timedelta(seconds=30) + ) + return await workflow.execute_activity( + ignore_handle, handle, start_to_close_timeout=timedelta(seconds=30) + ) + + +@workflow.defn +class ChildProducerWorkflow: + @workflow.run + async def run(self) -> str: + return _BIG + + +@workflow.defn +class ParentChildResultAsHandleWorkflow: + @workflow.run + async def run(self) -> str: + # Upgrade an unchanged child workflow's result to a handle and forward it + # without materializing it in this (parent) workflow. + handle = await workflow.execute_child_workflow_as_handle( + ChildProducerWorkflow.run + ) + return await workflow.execute_activity( + ignore_handle, handle, start_to_close_timeout=timedelta(seconds=30) + ) + + def _data_converter(driver: InMemoryTestDriver) -> temporalio.converter.DataConverter: return dataclasses.replace( temporalio.converter.default(), @@ -120,3 +174,67 @@ async def test_workflow_pass_through_no_download(env: WorkflowEnvironment) -> No ).replay_workflow(history, raise_on_replay_failure=True) assert replay_result is not None assert driver._retrieve_calls == 0 + + +async def test_activity_result_as_handle_materializes_once( + env: WorkflowEnvironment, +) -> None: + driver = InMemoryTestDriver() + client = await _client(env, driver) + async with new_worker( + client, + ResultAsHandleConsumeWorkflow, + activities=[produce_big, consume_handle], + ) as worker: + result = await client.execute_workflow( + ResultAsHandleConsumeWorkflow.run, + id=f"wf-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + assert result == len(_BIG) + # The producing activity's result was offloaded, upgraded to a handle in the + # workflow (not downloaded there), and materialized exactly once downstream. + assert driver._retrieve_calls == 1 + + +async def test_activity_result_as_handle_pass_through_no_download( + env: WorkflowEnvironment, +) -> None: + driver = InMemoryTestDriver() + client = await _client(env, driver) + async with new_worker( + client, + ResultAsHandlePassThroughWorkflow, + activities=[produce_big, ignore_handle], + ) as worker: + result = await client.execute_workflow( + ResultAsHandlePassThroughWorkflow.run, + id=f"wf-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + assert result == "ignored" + # The unchanged activity's offloaded result was never downloaded: the + # workflow received it as a handle and forwarded it without materializing. + assert driver._retrieve_calls == 0 + + +async def test_child_workflow_result_as_handle_pass_through( + env: WorkflowEnvironment, +) -> None: + driver = InMemoryTestDriver() + client = await _client(env, driver) + async with new_worker( + client, + ParentChildResultAsHandleWorkflow, + ChildProducerWorkflow, + activities=[ignore_handle], + ) as worker: + result = await client.execute_workflow( + ParentChildResultAsHandleWorkflow.run, + id=f"wf-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + assert result == "ignored" + # The child workflow's offloaded result was upgraded to a handle in the + # parent and forwarded without ever being downloaded. + assert driver._retrieve_calls == 0 From 8075166e36431dd32587c0c38136809b22e83342 Mon Sep 17 00:00:00 2001 From: jmaeagle99 <44687433+jmaeagle99@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:34:10 -0700 Subject: [PATCH 3/7] exp: content-neutral deferal --- temporalio/bridge/worker.py | 72 +++++---------- temporalio/worker/_command_aware_visitor.py | 45 ++++++++++ temporalio/worker/_workflow.py | 98 +++++++++------------ 3 files changed, 108 insertions(+), 107 deletions(-) diff --git a/temporalio/bridge/worker.py b/temporalio/bridge/worker.py index dd9ad14c8..6252fe239 100644 --- a/temporalio/bridge/worker.py +++ b/temporalio/bridge/worker.py @@ -20,7 +20,6 @@ import temporalio.bridge.runtime import temporalio.bridge.temporal_sdk_bridge import temporalio.converter -import temporalio.converter._data_converter import temporalio.converter._extstore from temporalio.api.common.v1.message_pb2 import Payload from temporalio.bridge._visitor_functions import PayloadSequence, VisitorFunctions @@ -304,53 +303,23 @@ async def visit_payloads(self, payloads: PayloadSequence) -> None: payloads.extend(new_payloads) -def _skip_payloads( +def _skip_deferred( f: Callable[[Sequence[Payload]], Awaitable[list[Payload]]], - skip: set[bytes], + defer: Callable[[], bool], ) -> Callable[[Sequence[Payload]], Awaitable[list[Payload]]]: - """Wrap a transform so matching payloads pass through untransformed. + """Wrap a transform so payloads at a deferred position pass through. - Payloads are matched by deterministic serialization rather than object - identity: the payload visitor may hand out fresh wrapper objects for the - same underlying proto (e.g. under the upb implementation), so id() is not - stable, and deterministic serialization also neutralizes metadata-map - ordering. - """ - - def key(payload: Payload) -> bytes: - return payload.SerializeToString(deterministic=True) - - async def wrapped(payloads: Sequence[Payload]) -> list[Payload]: - to_transform = [p for p in payloads if key(p) not in skip] - if len(to_transform) == len(payloads): - return await f(payloads) - if not to_transform: - return list(payloads) - transformed = iter(await f(to_transform)) - return [p if key(p) in skip else next(transformed) for p in payloads] - - return wrapped - - -def _skip_reference_payloads( - f: Callable[[Sequence[Payload]], Awaitable[list[Payload]]], -) -> Callable[[Sequence[Payload]], Awaitable[list[Payload]]]: - """Wrap a transform so external-storage reference payloads pass through. - - References are never codec-encoded (they are created after codec-encode + - store on the way out), so codec-decoding one would be wrong. A reference - only survives to this point when its retrieval was deferred for a handle. + ``defer`` is a content-neutral predicate that reads the visitor's current + command / run-argument context (set during traversal) and returns True when + the payload(s) being visited should keep their opaque form -- i.e. skip + external-storage retrieval and codec decode so they surface as forward-only + handles. The decision never inspects the payload proto itself. """ async def wrapped(payloads: Sequence[Payload]) -> list[Payload]: - is_ref = temporalio.converter._data_converter._is_reference_payload - to_transform = [p for p in payloads if not is_ref(p)] - if len(to_transform) == len(payloads): - return await f(payloads) - if not to_transform: + if defer(): return list(payloads) - transformed = iter(await f(to_transform)) - return [p if is_ref(p) else next(transformed) for p in payloads] + return await f(payloads) return wrapped @@ -360,15 +329,18 @@ async def decode_activation( data_converter: temporalio.converter.DataConverter, decode_headers: bool, storage_concurrency_limit: int, - defer_retrieval_payloads: set[bytes] | None = None, + defer: Callable[[], bool] | None = None, + index_run_args: bool = False, ) -> temporalio.converter._extstore.StorageOperationMetrics: """Decode all payloads in the activation. Args: - defer_retrieval_payloads: deterministic serializations of payloads - (workflow run args annotated as PayloadHandle) whose external-storage - retrieval and codec decode should be skipped so they surface as - forward-only handles inside the workflow. + defer: content-neutral predicate (reads the visitor's current command / + run-argument context) returning True when the payload(s) being + visited should be left un-retrieved and un-codec-decoded so they + surface as forward-only PayloadHandles. + index_run_args: visit run arguments one at a time so ``defer`` can decide + per argument position (only needed when a run arg is a PayloadHandle). Returns: Metrics from any external storage retrieval operations that occurred. @@ -379,9 +351,9 @@ async def decode_activation( decode: Callable[[Sequence[Payload]], Awaitable[list[Payload]]] = ( data_converter._decode_payload_sequence ) - if defer_retrieval_payloads: - retrieve = _skip_payloads(retrieve, defer_retrieval_payloads) - decode = _skip_reference_payloads(decode) + if defer is not None: + retrieve = _skip_deferred(retrieve, defer) + decode = _skip_deferred(decode, defer) metrics = temporalio.converter._extstore.StorageOperationMetrics() with metrics.track(): @@ -389,11 +361,13 @@ async def decode_activation( skip_search_attributes=True, skip_headers=not decode_headers, concurrency_limit=storage_concurrency_limit, + index_run_args=index_run_args, ).visit(_Visitor(retrieve), activation) await CommandAwarePayloadVisitor( skip_search_attributes=True, skip_headers=not decode_headers, + index_run_args=index_run_args, ).visit(_Visitor(decode), activation) return metrics diff --git a/temporalio/worker/_command_aware_visitor.py b/temporalio/worker/_command_aware_visitor.py index 500fc4db5..65ab40db8 100644 --- a/temporalio/worker/_command_aware_visitor.py +++ b/temporalio/worker/_command_aware_visitor.py @@ -9,6 +9,7 @@ from temporalio.bridge._visitor import PayloadVisitor from temporalio.bridge._visitor_functions import VisitorFunctions from temporalio.bridge.proto.workflow_activation.workflow_activation_pb2 import ( + InitializeWorkflow, ResolveActivity, ResolveChildWorkflowExecution, ResolveChildWorkflowExecutionStart, @@ -39,6 +40,13 @@ class CommandInfo: contextvars.ContextVar("current_command_info", default=None) ) +# Set to the positional index of a workflow run argument while that argument is +# being visited. Lets deferral be decided per argument position (content-neutral) +# rather than by inspecting the payload. +current_run_arg_index: contextvars.ContextVar[int | None] = contextvars.ContextVar( + "current_run_arg_index", default=None +) + class CommandAwarePayloadVisitor(PayloadVisitor): """Payload visitor that sets command context during traversal. @@ -53,6 +61,7 @@ def __init__( skip_search_attributes: bool = False, skip_headers: bool = False, concurrency_limit: int = 1, + index_run_args: bool = False, ) -> None: """Creates a new command-aware payload visitor. @@ -61,12 +70,48 @@ def __init__( skip_headers: If True, headers are not visited. concurrency_limit: Maximum number of payload visits that may run concurrently during a single call to visit(). Defaults to 1. + index_run_args: If True, workflow run arguments are visited one at a + time with :py:data:`current_run_arg_index` set, so retrieval can + be deferred per argument position. Left False (batched) unless a + run argument is consumed as a PayloadHandle. """ super().__init__( skip_search_attributes=skip_search_attributes, skip_headers=skip_headers, concurrency_limit=concurrency_limit, ) + self._index_run_args = index_run_args + + async def _visit_coresdk_workflow_activation_InitializeWorkflow( + self, fs: VisitorFunctions, o: InitializeWorkflow + ) -> None: + if not self._index_run_args: + await super()._visit_coresdk_workflow_activation_InitializeWorkflow(fs, o) + return + # Visit each run argument individually so per-argument-position deferral + # can be decided, then visit the remaining fields normally. Keep the + # "remaining fields" list in sync with the generated base method. + for index, argument in enumerate(o.arguments): + token = current_run_arg_index.set(index) + try: + await self._visit_temporal_api_common_v1_Payload(fs, argument) + finally: + current_run_arg_index.reset(token) + if not self.skip_headers: + for header in o.headers.values(): + await self._visit_temporal_api_common_v1_Payload(fs, header) + if o.HasField("continued_failure"): + await self._visit_temporal_api_failure_v1_Failure(fs, o.continued_failure) + if o.HasField("last_completion_result"): + await self._visit_temporal_api_common_v1_Payloads( + fs, o.last_completion_result + ) + if o.HasField("memo"): + await self._visit_temporal_api_common_v1_Memo(fs, o.memo) + if o.HasField("search_attributes"): + await self._visit_temporal_api_common_v1_SearchAttributes( + fs, o.search_attributes + ) # Workflow commands with payloads async def _visit_coresdk_workflow_commands_CompleteWorkflowExecution( diff --git a/temporalio/worker/_workflow.py b/temporalio/worker/_workflow.py index 45b2ce444..138854d7a 100644 --- a/temporalio/worker/_workflow.py +++ b/temporalio/worker/_workflow.py @@ -14,7 +14,6 @@ from dataclasses import dataclass from datetime import timedelta, timezone from types import TracebackType -from typing import Any import temporalio.api.common.v1 import temporalio.api.enums.v1.command_type_pb2 @@ -291,69 +290,52 @@ def run_inline() -> None: loop.call_soon(run_inline) return await future - def _deferred_payloads( + def _defer_retrieval( self, - act: temporalio.bridge.proto.workflow_activation.WorkflowActivation, init_job: temporalio.bridge.proto.workflow_activation.InitializeWorkflow | None, workflow: _RunningWorkflow | None, - ) -> set[bytes] | None: - """Deterministic serializations of payloads whose external-storage - retrieval should be deferred, so the workflow receives forward-only - PayloadHandles it can forward without downloading. - - One unified skip set from two sources: - - Run args annotated ``PayloadHandle`` (from the workflow definition). - - Activity / local-activity / child-workflow results whose call requested - a ``PayloadHandle`` result type (looked up per seq on the running - instance via ``is_result_deferred``). - - Matched by deterministic serialization (see ``_skip_payloads``); this can - over-defer if two positions carry byte-identical offloaded payloads, - which is acceptable for the prototype. + ) -> tuple[Callable[[], bool] | None, bool]: + """Build a content-neutral deferral predicate for ``decode_activation``. + + The predicate reads the payload visitor's current context (run-argument + index, or command seq for a resolved activity/child result) and returns + True when that position's payload should stay a forward-only PayloadHandle + -- so retrieval and codec decode are skipped. It decides purely from + API-declared types by position, never by inspecting the payload proto. + + Returns the predicate (or None if nothing is deferrable) and whether run + arguments must be visited per-index (only when a run arg is a handle). """ is_handle = temporalio.converter._payload_handle._is_payload_handle_hint - deferred: set[bytes] = set() - - # Run args: present on the first activation and again on replay. + run_arg_indices: set[int] = set() if init_job: defn = self._workflows.get(init_job.workflow_type) if defn and defn.arg_types: - for i, payload in enumerate(init_job.arguments): - if i < len(defn.arg_types) and is_handle(defn.arg_types[i]): - deferred.add(payload.SerializeToString(deterministic=True)) - - # Results resolved this activation. The instance exists whenever a - # resolve is present (the schedule happened on a prior task). - if workflow is not None: - instance = workflow.instance - command_type = temporalio.api.enums.v1.command_type_pb2.CommandType - result: Any - info: _command_aware_visitor.CommandInfo - for job in act.jobs: - if job.HasField("resolve_activity"): - result = job.resolve_activity.result - info = _command_aware_visitor.CommandInfo( - command_type=command_type.COMMAND_TYPE_SCHEDULE_ACTIVITY_TASK, - command_seq=job.resolve_activity.seq, - ) - elif job.HasField("resolve_child_workflow_execution"): - result = job.resolve_child_workflow_execution.result - info = _command_aware_visitor.CommandInfo( - command_type=command_type.COMMAND_TYPE_START_CHILD_WORKFLOW_EXECUTION, - command_seq=job.resolve_child_workflow_execution.seq, - ) - else: - continue - if ( - result.HasField("completed") - and result.completed.HasField("result") - and instance.is_result_deferred(info) - ): - deferred.add( - result.completed.result.SerializeToString(deterministic=True) - ) + run_arg_indices = { + i for i, t in enumerate(defn.arg_types) if is_handle(t) + } + + if not run_arg_indices and workflow is None: + return None, False + + instance = workflow.instance if workflow is not None else None + command_type = temporalio.api.enums.v1.command_type_pb2.CommandType + result_command_types = { + command_type.COMMAND_TYPE_SCHEDULE_ACTIVITY_TASK, + command_type.COMMAND_TYPE_START_CHILD_WORKFLOW_EXECUTION, + } + + def defer() -> bool: + arg_index = _command_aware_visitor.current_run_arg_index.get() + if arg_index is not None: + return arg_index in run_arg_indices + if instance is not None: + info = _command_aware_visitor.current_command_info.get() + if info is not None and info.command_type in result_command_types: + return instance.is_result_deferred(info) + return False - return deferred or None + return defer, bool(run_arg_indices) async def _handle_activation( self, act: temporalio.bridge.proto.workflow_activation.WorkflowActivation @@ -431,14 +413,14 @@ async def _handle_activation( workflow_context_dc=data_converter, workflow_context=workflow_context, ) + defer_predicate, index_run_args = self._defer_retrieval(init_job, workflow) download_metrics = await temporalio.bridge.worker.decode_activation( act, data_converter, decode_headers=self._encode_headers, storage_concurrency_limit=self._max_workflow_task_external_storage_concurrency, - defer_retrieval_payloads=self._deferred_payloads( - act, init_job, workflow - ), + defer=defer_predicate, + index_run_args=index_run_args, ) if not workflow: assert init_job From fa81cb792723aedc08885b8a8dcea7046a29bc32 Mon Sep 17 00:00:00 2001 From: jmaeagle99 <44687433+jmaeagle99@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:13:11 -0700 Subject: [PATCH 4/7] exp: retyping handle with PayloadHandle --- temporalio/worker/_workflow_instance.py | 44 ++-- temporalio/workflow/__init__.py | 6 - temporalio/workflow/_activities.py | 332 +----------------------- temporalio/workflow/_workflow_ops.py | 189 +------------- tests/worker/test_payload_handle.py | 13 +- 5 files changed, 47 insertions(+), 537 deletions(-) diff --git a/temporalio/worker/_workflow_instance.py b/temporalio/worker/_workflow_instance.py index d5536e805..473392e83 100644 --- a/temporalio/worker/_workflow_instance.py +++ b/temporalio/worker/_workflow_instance.py @@ -84,32 +84,6 @@ logger = logging.getLogger(__name__) -def _result_ret_type( - result_type: type | None, declared_ret_type: type | None -) -> type | None: - """Resolve the decode type for a callable activity/child result. - - Normally the callable's declared return type. If the caller opted to consume - the result as a handle (``result_type`` is a ``PayloadHandle`` hint, e.g. via - ``execute_activity_as_handle``), keep an explicit inner type if one was given, - else wrap the declared return type as ``PayloadHandle[declared]``. This lets a - workflow upgrade an unchanged activity's result to a handle. - """ - if ( - result_type is not None - and temporalio.converter._payload_handle._is_payload_handle_hint(result_type) - ): - if ( - temporalio.converter._payload_handle._payload_handle_inner_type(result_type) - is not None - ): - return result_type - return temporalio.converter._payload_handle._payload_handle_hint( - declared_ret_type - ) - return declared_ret_type - - # Set to true to log all cases where we're ignoring things during delete LOG_IGNORE_DURING_DELETE = False @@ -1558,7 +1532,7 @@ def workflow_start_activity( raise ValueError("Cannot invoke dynamic activity explicitly") name = defn.name arg_types = defn.arg_types - ret_type = _result_ret_type(result_type, defn.ret_type) + ret_type = defn.ret_type else: raise TypeError("Activity must be a string or callable") @@ -1622,7 +1596,7 @@ async def workflow_start_child_workflow( raise TypeError("Cannot invoke dynamic workflow explicitly") name = defn.name arg_types = defn.arg_types - ret_type = _result_ret_type(result_type, defn.ret_type) + ret_type = defn.ret_type else: raise TypeError("Workflow must be a string or callable") @@ -1678,7 +1652,7 @@ def workflow_start_local_activity( raise ValueError("Cannot invoke dynamic activity explicitly") name = defn.name arg_types = defn.arg_types - ret_type = _result_ret_type(result_type, defn.ret_type) + ret_type = defn.ret_type else: raise TypeError("Activity must be a string or callable") @@ -3247,6 +3221,12 @@ def __init__( ) ) + def as_payload_handle(self) -> temporalio.workflow.ActivityHandle[Any]: + ph = temporalio.converter._payload_handle + if not ph._is_payload_handle_hint(self._input.ret_type): + self._input.ret_type = ph._payload_handle_hint(self._input.ret_type) + return self + def cancel(self, msg: Any | None = None) -> bool: # Allow the cancel to go through for the task even if we're deleting, # just don't do any commands @@ -3400,6 +3380,12 @@ def __init__( workflow_context ) + def as_payload_handle(self) -> temporalio.workflow.ChildWorkflowHandle[Any, Any]: + ph = temporalio.converter._payload_handle + if not ph._is_payload_handle_hint(self._input.ret_type): + self._input.ret_type = ph._payload_handle_hint(self._input.ret_type) + return self + @property def id(self) -> str: return self._input.id diff --git a/temporalio/workflow/__init__.py b/temporalio/workflow/__init__.py index 13b78fa7c..3d5a65c77 100644 --- a/temporalio/workflow/__init__.py +++ b/temporalio/workflow/__init__.py @@ -38,11 +38,9 @@ LocalActivityConfig, _AsyncioTask, execute_activity, - execute_activity_as_handle, execute_activity_class, execute_activity_method, execute_local_activity, - execute_local_activity_as_handle, execute_local_activity_class, execute_local_activity_method, start_activity, @@ -158,7 +156,6 @@ all_handlers_finished, continue_as_new, execute_child_workflow, - execute_child_workflow_as_handle, get_dynamic_query_handler, get_dynamic_signal_handler, get_dynamic_update_handler, @@ -182,11 +179,9 @@ "ActivityHandle", "LocalActivityConfig", "execute_activity", - "execute_activity_as_handle", "execute_activity_class", "execute_activity_method", "execute_local_activity", - "execute_local_activity_as_handle", "execute_local_activity_class", "execute_local_activity_method", "start_activity", @@ -264,7 +259,6 @@ "all_handlers_finished", "continue_as_new", "execute_child_workflow", - "execute_child_workflow_as_handle", "get_dynamic_query_handler", "get_dynamic_signal_handler", "get_dynamic_update_handler", diff --git a/temporalio/workflow/_activities.py b/temporalio/workflow/_activities.py index 9f74c4d01..0f66ed084 100644 --- a/temporalio/workflow/_activities.py +++ b/temporalio/workflow/_activities.py @@ -66,7 +66,19 @@ class ActivityHandle(_AsyncioTask[ReturnType]): # type: ignore[type-var] This extends :py:class:`asyncio.Task` and supports all task features. """ - pass + def as_payload_handle( + self, + ) -> ActivityHandle[temporalio.converter.PayloadHandle[ReturnType]]: + """Consume this activity's result as a lazy PayloadHandle. + + The activity is unchanged and still returns its declared type; awaiting + this handle yields a :py:class:`temporalio.converter.PayloadHandle` of + that type instead of the materialized value. If the result was offloaded + to external storage it is not downloaded into the workflow -- forward the + handle to an activity (or materialize it there) to avoid paying for data + the workflow only routes. + """ + raise NotImplementedError class ActivityCancellationType(IntEnum): @@ -499,180 +511,6 @@ async def execute_activity( ) -# Overload for async no-param activity -@overload -async def execute_activity_as_handle( - activity: CallableAsyncNoParam[ReturnType], - *, - task_queue: str | None = None, - schedule_to_close_timeout: timedelta | None = None, - schedule_to_start_timeout: timedelta | None = None, - start_to_close_timeout: timedelta | None = None, - heartbeat_timeout: timedelta | None = None, - retry_policy: temporalio.common.RetryPolicy | None = None, - cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, - activity_id: str | None = None, - versioning_intent: VersioningIntent | None = None, - summary: str | None = None, - priority: temporalio.common.Priority = temporalio.common.Priority.default, -) -> temporalio.converter.PayloadHandle[ReturnType]: ... - - -# Overload for sync no-param activity -@overload -async def execute_activity_as_handle( - activity: CallableSyncNoParam[ReturnType], - *, - task_queue: str | None = None, - schedule_to_close_timeout: timedelta | None = None, - schedule_to_start_timeout: timedelta | None = None, - start_to_close_timeout: timedelta | None = None, - heartbeat_timeout: timedelta | None = None, - retry_policy: temporalio.common.RetryPolicy | None = None, - cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, - activity_id: str | None = None, - versioning_intent: VersioningIntent | None = None, - summary: str | None = None, - priority: temporalio.common.Priority = temporalio.common.Priority.default, -) -> temporalio.converter.PayloadHandle[ReturnType]: ... - - -# Overload for async single-param activity -@overload -async def execute_activity_as_handle( - activity: CallableAsyncSingleParam[ParamType, ReturnType], - arg: ParamType, - *, - task_queue: str | None = None, - schedule_to_close_timeout: timedelta | None = None, - schedule_to_start_timeout: timedelta | None = None, - start_to_close_timeout: timedelta | None = None, - heartbeat_timeout: timedelta | None = None, - retry_policy: temporalio.common.RetryPolicy | None = None, - cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, - activity_id: str | None = None, - versioning_intent: VersioningIntent | None = None, - summary: str | None = None, - priority: temporalio.common.Priority = temporalio.common.Priority.default, -) -> temporalio.converter.PayloadHandle[ReturnType]: ... - - -# Overload for sync single-param activity -@overload -async def execute_activity_as_handle( - activity: CallableSyncSingleParam[ParamType, ReturnType], - arg: ParamType, - *, - task_queue: str | None = None, - schedule_to_close_timeout: timedelta | None = None, - schedule_to_start_timeout: timedelta | None = None, - start_to_close_timeout: timedelta | None = None, - heartbeat_timeout: timedelta | None = None, - retry_policy: temporalio.common.RetryPolicy | None = None, - cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, - activity_id: str | None = None, - versioning_intent: VersioningIntent | None = None, - summary: str | None = None, - priority: temporalio.common.Priority = temporalio.common.Priority.default, -) -> temporalio.converter.PayloadHandle[ReturnType]: ... - - -# Overload for multi-param activity -@overload -async def execute_activity_as_handle( - activity: Callable[..., Awaitable[ReturnType]] | Callable[..., ReturnType], - *, - args: Sequence[Any], - task_queue: str | None = None, - schedule_to_close_timeout: timedelta | None = None, - schedule_to_start_timeout: timedelta | None = None, - start_to_close_timeout: timedelta | None = None, - heartbeat_timeout: timedelta | None = None, - retry_policy: temporalio.common.RetryPolicy | None = None, - cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, - activity_id: str | None = None, - versioning_intent: VersioningIntent | None = None, - summary: str | None = None, - priority: temporalio.common.Priority = temporalio.common.Priority.default, -) -> temporalio.converter.PayloadHandle[ReturnType]: ... - - -# Overload for string-name activity -@overload -async def execute_activity_as_handle( - activity: str, - arg: Any = temporalio.common._arg_unset, - *, - args: Sequence[Any] = [], - task_queue: str | None = None, - result_type: type | None = None, - schedule_to_close_timeout: timedelta | None = None, - schedule_to_start_timeout: timedelta | None = None, - start_to_close_timeout: timedelta | None = None, - heartbeat_timeout: timedelta | None = None, - retry_policy: temporalio.common.RetryPolicy | None = None, - cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, - activity_id: str | None = None, - versioning_intent: VersioningIntent | None = None, - summary: str | None = None, - priority: temporalio.common.Priority = temporalio.common.Priority.default, -) -> temporalio.converter.PayloadHandle[Any]: ... - - -async def execute_activity_as_handle( - activity: Any, - arg: Any = temporalio.common._arg_unset, - *, - args: Sequence[Any] = [], - task_queue: str | None = None, - result_type: type | None = None, - schedule_to_close_timeout: timedelta | None = None, - schedule_to_start_timeout: timedelta | None = None, - start_to_close_timeout: timedelta | None = None, - heartbeat_timeout: timedelta | None = None, - retry_policy: temporalio.common.RetryPolicy | None = None, - cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, - activity_id: str | None = None, - versioning_intent: VersioningIntent | None = None, - summary: str | None = None, - priority: temporalio.common.Priority = temporalio.common.Priority.default, -) -> Any: - """Execute an activity and receive its result as a lazy PayloadHandle. - - Identical to :py:func:`execute_activity` except the activity is left - unchanged and its result is delivered as a - :py:class:`temporalio.converter.PayloadHandle` of the declared return type. - If the result was offloaded to external storage, it is not downloaded into - the workflow; forward the handle to an activity (or materialize it there) to - avoid paying for data the workflow only routes. - - For a string activity name, pass ``result_type=PayloadHandle[T]`` to keep the - materialized type; otherwise the declared return type is used. - """ - handle_result_type = ( - result_type - if result_type is not None - and result_type is not temporalio.converter.PayloadHandle - else temporalio.converter.PayloadHandle - ) - return await _Runtime.current().workflow_start_activity( - activity, - *temporalio.common._arg_or_args(arg, args), - task_queue=task_queue, - result_type=handle_result_type, - schedule_to_close_timeout=schedule_to_close_timeout, - schedule_to_start_timeout=schedule_to_start_timeout, - start_to_close_timeout=start_to_close_timeout, - heartbeat_timeout=heartbeat_timeout, - retry_policy=retry_policy, - cancellation_type=cancellation_type, - activity_id=activity_id, - versioning_intent=versioning_intent, - summary=summary, - priority=priority, - ) - - # Overload for async no-param activity @overload def start_activity_class( @@ -1653,150 +1491,6 @@ async def execute_local_activity( ) -# Overload for async no-param local activity -@overload -async def execute_local_activity_as_handle( - activity: CallableAsyncNoParam[ReturnType], - *, - schedule_to_close_timeout: timedelta | None = None, - schedule_to_start_timeout: timedelta | None = None, - start_to_close_timeout: timedelta | None = None, - retry_policy: temporalio.common.RetryPolicy | None = None, - local_retry_threshold: timedelta | None = None, - cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, - activity_id: str | None = None, - summary: str | None = None, -) -> temporalio.converter.PayloadHandle[ReturnType]: ... - - -# Overload for sync no-param local activity -@overload -async def execute_local_activity_as_handle( - activity: CallableSyncNoParam[ReturnType], - *, - schedule_to_close_timeout: timedelta | None = None, - schedule_to_start_timeout: timedelta | None = None, - start_to_close_timeout: timedelta | None = None, - retry_policy: temporalio.common.RetryPolicy | None = None, - local_retry_threshold: timedelta | None = None, - cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, - activity_id: str | None = None, - summary: str | None = None, -) -> temporalio.converter.PayloadHandle[ReturnType]: ... - - -# Overload for async single-param local activity -@overload -async def execute_local_activity_as_handle( - activity: CallableAsyncSingleParam[ParamType, ReturnType], - arg: ParamType, - *, - schedule_to_close_timeout: timedelta | None = None, - schedule_to_start_timeout: timedelta | None = None, - start_to_close_timeout: timedelta | None = None, - retry_policy: temporalio.common.RetryPolicy | None = None, - local_retry_threshold: timedelta | None = None, - cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, - activity_id: str | None = None, - summary: str | None = None, -) -> temporalio.converter.PayloadHandle[ReturnType]: ... - - -# Overload for sync single-param local activity -@overload -async def execute_local_activity_as_handle( - activity: CallableSyncSingleParam[ParamType, ReturnType], - arg: ParamType, - *, - schedule_to_close_timeout: timedelta | None = None, - schedule_to_start_timeout: timedelta | None = None, - start_to_close_timeout: timedelta | None = None, - retry_policy: temporalio.common.RetryPolicy | None = None, - local_retry_threshold: timedelta | None = None, - cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, - activity_id: str | None = None, - summary: str | None = None, -) -> temporalio.converter.PayloadHandle[ReturnType]: ... - - -# Overload for multi-param local activity -@overload -async def execute_local_activity_as_handle( - activity: Callable[..., Awaitable[ReturnType]] | Callable[..., ReturnType], - *, - args: Sequence[Any], - schedule_to_close_timeout: timedelta | None = None, - schedule_to_start_timeout: timedelta | None = None, - start_to_close_timeout: timedelta | None = None, - retry_policy: temporalio.common.RetryPolicy | None = None, - local_retry_threshold: timedelta | None = None, - cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, - activity_id: str | None = None, - summary: str | None = None, -) -> temporalio.converter.PayloadHandle[ReturnType]: ... - - -# Overload for string-name local activity -@overload -async def execute_local_activity_as_handle( - activity: str, - arg: Any = temporalio.common._arg_unset, - *, - args: Sequence[Any] = [], - result_type: type | None = None, - schedule_to_close_timeout: timedelta | None = None, - schedule_to_start_timeout: timedelta | None = None, - start_to_close_timeout: timedelta | None = None, - retry_policy: temporalio.common.RetryPolicy | None = None, - local_retry_threshold: timedelta | None = None, - cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, - activity_id: str | None = None, - summary: str | None = None, -) -> temporalio.converter.PayloadHandle[Any]: ... - - -async def execute_local_activity_as_handle( - activity: Any, - arg: Any = temporalio.common._arg_unset, - *, - args: Sequence[Any] = [], - result_type: type | None = None, - schedule_to_close_timeout: timedelta | None = None, - schedule_to_start_timeout: timedelta | None = None, - start_to_close_timeout: timedelta | None = None, - retry_policy: temporalio.common.RetryPolicy | None = None, - local_retry_threshold: timedelta | None = None, - cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, - activity_id: str | None = None, - summary: str | None = None, -) -> Any: - """Execute a local activity and receive its result as a lazy PayloadHandle. - - Like :py:func:`execute_local_activity` but the (unchanged) activity's result - is delivered as a :py:class:`temporalio.converter.PayloadHandle` of the - declared return type, avoiding an eager download of an offloaded result. - """ - handle_result_type = ( - result_type - if result_type is not None - and result_type is not temporalio.converter.PayloadHandle - else temporalio.converter.PayloadHandle - ) - return await _Runtime.current().workflow_start_local_activity( - activity, - *temporalio.common._arg_or_args(arg, args), - result_type=handle_result_type, - schedule_to_close_timeout=schedule_to_close_timeout, - schedule_to_start_timeout=schedule_to_start_timeout, - start_to_close_timeout=start_to_close_timeout, - retry_policy=retry_policy, - local_retry_threshold=local_retry_threshold, - cancellation_type=cancellation_type, - activity_id=activity_id, - summary=summary, - ) - - # Overload for async no-param activity @overload def start_local_activity_class( diff --git a/temporalio/workflow/_workflow_ops.py b/temporalio/workflow/_workflow_ops.py index 2b20313d6..9f7100b99 100644 --- a/temporalio/workflow/_workflow_ops.py +++ b/temporalio/workflow/_workflow_ops.py @@ -69,6 +69,19 @@ def first_execution_run_id(self) -> str | None: """Run ID for the workflow.""" raise NotImplementedError + def as_payload_handle( + self, + ) -> ChildWorkflowHandle[SelfType, temporalio.converter.PayloadHandle[ReturnType]]: + """Consume this child workflow's result as a lazy PayloadHandle. + + The child workflow is unchanged and still returns its declared type; + awaiting this handle yields a + :py:class:`temporalio.converter.PayloadHandle` of that type instead of + the materialized value. If the result was offloaded to external storage + it is not downloaded into the parent workflow. + """ + raise NotImplementedError + @overload async def signal( self, @@ -549,182 +562,6 @@ async def execute_child_workflow( return await handle -# Overload for no-param child workflow -@overload -async def execute_child_workflow_as_handle( - workflow: MethodAsyncNoParam[SelfType, ReturnType], - *, - id: str | None = None, - task_queue: str | None = None, - cancellation_type: ChildWorkflowCancellationType = ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED, - parent_close_policy: ParentClosePolicy = ParentClosePolicy.TERMINATE, - execution_timeout: timedelta | None = None, - run_timeout: timedelta | None = None, - task_timeout: timedelta | None = None, - id_reuse_policy: temporalio.common.WorkflowIDReusePolicy = temporalio.common.WorkflowIDReusePolicy.ALLOW_DUPLICATE, - retry_policy: temporalio.common.RetryPolicy | None = None, - cron_schedule: str = "", - memo: Mapping[str, Any] | None = None, - search_attributes: None - | ( - temporalio.common.SearchAttributes | temporalio.common.TypedSearchAttributes - ) = None, - versioning_intent: VersioningIntent | None = None, - static_summary: str | None = None, - static_details: str | None = None, - priority: temporalio.common.Priority = temporalio.common.Priority.default, -) -> temporalio.converter.PayloadHandle[ReturnType]: ... - - -# Overload for single-param child workflow -@overload -async def execute_child_workflow_as_handle( - workflow: MethodAsyncSingleParam[SelfType, ParamType, ReturnType], - arg: ParamType, - *, - id: str | None = None, - task_queue: str | None = None, - cancellation_type: ChildWorkflowCancellationType = ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED, - parent_close_policy: ParentClosePolicy = ParentClosePolicy.TERMINATE, - execution_timeout: timedelta | None = None, - run_timeout: timedelta | None = None, - task_timeout: timedelta | None = None, - id_reuse_policy: temporalio.common.WorkflowIDReusePolicy = temporalio.common.WorkflowIDReusePolicy.ALLOW_DUPLICATE, - retry_policy: temporalio.common.RetryPolicy | None = None, - cron_schedule: str = "", - memo: Mapping[str, Any] | None = None, - search_attributes: None - | ( - temporalio.common.SearchAttributes | temporalio.common.TypedSearchAttributes - ) = None, - versioning_intent: VersioningIntent | None = None, - static_summary: str | None = None, - static_details: str | None = None, - priority: temporalio.common.Priority = temporalio.common.Priority.default, -) -> temporalio.converter.PayloadHandle[ReturnType]: ... - - -# Overload for multi-param child workflow -@overload -async def execute_child_workflow_as_handle( - workflow: Callable[Concatenate[SelfType, MultiParamSpec], Awaitable[ReturnType]], - *, - args: Sequence[Any], - id: str | None = None, - task_queue: str | None = None, - cancellation_type: ChildWorkflowCancellationType = ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED, - parent_close_policy: ParentClosePolicy = ParentClosePolicy.TERMINATE, - execution_timeout: timedelta | None = None, - run_timeout: timedelta | None = None, - task_timeout: timedelta | None = None, - id_reuse_policy: temporalio.common.WorkflowIDReusePolicy = temporalio.common.WorkflowIDReusePolicy.ALLOW_DUPLICATE, - retry_policy: temporalio.common.RetryPolicy | None = None, - cron_schedule: str = "", - memo: Mapping[str, Any] | None = None, - search_attributes: None - | ( - temporalio.common.SearchAttributes | temporalio.common.TypedSearchAttributes - ) = None, - versioning_intent: VersioningIntent | None = None, - static_summary: str | None = None, - static_details: str | None = None, - priority: temporalio.common.Priority = temporalio.common.Priority.default, -) -> temporalio.converter.PayloadHandle[ReturnType]: ... - - -# Overload for string-name child workflow -@overload -async def execute_child_workflow_as_handle( - workflow: str, - arg: Any = temporalio.common._arg_unset, - *, - args: Sequence[Any] = [], - id: str | None = None, - task_queue: str | None = None, - result_type: type | None = None, - cancellation_type: ChildWorkflowCancellationType = ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED, - parent_close_policy: ParentClosePolicy = ParentClosePolicy.TERMINATE, - execution_timeout: timedelta | None = None, - run_timeout: timedelta | None = None, - task_timeout: timedelta | None = None, - id_reuse_policy: temporalio.common.WorkflowIDReusePolicy = temporalio.common.WorkflowIDReusePolicy.ALLOW_DUPLICATE, - retry_policy: temporalio.common.RetryPolicy | None = None, - cron_schedule: str = "", - memo: Mapping[str, Any] | None = None, - search_attributes: None - | ( - temporalio.common.SearchAttributes | temporalio.common.TypedSearchAttributes - ) = None, - versioning_intent: VersioningIntent | None = None, - static_summary: str | None = None, - static_details: str | None = None, - priority: temporalio.common.Priority = temporalio.common.Priority.default, -) -> temporalio.converter.PayloadHandle[Any]: ... - - -async def execute_child_workflow_as_handle( - workflow: Any, - arg: Any = temporalio.common._arg_unset, - *, - args: Sequence[Any] = [], - id: str | None = None, - task_queue: str | None = None, - result_type: type | None = None, - cancellation_type: ChildWorkflowCancellationType = ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED, - parent_close_policy: ParentClosePolicy = ParentClosePolicy.TERMINATE, - execution_timeout: timedelta | None = None, - run_timeout: timedelta | None = None, - task_timeout: timedelta | None = None, - id_reuse_policy: temporalio.common.WorkflowIDReusePolicy = temporalio.common.WorkflowIDReusePolicy.ALLOW_DUPLICATE, - retry_policy: temporalio.common.RetryPolicy | None = None, - cron_schedule: str = "", - memo: Mapping[str, Any] | None = None, - search_attributes: None - | ( - temporalio.common.SearchAttributes | temporalio.common.TypedSearchAttributes - ) = None, - versioning_intent: VersioningIntent | None = None, - static_summary: str | None = None, - static_details: str | None = None, - priority: temporalio.common.Priority = temporalio.common.Priority.default, -) -> Any: - """Execute a child workflow and receive its result as a lazy PayloadHandle. - - Like :py:func:`execute_child_workflow` but the (unchanged) child's result is - delivered as a :py:class:`temporalio.converter.PayloadHandle` of the declared - return type, avoiding an eager download of an offloaded result. - """ - temporalio.common._warn_on_deprecated_search_attributes(search_attributes) - handle_result_type = ( - result_type - if result_type is not None - and result_type is not temporalio.converter.PayloadHandle - else temporalio.converter.PayloadHandle - ) - handle = await _Runtime.current().workflow_start_child_workflow( - workflow, - *temporalio.common._arg_or_args(arg, args), - id=id or str(uuid4()), - task_queue=task_queue, - result_type=handle_result_type, - cancellation_type=cancellation_type, - parent_close_policy=parent_close_policy, - execution_timeout=execution_timeout, - run_timeout=run_timeout, - task_timeout=task_timeout, - id_reuse_policy=id_reuse_policy, - retry_policy=retry_policy, - cron_schedule=cron_schedule, - memo=memo, - search_attributes=search_attributes, - versioning_intent=versioning_intent, - static_summary=static_summary, - static_details=static_details, - priority=priority, - ) - return await handle - - class ExternalWorkflowHandle(Generic[SelfType]): """Handle for interacting with an external workflow. diff --git a/tests/worker/test_payload_handle.py b/tests/worker/test_payload_handle.py index abf3a9ad2..217e5502d 100644 --- a/tests/worker/test_payload_handle.py +++ b/tests/worker/test_payload_handle.py @@ -72,9 +72,9 @@ class ResultAsHandleConsumeWorkflow: async def run(self) -> int: # Upgrade an unchanged activity's result to a handle, then forward it to # an activity that materializes it. - handle = await workflow.execute_activity_as_handle( + handle = await workflow.start_activity( produce_big, start_to_close_timeout=timedelta(seconds=30) - ) + ).as_payload_handle() return await workflow.execute_activity( consume_handle, handle, start_to_close_timeout=timedelta(seconds=30) ) @@ -84,9 +84,9 @@ async def run(self) -> int: class ResultAsHandlePassThroughWorkflow: @workflow.run async def run(self) -> str: - handle = await workflow.execute_activity_as_handle( + handle = await workflow.start_activity( produce_big, start_to_close_timeout=timedelta(seconds=30) - ) + ).as_payload_handle() return await workflow.execute_activity( ignore_handle, handle, start_to_close_timeout=timedelta(seconds=30) ) @@ -105,9 +105,8 @@ class ParentChildResultAsHandleWorkflow: async def run(self) -> str: # Upgrade an unchanged child workflow's result to a handle and forward it # without materializing it in this (parent) workflow. - handle = await workflow.execute_child_workflow_as_handle( - ChildProducerWorkflow.run - ) + child = await workflow.start_child_workflow(ChildProducerWorkflow.run) + handle = await child.as_payload_handle() return await workflow.execute_activity( ignore_handle, handle, start_to_close_timeout=timedelta(seconds=30) ) From 009b9cea87c2598e7be8bdc0d51455cb2e236057 Mon Sep 17 00:00:00 2001 From: jmaeagle99 <44687433+jmaeagle99@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:20:41 -0700 Subject: [PATCH 5/7] exp: move value materialization to activity --- temporalio/activity.py | 23 +++- temporalio/converter/_data_converter.py | 41 +++++-- temporalio/converter/_payload_handle.py | 136 +++++++++--------------- temporalio/worker/_activity.py | 1 + tests/test_payload_handle.py | 45 ++++---- tests/test_payload_handle_annotated.py | 81 ++++++++++++++ tests/worker/test_payload_handle.py | 6 +- 7 files changed, 216 insertions(+), 117 deletions(-) create mode 100644 tests/test_payload_handle_annotated.py diff --git a/temporalio/activity.py b/temporalio/activity.py index 4e632701e..06150caad 100644 --- a/temporalio/activity.py +++ b/temporalio/activity.py @@ -30,7 +30,7 @@ import temporalio.common import temporalio.converter -from .types import CallableType +from .types import AnyType, CallableType if TYPE_CHECKING: from temporalio.client import Client @@ -206,6 +206,7 @@ class _Context: runtime_metric_meter: temporalio.common.MetricMeter | None client: Client | None cancellation_details: _ActivityCancellationDetailsHolder + data_converter: temporalio.converter.DataConverter | None = None _logger_details: Mapping[str, Any] | None = None _payload_converter: temporalio.converter.PayloadConverter | None = None _metric_meter: temporalio.common.MetricMeter | None = None @@ -458,6 +459,26 @@ def payload_converter() -> temporalio.converter.PayloadConverter: return _Context.current().payload_converter +async def get_handle_value( + handle: temporalio.converter.PayloadHandle[AnyType], +) -> AnyType: + """Acquire the value a :py:class:`temporalio.converter.PayloadHandle` refers to. + + Uses this activity's data converter to run the handle's deferred inbound + pipeline (external-storage retrieval if offloaded, codec decode, then + deserialization) under the activity's serialization context. Call it from + activity code, where acquisition I/O is permitted; a workflow forwards + handles but does not acquire their values. + """ + context = _Context.current() + if context.data_converter is None: + raise RuntimeError( + "No data converter is available in this activity context; " + "cannot acquire a PayloadHandle value." + ) + return await context.data_converter.get_handle_value(handle) + + def metric_meter() -> temporalio.common.MetricMeter: """Get the metric meter for the current activity. diff --git a/temporalio/converter/_data_converter.py b/temporalio/converter/_data_converter.py index c9ac8ad06..2a75a403d 100644 --- a/temporalio/converter/_data_converter.py +++ b/temporalio/converter/_data_converter.py @@ -30,13 +30,14 @@ PayloadConverter, ) from temporalio.converter._payload_handle import ( - _bind_data_converter, + PayloadHandle, _is_payload_handle_hint, ) from temporalio.converter._serialization_context import ( SerializationContext, WithSerializationContext, ) +from temporalio.types import AnyType _REFERENCE_MESSAGE_TYPE = ExternalStorageReference.DESCRIPTOR.full_name.encode() @@ -134,9 +135,9 @@ async def decode( """ # Positions annotated as PayloadHandle defer acquisition: keep their # opaque payload and skip eager external-storage retrieval + codec - # decode so the produced handle can materialize on demand. The handle - # binds to this (context-applied) converter so materialize() uses the - # correct serialization context and codec. + # decode, producing a data-only handle. Acquisition is a boundary + # operation (get_handle_value), not something the handle does itself, so + # no converter is captured here. if type_hints is not None and any( _is_payload_handle_hint(h) for h in type_hints ): @@ -154,13 +155,41 @@ async def decode( transformed = await self._decode_payload_sequence(transformed) for i, payload in zip(transform_indexes, transformed): payloads[i] = payload - with _bind_data_converter(self): - return self.payload_converter.from_payloads(payloads, type_hints) + return self.payload_converter.from_payloads(payloads, type_hints) payloads = await self._external_retrieve_payload_sequence(payloads) payloads = await self._decode_payload_sequence(payloads) return self.payload_converter.from_payloads(payloads, type_hints) + async def get_handle_value(self, handle: PayloadHandle[AnyType]) -> AnyType: + """Acquire the value a :py:class:`PayloadHandle` refers to. + + This is the boundary operation for handles: the handle is a plain value + carrying the opaque payload and its inner type, and this converter + supplies the machinery. It runs the deferred inbound pipeline + (external-storage retrieval if offloaded, then codec decode) under this + converter's serialization context, and deserializes into the handle's + captured type ``T``. + + Call it where acquisition I/O is permitted (an activity or client + boundary), never inside the workflow sandbox. In activity code, prefer + :py:func:`temporalio.activity.get_handle_value`, which uses the + activity's converter. + + Raises: + RuntimeError: if the handle carries no concrete type (a bare + ``PayloadHandle`` annotation), since conversion needs a type. + """ + inner_type = handle._type + if inner_type is None: + raise RuntimeError( + "[TMPRL1106] PayloadHandle has no type to acquire into. " + "Annotate the value as PayloadHandle[T] with a concrete type T." + ) + payload = await self._transform_inbound_payload(handle._payload) + values = self.payload_converter.from_payloads([payload], [inner_type]) + return values[0] + async def encode_wrapper( self, values: Sequence[Any] ) -> temporalio.api.common.v1.Payloads: diff --git a/temporalio/converter/_payload_handle.py b/temporalio/converter/_payload_handle.py index 272064fde..e2c63a534 100644 --- a/temporalio/converter/_payload_handle.py +++ b/temporalio/converter/_payload_handle.py @@ -2,10 +2,19 @@ A :py:class:`PayloadHandle` is used as a parameter or return annotation (``PayloadHandle[T]``) to defer *acquiring* a value -- external-storage -retrieval, codec decoding, and deserialization -- until it is explicitly -awaited via :py:meth:`PayloadHandle.materialize`. Until then the handle just -carries the opaque, end-of-pipeline payload and can be forwarded (e.g. from a -workflow to an activity) without paying to materialize it. +retrieval, codec decoding, and deserialization. A handle is a plain, immutable +*value*: it carries the opaque, end-of-pipeline payload and the inner type, and +can be forwarded (e.g. from a workflow to an activity) without paying to +acquire it. + +The handle deliberately owns no behavior beyond introspection of what it +carries. Acquiring the value needs machinery -- a data converter, codec chain, +and storage driver -- that belongs to an execution surface (the activity worker +or the client), not to a payload value. So acquisition is a boundary operation +(:py:meth:`temporalio.converter.DataConverter.get_handle_value`, and +:py:func:`temporalio.activity.get_handle_value` in activity code), never a +method on the handle. This keeps the handle portable with no captured runtime +state, and avoids relying on an ambient mechanism to inject a converter. This mirrors :py:class:`temporalio.common.RawValue`: the annotation, not any wire encoding, is what triggers handle behavior, so a handle works on any @@ -14,16 +23,11 @@ from __future__ import annotations -import contextvars -from contextlib import contextmanager from dataclasses import dataclass, field from typing import ( - TYPE_CHECKING, Any, Generic, - Iterator, Optional, - cast, get_args, get_origin, ) @@ -31,29 +35,6 @@ import temporalio.api.common.v1 from temporalio.types import AnyType -if TYPE_CHECKING: - from temporalio.converter._data_converter import DataConverter - - -# The data converter needed to materialize a handle only exists at the async -# worker/client boundary (never inside the workflow sandbox, where I/O is -# forbidden). Boundary decodes publish it here so handles built during -# conversion can bind to it; the sandbox leaves it unset, yielding forward-only -# handles. -_current_data_converter: contextvars.ContextVar[Optional[DataConverter]] = ( - contextvars.ContextVar("_temporal_payload_handle_data_converter", default=None) -) - - -@contextmanager -def _bind_data_converter(data_converter: DataConverter) -> Iterator[None]: - """Bind the data converter used by handles created within this context.""" - token = _current_data_converter.set(data_converter) - try: - yield - finally: - _current_data_converter.reset(token) - @dataclass(frozen=True) class PayloadHandle(Generic[AnyType]): @@ -61,63 +42,23 @@ class PayloadHandle(Generic[AnyType]): Annotate a workflow/activity/signal parameter or return value as ``PayloadHandle[T]`` to receive one of these instead of the materialized - value. Forward it onward without cost, or call - :py:meth:`materialize` where the value is actually needed. + value. Forward it onward without cost, and acquire its value at a boundary + (an activity or client) where fetching data is permitted, via + :py:func:`temporalio.activity.get_handle_value` or + :py:meth:`temporalio.converter.DataConverter.get_handle_value`. A handle + does not acquire its own value. """ # The opaque end-of-pipeline payload (may be an external-storage reference - # or a codec-encoded inline payload). Kept private: the handle is - # backing-agnostic and exposes nothing about how the value is stored. + # or a codec-encoded inline payload). The handle is backing-agnostic; this + # is read by the boundary converter when acquiring the value. _payload: temporalio.api.common.v1.Payload - # Inner type ``T`` captured from the annotation, used as the decode hint at - # materialize time. May be None for a bare ``PayloadHandle`` annotation. + # Inner type ``T`` captured from the annotation, used as the decode hint + # when acquiring the value. May be None for a bare ``PayloadHandle``. _type: Optional[type] = field(default=None, compare=False) - # Set only for handles created at the async boundary; None => forward-only. - _data_converter: Optional[DataConverter] = field( - default=None, compare=False, repr=False - ) - - async def materialize(self) -> AnyType: - """Acquire and return the underlying value. - - Runs the deferred inbound pipeline (external-storage retrieval if - offloaded, codec decoding, then deserialization into the real type - ``T`` captured from the ``PayloadHandle[T]`` annotation). The return type - is that ``T`` -- annotate handles as ``PayloadHandle[T]`` so callers keep - full type information rather than an untyped value. - - Raises: - RuntimeError: if the handle is forward-only (e.g. received inside a - workflow, where acquisition I/O is not permitted), or if it - carries no real type (a bare ``PayloadHandle`` annotation), since - payload conversion needs a concrete type. - """ - data_converter = self._data_converter - if data_converter is None: - raise RuntimeError( - "[TMPRL1106] PayloadHandle is forward-only in this context " - "(such as inside a workflow) and cannot be materialized. Forward " - "it to an activity, or materialize it from client code, instead." - ) - if self._type is None: - raise RuntimeError( - "[TMPRL1106] PayloadHandle has no type to materialize into. " - "Annotate the value as PayloadHandle[T] with a concrete type T." - ) - # Reuse the standard inbound transform (retrieve -> codec-decode) that - # eager decoding would have applied, then deserialize to the real type. - payload = await data_converter._transform_inbound_payload(self._payload) - [value] = data_converter.payload_converter.from_payloads( - [payload], [self._type] - ) - return cast(AnyType, value) def __getstate__(self) -> object: - """Pickle support (workflow sandbox caching). - - Excludes the bound data converter so a rehydrated handle is forward-only, - reinforcing that materialization never happens on the sandbox side. - """ + """Pickle support (workflow sandbox caching).""" return {"payload": self._payload.SerializeToString(), "type": self._type} def __setstate__(self, state: object) -> None: @@ -130,16 +71,35 @@ def __setstate__(self, state: object) -> None: temporalio.api.common.v1.Payload.FromString(state["payload"]), ) object.__setattr__(self, "_type", state.get("type")) - object.__setattr__(self, "_data_converter", None) + + +class AsHandle: + """Marker for ``Annotated[T, AsHandle]``: consume ``T`` as a forward-only + :py:class:`PayloadHandle` without ``T`` leaving the shared contract. + + ``Annotated[T, AsHandle]`` is transparent to type checkers (both a caller and + the callee still see ``T``), so a caller passes a plain ``T`` -- no coupling. + The SDK recovers the marker via ``get_type_hints(..., include_extras=True)`` + and delivers a handle instead of materializing. This is the *forward-only* + (type-erased) option: the callee's variable is still statically ``T``, so it + can forward the value but cannot call handle methods on it statically. + """ def _is_payload_handle_hint(hint: Any) -> bool: - """Return True if a type hint is ``PayloadHandle`` or ``PayloadHandle[T]``.""" - return hint is PayloadHandle or get_origin(hint) is PayloadHandle + """Return True for ``PayloadHandle``, ``PayloadHandle[T]``, or ``Annotated[T, AsHandle]``.""" + return ( + hint is PayloadHandle + or get_origin(hint) is PayloadHandle + or AsHandle in getattr(hint, "__metadata__", ()) + ) def _payload_handle_inner_type(hint: Any) -> Optional[type]: - """Return ``T`` from ``PayloadHandle[T]``, or None for a bare hint.""" + """Return ``T`` from ``PayloadHandle[T]`` or ``Annotated[T, AsHandle]``, else None.""" + # Annotated[T, AsHandle]: the base type T is the inner (materialized) type. + if AsHandle in getattr(hint, "__metadata__", ()): + return hint.__origin__ args = get_args(hint) return args[0] if args else None @@ -160,5 +120,5 @@ def _payload_handle_hint(inner_type: Optional[type]) -> Any: def _create_handle( payload: temporalio.api.common.v1.Payload, inner_type: Optional[type] ) -> PayloadHandle[Any]: - """Build a handle, binding it to the current boundary converter if any.""" - return PayloadHandle(payload, inner_type, _current_data_converter.get()) + """Build a data-only handle (no captured converter).""" + return PayloadHandle(payload, inner_type) diff --git a/temporalio/worker/_activity.py b/temporalio/worker/_activity.py index 0304b3b75..89ae296b1 100644 --- a/temporalio/worker/_activity.py +++ b/temporalio/worker/_activity.py @@ -666,6 +666,7 @@ async def _execute_activity( runtime_metric_meter=None if sync_non_threaded else self._metric_meter, client=self._client if not running_activity.sync else None, cancellation_details=running_activity.cancellation_details, + data_converter=data_converter, ) ) temporalio.activity.logger.debug("Starting activity") diff --git a/tests/test_payload_handle.py b/tests/test_payload_handle.py index f39b9a964..2924d8831 100644 --- a/tests/test_payload_handle.py +++ b/tests/test_payload_handle.py @@ -2,8 +2,9 @@ These exercise the converter-level behavior: a PayloadHandle[T] annotation defers acquisition (external-storage retrieval, codec decode, deserialization) -until materialize() is awaited, and forwarding a handle re-emits its opaque -payload without downloading. The proof is the driver's retrieve-call count. +until the value is acquired at a boundary via DataConverter.get_handle_value, +and forwarding a handle re-emits its opaque payload without downloading. The +proof is the driver's retrieve-call count. """ from __future__ import annotations @@ -35,7 +36,7 @@ def _storage_converter( ) -async def test_toplevel_reference_becomes_bound_handle() -> None: +async def test_toplevel_reference_becomes_handle() -> None: driver = InMemoryTestDriver() dc = _storage_converter(driver) @@ -47,28 +48,35 @@ async def test_toplevel_reference_becomes_bound_handle() -> None: # No download happened just by receiving the handle. assert driver._retrieve_calls == 0 - assert await handle.materialize() == _BIG + # The value is acquired at the boundary, through the converter. + assert await dc.get_handle_value(handle) == _BIG assert driver._retrieve_calls == 1 -async def test_forward_only_handle_roundtrips_without_download() -> None: +async def test_handle_is_data_only_and_forwards_without_download() -> None: driver = InMemoryTestDriver() dc = _storage_converter(driver) [reference] = await dc.encode([_BIG]) - # Decoding through the bare payload converter (no boundary binding, as in - # the workflow sandbox) yields a forward-only handle. + # A handle is a data-only value: holding it downloads nothing, and it owns + # no acquire behavior (acquisition is a boundary operation, not a method on + # the handle). Forward-only-ness lives on the workflow surface, which simply + # does not expose get_handle_value, not in the handle's state. [handle] = dc.payload_converter.from_payloads([reference], [PayloadHandle[str]]) assert isinstance(handle, PayloadHandle) + assert not hasattr(handle, "materialize") + assert not hasattr(handle, "get_handle_value") + assert driver._retrieve_calls == 0 - with pytest.raises(RuntimeError, match="forward-only"): - await handle.materialize() - - # Forwarding re-emits a byte-identical reference payload. + # Forwarding re-emits a byte-identical reference payload, still no download. [out] = dc.payload_converter.to_payloads([handle]) assert out.SerializeToString() == reference.SerializeToString() assert driver._retrieve_calls == 0 + # The value is acquired only through a boundary converter. + assert await dc.get_handle_value(handle) == _BIG + assert driver._retrieve_calls == 1 + async def test_non_handle_annotation_is_eager() -> None: driver = InMemoryTestDriver() @@ -112,7 +120,7 @@ async def decode(self, payloads: Sequence[Payload]) -> list[Payload]: return out -async def test_codec_deferred_until_materialize() -> None: +async def test_codec_deferred_until_acquired() -> None: driver = InMemoryTestDriver() codec = _MarkerCodec() dc = _storage_converter(driver, codec=codec) @@ -122,11 +130,11 @@ async def test_codec_deferred_until_materialize() -> None: # The reference is not codec-decoded when the handle is produced. assert codec.decode_calls == 0 - assert await handle.materialize() == _BIG + assert await dc.get_handle_value(handle) == _BIG assert codec.decode_calls == 1 -async def test_pickled_handle_is_forward_only() -> None: +async def test_pickled_handle_survives_and_forwards() -> None: driver = InMemoryTestDriver() dc = _storage_converter(driver) payloads = await dc.encode([_BIG]) @@ -134,10 +142,9 @@ async def test_pickled_handle_is_forward_only() -> None: restored = pickle.loads(pickle.dumps(handle)) assert isinstance(restored, PayloadHandle) - with pytest.raises(RuntimeError, match="forward-only"): - await restored.materialize() - # The opaque payload survives, so a rehydrated handle can still be forwarded. - # Compare with proto equality: re-parsing may reorder the metadata map, so - # serialized bytes are not a reliable equality check here. + # The opaque payload survives, so a rehydrated handle can still be forwarded + # and acquired at a boundary. Compare with proto equality: re-parsing may + # reorder the metadata map, so serialized bytes are not a reliable check. [out] = dc.payload_converter.to_payloads([restored]) assert out == payloads[0] + assert await dc.get_handle_value(restored) == _BIG diff --git a/tests/test_payload_handle_annotated.py b/tests/test_payload_handle_annotated.py new file mode 100644 index 000000000..8c812b99b --- /dev/null +++ b/tests/test_payload_handle_annotated.py @@ -0,0 +1,81 @@ +"""Demonstration of the ``Annotated[T, AsHandle]`` forward-only consumption option. + +This is the "type-erased" option from the consumption-decoupling design: the +shared contract type stays ``T``, and a *marker* on the annotation (not the type +itself) drives handle consumption. A caller passes a plain ``T`` because +``Annotated[T, AsHandle]`` is transparent to type checkers, so there is no +coupling; the SDK recovers the marker and delivers a forward-only +:py:class:`PayloadHandle` instead of materializing. + +Scope note: this exercises the converter layer directly, which is where the +mechanism lives. It does NOT yet work end to end through a running workflow, +because the SDK resolves argument/return types with ``get_type_hints()`` WITHOUT +``include_extras`` (``temporalio/common.py`` around line 1398), which strips the +``Annotated`` metadata before the converter ever sees it. Turning that on (and +threading it through arg-type resolution) is the remaining wiring; this test +isolates and demonstrates the underlying mechanism. +""" + +from __future__ import annotations + +from typing import Annotated, Any, get_type_hints + +from temporalio.converter import DataConverter, PayloadHandle +from temporalio.converter._payload_handle import ( + AsHandle, + _is_payload_handle_hint, + _payload_handle_inner_type, +) + + +def test_annotated_is_transparent_but_marker_recoverable() -> None: + def handler(data: Annotated[str, AsHandle]) -> None: ... + + # A caller and a type checker see a plain `str` -- the contract is unchanged, + # so there is no coupling: the caller passes a `str`, not a PayloadHandle. + assert get_type_hints(handler)["data"] is str + # The SDK can still recover the marker when it asks for extras. + assert ( + get_type_hints(handler, include_extras=True)["data"] == Annotated[str, AsHandle] + ) + + +def test_annotated_marker_is_recognized_as_a_handle_hint() -> None: + hint = Annotated[str, AsHandle] + assert _is_payload_handle_hint(hint) + # The inner (materialized) type is the base type of the annotation. + assert _payload_handle_inner_type(hint) is str + + +async def test_same_payload_consumed_as_value_or_handle_by_marker() -> None: + dc = DataConverter() + # An ordinary inline payload (no external storage): the mechanism is neutral + # to the payload's shape -- it does not require an offloaded reference. + [payload] = await dc.encode(["big-value"]) + + # Consumed as the contract type -> the materialized value. + [value] = await dc.decode([payload], [str]) + assert value == "big-value" + + # Consumed via the marker -> a PayloadHandle, with no change to the `str` + # contract. Its value is acquired at the boundary through the converter. + handle_hint: Any = Annotated[str, AsHandle] + [handle] = await dc.decode([payload], [handle_hint]) + assert isinstance(handle, PayloadHandle) + assert await dc.get_handle_value(handle) == "big-value" + + +async def test_annotated_handle_is_a_data_only_value() -> None: + dc = DataConverter() + [payload] = dc.payload_converter.to_payloads(["big-value"]) + + # Sync conversion, as inside the workflow sandbox, yields a data-only handle: + # it carries the payload but owns no acquire behavior. Forward-only-ness is a + # property of the workflow surface (which does not expose get_handle_value), + # not of the handle; acquisition is a boundary operation. + handle_hint: Any = Annotated[str, AsHandle] + [handle] = dc.payload_converter.from_payloads([payload], [handle_hint]) + assert isinstance(handle, PayloadHandle) + assert not hasattr(handle, "materialize") + # Through a boundary converter the value is acquirable. + assert await dc.get_handle_value(handle) == "big-value" diff --git a/tests/worker/test_payload_handle.py b/tests/worker/test_payload_handle.py index 217e5502d..116936029 100644 --- a/tests/worker/test_payload_handle.py +++ b/tests/worker/test_payload_handle.py @@ -3,7 +3,7 @@ Demonstrates the headline behavior: a workflow whose run argument is annotated ``PayloadHandle[T]`` receives a forward-only handle instead of an eagerly downloaded value, forwards it to an activity without downloading, and the -activity materializes it on demand. The proof is the driver's retrieve count: +activity acquires it on demand. The proof is the driver's retrieve count: 0 for pass-through (and on replay), exactly 1 when an activity materializes. """ @@ -29,8 +29,8 @@ @activity.defn async def consume_handle(data: PayloadHandle[str]) -> int: - # The activity needs the bytes, so it materializes on demand. - value = await data.materialize() + # The activity needs the bytes, so it acquires them on demand at the boundary. + value = await activity.get_handle_value(data) return len(value) From c5f72fd48dfb16c8ec5edc915e23d06c712773ab Mon Sep 17 00:00:00 2001 From: jmaeagle99 <44687433+jmaeagle99@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:28:16 -0700 Subject: [PATCH 6/7] exp: create handle, handle rename, move to common --- temporalio/activity.py | 33 +++++- temporalio/bridge/worker.py | 4 +- temporalio/common.py | 73 +++++++++++- temporalio/converter/__init__.py | 2 - temporalio/converter/_data_converter.py | 43 +++++-- temporalio/converter/_payload_converter.py | 8 +- temporalio/converter/_payload_handle.py | 118 +++++--------------- temporalio/worker/_command_aware_visitor.py | 2 +- temporalio/worker/_workflow.py | 2 +- temporalio/worker/_workflow_instance.py | 6 +- temporalio/workflow/_activities.py | 8 +- temporalio/workflow/_workflow_ops.py | 8 +- tests/test_payload_handle.py | 55 ++++++--- tests/test_payload_handle_annotated.py | 20 ++-- tests/worker/test_payload_handle.py | 69 ++++++++++-- 15 files changed, 288 insertions(+), 163 deletions(-) diff --git a/temporalio/activity.py b/temporalio/activity.py index 06150caad..3b2774502 100644 --- a/temporalio/activity.py +++ b/temporalio/activity.py @@ -459,10 +459,10 @@ def payload_converter() -> temporalio.converter.PayloadConverter: return _Context.current().payload_converter -async def get_handle_value( - handle: temporalio.converter.PayloadHandle[AnyType], +async def resolve_value_handle( + handle: temporalio.common.ValueHandle[AnyType], ) -> AnyType: - """Acquire the value a :py:class:`temporalio.converter.PayloadHandle` refers to. + """Acquire the value a :py:class:`temporalio.common.ValueHandle` refers to. Uses this activity's data converter to run the handle's deferred inbound pipeline (external-storage retrieval if offloaded, codec decode, then @@ -474,9 +474,32 @@ async def get_handle_value( if context.data_converter is None: raise RuntimeError( "No data converter is available in this activity context; " - "cannot acquire a PayloadHandle value." + "cannot acquire a ValueHandle value." ) - return await context.data_converter.get_handle_value(handle) + return await context.data_converter.resolve_value_handle(handle) + + +async def create_value_handle( + value: AnyType, + *, + metadata: Mapping[str, str] | None = None, +) -> temporalio.common.ValueHandle[AnyType]: + """Produce a :py:class:`~temporalio.common.ValueHandle` from a value. + + Stores the value via this activity's data converter (offloading to external + storage if configured) under the activity's serialization context, and + returns a handle carrying a reference plus any ``metadata``. A consumer can + read that metadata without acquiring the value. Call it from activity code, + where storage I/O is permitted; a workflow forwards handles but does not + create or acquire their values. + """ + context = _Context.current() + if context.data_converter is None: + raise RuntimeError( + "No data converter is available in this activity context; " + "cannot create a ValueHandle." + ) + return await context.data_converter.create_value_handle(value, metadata=metadata) def metric_meter() -> temporalio.common.MetricMeter: diff --git a/temporalio/bridge/worker.py b/temporalio/bridge/worker.py index 6252fe239..66e63bda9 100644 --- a/temporalio/bridge/worker.py +++ b/temporalio/bridge/worker.py @@ -338,9 +338,9 @@ async def decode_activation( defer: content-neutral predicate (reads the visitor's current command / run-argument context) returning True when the payload(s) being visited should be left un-retrieved and un-codec-decoded so they - surface as forward-only PayloadHandles. + surface as forward-only ValueHandles. index_run_args: visit run arguments one at a time so ``defer`` can decide - per argument position (only needed when a run arg is a PayloadHandle). + per argument position (only needed when a run arg is a ValueHandle). Returns: Metrics from any external storage retrieval operations that occurred. diff --git a/temporalio/common.py b/temporalio/common.py index ad75b56b9..66f6fd11a 100644 --- a/temporalio/common.py +++ b/temporalio/common.py @@ -9,7 +9,7 @@ import warnings from abc import ABC, abstractmethod from collections.abc import Callable, Collection, Iterator, Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime, timedelta from enum import IntEnum from typing import ( @@ -378,6 +378,77 @@ def __setstate__(self, state: object) -> None: ) +# User metadata attached to a value handle is stored under payload-metadata keys +# with this prefix. It is server-opaque and travels with the reference, so +# consumers can read it without acquiring the value. +_HANDLE_METADATA_PREFIX = "_temporal-handle-meta." + + +@dataclass(frozen=True) +class ValueHandle(Generic[temporalio.types.AnyType]): + """A lazy, immutable, pass-by-reference handle to a value. + + The lazy sibling of :py:class:`RawValue`. Annotate a workflow/activity/signal + parameter or return value as ``ValueHandle[T]`` to receive one of these + instead of the materialized value. Forward it onward without cost, and + acquire its value at a boundary (an activity or client) where fetching data + is permitted, via :py:func:`temporalio.activity.resolve_value_handle` or + :py:meth:`temporalio.converter.DataConverter.resolve_value_handle`. A handle + does not acquire its own value. + """ + + # The opaque end-of-pipeline payload (may be an external-storage reference + # or a codec-encoded inline payload). The handle is backing-agnostic; this + # is read by the boundary converter when acquiring the value. + _payload: temporalio.api.common.v1.Payload + # Inner type ``T`` captured from the annotation, used as the decode hint + # when acquiring the value. May be None for a bare ``ValueHandle``. + _type: type | None = field(default=None, compare=False) + + @property + def metadata(self) -> Mapping[str, str]: + """User metadata attached at creation, readable without acquiring the value. + + Lets a consumer route or filter on small descriptive data (size, format, + etc.) without downloading the underlying payload. Empty if none was + attached. + """ + prefix = _HANDLE_METADATA_PREFIX + return { + key[len(prefix) :]: value.decode() + for key, value in self._payload.metadata.items() + if key.startswith(prefix) + } + + def __getstate__(self) -> object: + """Pickle support (workflow sandbox caching).""" + return {"payload": self._payload.SerializeToString(), "type": self._type} + + def __setstate__(self, state: object) -> None: + """Pickle support.""" + if not isinstance(state, dict): + raise TypeError(f"Expected dict state, got {type(state)}") + object.__setattr__( + self, + "_payload", + temporalio.api.common.v1.Payload.FromString(state["payload"]), + ) + object.__setattr__(self, "_type", state.get("type")) + + +class AsHandle: + """Marker for ``Annotated[T, AsHandle]``: consume ``T`` as a forward-only + :py:class:`ValueHandle` without ``T`` leaving the shared contract. + + ``Annotated[T, AsHandle]`` is transparent to type checkers (both a caller and + the callee still see ``T``), so a caller passes a plain ``T`` -- no coupling. + The SDK recovers the marker via ``get_type_hints(..., include_extras=True)`` + and delivers a handle instead of materializing. This is the *forward-only* + (type-erased) option: the callee's variable is still statically ``T``, so it + can forward the value but cannot call handle methods on it statically. + """ + + # We choose to make this a list instead of an sequence so we can catch if people # are not sending lists each time but maybe accidentally sending a string (which # is a sequence) diff --git a/temporalio/converter/__init__.py b/temporalio/converter/__init__.py index 4cf6177f8..9192eb704 100644 --- a/temporalio/converter/__init__.py +++ b/temporalio/converter/__init__.py @@ -35,7 +35,6 @@ PayloadConverter, value_to_type, ) -from temporalio.converter._payload_handle import PayloadHandle from temporalio.converter._search_attributes import ( decode_search_attributes, decode_typed_search_attributes, @@ -77,7 +76,6 @@ "JSONTypeConverterUnhandled", "PayloadCodec", "PayloadConverter", - "PayloadHandle", "SerializationContext", "WithSerializationContext", "WorkflowSerializationContext", diff --git a/temporalio/converter/_data_converter.py b/temporalio/converter/_data_converter.py index 2a75a403d..a61fb9a71 100644 --- a/temporalio/converter/_data_converter.py +++ b/temporalio/converter/_data_converter.py @@ -30,7 +30,8 @@ PayloadConverter, ) from temporalio.converter._payload_handle import ( - PayloadHandle, + ValueHandle, + _attach_metadata, _is_payload_handle_hint, ) from temporalio.converter._serialization_context import ( @@ -133,10 +134,10 @@ async def decode( Returns: Decoded and converted values. """ - # Positions annotated as PayloadHandle defer acquisition: keep their + # Positions annotated as ValueHandle defer acquisition: keep their # opaque payload and skip eager external-storage retrieval + codec # decode, producing a data-only handle. Acquisition is a boundary - # operation (get_handle_value), not something the handle does itself, so + # operation (resolve_value_handle), not something the handle does itself, so # no converter is captured here. if type_hints is not None and any( _is_payload_handle_hint(h) for h in type_hints @@ -161,8 +162,8 @@ async def decode( payloads = await self._decode_payload_sequence(payloads) return self.payload_converter.from_payloads(payloads, type_hints) - async def get_handle_value(self, handle: PayloadHandle[AnyType]) -> AnyType: - """Acquire the value a :py:class:`PayloadHandle` refers to. + async def resolve_value_handle(self, handle: ValueHandle[AnyType]) -> AnyType: + """Acquire the value a :py:class:`ValueHandle` refers to. This is the boundary operation for handles: the handle is a plain value carrying the opaque payload and its inner type, and this converter @@ -173,23 +174,47 @@ async def get_handle_value(self, handle: PayloadHandle[AnyType]) -> AnyType: Call it where acquisition I/O is permitted (an activity or client boundary), never inside the workflow sandbox. In activity code, prefer - :py:func:`temporalio.activity.get_handle_value`, which uses the + :py:func:`temporalio.activity.resolve_value_handle`, which uses the activity's converter. Raises: RuntimeError: if the handle carries no concrete type (a bare - ``PayloadHandle`` annotation), since conversion needs a type. + ``ValueHandle`` annotation), since conversion needs a type. """ inner_type = handle._type if inner_type is None: raise RuntimeError( - "[TMPRL1106] PayloadHandle has no type to acquire into. " - "Annotate the value as PayloadHandle[T] with a concrete type T." + "[TMPRL1106] ValueHandle has no type to acquire into. " + "Annotate the value as ValueHandle[T] with a concrete type T." ) payload = await self._transform_inbound_payload(handle._payload) values = self.payload_converter.from_payloads([payload], [inner_type]) return values[0] + async def create_value_handle( + self, + value: Any, + *, + metadata: Mapping[str, str] | None = None, + ) -> ValueHandle[Any]: + """Produce a :py:class:`ValueHandle` from a value. + + The producer-side counterpart to :py:meth:`resolve_value_handle`: it runs the + outbound pipeline (convert, codec encode, external-storage offload if + configured) under this converter's serialization context, so the value + is stored once and the handle carries a reference. Optional ``metadata`` + is attached as server-opaque keys that a consumer can probe without + acquiring the value. + + Call it where storage I/O is permitted (an activity or client boundary), + never inside the workflow sandbox. In activity code, prefer + :py:func:`temporalio.activity.create_value_handle`. + """ + [payload] = await self.encode([value]) + if metadata: + _attach_metadata(payload, metadata) + return ValueHandle(payload, type(value)) + async def encode_wrapper( self, values: Sequence[Any] ) -> temporalio.api.common.v1.Payloads: diff --git a/temporalio/converter/_payload_converter.py b/temporalio/converter/_payload_converter.py index d3a3f3184..7fc3a9845 100644 --- a/temporalio/converter/_payload_converter.py +++ b/temporalio/converter/_payload_converter.py @@ -46,7 +46,7 @@ from enum import StrEnum # type: ignore[reportUnreachable] from temporalio.converter._payload_handle import ( - PayloadHandle, + ValueHandle, _create_handle, _is_payload_handle_hint, _payload_handle_inner_type, @@ -267,10 +267,10 @@ def to_payloads( # RawValue should just pass through if isinstance(value, temporalio.common.RawValue): payload = value.payload - # A PayloadHandle re-emits its opaque payload unchanged, so + # A ValueHandle re-emits its opaque payload unchanged, so # forwarding it (e.g. workflow -> activity) neither downloads nor # re-stores the underlying data. - elif isinstance(value, PayloadHandle): + elif isinstance(value, ValueHandle): payload = value._payload else: for converter in self.converters.values(): @@ -304,7 +304,7 @@ def from_payloads( if type_hint == temporalio.common.RawValue: values.append(temporalio.common.RawValue(payload)) continue - # A PayloadHandle[T] hint defers acquisition: wrap the opaque payload + # A ValueHandle[T] hint defers acquisition: wrap the opaque payload # regardless of its encoding rather than materializing it now. The # handle binds to the current boundary converter (via contextvar) if # one is set, else it is forward-only. diff --git a/temporalio/converter/_payload_handle.py b/temporalio/converter/_payload_handle.py index e2c63a534..17b7cd458 100644 --- a/temporalio/converter/_payload_handle.py +++ b/temporalio/converter/_payload_handle.py @@ -1,102 +1,36 @@ -"""Payload handles: lazy, pass-by-reference payload values. +"""Converter-internal helpers for value handles. -A :py:class:`PayloadHandle` is used as a parameter or return annotation -(``PayloadHandle[T]``) to defer *acquiring* a value -- external-storage -retrieval, codec decoding, and deserialization. A handle is a plain, immutable -*value*: it carries the opaque, end-of-pipeline payload and the inner type, and -can be forwarded (e.g. from a workflow to an activity) without paying to -acquire it. - -The handle deliberately owns no behavior beyond introspection of what it -carries. Acquiring the value needs machinery -- a data converter, codec chain, -and storage driver -- that belongs to an execution surface (the activity worker -or the client), not to a payload value. So acquisition is a boundary operation -(:py:meth:`temporalio.converter.DataConverter.get_handle_value`, and -:py:func:`temporalio.activity.get_handle_value` in activity code), never a -method on the handle. This keeps the handle portable with no captured runtime -state, and avoids relying on an ambient mechanism to inject a converter. - -This mirrors :py:class:`temporalio.common.RawValue`: the annotation, not any -wire encoding, is what triggers handle behavior, so a handle works on any -already-stored payload in history and is replay-safe. +The user-facing :py:class:`temporalio.common.ValueHandle` type and the +``AsHandle`` marker live in :py:mod:`temporalio.common`, next to +:py:class:`temporalio.common.RawValue`. This module holds only the +converter-internal helpers that recognize handle type hints and build handles +during payload conversion. """ from __future__ import annotations -from dataclasses import dataclass, field -from typing import ( - Any, - Generic, - Optional, - get_args, - get_origin, -) +from collections.abc import Mapping +from typing import Any, Optional, get_args, get_origin import temporalio.api.common.v1 -from temporalio.types import AnyType - - -@dataclass(frozen=True) -class PayloadHandle(Generic[AnyType]): - """A lazy, immutable, pass-by-reference handle to a payload value. - - Annotate a workflow/activity/signal parameter or return value as - ``PayloadHandle[T]`` to receive one of these instead of the materialized - value. Forward it onward without cost, and acquire its value at a boundary - (an activity or client) where fetching data is permitted, via - :py:func:`temporalio.activity.get_handle_value` or - :py:meth:`temporalio.converter.DataConverter.get_handle_value`. A handle - does not acquire its own value. - """ - - # The opaque end-of-pipeline payload (may be an external-storage reference - # or a codec-encoded inline payload). The handle is backing-agnostic; this - # is read by the boundary converter when acquiring the value. - _payload: temporalio.api.common.v1.Payload - # Inner type ``T`` captured from the annotation, used as the decode hint - # when acquiring the value. May be None for a bare ``PayloadHandle``. - _type: Optional[type] = field(default=None, compare=False) - - def __getstate__(self) -> object: - """Pickle support (workflow sandbox caching).""" - return {"payload": self._payload.SerializeToString(), "type": self._type} - - def __setstate__(self, state: object) -> None: - """Pickle support.""" - if not isinstance(state, dict): - raise TypeError(f"Expected dict state, got {type(state)}") - object.__setattr__( - self, - "_payload", - temporalio.api.common.v1.Payload.FromString(state["payload"]), - ) - object.__setattr__(self, "_type", state.get("type")) - - -class AsHandle: - """Marker for ``Annotated[T, AsHandle]``: consume ``T`` as a forward-only - :py:class:`PayloadHandle` without ``T`` leaving the shared contract. - - ``Annotated[T, AsHandle]`` is transparent to type checkers (both a caller and - the callee still see ``T``), so a caller passes a plain ``T`` -- no coupling. - The SDK recovers the marker via ``get_type_hints(..., include_extras=True)`` - and delivers a handle instead of materializing. This is the *forward-only* - (type-erased) option: the callee's variable is still statically ``T``, so it - can forward the value but cannot call handle methods on it statically. - """ +from temporalio.common import ( + _HANDLE_METADATA_PREFIX, + AsHandle, + ValueHandle, +) def _is_payload_handle_hint(hint: Any) -> bool: - """Return True for ``PayloadHandle``, ``PayloadHandle[T]``, or ``Annotated[T, AsHandle]``.""" + """Return True for ``ValueHandle``, ``ValueHandle[T]``, or ``Annotated[T, AsHandle]``.""" return ( - hint is PayloadHandle - or get_origin(hint) is PayloadHandle + hint is ValueHandle + or get_origin(hint) is ValueHandle or AsHandle in getattr(hint, "__metadata__", ()) ) def _payload_handle_inner_type(hint: Any) -> Optional[type]: - """Return ``T`` from ``PayloadHandle[T]`` or ``Annotated[T, AsHandle]``, else None.""" + """Return ``T`` from ``ValueHandle[T]`` or ``Annotated[T, AsHandle]``, else None.""" # Annotated[T, AsHandle]: the base type T is the inner (materialized) type. if AsHandle in getattr(hint, "__metadata__", ()): return hint.__origin__ @@ -105,20 +39,28 @@ def _payload_handle_inner_type(hint: Any) -> Optional[type]: def _payload_handle_hint(inner_type: Optional[type]) -> Any: - """Build a ``PayloadHandle[inner_type]`` hint (bare if ``inner_type`` is None). + """Build a ``ValueHandle[inner_type]`` hint (bare if ``inner_type`` is None). Used to upgrade a call's result type so an unchanged activity/child result is consumed as a handle: the declared return type becomes the handle's ``T``. """ return ( - PayloadHandle[inner_type] # type: ignore[valid-type] + ValueHandle[inner_type] # type: ignore[valid-type] if inner_type is not None - else PayloadHandle + else ValueHandle ) def _create_handle( payload: temporalio.api.common.v1.Payload, inner_type: Optional[type] -) -> PayloadHandle[Any]: +) -> ValueHandle[Any]: """Build a data-only handle (no captured converter).""" - return PayloadHandle(payload, inner_type) + return ValueHandle(payload, inner_type) + + +def _attach_metadata( + payload: temporalio.api.common.v1.Payload, metadata: Mapping[str, str] +) -> None: + """Attach user metadata to a payload as server-opaque, prefixed keys.""" + for key, value in metadata.items(): + payload.metadata[_HANDLE_METADATA_PREFIX + key] = value.encode() diff --git a/temporalio/worker/_command_aware_visitor.py b/temporalio/worker/_command_aware_visitor.py index 65ab40db8..8b08b22a6 100644 --- a/temporalio/worker/_command_aware_visitor.py +++ b/temporalio/worker/_command_aware_visitor.py @@ -73,7 +73,7 @@ def __init__( index_run_args: If True, workflow run arguments are visited one at a time with :py:data:`current_run_arg_index` set, so retrieval can be deferred per argument position. Left False (batched) unless a - run argument is consumed as a PayloadHandle. + run argument is consumed as a ValueHandle. """ super().__init__( skip_search_attributes=skip_search_attributes, diff --git a/temporalio/worker/_workflow.py b/temporalio/worker/_workflow.py index 138854d7a..7e9252a71 100644 --- a/temporalio/worker/_workflow.py +++ b/temporalio/worker/_workflow.py @@ -299,7 +299,7 @@ def _defer_retrieval( The predicate reads the payload visitor's current context (run-argument index, or command seq for a resolved activity/child result) and returns - True when that position's payload should stay a forward-only PayloadHandle + True when that position's payload should stay a forward-only ValueHandle -- so retrieval and codec decode are skipped. It decides purely from API-declared types by position, never by inspecting the payload proto. diff --git a/temporalio/worker/_workflow_instance.py b/temporalio/worker/_workflow_instance.py index 473392e83..f7b59616a 100644 --- a/temporalio/worker/_workflow_instance.py +++ b/temporalio/worker/_workflow_instance.py @@ -232,7 +232,7 @@ def is_result_deferred( Not abstract: defaults to False (eager retrieval, the prior behavior). Overridden to return True when the pending activity/child-workflow - requested its result as a ``PayloadHandle``, so the worker can skip + requested its result as a ``ValueHandle``, so the worker can skip downloading an offloaded result the workflow only forwards. """ return False @@ -3221,7 +3221,7 @@ def __init__( ) ) - def as_payload_handle(self) -> temporalio.workflow.ActivityHandle[Any]: + def as_value_handle(self) -> temporalio.workflow.ActivityHandle[Any]: ph = temporalio.converter._payload_handle if not ph._is_payload_handle_hint(self._input.ret_type): self._input.ret_type = ph._payload_handle_hint(self._input.ret_type) @@ -3380,7 +3380,7 @@ def __init__( workflow_context ) - def as_payload_handle(self) -> temporalio.workflow.ChildWorkflowHandle[Any, Any]: + def as_value_handle(self) -> temporalio.workflow.ChildWorkflowHandle[Any, Any]: ph = temporalio.converter._payload_handle if not ph._is_payload_handle_hint(self._input.ret_type): self._input.ret_type = ph._payload_handle_hint(self._input.ret_type) diff --git a/temporalio/workflow/_activities.py b/temporalio/workflow/_activities.py index 0f66ed084..2c8192353 100644 --- a/temporalio/workflow/_activities.py +++ b/temporalio/workflow/_activities.py @@ -66,13 +66,13 @@ class ActivityHandle(_AsyncioTask[ReturnType]): # type: ignore[type-var] This extends :py:class:`asyncio.Task` and supports all task features. """ - def as_payload_handle( + def as_value_handle( self, - ) -> ActivityHandle[temporalio.converter.PayloadHandle[ReturnType]]: - """Consume this activity's result as a lazy PayloadHandle. + ) -> ActivityHandle[temporalio.common.ValueHandle[ReturnType]]: + """Consume this activity's result as a lazy ValueHandle. The activity is unchanged and still returns its declared type; awaiting - this handle yields a :py:class:`temporalio.converter.PayloadHandle` of + this handle yields a :py:class:`temporalio.common.ValueHandle` of that type instead of the materialized value. If the result was offloaded to external storage it is not downloaded into the workflow -- forward the handle to an activity (or materialize it there) to avoid paying for data diff --git a/temporalio/workflow/_workflow_ops.py b/temporalio/workflow/_workflow_ops.py index 9f7100b99..9cbffc272 100644 --- a/temporalio/workflow/_workflow_ops.py +++ b/temporalio/workflow/_workflow_ops.py @@ -69,14 +69,14 @@ def first_execution_run_id(self) -> str | None: """Run ID for the workflow.""" raise NotImplementedError - def as_payload_handle( + def as_value_handle( self, - ) -> ChildWorkflowHandle[SelfType, temporalio.converter.PayloadHandle[ReturnType]]: - """Consume this child workflow's result as a lazy PayloadHandle. + ) -> ChildWorkflowHandle[SelfType, temporalio.common.ValueHandle[ReturnType]]: + """Consume this child workflow's result as a lazy ValueHandle. The child workflow is unchanged and still returns its declared type; awaiting this handle yields a - :py:class:`temporalio.converter.PayloadHandle` of that type instead of + :py:class:`temporalio.common.ValueHandle` of that type instead of the materialized value. If the result was offloaded to external storage it is not downloaded into the parent workflow. """ diff --git a/tests/test_payload_handle.py b/tests/test_payload_handle.py index 2924d8831..24b23878b 100644 --- a/tests/test_payload_handle.py +++ b/tests/test_payload_handle.py @@ -1,8 +1,8 @@ -"""Unit tests for PayloadHandle (Phase 1 prototype), server-free. +"""Unit tests for ValueHandle (Phase 1 prototype), server-free. -These exercise the converter-level behavior: a PayloadHandle[T] annotation +These exercise the converter-level behavior: a ValueHandle[T] annotation defers acquisition (external-storage retrieval, codec decode, deserialization) -until the value is acquired at a boundary via DataConverter.get_handle_value, +until the value is acquired at a boundary via DataConverter.resolve_value_handle, and forwarding a handle re-emits its opaque payload without downloading. The proof is the driver's retrieve-call count. """ @@ -15,11 +15,11 @@ import pytest from temporalio.api.common.v1 import Payload +from temporalio.common import ValueHandle from temporalio.converter import ( DataConverter, ExternalStorage, PayloadCodec, - PayloadHandle, ) from tests.test_extstore import InMemoryTestDriver @@ -43,13 +43,13 @@ async def test_toplevel_reference_becomes_handle() -> None: payloads = await dc.encode([_BIG]) assert driver._store_calls == 1 - [handle] = await dc.decode(payloads, [PayloadHandle[str]]) - assert isinstance(handle, PayloadHandle) + [handle] = await dc.decode(payloads, [ValueHandle[str]]) + assert isinstance(handle, ValueHandle) # No download happened just by receiving the handle. assert driver._retrieve_calls == 0 # The value is acquired at the boundary, through the converter. - assert await dc.get_handle_value(handle) == _BIG + assert await dc.resolve_value_handle(handle) == _BIG assert driver._retrieve_calls == 1 @@ -61,11 +61,11 @@ async def test_handle_is_data_only_and_forwards_without_download() -> None: # A handle is a data-only value: holding it downloads nothing, and it owns # no acquire behavior (acquisition is a boundary operation, not a method on # the handle). Forward-only-ness lives on the workflow surface, which simply - # does not expose get_handle_value, not in the handle's state. - [handle] = dc.payload_converter.from_payloads([reference], [PayloadHandle[str]]) - assert isinstance(handle, PayloadHandle) + # does not expose resolve_value_handle, not in the handle's state. + [handle] = dc.payload_converter.from_payloads([reference], [ValueHandle[str]]) + assert isinstance(handle, ValueHandle) assert not hasattr(handle, "materialize") - assert not hasattr(handle, "get_handle_value") + assert not hasattr(handle, "resolve_value_handle") assert driver._retrieve_calls == 0 # Forwarding re-emits a byte-identical reference payload, still no download. @@ -74,7 +74,7 @@ async def test_handle_is_data_only_and_forwards_without_download() -> None: assert driver._retrieve_calls == 0 # The value is acquired only through a boundary converter. - assert await dc.get_handle_value(handle) == _BIG + assert await dc.resolve_value_handle(handle) == _BIG assert driver._retrieve_calls == 1 @@ -86,7 +86,7 @@ async def test_non_handle_annotation_is_eager() -> None: # Default behavior is unchanged: a real-type hint materializes eagerly. [value] = await dc.decode(payloads, [str]) assert value == _BIG - assert not isinstance(value, PayloadHandle) + assert not isinstance(value, ValueHandle) assert driver._retrieve_calls == 1 @@ -126,11 +126,11 @@ async def test_codec_deferred_until_acquired() -> None: dc = _storage_converter(driver, codec=codec) payloads = await dc.encode([_BIG]) - [handle] = await dc.decode(payloads, [PayloadHandle[str]]) + [handle] = await dc.decode(payloads, [ValueHandle[str]]) # The reference is not codec-decoded when the handle is produced. assert codec.decode_calls == 0 - assert await dc.get_handle_value(handle) == _BIG + assert await dc.resolve_value_handle(handle) == _BIG assert codec.decode_calls == 1 @@ -138,13 +138,32 @@ async def test_pickled_handle_survives_and_forwards() -> None: driver = InMemoryTestDriver() dc = _storage_converter(driver) payloads = await dc.encode([_BIG]) - [handle] = await dc.decode(payloads, [PayloadHandle[str]]) + [handle] = await dc.decode(payloads, [ValueHandle[str]]) restored = pickle.loads(pickle.dumps(handle)) - assert isinstance(restored, PayloadHandle) + assert isinstance(restored, ValueHandle) # The opaque payload survives, so a rehydrated handle can still be forwarded # and acquired at a boundary. Compare with proto equality: re-parsing may # reorder the metadata map, so serialized bytes are not a reliable check. [out] = dc.payload_converter.to_payloads([restored]) assert out == payloads[0] - assert await dc.get_handle_value(restored) == _BIG + assert await dc.resolve_value_handle(restored) == _BIG + + +async def test_create_value_handle_stores_once_with_probeable_metadata() -> None: + driver = InMemoryTestDriver() + dc = _storage_converter(driver) + + # Producing a handle from a value stores it once and wraps the reference. + handle = await dc.create_value_handle(_BIG, metadata={"pages": "42"}) + assert isinstance(handle, ValueHandle) + assert driver._store_calls == 1 + assert driver._retrieve_calls == 0 + + # Metadata is readable without acquiring (downloading) the value. + assert handle.metadata == {"pages": "42"} + assert driver._retrieve_calls == 0 + + # The value round-trips through a boundary acquire. + assert await dc.resolve_value_handle(handle) == _BIG + assert driver._retrieve_calls == 1 diff --git a/tests/test_payload_handle_annotated.py b/tests/test_payload_handle_annotated.py index 8c812b99b..7df58e3e1 100644 --- a/tests/test_payload_handle_annotated.py +++ b/tests/test_payload_handle_annotated.py @@ -5,7 +5,7 @@ itself) drives handle consumption. A caller passes a plain ``T`` because ``Annotated[T, AsHandle]`` is transparent to type checkers, so there is no coupling; the SDK recovers the marker and delivers a forward-only -:py:class:`PayloadHandle` instead of materializing. +:py:class:`ValueHandle` instead of materializing. Scope note: this exercises the converter layer directly, which is where the mechanism lives. It does NOT yet work end to end through a running workflow, @@ -20,9 +20,9 @@ from typing import Annotated, Any, get_type_hints -from temporalio.converter import DataConverter, PayloadHandle +from temporalio.common import AsHandle, ValueHandle +from temporalio.converter import DataConverter from temporalio.converter._payload_handle import ( - AsHandle, _is_payload_handle_hint, _payload_handle_inner_type, ) @@ -32,7 +32,7 @@ def test_annotated_is_transparent_but_marker_recoverable() -> None: def handler(data: Annotated[str, AsHandle]) -> None: ... # A caller and a type checker see a plain `str` -- the contract is unchanged, - # so there is no coupling: the caller passes a `str`, not a PayloadHandle. + # so there is no coupling: the caller passes a `str`, not a ValueHandle. assert get_type_hints(handler)["data"] is str # The SDK can still recover the marker when it asks for extras. assert ( @@ -57,12 +57,12 @@ async def test_same_payload_consumed_as_value_or_handle_by_marker() -> None: [value] = await dc.decode([payload], [str]) assert value == "big-value" - # Consumed via the marker -> a PayloadHandle, with no change to the `str` + # Consumed via the marker -> a ValueHandle, with no change to the `str` # contract. Its value is acquired at the boundary through the converter. handle_hint: Any = Annotated[str, AsHandle] [handle] = await dc.decode([payload], [handle_hint]) - assert isinstance(handle, PayloadHandle) - assert await dc.get_handle_value(handle) == "big-value" + assert isinstance(handle, ValueHandle) + assert await dc.resolve_value_handle(handle) == "big-value" async def test_annotated_handle_is_a_data_only_value() -> None: @@ -71,11 +71,11 @@ async def test_annotated_handle_is_a_data_only_value() -> None: # Sync conversion, as inside the workflow sandbox, yields a data-only handle: # it carries the payload but owns no acquire behavior. Forward-only-ness is a - # property of the workflow surface (which does not expose get_handle_value), + # property of the workflow surface (which does not expose resolve_value_handle), # not of the handle; acquisition is a boundary operation. handle_hint: Any = Annotated[str, AsHandle] [handle] = dc.payload_converter.from_payloads([payload], [handle_hint]) - assert isinstance(handle, PayloadHandle) + assert isinstance(handle, ValueHandle) assert not hasattr(handle, "materialize") # Through a boundary converter the value is acquirable. - assert await dc.get_handle_value(handle) == "big-value" + assert await dc.resolve_value_handle(handle) == "big-value" diff --git a/tests/worker/test_payload_handle.py b/tests/worker/test_payload_handle.py index 116936029..ef65df3e6 100644 --- a/tests/worker/test_payload_handle.py +++ b/tests/worker/test_payload_handle.py @@ -1,7 +1,7 @@ -"""End-to-end tests for PayloadHandle (Phase 1 prototype). +"""End-to-end tests for ValueHandle (Phase 1 prototype). Demonstrates the headline behavior: a workflow whose run argument is annotated -``PayloadHandle[T]`` receives a forward-only handle instead of an eagerly +``ValueHandle[T]`` receives a forward-only handle instead of an eagerly downloaded value, forwards it to an activity without downloading, and the activity acquires it on demand. The proof is the driver's retrieve count: 0 for pass-through (and on replay), exactly 1 when an activity materializes. @@ -16,7 +16,8 @@ import temporalio.converter from temporalio import activity, workflow from temporalio.client import Client -from temporalio.converter import ExternalStorage, PayloadHandle +from temporalio.common import ValueHandle +from temporalio.converter import ExternalStorage from temporalio.testing import WorkflowEnvironment from temporalio.worker import Replayer from tests.helpers import new_worker @@ -28,14 +29,14 @@ @activity.defn -async def consume_handle(data: PayloadHandle[str]) -> int: +async def consume_handle(data: ValueHandle[str]) -> int: # The activity needs the bytes, so it acquires them on demand at the boundary. - value = await activity.get_handle_value(data) + value = await activity.resolve_value_handle(data) return len(value) @activity.defn -async def ignore_handle(data: PayloadHandle[str]) -> str: +async def ignore_handle(data: ValueHandle[str]) -> str: # Never materializes; the handle is just passed through. return "ignored" @@ -43,7 +44,7 @@ async def ignore_handle(data: PayloadHandle[str]) -> str: @workflow.defn class ForwardToConsumeWorkflow: @workflow.run - async def run(self, data: PayloadHandle[str]) -> int: + async def run(self, data: ValueHandle[str]) -> int: # Forward the handle to an activity without materializing it here. return await workflow.execute_activity( consume_handle, data, start_to_close_timeout=timedelta(seconds=30) @@ -53,7 +54,7 @@ async def run(self, data: PayloadHandle[str]) -> int: @workflow.defn class ForwardToIgnoreWorkflow: @workflow.run - async def run(self, data: PayloadHandle[str]) -> str: + async def run(self, data: ValueHandle[str]) -> str: return await workflow.execute_activity( ignore_handle, data, start_to_close_timeout=timedelta(seconds=30) ) @@ -74,7 +75,7 @@ async def run(self) -> int: # an activity that materializes it. handle = await workflow.start_activity( produce_big, start_to_close_timeout=timedelta(seconds=30) - ).as_payload_handle() + ).as_value_handle() return await workflow.execute_activity( consume_handle, handle, start_to_close_timeout=timedelta(seconds=30) ) @@ -86,7 +87,7 @@ class ResultAsHandlePassThroughWorkflow: async def run(self) -> str: handle = await workflow.start_activity( produce_big, start_to_close_timeout=timedelta(seconds=30) - ).as_payload_handle() + ).as_value_handle() return await workflow.execute_activity( ignore_handle, handle, start_to_close_timeout=timedelta(seconds=30) ) @@ -106,12 +107,35 @@ async def run(self) -> str: # Upgrade an unchanged child workflow's result to a handle and forward it # without materializing it in this (parent) workflow. child = await workflow.start_child_workflow(ChildProducerWorkflow.run) - handle = await child.as_payload_handle() + handle = await child.as_value_handle() return await workflow.execute_activity( ignore_handle, handle, start_to_close_timeout=timedelta(seconds=30) ) +@activity.defn +async def produce_handle_with_metadata() -> ValueHandle[str]: + # The activity creates a handle from its (large) result and attaches metadata + # that a consumer can probe without downloading the value. + return await activity.create_value_handle(_BIG, metadata={"length": str(len(_BIG))}) + + +@workflow.defn +class ProbeMetadataThenForwardWorkflow: + @workflow.run + async def run(self) -> int: + handle = await workflow.execute_activity( + produce_handle_with_metadata, + start_to_close_timeout=timedelta(seconds=30), + ) + # Probe metadata in the workflow to decide, without downloading the value. + assert handle.metadata["length"] == str(len(_BIG)) + # Forward the handle to an activity that materializes it. + return await workflow.execute_activity( + consume_handle, handle, start_to_close_timeout=timedelta(seconds=30) + ) + + def _data_converter(driver: InMemoryTestDriver) -> temporalio.converter.DataConverter: return dataclasses.replace( temporalio.converter.default(), @@ -237,3 +261,26 @@ async def test_child_workflow_result_as_handle_pass_through( # The child workflow's offloaded result was upgraded to a handle in the # parent and forwarded without ever being downloaded. assert driver._retrieve_calls == 0 + + +async def test_activity_creates_handle_with_probeable_metadata( + env: WorkflowEnvironment, +) -> None: + driver = InMemoryTestDriver() + client = await _client(env, driver) + async with new_worker( + client, + ProbeMetadataThenForwardWorkflow, + activities=[produce_handle_with_metadata, consume_handle], + ) as worker: + result = await client.execute_workflow( + ProbeMetadataThenForwardWorkflow.run, + id=f"wf-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + assert result == len(_BIG) + # Stored once (when the activity created the handle) and downloaded once (when + # the consuming activity materialized it). The workflow probed metadata and + # forwarded the handle without any download. + assert driver._store_calls == 1 + assert driver._retrieve_calls == 1 From 42e6d9fb60bce02dc633240367c4cec132211b29 Mon Sep 17 00:00:00 2001 From: jmaeagle99 <44687433+jmaeagle99@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:00:18 -0700 Subject: [PATCH 7/7] exp: defer encode and store until commit --- temporalio/activity.py | 21 ++++--- temporalio/common.py | 21 +++++-- temporalio/converter/_data_converter.py | 73 +++++++++++++++++++------ tests/test_payload_handle.py | 41 ++++++++++---- 4 files changed, 118 insertions(+), 38 deletions(-) diff --git a/temporalio/activity.py b/temporalio/activity.py index 3b2774502..bec4d1d76 100644 --- a/temporalio/activity.py +++ b/temporalio/activity.py @@ -484,14 +484,19 @@ async def create_value_handle( *, metadata: Mapping[str, str] | None = None, ) -> temporalio.common.ValueHandle[AnyType]: - """Produce a :py:class:`~temporalio.common.ValueHandle` from a value. - - Stores the value via this activity's data converter (offloading to external - storage if configured) under the activity's serialization context, and - returns a handle carrying a reference plus any ``metadata``. A consumer can - read that metadata without acquiring the value. Call it from activity code, - where storage I/O is permitted; a workflow forwards handles but does not - create or acquire their values. + """Produce a :py:class:`~temporalio.common.ValueHandle` from a value, deferring the store. + + Converts the value immediately (the convert does no I/O) and returns a + *pending* handle. The stored form -- codec encode plus external-storage upload + if configured, with the ``metadata`` attached -- is produced only when the + activity's result is committed, using the activity's serialization context. If + the activity faults before returning the handle, nothing is uploaded and no + external-storage blob is orphaned. A consumer can read the metadata without + acquiring the value. A workflow forwards handles but does not create or acquire + their values. + + Async for consistency with :py:func:`resolve_value_handle` and the SDK's other + boundary operations, even though the convert itself does no I/O. """ context = _Context.current() if context.data_converter is None: diff --git a/temporalio/common.py b/temporalio/common.py index 66f6fd11a..972ef6e0f 100644 --- a/temporalio/common.py +++ b/temporalio/common.py @@ -397,13 +397,22 @@ class ValueHandle(Generic[temporalio.types.AnyType]): does not acquire its own value. """ - # The opaque end-of-pipeline payload (may be an external-storage reference - # or a codec-encoded inline payload). The handle is backing-agnostic; this - # is read by the boundary converter when acquiring the value. - _payload: temporalio.api.common.v1.Payload + # The handle's payload. For a realized handle this is the stored reference + # the boundary converter acquires from. For a *pending* handle (see create at + # the activity boundary) it is the value already converted at create time, + # still awaiting the deferred codec encode and external-storage upload. + _payload: temporalio.api.common.v1.Payload | None = None # Inner type ``T`` captured from the annotation, used as the decode hint # when acquiring the value. May be None for a bare ``ValueHandle``. _type: type | None = field(default=None, compare=False) + # Pending-handle state: True while the store (codec encode + external-storage + # upload) is still deferred to commit, plus the metadata to attach when it is + # stored. This state is transient and producer-side only; a handle only ever + # crosses the wire realized. + _pending: bool = field(default=False, compare=False) + _pending_metadata: Mapping[str, str] | None = field( + default=None, compare=False, repr=False + ) @property def metadata(self) -> Mapping[str, str]: @@ -413,6 +422,10 @@ def metadata(self) -> Mapping[str, str]: etc.) without downloading the underlying payload. Empty if none was attached. """ + if self._pending_metadata is not None: + return dict(self._pending_metadata) + if self._payload is None: + return {} prefix = _HANDLE_METADATA_PREFIX return { key[len(prefix) :]: value.decode() diff --git a/temporalio/converter/_data_converter.py b/temporalio/converter/_data_converter.py index a61fb9a71..45707e784 100644 --- a/temporalio/converter/_data_converter.py +++ b/temporalio/converter/_data_converter.py @@ -114,6 +114,17 @@ async def encode( same number as values given, but must be at least one and cannot be more than was given. """ + # Realize any pending (deferred-store) value handles here, at commit. + # This is where a deferred create_value_handle actually stores. If a + # producing activity faults before returning its result, encode is never + # called for that result, so a pending handle is simply dropped: nothing + # is uploaded and no external-storage blob is orphaned. + values = [ + await self._realize_value_handle(value) + if isinstance(value, ValueHandle) and value._pending + else value + for value in values + ] payloads = self.payload_converter.to_payloads(values) payloads = await self._encode_payload_sequence(payloads) payloads = await self._external_store_payload_sequence(payloads) @@ -181,6 +192,12 @@ async def resolve_value_handle(self, handle: ValueHandle[AnyType]) -> AnyType: RuntimeError: if the handle carries no concrete type (a bare ``ValueHandle`` annotation), since conversion needs a type. """ + # A pending handle (created but not yet committed) holds the value as an + # already-converted payload; deserialize it directly, with no I/O. + if handle._pending: + return self.payload_converter.from_payloads( + [handle._payload], [handle._type] + )[0] inner_type = handle._type if inner_type is None: raise RuntimeError( @@ -197,23 +214,47 @@ async def create_value_handle( *, metadata: Mapping[str, str] | None = None, ) -> ValueHandle[Any]: - """Produce a :py:class:`ValueHandle` from a value. - - The producer-side counterpart to :py:meth:`resolve_value_handle`: it runs the - outbound pipeline (convert, codec encode, external-storage offload if - configured) under this converter's serialization context, so the value - is stored once and the handle carries a reference. Optional ``metadata`` - is attached as server-opaque keys that a consumer can probe without - acquiring the value. - - Call it where storage I/O is permitted (an activity or client boundary), - never inside the workflow sandbox. In activity code, prefer - :py:func:`temporalio.activity.create_value_handle`. + """Produce a :py:class:`ValueHandle` from a value, deferring the store. + + The producer-side counterpart to :py:meth:`resolve_value_handle`. The + value is *converted* immediately (as a workflow method invocation does), + so it is snapshotted and any serialization error surfaces here at the call + site. But the I/O-bearing tail -- codec encode and external-storage upload + -- is deferred: it runs only when the handle is encoded at result/input + commit (see :py:meth:`encode` and :py:meth:`_realize_value_handle`). So if + a producing activity faults before returning the handle, nothing is + uploaded and no external-storage blob is orphaned. + + This is async for consistency with :py:meth:`resolve_value_handle` and the + SDK's other boundary operations, and to leave room to perform I/O (such as + an eager store) without a breaking signature change; the convert itself + does none. Call it where the deferred upload will be permitted at commit + (an activity or client boundary), never inside the workflow sandbox. In + activity code, prefer :py:func:`temporalio.activity.create_value_handle`. """ - [payload] = await self.encode([value]) - if metadata: - _attach_metadata(payload, metadata) - return ValueHandle(payload, type(value)) + [payload] = self.payload_converter.to_payloads([value]) + return ValueHandle( + _payload=payload, + _type=type(value), + _pending=True, + _pending_metadata=metadata, + ) + + async def _realize_value_handle( + self, handle: ValueHandle[Any] + ) -> ValueHandle[Any]: + """Store a pending handle's converted value now, returning a realized handle. + + The value was already converted at create time; this runs the deferred, + I/O-bearing tail of the outbound pipeline -- codec encode, then + external-storage offload if configured -- and attaches the pending + metadata to the resulting reference. Called from :py:meth:`encode` at commit. + """ + [payload] = await self._encode_payload_sequence([handle._payload]) + [payload] = await self._external_store_payload_sequence([payload]) + if handle._pending_metadata: + _attach_metadata(payload, handle._pending_metadata) + return ValueHandle(payload, handle._type) async def encode_wrapper( self, values: Sequence[Any] diff --git a/tests/test_payload_handle.py b/tests/test_payload_handle.py index 24b23878b..2e046ee32 100644 --- a/tests/test_payload_handle.py +++ b/tests/test_payload_handle.py @@ -150,20 +150,41 @@ async def test_pickled_handle_survives_and_forwards() -> None: assert await dc.resolve_value_handle(restored) == _BIG -async def test_create_value_handle_stores_once_with_probeable_metadata() -> None: +async def test_create_value_handle_defers_store_until_commit() -> None: driver = InMemoryTestDriver() - dc = _storage_converter(driver) + # A realistic threshold: the value offloads, its small reference does not. + dc = DataConverter( + external_storage=ExternalStorage(drivers=[driver], payload_size_threshold=1024) + ) + value = "x" * 4096 - # Producing a handle from a value stores it once and wraps the reference. - handle = await dc.create_value_handle(_BIG, metadata={"pages": "42"}) + # Creating a handle stores nothing: the store is deferred to commit (encode). + # create_value_handle does no I/O at call time (the convert is synchronous). + handle = await dc.create_value_handle(value, metadata={"pages": "42"}) assert isinstance(handle, ValueHandle) + assert driver._store_calls == 0 + # Metadata is known without any store. + assert handle.metadata == {"pages": "42"} + + # Encoding the handle, as at result/input commit, is where the store happens. + [payload] = await dc.encode([handle]) assert driver._store_calls == 1 - assert driver._retrieve_calls == 0 - # Metadata is readable without acquiring (downloading) the value. - assert handle.metadata == {"pages": "42"} + # The committed payload is a realized reference carrying the metadata: a + # consumer probes the metadata without downloading, then resolves to the value. + [realized] = await dc.decode([payload], [ValueHandle[str]]) + assert realized.metadata == {"pages": "42"} assert driver._retrieve_calls == 0 - - # The value round-trips through a boundary acquire. - assert await dc.resolve_value_handle(handle) == _BIG + assert await dc.resolve_value_handle(realized) == value assert driver._retrieve_calls == 1 + + +async def test_pending_handle_dropped_without_commit_is_never_stored() -> None: + driver = InMemoryTestDriver() + dc = _storage_converter(driver) + + # A producer that creates a handle but never commits it (e.g. an activity that + # faults before returning) uploads nothing, so no external-storage blob is + # orphaned. + _ = await dc.create_value_handle(_BIG, metadata={"pages": "42"}) + assert driver._store_calls == 0