From c9206626d81ec02ba5f779709435f2f86e19bcee Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 22 Oct 2026 10:32:00 -0700 Subject: [PATCH 01/11] Wrote Workflow Records through one Local Activity per call. --- temporalio/contrib/streams/_backend.py | 33 -- temporalio/contrib/streams/_errors.py | 21 +- temporalio/contrib/streams/_writes.py | 118 +++++++ temporalio/contrib/streams/redis.py | 305 +++--------------- temporalio/contrib/streams/testing.py | 6 +- temporalio/contrib/streams/workflow.py | 213 ++++++++---- .../streams/examples/agent_publishes_turns.py | 3 +- tests/contrib/streams/test_redis.py | 82 +---- tests/contrib/streams/test_workflow_module.py | 64 +++- 9 files changed, 390 insertions(+), 455 deletions(-) create mode 100644 temporalio/contrib/streams/_writes.py diff --git a/temporalio/contrib/streams/_backend.py b/temporalio/contrib/streams/_backend.py index d17682742..ba832d07d 100644 --- a/temporalio/contrib/streams/_backend.py +++ b/temporalio/contrib/streams/_backend.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import timedelta from typing import TYPE_CHECKING, Protocol, TypeVar import google.protobuf.message @@ -96,13 +95,6 @@ def writers( _ExtensionTransport(self, namespace), data_converter=data_converter ) - def call(self, method: str, request: google.protobuf.message.Message) -> bytes: - """Call a synchronous extension method.""" - try: - return self._extension.call(method, request.SerializeToString()) - except temporalio.worker.ExtensionError as err: - raise _errors.from_extension_error(err) from None - async def call_async( self, method: str, request: google.protobuf.message.Message, response: type[_M] ) -> _M: @@ -113,22 +105,6 @@ async def call_async( raise _errors.from_extension_error(err) from None return response.FromString(data) - async def flush_run_buffer( - self, namespace: str, run_id: str, workflow_task: stream_pb.WorkflowTaskEpoch - ) -> int: - """Write a run's buffered Workflow calls before a Local Activity of the run starts. - - Returns how many Records it wrote. - """ - response = await self.call_async( - "FlushRunBuffer", - ext_pb.FlushRunBufferRequest( - namespace=namespace, run_id=run_id, workflow_task=workflow_task - ), - ext_pb.FlushRunBufferResponse, - ) - return response.records - async def close_writers(self) -> int: """Drop every open writer of the extension, and return how many failed.""" response = await self.call_async( @@ -136,15 +112,6 @@ async def close_writers(self) -> int: ) return response.failed - async def drain_closes(self, timeout: timedelta) -> int: - """Wait for the Workflow closes in flight, and return how many are left.""" - request = ext_pb.DrainClosesRequest() - request.timeout.FromTimedelta(timeout) - response = await self.call_async( - "DrainCloses", request, ext_pb.DrainClosesResponse - ) - return response.pending - class _ExtensionTransport: # The generated client takes a transport, so the five operations reach the extension as diff --git a/temporalio/contrib/streams/_errors.py b/temporalio/contrib/streams/_errors.py index de203ebed..4946aeb3e 100644 --- a/temporalio/contrib/streams/_errors.py +++ b/temporalio/contrib/streams/_errors.py @@ -30,8 +30,12 @@ def __init__( code: temporalio.service.RPCStatusCode | None = None, ) -> None: """Create a Stream error.""" + # The reason goes in the details, so it comes back from a Local Activity. super().__init__( - message, type=type(self).__name__, non_retryable=not self._retryable + message, + *([reason] if reason else []), + type=type(self).__name__, + non_retryable=not self._retryable, ) self._reason = reason self._code = code @@ -116,6 +120,21 @@ def from_extension_error(err: temporalio.worker.ExtensionError) -> StreamError: ) +_BY_TYPE = {cls.__name__: cls for cls in (StreamError, *_BY_REASON.values())} + + +def from_activity_error(err: Exception) -> Exception: + """The Stream error that a write Local Activity raised, else ``err``.""" + cause = err.cause if isinstance(err, temporalio.exceptions.ActivityError) else err + if ( + isinstance(cause, temporalio.exceptions.ApplicationError) + and cause.type in _BY_TYPE + ): + reason = str(cause.details[0]) if cause.details else "" + return _BY_TYPE[cause.type](cause.message, reason=reason) + return err + + def from_rpc_error(err: temporalio.service.RPCError) -> Exception: """The Stream error for a server failure with Stream error details, else ``err``.""" for detail in err.grpc_status.details: diff --git a/temporalio/contrib/streams/_writes.py b/temporalio/contrib/streams/_writes.py new file mode 100644 index 000000000..4ea682d87 --- /dev/null +++ b/temporalio/contrib/streams/_writes.py @@ -0,0 +1,118 @@ +"""The Local Activities that write for Workflow code. + +Each Workflow ``create_stream``, ``publish`` and ``close`` is one of them, so History has the +answer and not the values. +""" + +from __future__ import annotations + +from typing import Any + +import temporalio.activity +import temporalio.api.streamservice.v1 as stream_pb +import temporalio.common +from temporalio.contrib.streams import _activity, _errors +from temporalio.contrib.streams.system import client as service_client +from temporalio.contrib.streams.system import models +from temporalio.contrib.streams.workflow import ( + APPEND_ACTIVITY, + CLOSE_ACTIVITY, + CREATE_ACTIVITY, +) + + +def _context() -> tuple[_activity.ActivityStreams, service_client.StreamServiceClient]: + streams = _activity.current.get() + if streams is None: + raise _errors.StreamConfigError("No Stream plugin runs this Local Activity") + return streams, streams.backend.service(streams.namespace, streams.converter) + + +def _producer(streams: _activity.ActivityStreams) -> models.Producer: + # Every attempt writes as one Producer epoch with one sequence, so the backend drops the + # copy that a retry sends. + producer = streams.producer + return models.Producer( + kind=producer.kind, + id=producer.id, + run_id=producer.run_id or None, + epoch=models.ProducerEpoch(attempt=1), + ) + + +def _ref(ref: stream_pb.StreamRef) -> models.StreamRef: + return models.StreamRef( + id=ref.id, + workflow_id=ref.workflow_id or None, + first_execution_run_id=ref.first_execution_run_id or None, + ) + + +@temporalio.activity.defn(name=CREATE_ACTIVITY) +async def create_stream(request: stream_pb.CreateStreamRequest) -> int: + """Create a Workflow's Stream.""" + _, service = _context() + response = await service.create_stream( + models.CreateStreamRequest( + stream_id=request.stream_id, + retention=request.retention.ToTimedelta() + if request.HasField("retention") + else None, + auto_close=request.auto_close.ToTimedelta() + if request.HasField("auto_close") + else None, + withdraw_run=request.withdraw_run or None, + ) + ) + if response.status == stream_pb.StreamStatus.STREAM_STATUS_CLOSED: + raise _errors.StreamClosedError( + f"The Stream {request.stream_id} was created and closed before", + reason="stream_closed", + ) + return 0 + + +@temporalio.activity.defn(name=APPEND_ACTIVITY) +async def append_records(request: stream_pb.AppendRecordsRequest) -> int: + """Append a Workflow's Records as one batch, and return the last offset.""" + streams, service = _context() + response = await service.append_records( + models.AppendRecordsRequest( + stream=_ref(request.stream), + producer=_producer(streams), + sequence=1, + records=[ + # The Workflow converted the values, so only the codec runs on them here. + models.AppendRecord( + topic=record.topic, body=temporalio.common.RawValue(record.body) + ) + for record in request.records + ], + ) + ) + return response.last_offset or 0 + + +@temporalio.activity.defn(name=CLOSE_ACTIVITY) +async def close_stream(request: stream_pb.CloseStreamRequest) -> int: + """Close a Workflow's Stream.""" + streams, service = _context() + failure: Any = None + if request.HasField("failure"): + failure = streams.converter.failure_converter.from_failure( + request.failure, streams.converter.payload_converter + ) + await service.close_stream( + models.CloseStreamRequest( + stream=_ref(request.stream), + producer=_producer(streams), + result=temporalio.common.RawValue(request.result) + if request.HasField("result") + else None, + failure=failure, + ) + ) + return 0 + + +ACTIVITIES = [create_stream, append_records, close_stream] diff --git a/temporalio/contrib/streams/redis.py b/temporalio/contrib/streams/redis.py index 69733147e..e8c87c980 100644 --- a/temporalio/contrib/streams/redis.py +++ b/temporalio/contrib/streams/redis.py @@ -9,8 +9,7 @@ import asyncio import dataclasses import logging -from collections.abc import Awaitable, Callable, Generator, Mapping, Sequence -from dataclasses import dataclass +from collections.abc import Mapping, Sequence from datetime import datetime, timedelta from typing import Any, NoReturn @@ -24,7 +23,7 @@ import temporalio.runtime import temporalio.worker import temporalio.workflow -from temporalio.contrib.streams import _activity, _errors, _ids +from temporalio.contrib.streams import _activity, _ids, _writes from temporalio.contrib.streams import workflow as streams_workflow from temporalio.contrib.streams._backend import _ExtensionStreamBackend from temporalio.contrib.streams._handle import StreamHandle @@ -37,9 +36,6 @@ EXTENSION_NAME = "temporalio-streams-redis" -_SERVICE = "temporal.api.streamservice.v1.StreamService" -# How long a Worker's shutdown waits for the Stream closes that are still going out. -_DRAIN_TIMEOUT = timedelta(seconds=10) # The plugin puts this key into the headers of each Activity and Local Activity that a Workflow # schedules. It keeps the Producer id the same across retries. _KEY_HEADER = "__temporal_stream_key" @@ -76,6 +72,7 @@ def __init__( default=default, stream_backend=self._backend, interceptors=[_StreamInterceptor(self)], + activities=_writes.ACTIVITIES, ) def configure_worker( @@ -103,63 +100,13 @@ def configure_worker( ) return config - async def run_worker( - self, - worker: temporalio.worker.Worker, - next: Callable[[temporalio.worker.Worker], Awaitable[None]], - ) -> None: - """Run the Worker, then wait for the Stream closes that are still going out. - - Each Activity attempt already closed its writers. Client writers may still be in use, - so the Worker leaves them. Call :py:meth:`shutdown` when the process is done. - """ - try: - await super().run_worker(worker, next) - finally: - pending = await self._backend.drain_closes(_DRAIN_TIMEOUT) - if pending: - logger.warning( - "Worker shutdown left %d Stream closes in flight", pending - ) - async def shutdown(self) -> None: - """Flush and drop every open writer, including the client's, and wait for the closes - in flight. Call it once the process no longer writes Streams. + """Drop every open writer, including the client's. Call it once the process no longer + writes Streams. """ - backend = self._backend - failed = await backend.close_writers() - pending = await backend.drain_closes(_DRAIN_TIMEOUT) - if failed or pending: - logger.warning( - "Stream shutdown left %d failed writers and %d closes in flight", - failed, - pending, - ) - - async def on_workflow_activation_complete( - self, input: temporalio.worker.WorkflowActivationCompleteInput - ) -> None: - """Hand the Activation's Stream calls to the runtime, encoded.""" - calls = [ - item - for item in input.items - if getattr(item, "_temporal_stream_call", False) - ] - if not calls: - return - info = input.info - request = ext_pb.BufferWorkflowCallsRequest( - run_id=input.run_id, - withdraw_run=_withdraw_run(info), - chain_continues=any(call.chain_continues for call in calls), - ) - for call in _merge_appends(calls): - request.calls.append( - await _encode_call(call, self._converter, info.namespace) - ) - # The call only adds to the run's buffer under a short lock, so it doesn't block the - # event loop. - self._backend.call("BufferWorkflowCalls", request) + failed = await self._backend.close_writers() + if failed: + logger.warning("Stream shutdown left %d failed writers", failed) class RedisStreamPlugin(_ExtensionStreamPlugin): @@ -182,8 +129,6 @@ def __init__( key_prefix: str | None = None, retention: timedelta | None = None, safety_ttl: timedelta | None = None, - durable_writes: int = 0, - durable_timeout: timedelta | None = None, response_timeout: timedelta | None = None, runtime: temporalio.runtime.Runtime | None = None, name: str = "redis", @@ -199,8 +144,6 @@ def __init__( retention: The default Retention of a new Stream. Defaults to 7 days. safety_ttl: How long an open Workflow-owned Stream stays after its last write. Defaults to 90 days. - durable_writes: When above zero, each Workflow flush waits for this many replicas. - durable_timeout: How long that wait takes at most. Defaults to 1 s. response_timeout: How long one Redis call takes at most. Defaults to 5 s. runtime: The runtime of the client. Defaults to the default runtime. name: The backend name that Stream handles carry, so every process routes a @@ -212,12 +155,10 @@ def __init__( urls=[url] if isinstance(url, str) else list(url), cluster=cluster, key_prefix=key_prefix or "", - durable_writes=durable_writes, ) for field, value in ( ("retention", retention), ("safety_ttl", safety_ttl), - ("durable_timeout", durable_timeout), ("response_timeout", response_timeout), ): if value is not None: @@ -232,107 +173,6 @@ def __init__( ) -def _withdraw_run(info: temporalio.workflow.Info) -> str: - # A retry run withdraws the Records of the run it retries. - if info.attempt > 1 and info.continued_run_id: - return info.continued_run_id - return "" - - -@dataclass(frozen=True) -class _WorkflowCall: - # Marks the item by attribute, which holds even if a sandbox imports a second copy of the - # class. - _temporal_stream_call = True - - operation: str - request: Any - when_run_closes: bool = False - chain_continues: bool = False - - -def _merge_appends(calls: list[_WorkflowCall]) -> list[_WorkflowCall]: - # Each append is one batch with its own sequence, so adjacent appends to one Stream go - # as one. - merged: list[_WorkflowCall] = [] - for call in calls: - last = merged[-1] if merged else None - if ( - last is not None - and last.operation == call.operation == "AppendRecords" - and last.request.stream == call.request.stream - ): - records = [*(last.request.records or []), *(call.request.records or [])] - merged[-1] = dataclasses.replace( - last, request=dataclasses.replace(last.request, records=records) - ) - else: - merged.append(call) - return merged - - -async def _encode_call( - call: _WorkflowCall, - converter: temporalio.converter.DataConverter, - namespace: str, -) -> ext_pb.WorkflowCall: - request = call.request - if call.operation == "CreateStream": - create = stream_pb.CreateStreamRequest( - namespace=namespace, stream_id=request.stream_id - ) - if request.retention is not None: - create.retention.FromTimedelta(request.retention) - if request.auto_close is not None: - create.auto_close.FromTimedelta(request.auto_close) - return ext_pb.WorkflowCall(create=create) - ref = request.stream or models.StreamRef(id="") - stream = stream_pb.StreamRef( - id=ref.id, - workflow_id=ref.workflow_id or "", - first_execution_run_id=ref.first_execution_run_id or "", - ) - if call.operation == "AppendRecords": - records = request.records or [] - bodies = await converter.encode([record.body for record in records]) - return ext_pb.WorkflowCall( - append=stream_pb.AppendRecordsRequest( - namespace=namespace, - stream=stream, - records=[ - stream_pb.AppendRecord(topic=record.topic, body=body) - for record, body in zip(records, bodies) - ], - ) - ) - close = stream_pb.CloseStreamRequest(namespace=namespace, stream=stream) - if request.result is not None: - [result] = await converter.encode([request.result]) - close.result.CopyFrom(result) - if request.failure is not None: - await converter.encode_failure(request.failure, close.failure) - return ext_pb.WorkflowCall(close=close, when_run_closes=call.when_run_closes) - - -class _DoneHandle(temporalio.workflow.NexusOperationHandle[Any]): - # The call is in the Activation's list, so the handle is done at once. - def __init__(self, result: Any) -> None: - self._result = result - - def cancel(self) -> bool: - return False - - def __await__(self) -> Generator[Any, Any, Any]: - return self._value().__await__() - - async def _value(self) -> Any: - return self._result - - @property - def operation_token(self) -> str | None: - return None - - class _StreamInterceptor(temporalio.client.Interceptor, temporalio.worker.Interceptor): def __init__(self, plugin: _ExtensionStreamPlugin) -> None: super().__init__() @@ -366,8 +206,10 @@ async def execute_workflow( _take_owned_streams(input.headers) try: result = await super().execute_workflow(input) + await streams_workflow.drain() except temporalio.workflow.ContinueAsNewError: # The next run continues the run chain's Streams. + await streams_workflow.drain() raise except BaseException as err: if not isinstance(err, (Exception, asyncio.CancelledError)): @@ -379,24 +221,19 @@ async def execute_workflow( and temporalio.workflow.cancellation_reason() is not None ): # The run ends cancelled. - _close_with_run( + await _close_with_run( failure=temporalio.exceptions.CancelledError("Workflow cancelled") ) - elif _fails_run(err): - _close_with_run( - failure=err, - chain_continues=chain_continues( - temporalio.workflow.info(), temporalio.workflow.now(), err - ), - ) + elif _fails_run(err) and not chain_continues( + temporalio.workflow.info(), temporalio.workflow.now(), err + ): + await _close_with_run(failure=err) raise # A cron run that completes still goes on with the chain. - _close_with_run( - result=result, - chain_continues=chain_continues( - temporalio.workflow.info(), temporalio.workflow.now(), None - ), - ) + if not chain_continues( + temporalio.workflow.info(), temporalio.workflow.now(), None + ): + await _close_with_run(result=result) return result @@ -444,54 +281,27 @@ def _non_retryable(err: BaseException, types: Sequence[str] | None) -> bool: return type(err).__name__ in (types or []) -def _close_with_run( - *, - result: Any | None = None, - failure: BaseException | None = None, - chain_continues: bool = False, +async def _close_with_run( + *, result: Any | None = None, failure: BaseException | None = None ) -> None: - for run_stream in streams_workflow._state().streams.values(): + for stream_id, run_stream in streams_workflow._state().streams.items(): if not run_stream.close_with_run or run_stream.closed: continue - run_stream.closed = True - temporalio.workflow.send_to_plugins( - _WorkflowCall( - "CloseStream", - _convert_in_sandbox( - models.CloseStreamRequest( - stream=run_stream.ref, result=result, failure=failure - ) - ), - when_run_closes=True, - chain_continues=chain_continues, - ), - # A close is idempotent, and its run may have no task left to send it on. - during_replay=True, - ) - - -def _convert_in_sandbox(request: Any) -> Any: - # A value from Workflow code may be of a class that only the sandbox knows, so the - # Workflow's payload converter turns it into a Payload here. The hook outside runs only - # the codec. - converter = temporalio.workflow.payload_converter() - if isinstance(request, models.AppendRecordsRequest): - return dataclasses.replace( - request, - records=[ - dataclasses.replace( - record, - body=temporalio.common.RawValue(converter.to_payload(record.body)), - ) - for record in request.records or [] - ], + stream = streams_workflow.WorkflowStream( + id=stream_id, + workflow_id=run_stream.ref.workflow_id, + first_execution_run_id=run_stream.ref.first_execution_run_id, + backend=run_stream.backend, ) - if isinstance(request, models.CloseStreamRequest) and request.result is not None: - return dataclasses.replace( - request, - result=temporalio.common.RawValue(converter.to_payload(request.result)), - ) - return request + try: + await stream.close(result=result, failure=failure) + except Exception: + if failure is None: + raise + # The run's own failure wins over a close that failed. + temporalio.workflow.logger.warning( + "Failed closing the Stream %s with the run", stream_id, exc_info=True + ) def _owned_ref(stream_id: str) -> models.StreamRef: @@ -539,30 +349,6 @@ def _activity_headers(self) -> dict[str, temporalio.api.common.v1.Payload]: converter = temporalio.converter.PayloadConverter.default return {_KEY_HEADER: converter.to_payload(str(self._activity_calls))} - async def start_system_nexus_operation( - self, input: temporalio.worker.StartNexusOperationInput[Any, Any] - ) -> temporalio.workflow.NexusOperationHandle[Any]: - if input.service != _SERVICE: - return await super().start_system_nexus_operation(input) - operation = input.operation_name - if operation == "CreateStream": - result: Any = models.CreateStreamResponse( - stream=_owned_ref(input.input.stream_id) - ) - elif operation == "AppendRecords": - result = models.AppendRecordsResponse() - elif operation == "CloseStream": - result = models.CloseStreamResponse() - else: - raise _errors.StreamError(f"Workflow code can't call {operation}") - temporalio.workflow.send_to_plugins( - _WorkflowCall(operation, _convert_in_sandbox(input.input)), - # The runtime already has the Records of a replayed task. A create or a close is - # idempotent, so it goes again, for a run whose buffer an eviction dropped. - during_replay=operation != "AppendRecords", - ) - return _DoneHandle(result) - def continue_as_new(self, input: temporalio.worker.ContinueAsNewInput) -> NoReturn: # The next run needs each owned Stream's backend for stream_handle. owned = [ @@ -606,7 +392,6 @@ async def execute_activity( streams = _activity_streams(input, self._plugin) token = _activity.current.set(streams) try: - await _flush_run_before_local_activity(self._plugin._backend, streams) if temporalio.activity.info().attempt > 1: # The open of a new attempt withdraws the Records of the earlier ones, also # when this attempt publishes nothing. It creates no Stream. @@ -624,24 +409,6 @@ async def execute_activity( _activity.current.reset(token) -async def _flush_run_before_local_activity( - backend: _ExtensionStreamBackend, streams: _activity.ActivityStreams -) -> None: - # The run's buffered Records go before the Local Activity's, in the epoch of the Workflow - # Task that dispatched it. A failure fails the attempt, and the run's next completion - # fails too, so its retry withdraws what was written. - info = temporalio.activity.info() - task = info.workflow_task - if not info.is_local or task is None or not info.workflow_run_id: - return - epoch = stream_pb.WorkflowTaskEpoch( - started_event_id=task.started_event_id, attempt=task.attempt - ) - if task.started_time is not None: - epoch.started_time.FromDatetime(task.started_time) - await backend.flush_run_buffer(streams.namespace, info.workflow_run_id, epoch) - - def _stream_arguments(args: Sequence[Any]) -> list[StreamHandle[Any]]: # The Streams that an Activity received, which an earlier attempt may have written. They # may sit in fields of its input object, in slots, and in lists and dicts. diff --git a/temporalio/contrib/streams/testing.py b/temporalio/contrib/streams/testing.py index 2e25392be..eeda96f78 100644 --- a/temporalio/contrib/streams/testing.py +++ b/temporalio/contrib/streams/testing.py @@ -31,7 +31,6 @@ def __init__( *, retention: timedelta | None = None, safety_ttl: timedelta | None = None, - durable_writes: int = 0, runtime: temporalio.runtime.Runtime | None = None, name: str = "memory", default: bool = False, @@ -42,15 +41,12 @@ def __init__( retention: The default Retention of a new Stream. Defaults to 7 days. safety_ttl: How long an open Workflow-owned Stream stays after its last write. Defaults to 90 days. - durable_writes: When above zero, each Workflow flush waits for this many - replicas. The memory Store has none, so each Workflow flush fails with the - missing ``replica_wait`` capability. It is for tests of that failure. runtime: The runtime of the client. Defaults to the default runtime. name: The backend name that Stream handles carry. default: Make this the client's default backend when the client has more than one Stream plugin. """ - config = ext_pb.RedisStreamConfig(durable_writes=durable_writes) + config = ext_pb.RedisStreamConfig() if retention is not None: config.retention.FromTimedelta(retention) if safety_ttl is not None: diff --git a/temporalio/contrib/streams/workflow.py b/temporalio/contrib/streams/workflow.py index 43415b427..0c1c9c0c3 100644 --- a/temporalio/contrib/streams/workflow.py +++ b/temporalio/contrib/streams/workflow.py @@ -11,25 +11,31 @@ from __future__ import annotations import asyncio -import weakref -from collections.abc import Coroutine from dataclasses import dataclass, field from datetime import timedelta from types import TracebackType from typing import Any, TypeVar, overload +import google.protobuf.message + +import temporalio.api.streamservice.v1 as stream_pb +import temporalio.converter import temporalio.exceptions import temporalio.workflow from temporalio.contrib.streams import _errors, _ids from temporalio.contrib.streams._defn import TopicDefinition, definition_of from temporalio.contrib.streams._handle import StreamHandle, TopicBase -from temporalio.contrib.streams.system import create_stream as _create_stream from temporalio.contrib.streams.system import models -from temporalio.contrib.streams.system._resources.stream import Stream as _Stream E = TypeVar("E") T = TypeVar("T") -_R = TypeVar("_R") + +# The plugin's Local Activities that write for Workflow code. The marker keeps the answer only, +# since a Local Activity's input is not in History. +CREATE_ACTIVITY = "__temporal_streams_create" +APPEND_ACTIVITY = "__temporal_streams_append" +CLOSE_ACTIVITY = "__temporal_streams_close" +_WRITE_TIMEOUT = timedelta(seconds=30) __all__ = [ "WorkflowStream", @@ -53,6 +59,8 @@ class _RunStream: @dataclass class _RunState: streams: dict[str, _RunStream] = field(default_factory=dict) + # The last write of each Stream. Each write waits for it, so writes keep their order. + tails: dict[str, asyncio.Task[int]] = field(default_factory=dict) def stream(self, ref: models.StreamRef) -> _RunStream: known = self.streams.get(ref.id) @@ -62,18 +70,14 @@ def stream(self, ref: models.StreamRef) -> _RunStream: return known -# One state per Workflow instance. The plugin passes temporalio.contrib.streams through the -# sandbox, so the plugin's interceptor sees the same state, and it goes when the run leaves -# the cache. -_states: weakref.WeakKeyDictionary[Any, _RunState] = weakref.WeakKeyDictionary() - - def _state() -> _RunState: + # The state is an attribute of the run, not a weak map entry, since its tasks refer to the + # run's loop and would keep a map entry alive. runtime = temporalio.workflow._Runtime.current() - state = _states.get(runtime) + state = getattr(runtime, "_temporal_streams_state", None) if state is None: state = _RunState() - _states[runtime] = state + setattr(runtime, "_temporal_streams_state", state) return state @@ -104,16 +108,60 @@ def _check_backend(backend: str | None, action: str) -> str: return worker_backend -def _now(call: Coroutine[Any, Any, _R]) -> _R: - # The plugin's interceptor answers at once, so the call ends in its first step. - try: - call.send(None) - except StopIteration as done: - return done.value - call.close() - raise _errors.StreamConfigError( - "No Stream plugin took the call, such as RedisStreamPlugin" - ) +def _write( + stream_id: str, activity: str, request: google.protobuf.message.Message +) -> asyncio.Task[int]: + state = _state() + previous = state.tails.get(stream_id) + + async def run() -> int: + if previous is not None: + try: + await previous + except BaseException: + pass + try: + return await temporalio.workflow.execute_local_activity( + activity, + request, + result_type=int, + start_to_close_timeout=_WRITE_TIMEOUT, + ) + except ( + temporalio.exceptions.ActivityError, + temporalio.exceptions.ApplicationError, + ) as err: + raise _errors.from_activity_error(err) from None + + task = asyncio.get_running_loop().create_task(run()) + state.tails[stream_id] = task + return task + + +def _write_nowait(task: asyncio.Task[int]) -> None: + runtime = temporalio.workflow._Runtime.current() + + def done(task: asyncio.Task[int]) -> None: + # No caller awaits the write, so its error fails the Workflow Task. + if not task.cancelled() and task.exception() is not None: + setattr(runtime, "_current_activation_error", task.exception()) + + task.add_done_callback(done) + + +async def drain() -> None: + """Wait for the run's writes that nobody awaits, before the run ends.""" + for task in list(_state().tails.values()): + try: + await task + except BaseException: + # A write that nobody awaits already failed the Workflow Task with its error. + pass + + +def _payload(value: Any) -> Any: + # The Workflow's converter runs here, in the sandbox. The plugin runs only the codec. + return temporalio.workflow.payload_converter().to_payload(value) class WorkflowTopic(TopicBase[T]): @@ -123,29 +171,30 @@ class WorkflowTopic(TopicBase[T]): This API is experimental. """ - async def publish(self, *values: T) -> None: - """Add one Record for each value, in order. + async def publish(self, *values: T) -> int: + """Write one Record for each value, in order, and return the last Record's offset. - It returns when the Records are in the run's buffer. The backend writes them before - the Workflow Task completes, and before a Local Activity that the run starts later. A - list is one value. + The write is one Local Activity, so History has its answer and not the values. A + list is one value. Writes to one Stream keep the order of the calls. Raises: + StreamError: The backend refused the write, such as ``StreamClosedError`` for a + Stream that something else closed. StreamClosedError: This run closed the Stream. ReadOnlyContextError: The code must not change state, such as a query handler. StreamConfigError: No Stream plugin is on the Worker, or the Stream is of a backend that the Worker does not have. It fails the Workflow Task, not the run. """ - self.publish_nowait(*values) + return await self._stream._append(self._name, values) def publish_nowait(self, *values: T) -> None: - """Add one Record for each value, in order, with no await. + """Write one Record for each value, in order, with no await. It is for sync Workflow code, such as ``@workflow.init`` and sync signal and update - handlers, as ``asyncio.Queue.put_nowait`` is for code that can't await ``put``. It has - the buffer and the errors of :py:meth:`publish`. + handlers, as ``asyncio.Queue.put_nowait`` is for code that can't await ``put``. A + backend error fails the Workflow Task. """ - self._stream._append(self._name, values) + _write_nowait(self._stream._append(self._name, values)) class WorkflowStream(StreamHandle[E]): @@ -165,10 +214,12 @@ def _model_ref(self) -> models.StreamRef: first_execution_run_id=self.first_execution_run_id, ) - def _resource(self) -> _Stream: - # The generated Stream resource makes the System Nexus calls. This class adds the - # definition's typed Topics and the run's replay rules. - return _Stream(stream=self._model_ref()) + def _ref(self) -> stream_pb.StreamRef: + return stream_pb.StreamRef( + id=self.id, + workflow_id=self.workflow_id or "", + first_execution_run_id=self.first_execution_run_id or "", + ) @overload def topic(self, topic: TopicDefinition[T]) -> WorkflowTopic[T]: ... @@ -212,40 +263,49 @@ def _check(self, action: str, *, backend_checked: bool = False) -> _RunStream: _check_backend(self.backend, action) return run_stream - def _append(self, topic: str, values: tuple[Any, ...]) -> None: + def _append(self, topic: str, values: tuple[Any, ...]) -> asyncio.Task[int]: if not values: raise TypeError("publish takes one or more values") self._check("write to") - # The backend's answer has no offsets that replay could give again, so it is dropped. - _now( - self._resource().append_records( - records=[ - models.AppendRecord(topic=topic, body=value) for value in values - ] - ) + records = [ + stream_pb.AppendRecord(topic=topic, body=_payload(v)) for v in values + ] + return _write( + self.id, + APPEND_ACTIVITY, + stream_pb.AppendRecordsRequest(stream=self._ref(), records=records), ) async def close( self, *, result: Any | None = None, failure: BaseException | None = None ) -> None: - """Close the Stream. It goes in the run's buffer, as a publish does. + """Close the Stream with one Local Activity, after the writes before it. Readers get the Records before it, then a ``Closed`` item with ``result`` or ``failure``. Later writes in this run raise :py:class:`StreamClosedError`. """ - self.close_nowait(result=result, failure=failure) + await self._close(result, failure) def close_nowait( self, *, result: Any | None = None, failure: BaseException | None = None ) -> None: """Close the Stream with no await, for sync Workflow code. - It is to :py:meth:`close` what ``asyncio.Queue.put_nowait`` is to ``put``, with the same - buffer and errors. + It is to :py:meth:`close` what ``asyncio.Queue.put_nowait`` is to ``put``. A backend + error fails the Workflow Task. """ - run_stream = self._check("close") - run_stream.closed = True - _now(self._resource().close(result=result, failure=failure)) + _write_nowait(self._close(result, failure)) + + def _close(self, result: Any, failure: BaseException | None) -> asyncio.Task[int]: + self._check("close").closed = True + request = stream_pb.CloseStreamRequest(stream=self._ref()) + if result is not None: + request.result.CopyFrom(_payload(result)) + if failure is not None: + temporalio.converter.DataConverter.default.failure_converter.to_failure( + failure, temporalio.workflow.payload_converter(), request.failure + ) + return _write(self.id, CLOSE_ACTIVITY, request) async def __aenter__(self) -> WorkflowStream[E]: """Use the Stream in ``async with``, which closes it at the end.""" @@ -300,8 +360,7 @@ async def create_stream( """Create a Stream that this run chain owns. Its id is ``::``, so a client finds it from the Workflow - handle with no call. The create goes in the run's buffer, as a publish does. A bad setting - fails the Workflow Task when the buffer goes out. + handle with no call. The create is one Local Activity, and its error raises here. Args: definition: The Stream definition class. @@ -318,21 +377,18 @@ async def create_stream( ``backend="native" if workflow.patched("streams-native") else "redis"``. Raises: - StreamClosedError: This run closed the Stream. + StreamError: The backend refused the create, such as for another Retention. ReadOnlyContextError: The code must not change state, such as a query handler. StreamConfigError: No Stream plugin is on the Worker, or it has no backend of that name. It fails the Workflow Task, not the run. ValueError: ``close_with_run=False`` for a Stream that already closes with the run chain. """ - return create_stream_nowait( - definition, - name=name, - retention=retention, - auto_close=auto_close, - close_with_run=close_with_run, - backend=backend, + stream, task = _create( + definition, name, retention, auto_close, close_with_run, backend ) + await task + return stream def create_stream_nowait( @@ -347,9 +403,24 @@ def create_stream_nowait( """Create a Stream that this run chain owns, with no await. It is for sync Workflow code, such as ``@workflow.init``, as ``asyncio.Queue.put_nowait`` - is for code that can't await ``put``. It takes the options of :py:func:`create_stream` - and has the same buffer and errors. + is for code that can't await ``put``. It takes the options of :py:func:`create_stream`. A + backend error fails the Workflow Task. """ + stream, task = _create( + definition, name, retention, auto_close, close_with_run, backend + ) + _write_nowait(task) + return stream + + +def _create( + definition: type[E], + name: str | None, + retention: timedelta | None, + auto_close: timedelta | None, + close_with_run: bool, + backend: str | None, +) -> tuple[WorkflowStream[E], asyncio.Task[int]]: stream = _own_handle(definition, name, _check_backend(backend, "create")) run_stream = stream._check("create", backend_checked=True) if run_stream.close_with_run and not close_with_run: @@ -357,12 +428,18 @@ def create_stream_nowait( f"The Stream {stream.id} already closes with the run chain, and " "close_with_run=False can't turn that off" ) - _now( - _create_stream(stream_id=stream.id, retention=retention, auto_close=auto_close) - ) + request = stream_pb.CreateStreamRequest(stream_id=stream.id) + if retention is not None: + request.retention.FromTimedelta(retention) + if auto_close is not None: + request.auto_close.FromTimedelta(auto_close) + info = temporalio.workflow.info() + if info.attempt > 1 and info.continued_run_id: + # A retry run withdraws the Records of the run it retries. + request.withdraw_run = info.continued_run_id run_stream.close_with_run = close_with_run run_stream.backend = stream.backend - return stream + return stream, _write(stream.id, CREATE_ACTIVITY, request) def stream_handle( diff --git a/tests/contrib/streams/examples/agent_publishes_turns.py b/tests/contrib/streams/examples/agent_publishes_turns.py index 5eff8daa4..cd5096199 100644 --- a/tests/contrib/streams/examples/agent_publishes_turns.py +++ b/tests/contrib/streams/examples/agent_publishes_turns.py @@ -64,8 +64,7 @@ async def run(self, messages: list[str]) -> str: ) turns = stream.topic(Events.turns) while True: - # The publish goes in the run's buffer, so it and the model call start in one - # Workflow Task. + # The publish is one Local Activity. History keeps its offset, not the Turn. await turns.publish(Turn("model")) reply = await workflow.execute_activity( call_model, diff --git a/tests/contrib/streams/test_redis.py b/tests/contrib/streams/test_redis.py index 12ae75fa7..2c7ff7356 100644 --- a/tests/contrib/streams/test_redis.py +++ b/tests/contrib/streams/test_redis.py @@ -17,7 +17,6 @@ from temporalio.contrib import streams from temporalio.contrib.streams import StreamPlugin from temporalio.contrib.streams import workflow as stream_workflow -from temporalio.contrib.streams.proto import v1 as ext_pb from temporalio.contrib.streams.redis import ( RedisStreamPlugin, _ExtensionStreamPlugin, @@ -36,7 +35,7 @@ SandboxedWorkflowRunner, SandboxRestrictions, ) -from tests.helpers import assert_task_fail_eventually, new_worker +from tests.helpers import new_worker @dataclass @@ -588,40 +587,6 @@ async def test_eviction_prints_nothing( assert "Exception ignored" not in captured.err + captured.out -async def test_workflow_calls_carry_the_owner(stream_client: Client): - [plugin] = [ - p - for p in stream_client.config()["plugins"] - if isinstance(p, _ExtensionStreamPlugin) - ] - sent: list[bytes] = [] - call = plugin._backend.call - - def record(method: str, request: Any) -> bytes: - if method == "BufferWorkflowCalls": - sent.append(request.SerializeToString()) - return call(method, request) - - plugin._backend.call = record # type: ignore[method-assign] - async with new_worker(stream_client, QuickWriter) as worker: - handle = await stream_client.start_workflow( - QuickWriter.run, id=f"wf-{uuid.uuid4()}", task_queue=worker.task_queue - ) - await handle.result() - refs = [] - for data in sent: - request = ext_pb.BufferWorkflowCallsRequest.FromString(data) - for workflow_call in request.calls: - if workflow_call.HasField("append"): - refs.append(workflow_call.append.stream) - if workflow_call.HasField("close"): - refs.append(workflow_call.close.stream) - assert refs - for ref in refs: - assert ref.workflow_id == handle.id - assert ref.first_execution_run_id == handle.first_execution_run_id - - @workflow.defn class AlwaysFails: @workflow.run @@ -669,20 +634,6 @@ async def test_final_failure_under_a_retry_policy( assert not any(s.withdraws(written[-1]) for s in superseded) -async def test_missing_capability_fails_the_workflow_task(client: Client): - # The Workflow flush runs in Core's before_complete, so a Store that lacks a capability - # fails the Workflow Task with the capability's name, not a call in Workflow code. - config = client.config() - config["plugins"] = [MemoryStreamPlugin(durable_writes=1)] - memory_client = Client(**config) - async with new_worker(memory_client, QuickWriter) as worker: - handle = await memory_client.start_workflow( - QuickWriter.run, id=f"wf-{uuid.uuid4()}", task_queue=worker.task_queue - ) - await assert_task_fail_eventually(handle, message_contains="replica_wait") - await handle.terminate() - - async def test_reader_stops_on_its_own_timer_and_resumes(stream_client: Client): stream = await streams.StreamClient(stream_client).create( Events, id=f"timer-{uuid.uuid4()}" @@ -739,17 +690,18 @@ async def test_two_backends_on_one_client(client: Client, redis_url: str): class WritesToStream: @workflow.run async def run(self, stream: stream_workflow.WorkflowStream[Events]) -> str: - await stream.topic(Events.tokens).publish("from workflow") - return "done" + try: + await stream.topic(Events.tokens).publish("from workflow") + except streams.StreamClosedError as err: + return err.reason + return "wrote" -async def test_write_to_a_stream_closed_elsewhere_is_dropped(stream_client: Client): +async def test_write_to_a_closed_stream_raises_at_the_await(stream_client: Client): stream = await streams.StreamClient(stream_client).create( Events, id=f"closed-{uuid.uuid4()}" ) await stream.close() - # This run did not close the Stream, so replay can't raise it in Workflow code. A live - # run and its replay must act the same, so the backend drops the Records. async with new_worker(stream_client, WritesToStream) as worker: assert ( await stream_client.execute_workflow( @@ -758,30 +710,12 @@ async def test_write_to_a_stream_closed_elsewhere_is_dropped(stream_client: Clie id=f"wf-{uuid.uuid4()}", task_queue=worker.task_queue, ) - == "done" + == "stream_closed" ) items = [item async for item in stream.subscribe()] assert len(items) == 1 and isinstance(items[0], streams.Closed) -async def test_store_error_fails_the_task_with_the_stream_id(client: Client): - config = client.config() - config["plugins"] = [MemoryStreamPlugin(durable_writes=1)] - memory_client = Client(**config) - stream = await streams.StreamClient(memory_client).create( - Events, id=f"open-{uuid.uuid4()}" - ) - async with new_worker(memory_client, WritesToStream) as worker: - handle = await memory_client.start_workflow( - WritesToStream.run, - stream_workflow.WorkflowStream.from_handle(stream), - id=f"wf-{uuid.uuid4()}", - task_queue=worker.task_queue, - ) - await assert_task_fail_eventually(handle, message_contains=stream.id) - await handle.terminate() - - @activity.defn async def unknown_topic(input: CallModelInput) -> str: try: diff --git a/tests/contrib/streams/test_workflow_module.py b/tests/contrib/streams/test_workflow_module.py index 49323dfee..603e04349 100644 --- a/tests/contrib/streams/test_workflow_module.py +++ b/tests/contrib/streams/test_workflow_module.py @@ -15,7 +15,9 @@ ) from temporalio.contrib import streams from temporalio.contrib.streams import workflow as stream_workflow +from temporalio.contrib.streams.system import client as service_client from temporalio.exceptions import ApplicationError +from temporalio.worker import Replayer from tests.helpers import assert_task_fail_eventually, new_worker @@ -286,15 +288,16 @@ async def test_cron_runs_share_an_open_stream(stream_client: Client): class SyncHandlers: @workflow.init def __init__(self, prompt: str) -> None: - self._stream = stream_workflow.stream_handle(Events) + # The create goes first, so the signal's writes find the Stream. + self._stream = stream_workflow.create_stream_nowait( + Events, close_with_run=False + ) self._prompt = prompt self._done = False @workflow.run async def run(self, prompt: str) -> None: - # The init made the handle before the create, since a handle makes no call. assert prompt == self._prompt - await stream_workflow.create_stream(Events, close_with_run=False) await workflow.wait_condition(lambda: self._done) @workflow.signal @@ -351,3 +354,58 @@ async def test_init_creates_and_publishes_with_no_await(stream_client: Client): items = await read(stream_client, handle) assert data(items) == ["hi", "HI"] assert isinstance(items[-1], streams.Closed) and items[-1].result == "done" + + +@workflow.defn +class Gathers: + @workflow.run + async def run(self) -> list[int]: + tokens = (await stream_workflow.create_stream(Events)).topic(Events.tokens) + return list(await asyncio.gather(*(tokens.publish(str(n)) for n in range(5)))) + + +async def test_concurrent_publishes_keep_their_order(stream_client: Client): + async with new_worker(stream_client, Gathers) as worker: + handle = await stream_client.start_workflow( + Gathers.run, id=f"wf-{uuid.uuid4()}", task_queue=worker.task_queue + ) + offsets = await handle.result() + items = await read(stream_client, handle) + assert data(items) == ["0", "1", "2", "3", "4"] + assert offsets == sorted(offsets) + history = await handle.fetch_history() + # Replay answers from the markers and writes nothing. + plugins = [ + p + for p in stream_client.config()["plugins"] + if isinstance(p, streams.StreamPlugin) + ] + await Replayer(workflows=[Gathers], plugins=plugins).replay_workflow(history) + assert data(await read(stream_client, handle)) == ["0", "1", "2", "3", "4"] + + +async def test_retried_write_goes_in_once( + stream_client: Client, monkeypatch: pytest.MonkeyPatch +): + original = service_client.StreamServiceClient.append_records + calls = [] + + async def fails_after_the_first_write(self: Any, request: Any) -> Any: + answer = await original(self, request) + calls.append(answer) + if len(calls) == 1: + raise RuntimeError("the answer is lost") + return answer + + monkeypatch.setattr( + service_client.StreamServiceClient, + "append_records", + fails_after_the_first_write, + ) + async with new_worker(stream_client, Gathers) as worker: + handle = await stream_client.start_workflow( + Gathers.run, id=f"wf-{uuid.uuid4()}", task_queue=worker.task_queue + ) + await handle.result() + items = await read(stream_client, handle) + assert data(items) == ["0", "1", "2", "3", "4"] From 42d7e74b62936b65a6d11f8ca17009560556a7d1 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 22 Oct 2026 10:35:53 -0700 Subject: [PATCH 02/11] Removed the Workflow Activation plugin hook that nothing uses. --- temporalio/worker/__init__.py | 3 +- temporalio/worker/_plugin.py | 52 +-------- temporalio/worker/_worker.py | 6 - temporalio/worker/_workflow.py | 37 ------ temporalio/worker/_workflow_instance.py | 20 ---- .../worker/workflow_sandbox/_in_sandbox.py | 4 - temporalio/worker/workflow_sandbox/_runner.py | 13 --- temporalio/workflow/__init__.py | 2 - temporalio/workflow/_context.py | 22 ---- tests/worker/test_activation_complete_hook.py | 109 ------------------ 10 files changed, 3 insertions(+), 265 deletions(-) delete mode 100644 tests/worker/test_activation_complete_hook.py diff --git a/temporalio/worker/__init__.py b/temporalio/worker/__init__.py index 682ffaa6d..b36c6920b 100644 --- a/temporalio/worker/__init__.py +++ b/temporalio/worker/__init__.py @@ -26,7 +26,7 @@ WorkflowInterceptorClassInput, WorkflowOutboundInterceptor, ) -from ._plugin import Plugin, WorkflowActivationCompleteInput +from ._plugin import Plugin from ._replayer import ( Replayer, ReplayerConfig, @@ -90,7 +90,6 @@ "WorkflowOutboundInterceptor", "NexusOperationInboundInterceptor", "Plugin", - "WorkflowActivationCompleteInput", # Interceptor input "ContinueAsNewInput", "ExecuteActivityInput", diff --git a/temporalio/worker/_plugin.py b/temporalio/worker/_plugin.py index 11fcc7aa6..c222f836c 100644 --- a/temporalio/worker/_plugin.py +++ b/temporalio/worker/_plugin.py @@ -1,13 +1,10 @@ from __future__ import annotations import abc -from collections.abc import AsyncIterator, Awaitable, Callable, Sequence +from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import AbstractAsyncContextManager -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -import temporalio.converter -import temporalio.workflow from temporalio.client import WorkflowHistory if TYPE_CHECKING: @@ -20,32 +17,6 @@ ) -@dataclass(frozen=True) -class WorkflowActivationCompleteInput: - """Input for :py:meth:`Plugin.on_workflow_activation_complete`. - - .. warning:: - This API is experimental. - """ - - run_id: str - """Run ID of the Workflow.""" - - info: temporalio.workflow.Info - """Info of the Workflow.""" - - is_replaying: bool - """True if the Workflow replayed History in this Activation.""" - - items: Sequence[Any] - """Values that Workflow code gave to :py:func:`temporalio.workflow.send_to_plugins` in this - Activation, in order. Values given during replay are not here, unless they were given with - ``during_replay=True``.""" - - data_converter: temporalio.converter.DataConverter - """Data converter of the Worker, with the Workflow's serialization context.""" - - class Plugin(abc.ABC): """Base class for worker plugins that can intercept and modify worker behavior. @@ -93,25 +64,6 @@ async def run_worker( next: Callable to continue the worker execution. """ - async def on_workflow_activation_complete( - self, - input: WorkflowActivationCompleteInput, # pyright: ignore[reportUnusedParameter] - ) -> None: - """Hook called at the end of each successful Workflow Activation on a Worker. - - The Worker calls it outside the sandbox, after it encodes the Activation's completion and - before it sends the completion to Core. So the hook can run the payload codec and call - native code. An exception fails the Workflow Task. The :py:class:`Replayer` never calls - it. The default does nothing. - - .. warning:: - This API is experimental. - - Args: - input: The Activation's values and context. - """ - pass - @abc.abstractmethod def configure_replayer(self, config: ReplayerConfig) -> ReplayerConfig: """Hook called when creating a replayer to allow modification of configuration. diff --git a/temporalio/worker/_worker.py b/temporalio/worker/_worker.py index d003f8738..e999755dd 100644 --- a/temporalio/worker/_worker.py +++ b/temporalio/worker/_worker.py @@ -593,12 +593,6 @@ def check_activity(activity: str): encode_headers=client_config["header_codec_behavior"] != HeaderCodecBehavior.NO_CODEC, max_workflow_task_external_storage_concurrency=max_workflow_task_external_storage_concurrency, - activation_complete_plugins=[ - plugin - for plugin in self._plugins - if type(plugin).on_workflow_activation_complete - is not Plugin.on_workflow_activation_complete - ], ) tuner = config.get("tuner") diff --git a/temporalio/worker/_workflow.py b/temporalio/worker/_workflow.py index 6ef1dae87..bfff26cd9 100644 --- a/temporalio/worker/_workflow.py +++ b/temporalio/worker/_workflow.py @@ -39,7 +39,6 @@ WorkflowInboundInterceptor, WorkflowInterceptorClassInput, ) -from ._plugin import Plugin, WorkflowActivationCompleteInput from ._workflow_instance import ( _DEFAULT_ENABLED_WORKFLOW_LOGIC_FLAGS, PatchActivationInput, @@ -105,14 +104,12 @@ def __init__( encode_headers: bool, max_workflow_task_external_storage_concurrency: int, default_workflow_logic_flags: frozenset[_WorkflowLogicFlag] | None = None, - activation_complete_plugins: Sequence[Plugin] = (), ) -> None: # Debug mode is enabled if specified or if the TEMPORAL_DEBUG env var is truthy debug_mode = debug_mode or bool(os.environ.get("TEMPORAL_DEBUG")) self._bridge_worker = bridge_worker self._namespace = namespace - self._activation_complete_plugins = activation_complete_plugins self._task_queue = task_queue self._default_workflow_logic_flags = set( _DEFAULT_ENABLED_WORKFLOW_LOGIC_FLAGS @@ -513,15 +510,6 @@ async def _handle_activation( completion.failed.Clear() completion.failed.failure.message = f"Failed encoding completion: {err}" - if ( - workflow - and self._activation_complete_plugins - and completion.HasField("successful") - ): - await self._run_activation_complete_plugins( - workflow, act, completion, data_converter - ) - # Reported on the completion so core can include them in its workflow-task duration # log; core measures the duration itself. if download_metrics.payload_count > 0: @@ -544,31 +532,6 @@ async def _handle_activation( "Failed completing activation on workflow with run ID %s", act.run_id ) - async def _run_activation_complete_plugins( - self, - workflow: _RunningWorkflow, - act: temporalio.bridge.proto.workflow_activation.WorkflowActivation, - completion: temporalio.bridge.proto.workflow_completion.WorkflowActivationCompletion, - data_converter: temporalio.converter.DataConverter, - ) -> None: - input = WorkflowActivationCompleteInput( - run_id=act.run_id, - info=workflow.instance.get_info(), - is_replaying=act.is_replaying, - items=workflow.instance.take_plugin_items(), - data_converter=data_converter, - ) - try: - for plugin in self._activation_complete_plugins: - await plugin.on_workflow_activation_complete(input) - # A cancel too must fail the task, or the Activation never completes. - except BaseException as err: - logger.exception( - "Failed plugin activation hook on workflow with run ID %s", act.run_id - ) - completion.failed.Clear() - completion.failed.failure.message = f"Failed plugin activation hook: {err}" - async def _handle_cache_eviction( self, act: temporalio.bridge.proto.workflow_activation.WorkflowActivation, diff --git a/temporalio/worker/_workflow_instance.py b/temporalio/worker/_workflow_instance.py index f3c392999..d12db1136 100644 --- a/temporalio/worker/_workflow_instance.py +++ b/temporalio/worker/_workflow_instance.py @@ -239,15 +239,6 @@ def get_info(self) -> temporalio.workflow.Info: """Return the workflow info for this instance.""" raise NotImplementedError - def take_plugin_items(self) -> list[Any]: - """Return and clear the values that Workflow code gave to - :py:func:`temporalio.workflow.send_to_plugins` in the last Activation. - - Not an abstractmethod because it is not mandatory to implement. A runner that does not - implement it gives plugins no values. - """ - return [] - def get_thread_id(self) -> int | None: """Return the thread identifier that this workflow is running on. @@ -420,7 +411,6 @@ def __init__(self, det: WorkflowInstanceDetails) -> None: # The current details (as opposed to static details on workflow start), returned in the # metadata query self._current_details = "" - self._plugin_items: list[Any] = [] self._last_completion_result = det.last_completion_result self._last_failure = det.last_failure @@ -470,7 +460,6 @@ def activate( self._current_completion.successful.SetInParent() self._current_activation_error: Exception | None = None - self._plugin_items = [] self._deployment_version_for_current_task = ( act.deployment_version_for_current_task ) @@ -2027,15 +2016,6 @@ def workflow_set_current_details(self, details: str): self._assert_not_read_only("set current details") self._current_details = details - def workflow_send_to_plugins(self, value: Any, during_replay: bool) -> None: - self._assert_not_read_only("send to plugins") - if during_replay or not self._is_replaying: - self._plugin_items.append(value) - - def take_plugin_items(self) -> list[Any]: - items, self._plugin_items = self._plugin_items, [] - return items - def workflow_is_failure_exception(self, err: BaseException) -> bool: # An exception causes the workflow to fail (rather than the task) if it # is already a failure error, a timeout error, or an instance of any of the diff --git a/temporalio/worker/workflow_sandbox/_in_sandbox.py b/temporalio/worker/workflow_sandbox/_in_sandbox.py index 05c1045a0..d18374899 100644 --- a/temporalio/worker/workflow_sandbox/_in_sandbox.py +++ b/temporalio/worker/workflow_sandbox/_in_sandbox.py @@ -90,10 +90,6 @@ def get_serialization_context( """Get serialization context.""" return self.instance.get_serialization_context(command_info) - def take_plugin_items(self) -> list[Any]: - """Take the values for plugins from the last activation.""" - return self.instance.take_plugin_items() - def get_external_store_context( self, command_info: _command_aware_visitor.CommandInfo | None, diff --git a/temporalio/worker/workflow_sandbox/_runner.py b/temporalio/worker/workflow_sandbox/_runner.py index ce0d2b7df..9716520b3 100644 --- a/temporalio/worker/workflow_sandbox/_runner.py +++ b/temporalio/worker/workflow_sandbox/_runner.py @@ -238,16 +238,3 @@ def get_external_store_context( ) # type: ignore finally: self.importer.restriction_context.is_runtime = False - - def take_plugin_items(self) -> list[Any]: - # Forward call to the sandboxed instance - self.importer.restriction_context.is_runtime = True - try: - self._run_code( - "with __temporal_importer.applied():\n" - " __temporal_items = __temporal_in_sandbox.take_plugin_items()\n", - __temporal_importer=self.importer, - ) - return self.globals_and_locals.pop("__temporal_items", []) # type: ignore - finally: - self.importer.restriction_context.is_runtime = False diff --git a/temporalio/workflow/__init__.py b/temporalio/workflow/__init__.py index 685df3677..c631fd88e 100644 --- a/temporalio/workflow/__init__.py +++ b/temporalio/workflow/__init__.py @@ -87,7 +87,6 @@ random, random_seed, register_random_seed_callback, - send_to_plugins, set_current_details, sleep, time, @@ -224,7 +223,6 @@ "random", "random_seed", "register_random_seed_callback", - "send_to_plugins", "set_current_details", "sleep", "time", diff --git a/temporalio/workflow/_context.py b/temporalio/workflow/_context.py index dfa9ddb18..c86620eea 100644 --- a/temporalio/workflow/_context.py +++ b/temporalio/workflow/_context.py @@ -543,9 +543,6 @@ def workflow_get_current_details(self) -> str: ... @abstractmethod def workflow_set_current_details(self, details: str): ... - @abstractmethod - def workflow_send_to_plugins(self, value: Any, during_replay: bool) -> None: ... - @abstractmethod def workflow_is_failure_exception(self, err: BaseException) -> bool: ... @@ -789,25 +786,6 @@ def set_current_details(description: str) -> None: _Runtime.current().workflow_set_current_details(description) -def send_to_plugins(value: Any, *, during_replay: bool = False) -> None: - """Give a value to the Worker's plugins at the end of this Activation. - - The Worker passes the values of an Activation to - :py:meth:`temporalio.worker.Plugin.on_workflow_activation_complete`, outside the sandbox. A - plugin's Workflow interceptor uses it to hand work to code that can't run in the sandbox. The - value is not serialized and nothing goes into History. - - .. warning:: - This API is experimental. - - Args: - value: Any value that the plugin knows. - during_replay: If False, the default, a value given while the Workflow replays History is - dropped, as the plugin already got it when the Workflow first ran. - """ - _Runtime.current().workflow_send_to_plugins(value, during_replay) - - def metric_meter() -> temporalio.common.MetricMeter: """Get the metric meter for the current workflow. diff --git a/tests/worker/test_activation_complete_hook.py b/tests/worker/test_activation_complete_hook.py deleted file mode 100644 index 6ac1aa4e9..000000000 --- a/tests/worker/test_activation_complete_hook.py +++ /dev/null @@ -1,109 +0,0 @@ -import asyncio -import uuid -from datetime import timedelta - -import pytest - -from temporalio import workflow -from temporalio.client import Client -from temporalio.plugin import SimplePlugin -from temporalio.worker import ( - Replayer, - UnsandboxedWorkflowRunner, - WorkflowActivationCompleteInput, - WorkflowRunner, -) -from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner -from tests.helpers import assert_task_fail_eventually, new_worker - - -class RecordingPlugin(SimplePlugin): - def __init__(self, fail: BaseException | None = None) -> None: - super().__init__("test-activation-complete") - self.inputs: list[WorkflowActivationCompleteInput] = [] - self.fail = fail - - async def on_workflow_activation_complete( - self, input: WorkflowActivationCompleteInput - ) -> None: - self.inputs.append(input) - if self.fail: - raise self.fail - - def items(self) -> list[str]: - return [item for input in self.inputs for item in input.items] - - -@workflow.defn -class SendingWorkflow: - @workflow.run - async def run(self) -> str: - workflow.send_to_plugins("first") - workflow.send_to_plugins("first-replayed", during_replay=True) - await workflow.sleep(timedelta(milliseconds=10)) - workflow.send_to_plugins("second") - return "done" - - -@pytest.mark.parametrize( - "runner", [SandboxedWorkflowRunner(), UnsandboxedWorkflowRunner()] -) -async def test_plugin_gets_items_after_each_activation( - client: Client, runner: WorkflowRunner -): - plugin = RecordingPlugin() - workflow_id = f"wf-{uuid.uuid4()}" - async with new_worker( - client, SendingWorkflow, plugins=[plugin], workflow_runner=runner - ) as worker: - handle = await client.start_workflow( - SendingWorkflow.run, id=workflow_id, task_queue=worker.task_queue - ) - assert await handle.result() == "done" - assert plugin.items() == ["first", "first-replayed", "second"] - assert all(input.run_id == handle.result_run_id for input in plugin.inputs) - assert all(input.info.workflow_id == workflow_id for input in plugin.inputs) - assert not any(input.is_replaying for input in plugin.inputs) - assert plugin.inputs[0].data_converter is not None - - -async def test_plugin_gets_only_replay_items_on_cache_miss(client: Client): - plugin = RecordingPlugin() - async with new_worker( - client, SendingWorkflow, plugins=[plugin], max_cached_workflows=0 - ) as worker: - handle = await client.start_workflow( - SendingWorkflow.run, id=f"wf-{uuid.uuid4()}", task_queue=worker.task_queue - ) - assert await handle.result() == "done" - # The second Workflow Task replays the first, so only the value marked for replay comes - # again. - assert plugin.items() == ["first", "first-replayed", "first-replayed", "second"] - assert any(input.is_replaying for input in plugin.inputs) - - -@pytest.mark.parametrize( - "error", [RuntimeError("plugin refused"), asyncio.CancelledError("plugin refused")] -) -async def test_plugin_failure_fails_workflow_task(client: Client, error: BaseException): - plugin = RecordingPlugin(fail=error) - async with new_worker(client, SendingWorkflow, plugins=[plugin]) as worker: - handle = await client.start_workflow( - SendingWorkflow.run, id=f"wf-{uuid.uuid4()}", task_queue=worker.task_queue - ) - await assert_task_fail_eventually( - handle, message_contains="Failed plugin activation hook: plugin refused" - ) - - -async def test_replayer_does_not_call_plugin(client: Client): - async with new_worker(client, SendingWorkflow) as worker: - handle = await client.start_workflow( - SendingWorkflow.run, id=f"wf-{uuid.uuid4()}", task_queue=worker.task_queue - ) - assert await handle.result() == "done" - plugin = RecordingPlugin() - await Replayer(workflows=[SendingWorkflow], plugins=[plugin]).replay_workflow( - await handle.fetch_history() - ) - assert plugin.inputs == [] From 520eca5d7afd9be0e2f13b9e04c5915742468ff4 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 22 Oct 2026 10:35:54 -0700 Subject: [PATCH 03/11] Pinned crate s18 and withdrew a retried run on its first write. --- .../api/streamservice/v1/stream_service.proto | 8 +++ .../streamservice/v1/stream_service_pb2.py | 56 +++++++++---------- .../streamservice/v1/stream_service_pb2.pyi | 33 ++++++++++- temporalio/bridge/Cargo.lock | 2 +- temporalio/bridge/Cargo.toml | 2 +- temporalio/contrib/streams/_writes.py | 1 + temporalio/contrib/streams/workflow.py | 18 ++++-- tests/contrib/streams/test_redis.py | 15 ++++- 8 files changed, 99 insertions(+), 36 deletions(-) diff --git a/scripts/_proto/temporal/api/streamservice/v1/stream_service.proto b/scripts/_proto/temporal/api/streamservice/v1/stream_service.proto index 575ece730..97d860de2 100644 --- a/scripts/_proto/temporal/api/streamservice/v1/stream_service.proto +++ b/scripts/_proto/temporal/api/streamservice/v1/stream_service.proto @@ -127,6 +127,14 @@ message CreateStreamRequest { // TTL. The Store applies it on the next access, so no call sees the Stream open after the // period. UNIMPLEMENTED (detail stream_unsupported) on a Store that can't. google.protobuf.Duration auto_close = 5; + // The Workflow run that owns the Stream. Workflow kind, with its run_id and epoch. + Producer owner = 6; + // Close the Stream with the result or the failure of the owner's run chain when the chain + // closes, terminate and timeout included. Needs owner, and ref with the owner fields. + bool close_with_run = 7; + // The ref of the Stream. A Workflow-owned Stream sets workflow_id and + // first_execution_run_id, which name the owner's run chain. + StreamRef ref = 8; } enum StreamStatus { diff --git a/temporalio/api/streamservice/v1/stream_service_pb2.py b/temporalio/api/streamservice/v1/stream_service_pb2.py index a2664ae30..8d9d4db6e 100644 --- a/temporalio/api/streamservice/v1/stream_service_pb2.py +++ b/temporalio/api/streamservice/v1/stream_service_pb2.py @@ -26,7 +26,7 @@ ) DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n2temporal/api/streamservice/v1/stream_service.proto\x12\x1dtemporal.api.streamservice.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a$temporal/api/common/v1/message.proto\x1a%temporal/api/failure/v1/message.proto"]\n\tStreamRef\x12\n\n\x02id\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x1e\n\x16\x66irst_execution_run_id\x18\x03 \x01(\t\x12\x0f\n\x07\x62\x61\x63kend\x18\x04 \x01(\t"\xc8\x01\n\rProducerEpoch\x12\x1d\n\x15task_started_event_id\x18\x01 \x01(\x03\x12\x14\n\x0ctask_attempt\x18\x02 \x01(\x05\x12\x35\n\x11task_started_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12:\n\x16\x61ttempt_scheduled_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07\x61ttempt\x18\x04 \x01(\x05"\x9e\x01\n\x08Producer\x12\x39\n\x04kind\x18\x01 \x01(\x0e\x32+.temporal.api.streamservice.v1.ProducerKind\x12\n\n\x02id\x18\x02 \x01(\t\x12;\n\x05\x65poch\x18\x03 \x01(\x0b\x32,.temporal.api.streamservice.v1.ProducerEpoch\x12\x0e\n\x06run_id\x18\x04 \x01(\t"p\n\x11WorkflowTaskEpoch\x12\x18\n\x10started_event_id\x18\x01 \x01(\x03\x12\x0f\n\x07\x61ttempt\x18\x02 \x01(\x05\x12\x30\n\x0cstarted_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp"L\n\x0c\x41ppendRecord\x12\r\n\x05topic\x18\x01 \x01(\t\x12-\n\x04\x62ody\x18\x02 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload"\xb3\x02\n\x0cSupersession\x12\x42\n\rproducer_kind\x18\x01 \x01(\x0e\x32+.temporal.api.streamservice.v1.ProducerKind\x12\x13\n\x0bproducer_id\x18\x02 \x01(\t\x12;\n\x05\x65poch\x18\x03 \x01(\x0b\x32,.temporal.api.streamservice.v1.ProducerEpoch\x12Q\n\x17previous_completed_task\x18\x04 \x01(\x0b\x32\x30.temporal.api.streamservice.v1.WorkflowTaskEpoch\x12\x14\n\x0c\x66\x61iled_tasks\x18\x05 \x03(\x03\x12\x14\n\x0cwithdraw_run\x18\x06 \x01(\t\x12\x0e\n\x06run_id\x18\x07 \x01(\t"\x89\x03\n\x0cStreamRecord\x12=\n\x04kind\x18\x01 \x01(\x0e\x32/.temporal.api.streamservice.v1.StreamRecordKind\x12\x0e\n\x06offset\x18\x02 \x01(\x03\x12\r\n\x05topic\x18\x03 \x01(\t\x12\x39\n\x08producer\x18\x04 \x01(\x0b\x32\'.temporal.api.streamservice.v1.Producer\x12-\n\x04\x62ody\x18\x05 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x41\n\x0csupersession\x18\x06 \x01(\x0b\x32+.temporal.api.streamservice.v1.Supersession\x12\x35\n\x0c\x63lose_result\x18\x07 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x37\n\rclose_failure\x18\x08 \x01(\x0b\x32 .temporal.api.failure.v1.Failure"\xae\x01\n\x13\x43reateStreamRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tstream_id\x18\x02 \x01(\t\x12,\n\tretention\x18\x03 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x14\n\x0cwithdraw_run\x18\x04 \x01(\t\x12-\n\nauto_close\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration"\x9e\x01\n\x14\x43reateStreamResponse\x12\x38\n\x06stream\x18\x01 \x01(\x0b\x32(.temporal.api.streamservice.v1.StreamRef\x12\x0f\n\x07\x63reated\x18\x02 \x01(\x08\x12;\n\x06status\x18\x03 \x01(\x0e\x32+.temporal.api.streamservice.v1.StreamStatus"\xed\x02\n\x14\x41ppendRecordsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\x06stream\x18\x02 \x01(\x0b\x32(.temporal.api.streamservice.v1.StreamRef\x12\x39\n\x08producer\x18\x03 \x01(\x0b\x32\'.temporal.api.streamservice.v1.Producer\x12\x10\n\x08sequence\x18\x04 \x01(\x03\x12<\n\x07records\x18\x05 \x03(\x0b\x32+.temporal.api.streamservice.v1.AppendRecord\x12Q\n\x17previous_completed_task\x18\x06 \x01(\x0b\x32\x30.temporal.api.streamservice.v1.WorkflowTaskEpoch\x12\x14\n\x0c\x66\x61iled_tasks\x18\x07 \x03(\x03\x12\x14\n\x0cwithdraw_run\x18\x08 \x01(\t"B\n\x15\x41ppendRecordsResponse\x12\x14\n\x0c\x66irst_offset\x18\x01 \x01(\x03\x12\x13\n\x0blast_offset\x18\x02 \x01(\x03"\xe9\x02\n\x12\x43loseStreamRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\x06stream\x18\x02 \x01(\x0b\x32(.temporal.api.streamservice.v1.StreamRef\x12\x39\n\x08producer\x18\x03 \x01(\x0b\x32\'.temporal.api.streamservice.v1.Producer\x12/\n\x06result\x18\x04 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x31\n\x07\x66\x61ilure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12Q\n\x17previous_completed_task\x18\x06 \x01(\x0b\x32\x30.temporal.api.streamservice.v1.WorkflowTaskEpoch\x12\x14\n\x0c\x66\x61iled_tasks\x18\x07 \x03(\x03"\x15\n\x13\x43loseStreamResponse"\xd9\x02\n\x12ReadRecordsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\x06stream\x18\x02 \x01(\x0b\x32(.temporal.api.streamservice.v1.StreamRef\x12\x13\n\x0b\x66rom_offset\x18\x03 \x01(\x03\x12\x37\n\x05start\x18\x04 \x01(\x0e\x32(.temporal.api.streamservice.v1.ReadStart\x12\x0e\n\x06topics\x18\x05 \x03(\t\x12\x13\n\x0bmax_records\x18\x06 \x01(\x05\x12\'\n\x04wait\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12.\n\x0b\x63reate_wait\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x16\n\x0etopic_prefixes\x18\n \x03(\tJ\x04\x08\t\x10\nR\x0cidle_timeout"v\n\x13ReadRecordsResponse\x12<\n\x07records\x18\x01 \x03(\x0b\x32+.temporal.api.streamservice.v1.StreamRecord\x12\x13\n\x0bnext_offset\x18\x02 \x01(\x03\x12\x0c\n\x04\x64one\x18\x03 \x01(\x08"d\n\x15\x44\x65scribeStreamRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\x06stream\x18\x02 \x01(\x0b\x32(.temporal.api.streamservice.v1.StreamRef"\xc7\x01\n\x16\x44\x65scribeStreamResponse\x12;\n\x06status\x18\x01 \x01(\x0e\x32+.temporal.api.streamservice.v1.StreamStatus\x12\x13\n\x0blast_offset\x18\x02 \x01(\x03\x12,\n\tretention\x18\x03 \x01(\x0b\x32\x19.google.protobuf.Duration\x12-\n\nauto_close\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration*\xc8\x01\n\x0cProducerKind\x12\x1d\n\x19PRODUCER_KIND_UNSPECIFIED\x10\x00\x12\x1a\n\x16PRODUCER_KIND_WORKFLOW\x10\x01\x12 \n\x1cPRODUCER_KIND_LOCAL_ACTIVITY\x10\x02\x12\x1a\n\x16PRODUCER_KIND_ACTIVITY\x10\x03\x12\x18\n\x14PRODUCER_KIND_CLIENT\x10\x04\x12%\n!PRODUCER_KIND_STANDALONE_ACTIVITY\x10\x05*\xb3\x01\n\x10StreamRecordKind\x12"\n\x1eSTREAM_RECORD_KIND_UNSPECIFIED\x10\x00\x12\x1b\n\x17STREAM_RECORD_KIND_DATA\x10\x01\x12!\n\x1dSTREAM_RECORD_KIND_SUPERSEDED\x10\x02\x12\x1c\n\x18STREAM_RECORD_KIND_CLOSE\x10\x03"\x04\x08\x04\x10\x04*\x17STREAM_RECORD_KIND_IDLE*z\n\x0cStreamStatus\x12\x1d\n\x19STREAM_STATUS_UNSPECIFIED\x10\x00\x12\x16\n\x12STREAM_STATUS_OPEN\x10\x01\x12\x18\n\x14STREAM_STATUS_CLOSED\x10\x02\x12\x19\n\x15STREAM_STATUS_EXPIRED\x10\x03*U\n\tReadStart\x12\x1a\n\x16READ_START_UNSPECIFIED\x10\x00\x12\x18\n\x14READ_START_BEGINNING\x10\x01\x12\x12\n\x0eREAD_START_END\x10\x02\x42\xb2\x01\n io.temporal.api.streamservice.v1B\x12StreamServiceProtoP\x01Z1go.temporal.io/api/streamservice/v1;streamservice\xaa\x02\x1fTemporalio.Api.StreamService.V1\xea\x02"Temporalio::Api::StreamService::V1b\x06proto3' + b'\n2temporal/api/streamservice/v1/stream_service.proto\x12\x1dtemporal.api.streamservice.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a$temporal/api/common/v1/message.proto\x1a%temporal/api/failure/v1/message.proto"]\n\tStreamRef\x12\n\n\x02id\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x1e\n\x16\x66irst_execution_run_id\x18\x03 \x01(\t\x12\x0f\n\x07\x62\x61\x63kend\x18\x04 \x01(\t"\xc8\x01\n\rProducerEpoch\x12\x1d\n\x15task_started_event_id\x18\x01 \x01(\x03\x12\x14\n\x0ctask_attempt\x18\x02 \x01(\x05\x12\x35\n\x11task_started_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12:\n\x16\x61ttempt_scheduled_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07\x61ttempt\x18\x04 \x01(\x05"\x9e\x01\n\x08Producer\x12\x39\n\x04kind\x18\x01 \x01(\x0e\x32+.temporal.api.streamservice.v1.ProducerKind\x12\n\n\x02id\x18\x02 \x01(\t\x12;\n\x05\x65poch\x18\x03 \x01(\x0b\x32,.temporal.api.streamservice.v1.ProducerEpoch\x12\x0e\n\x06run_id\x18\x04 \x01(\t"p\n\x11WorkflowTaskEpoch\x12\x18\n\x10started_event_id\x18\x01 \x01(\x03\x12\x0f\n\x07\x61ttempt\x18\x02 \x01(\x05\x12\x30\n\x0cstarted_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp"L\n\x0c\x41ppendRecord\x12\r\n\x05topic\x18\x01 \x01(\t\x12-\n\x04\x62ody\x18\x02 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload"\xb3\x02\n\x0cSupersession\x12\x42\n\rproducer_kind\x18\x01 \x01(\x0e\x32+.temporal.api.streamservice.v1.ProducerKind\x12\x13\n\x0bproducer_id\x18\x02 \x01(\t\x12;\n\x05\x65poch\x18\x03 \x01(\x0b\x32,.temporal.api.streamservice.v1.ProducerEpoch\x12Q\n\x17previous_completed_task\x18\x04 \x01(\x0b\x32\x30.temporal.api.streamservice.v1.WorkflowTaskEpoch\x12\x14\n\x0c\x66\x61iled_tasks\x18\x05 \x03(\x03\x12\x14\n\x0cwithdraw_run\x18\x06 \x01(\t\x12\x0e\n\x06run_id\x18\x07 \x01(\t"\x89\x03\n\x0cStreamRecord\x12=\n\x04kind\x18\x01 \x01(\x0e\x32/.temporal.api.streamservice.v1.StreamRecordKind\x12\x0e\n\x06offset\x18\x02 \x01(\x03\x12\r\n\x05topic\x18\x03 \x01(\t\x12\x39\n\x08producer\x18\x04 \x01(\x0b\x32\'.temporal.api.streamservice.v1.Producer\x12-\n\x04\x62ody\x18\x05 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x41\n\x0csupersession\x18\x06 \x01(\x0b\x32+.temporal.api.streamservice.v1.Supersession\x12\x35\n\x0c\x63lose_result\x18\x07 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x37\n\rclose_failure\x18\x08 \x01(\x0b\x32 .temporal.api.failure.v1.Failure"\xb5\x02\n\x13\x43reateStreamRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tstream_id\x18\x02 \x01(\t\x12,\n\tretention\x18\x03 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x14\n\x0cwithdraw_run\x18\x04 \x01(\t\x12-\n\nauto_close\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x36\n\x05owner\x18\x06 \x01(\x0b\x32\'.temporal.api.streamservice.v1.Producer\x12\x16\n\x0e\x63lose_with_run\x18\x07 \x01(\x08\x12\x35\n\x03ref\x18\x08 \x01(\x0b\x32(.temporal.api.streamservice.v1.StreamRef"\x9e\x01\n\x14\x43reateStreamResponse\x12\x38\n\x06stream\x18\x01 \x01(\x0b\x32(.temporal.api.streamservice.v1.StreamRef\x12\x0f\n\x07\x63reated\x18\x02 \x01(\x08\x12;\n\x06status\x18\x03 \x01(\x0e\x32+.temporal.api.streamservice.v1.StreamStatus"\xed\x02\n\x14\x41ppendRecordsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\x06stream\x18\x02 \x01(\x0b\x32(.temporal.api.streamservice.v1.StreamRef\x12\x39\n\x08producer\x18\x03 \x01(\x0b\x32\'.temporal.api.streamservice.v1.Producer\x12\x10\n\x08sequence\x18\x04 \x01(\x03\x12<\n\x07records\x18\x05 \x03(\x0b\x32+.temporal.api.streamservice.v1.AppendRecord\x12Q\n\x17previous_completed_task\x18\x06 \x01(\x0b\x32\x30.temporal.api.streamservice.v1.WorkflowTaskEpoch\x12\x14\n\x0c\x66\x61iled_tasks\x18\x07 \x03(\x03\x12\x14\n\x0cwithdraw_run\x18\x08 \x01(\t"B\n\x15\x41ppendRecordsResponse\x12\x14\n\x0c\x66irst_offset\x18\x01 \x01(\x03\x12\x13\n\x0blast_offset\x18\x02 \x01(\x03"\xe9\x02\n\x12\x43loseStreamRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\x06stream\x18\x02 \x01(\x0b\x32(.temporal.api.streamservice.v1.StreamRef\x12\x39\n\x08producer\x18\x03 \x01(\x0b\x32\'.temporal.api.streamservice.v1.Producer\x12/\n\x06result\x18\x04 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x31\n\x07\x66\x61ilure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12Q\n\x17previous_completed_task\x18\x06 \x01(\x0b\x32\x30.temporal.api.streamservice.v1.WorkflowTaskEpoch\x12\x14\n\x0c\x66\x61iled_tasks\x18\x07 \x03(\x03"\x15\n\x13\x43loseStreamResponse"\xd9\x02\n\x12ReadRecordsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\x06stream\x18\x02 \x01(\x0b\x32(.temporal.api.streamservice.v1.StreamRef\x12\x13\n\x0b\x66rom_offset\x18\x03 \x01(\x03\x12\x37\n\x05start\x18\x04 \x01(\x0e\x32(.temporal.api.streamservice.v1.ReadStart\x12\x0e\n\x06topics\x18\x05 \x03(\t\x12\x13\n\x0bmax_records\x18\x06 \x01(\x05\x12\'\n\x04wait\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12.\n\x0b\x63reate_wait\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x16\n\x0etopic_prefixes\x18\n \x03(\tJ\x04\x08\t\x10\nR\x0cidle_timeout"v\n\x13ReadRecordsResponse\x12<\n\x07records\x18\x01 \x03(\x0b\x32+.temporal.api.streamservice.v1.StreamRecord\x12\x13\n\x0bnext_offset\x18\x02 \x01(\x03\x12\x0c\n\x04\x64one\x18\x03 \x01(\x08"d\n\x15\x44\x65scribeStreamRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\x06stream\x18\x02 \x01(\x0b\x32(.temporal.api.streamservice.v1.StreamRef"\xc7\x01\n\x16\x44\x65scribeStreamResponse\x12;\n\x06status\x18\x01 \x01(\x0e\x32+.temporal.api.streamservice.v1.StreamStatus\x12\x13\n\x0blast_offset\x18\x02 \x01(\x03\x12,\n\tretention\x18\x03 \x01(\x0b\x32\x19.google.protobuf.Duration\x12-\n\nauto_close\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration*\xc8\x01\n\x0cProducerKind\x12\x1d\n\x19PRODUCER_KIND_UNSPECIFIED\x10\x00\x12\x1a\n\x16PRODUCER_KIND_WORKFLOW\x10\x01\x12 \n\x1cPRODUCER_KIND_LOCAL_ACTIVITY\x10\x02\x12\x1a\n\x16PRODUCER_KIND_ACTIVITY\x10\x03\x12\x18\n\x14PRODUCER_KIND_CLIENT\x10\x04\x12%\n!PRODUCER_KIND_STANDALONE_ACTIVITY\x10\x05*\xb3\x01\n\x10StreamRecordKind\x12"\n\x1eSTREAM_RECORD_KIND_UNSPECIFIED\x10\x00\x12\x1b\n\x17STREAM_RECORD_KIND_DATA\x10\x01\x12!\n\x1dSTREAM_RECORD_KIND_SUPERSEDED\x10\x02\x12\x1c\n\x18STREAM_RECORD_KIND_CLOSE\x10\x03"\x04\x08\x04\x10\x04*\x17STREAM_RECORD_KIND_IDLE*z\n\x0cStreamStatus\x12\x1d\n\x19STREAM_STATUS_UNSPECIFIED\x10\x00\x12\x16\n\x12STREAM_STATUS_OPEN\x10\x01\x12\x18\n\x14STREAM_STATUS_CLOSED\x10\x02\x12\x19\n\x15STREAM_STATUS_EXPIRED\x10\x03*U\n\tReadStart\x12\x1a\n\x16READ_START_UNSPECIFIED\x10\x00\x12\x18\n\x14READ_START_BEGINNING\x10\x01\x12\x12\n\x0eREAD_START_END\x10\x02\x42\xb2\x01\n io.temporal.api.streamservice.v1B\x12StreamServiceProtoP\x01Z1go.temporal.io/api/streamservice/v1;streamservice\xaa\x02\x1fTemporalio.Api.StreamService.V1\xea\x02"Temporalio::Api::StreamService::V1b\x06proto3' ) _PRODUCERKIND = DESCRIPTOR.enum_types_by_name["ProducerKind"] @@ -263,14 +263,14 @@ if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n io.temporal.api.streamservice.v1B\022StreamServiceProtoP\001Z1go.temporal.io/api/streamservice/v1;streamservice\252\002\037Temporalio.Api.StreamService.V1\352\002"Temporalio::Api::StreamService::V1' - _PRODUCERKIND._serialized_start = 3518 - _PRODUCERKIND._serialized_end = 3718 - _STREAMRECORDKIND._serialized_start = 3721 - _STREAMRECORDKIND._serialized_end = 3900 - _STREAMSTATUS._serialized_start = 3902 - _STREAMSTATUS._serialized_end = 4024 - _READSTART._serialized_start = 4026 - _READSTART._serialized_end = 4111 + _PRODUCERKIND._serialized_start = 3653 + _PRODUCERKIND._serialized_end = 3853 + _STREAMRECORDKIND._serialized_start = 3856 + _STREAMRECORDKIND._serialized_end = 4035 + _STREAMSTATUS._serialized_start = 4037 + _STREAMSTATUS._serialized_end = 4159 + _READSTART._serialized_start = 4161 + _READSTART._serialized_end = 4246 _STREAMREF._serialized_start = 227 _STREAMREF._serialized_end = 320 _PRODUCEREPOCH._serialized_start = 323 @@ -286,23 +286,23 @@ _STREAMRECORD._serialized_start = 1189 _STREAMRECORD._serialized_end = 1582 _CREATESTREAMREQUEST._serialized_start = 1585 - _CREATESTREAMREQUEST._serialized_end = 1759 - _CREATESTREAMRESPONSE._serialized_start = 1762 - _CREATESTREAMRESPONSE._serialized_end = 1920 - _APPENDRECORDSREQUEST._serialized_start = 1923 - _APPENDRECORDSREQUEST._serialized_end = 2288 - _APPENDRECORDSRESPONSE._serialized_start = 2290 - _APPENDRECORDSRESPONSE._serialized_end = 2356 - _CLOSESTREAMREQUEST._serialized_start = 2359 - _CLOSESTREAMREQUEST._serialized_end = 2720 - _CLOSESTREAMRESPONSE._serialized_start = 2722 - _CLOSESTREAMRESPONSE._serialized_end = 2743 - _READRECORDSREQUEST._serialized_start = 2746 - _READRECORDSREQUEST._serialized_end = 3091 - _READRECORDSRESPONSE._serialized_start = 3093 - _READRECORDSRESPONSE._serialized_end = 3211 - _DESCRIBESTREAMREQUEST._serialized_start = 3213 - _DESCRIBESTREAMREQUEST._serialized_end = 3313 - _DESCRIBESTREAMRESPONSE._serialized_start = 3316 - _DESCRIBESTREAMRESPONSE._serialized_end = 3515 + _CREATESTREAMREQUEST._serialized_end = 1894 + _CREATESTREAMRESPONSE._serialized_start = 1897 + _CREATESTREAMRESPONSE._serialized_end = 2055 + _APPENDRECORDSREQUEST._serialized_start = 2058 + _APPENDRECORDSREQUEST._serialized_end = 2423 + _APPENDRECORDSRESPONSE._serialized_start = 2425 + _APPENDRECORDSRESPONSE._serialized_end = 2491 + _CLOSESTREAMREQUEST._serialized_start = 2494 + _CLOSESTREAMREQUEST._serialized_end = 2855 + _CLOSESTREAMRESPONSE._serialized_start = 2857 + _CLOSESTREAMRESPONSE._serialized_end = 2878 + _READRECORDSREQUEST._serialized_start = 2881 + _READRECORDSREQUEST._serialized_end = 3226 + _READRECORDSRESPONSE._serialized_start = 3228 + _READRECORDSRESPONSE._serialized_end = 3346 + _DESCRIBESTREAMREQUEST._serialized_start = 3348 + _DESCRIBESTREAMREQUEST._serialized_end = 3448 + _DESCRIBESTREAMRESPONSE._serialized_start = 3451 + _DESCRIBESTREAMRESPONSE._serialized_end = 3650 # @@protoc_insertion_point(module_scope) diff --git a/temporalio/api/streamservice/v1/stream_service_pb2.pyi b/temporalio/api/streamservice/v1/stream_service_pb2.pyi index 1f50d9644..273ef5d6f 100644 --- a/temporalio/api/streamservice/v1/stream_service_pb2.pyi +++ b/temporalio/api/streamservice/v1/stream_service_pb2.pyi @@ -481,6 +481,9 @@ class CreateStreamRequest(google.protobuf.message.Message): RETENTION_FIELD_NUMBER: builtins.int WITHDRAW_RUN_FIELD_NUMBER: builtins.int AUTO_CLOSE_FIELD_NUMBER: builtins.int + OWNER_FIELD_NUMBER: builtins.int + CLOSE_WITH_RUN_FIELD_NUMBER: builtins.int + REF_FIELD_NUMBER: builtins.int namespace: builtins.str stream_id: builtins.str """Required from Workflow code.""" @@ -496,6 +499,18 @@ class CreateStreamRequest(google.protobuf.message.Message): TTL. The Store applies it on the next access, so no call sees the Stream open after the period. UNIMPLEMENTED (detail stream_unsupported) on a Store that can't. """ + @property + def owner(self) -> global___Producer: + """The Workflow run that owns the Stream. Workflow kind, with its run_id and epoch.""" + close_with_run: builtins.bool + """Close the Stream with the result or the failure of the owner's run chain when the chain + closes, terminate and timeout included. Needs owner, and ref with the owner fields. + """ + @property + def ref(self) -> global___StreamRef: + """The ref of the Stream. A Workflow-owned Stream sets workflow_id and + first_execution_run_id, which name the owner's run chain. + """ def __init__( self, *, @@ -504,11 +519,21 @@ class CreateStreamRequest(google.protobuf.message.Message): retention: google.protobuf.duration_pb2.Duration | None = ..., withdraw_run: builtins.str = ..., auto_close: google.protobuf.duration_pb2.Duration | None = ..., + owner: global___Producer | None = ..., + close_with_run: builtins.bool = ..., + ref: global___StreamRef | None = ..., ) -> None: ... def HasField( self, field_name: typing_extensions.Literal[ - "auto_close", b"auto_close", "retention", b"retention" + "auto_close", + b"auto_close", + "owner", + b"owner", + "ref", + b"ref", + "retention", + b"retention", ], ) -> builtins.bool: ... def ClearField( @@ -516,8 +541,14 @@ class CreateStreamRequest(google.protobuf.message.Message): field_name: typing_extensions.Literal[ "auto_close", b"auto_close", + "close_with_run", + b"close_with_run", "namespace", b"namespace", + "owner", + b"owner", + "ref", + b"ref", "retention", b"retention", "stream_id", diff --git a/temporalio/bridge/Cargo.lock b/temporalio/bridge/Cargo.lock index ee3eed841..edd72b271 100644 --- a/temporalio/bridge/Cargo.lock +++ b/temporalio/bridge/Cargo.lock @@ -2879,7 +2879,7 @@ dependencies = [ [[package]] name = "temporalio-streams" version = "0.1.0" -source = "git+https://github.com/moetemp/sdk-streams?rev=ef17adf7b4532ac652d53201f7e8703082e4bbb0#ef17adf7b4532ac652d53201f7e8703082e4bbb0" +source = "git+https://github.com/moetemp/sdk-streams?rev=112366b92b069fc925d8c4fe09fce21fcbac51c5#112366b92b069fc925d8c4fe09fce21fcbac51c5" dependencies = [ "async-trait", "aws-lc-rs", diff --git a/temporalio/bridge/Cargo.toml b/temporalio/bridge/Cargo.toml index 254746cd9..681bc7d30 100644 --- a/temporalio/bridge/Cargo.toml +++ b/temporalio/bridge/Cargo.toml @@ -94,7 +94,7 @@ url = "2.2" # crates, so the bridge sees one type of each. [dependencies.temporalio-streams] git = "https://github.com/moetemp/sdk-streams" -rev = "ef17adf7b4532ac652d53201f7e8703082e4bbb0" +rev = "112366b92b069fc925d8c4fe09fce21fcbac51c5" default-features = false optional = true diff --git a/temporalio/contrib/streams/_writes.py b/temporalio/contrib/streams/_writes.py index 4ea682d87..e73b330c1 100644 --- a/temporalio/contrib/streams/_writes.py +++ b/temporalio/contrib/streams/_writes.py @@ -88,6 +88,7 @@ async def append_records(request: stream_pb.AppendRecordsRequest) -> int: ) for record in request.records ], + withdraw_run=request.withdraw_run or None, ) ) return response.last_offset or 0 diff --git a/temporalio/contrib/streams/workflow.py b/temporalio/contrib/streams/workflow.py index 0c1c9c0c3..c14a2cf1f 100644 --- a/temporalio/contrib/streams/workflow.py +++ b/temporalio/contrib/streams/workflow.py @@ -61,6 +61,8 @@ class _RunState: streams: dict[str, _RunStream] = field(default_factory=dict) # The last write of each Stream. Each write waits for it, so writes keep their order. tails: dict[str, asyncio.Task[int]] = field(default_factory=dict) + # The Streams that a retry run's first write has withdrawn the previous run on. + withdrawn: set[str] = field(default_factory=set) def stream(self, ref: models.StreamRef) -> _RunStream: known = self.streams.get(ref.id) @@ -113,6 +115,18 @@ def _write( ) -> asyncio.Task[int]: state = _state() previous = state.tails.get(stream_id) + info = temporalio.workflow.info() + if ( + info.attempt > 1 + and info.continued_run_id + and stream_id not in state.withdrawn + and isinstance( + request, (stream_pb.CreateStreamRequest, stream_pb.AppendRecordsRequest) + ) + ): + # A retry run's first write to a Stream withdraws the Records of the run it retries. + setattr(request, "withdraw_run", info.continued_run_id) + state.withdrawn.add(stream_id) async def run() -> int: if previous is not None: @@ -433,10 +447,6 @@ def _create( request.retention.FromTimedelta(retention) if auto_close is not None: request.auto_close.FromTimedelta(auto_close) - info = temporalio.workflow.info() - if info.attempt > 1 and info.continued_run_id: - # A retry run withdraws the Records of the run it retries. - request.withdraw_run = info.continued_run_id run_stream.close_with_run = close_with_run run_stream.backend = stream.backend return stream, _write(stream.id, CREATE_ACTIVITY, request) diff --git a/tests/contrib/streams/test_redis.py b/tests/contrib/streams/test_redis.py index 2c7ff7356..cf997cc57 100644 --- a/tests/contrib/streams/test_redis.py +++ b/tests/contrib/streams/test_redis.py @@ -692,11 +692,24 @@ class WritesToStream: async def run(self, stream: stream_workflow.WorkflowStream[Events]) -> str: try: await stream.topic(Events.tokens).publish("from workflow") - except streams.StreamClosedError as err: + except streams.StreamError as err: return err.reason return "wrote" +async def test_write_to_a_missing_stream_raises_at_the_await(stream_client: Client): + async with new_worker(stream_client, WritesToStream) as worker: + assert ( + await stream_client.execute_workflow( + WritesToStream.run, + stream_workflow.WorkflowStream(id=f"missing-{uuid.uuid4()}"), + id=f"wf-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + == "stream_not_found" + ) + + async def test_write_to_a_closed_stream_raises_at_the_await(stream_client: Client): stream = await streams.StreamClient(stream_client).create( Events, id=f"closed-{uuid.uuid4()}" From f35813baf367e8cf177dddbbb4342b39cf99ebd1 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 22 Oct 2026 10:37:43 -0700 Subject: [PATCH 04/11] Typed the Stream error map and the run-end close. --- temporalio/contrib/streams/_errors.py | 5 ++++- temporalio/contrib/streams/redis.py | 3 +-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/temporalio/contrib/streams/_errors.py b/temporalio/contrib/streams/_errors.py index 4946aeb3e..8e1bcc220 100644 --- a/temporalio/contrib/streams/_errors.py +++ b/temporalio/contrib/streams/_errors.py @@ -120,7 +120,10 @@ def from_extension_error(err: temporalio.worker.ExtensionError) -> StreamError: ) -_BY_TYPE = {cls.__name__: cls for cls in (StreamError, *_BY_REASON.values())} +_BY_TYPE: dict[str, type[StreamError]] = { + "StreamError": StreamError, + **{cls.__name__: cls for cls in _BY_REASON.values()}, +} def from_activity_error(err: Exception) -> Exception: diff --git a/temporalio/contrib/streams/redis.py b/temporalio/contrib/streams/redis.py index e8c87c980..8f54926d6 100644 --- a/temporalio/contrib/streams/redis.py +++ b/temporalio/contrib/streams/redis.py @@ -17,7 +17,6 @@ import temporalio.api.common.v1 import temporalio.api.streamservice.v1 as stream_pb import temporalio.client -import temporalio.common import temporalio.converter import temporalio.exceptions import temporalio.runtime @@ -287,7 +286,7 @@ async def _close_with_run( for stream_id, run_stream in streams_workflow._state().streams.items(): if not run_stream.close_with_run or run_stream.closed: continue - stream = streams_workflow.WorkflowStream( + stream: streams_workflow.WorkflowStream[Any] = streams_workflow.WorkflowStream( id=stream_id, workflow_id=run_stream.ref.workflow_id, first_execution_run_id=run_stream.ref.first_execution_run_id, From 6e64b095c0764a84401793d6140a40f028e5a847 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 22 Oct 2026 10:42:31 -0700 Subject: [PATCH 05/11] Pinned crate s18 and named the Stream owner on a Workflow create. --- scripts/_wit/stream-service.wit | 6 + temporalio/bridge/Cargo.lock | 2 +- temporalio/bridge/Cargo.toml | 2 +- temporalio/contrib/streams/_writes.py | 5 + temporalio/contrib/streams/system/models.py | 474 +++++++++--------- .../system/operations/create_stream.py | 12 + temporalio/contrib/streams/workflow.py | 11 +- 7 files changed, 284 insertions(+), 228 deletions(-) diff --git a/scripts/_wit/stream-service.wit b/scripts/_wit/stream-service.wit index 63bdb7fa7..f32251937 100644 --- a/scripts/_wit/stream-service.wit +++ b/scripts/_wit/stream-service.wit @@ -162,6 +162,12 @@ interface stream-service { withdraw-run: option, /// @nexus.doc "Close with no result after this long with no append. Empty never auto-closes." auto-close: option, + /// @nexus.doc "The Workflow run that owns the Stream. The Workflow transport sets it." + owner: option, + /// @nexus.doc "Close with the owner run chain's result when it closes. Native only." + close-with-run: option, + /// @nexus.doc "The Stream's ref, with the owner fields." + %ref: option, /// @nexus.omit namespace: placeholder, } diff --git a/temporalio/bridge/Cargo.lock b/temporalio/bridge/Cargo.lock index edd72b271..85886502a 100644 --- a/temporalio/bridge/Cargo.lock +++ b/temporalio/bridge/Cargo.lock @@ -2879,7 +2879,7 @@ dependencies = [ [[package]] name = "temporalio-streams" version = "0.1.0" -source = "git+https://github.com/moetemp/sdk-streams?rev=112366b92b069fc925d8c4fe09fce21fcbac51c5#112366b92b069fc925d8c4fe09fce21fcbac51c5" +source = "git+https://github.com/moetemp/sdk-streams?rev=d1af72c514330e36bd89bdf93676b7a5ae0d8961#d1af72c514330e36bd89bdf93676b7a5ae0d8961" dependencies = [ "async-trait", "aws-lc-rs", diff --git a/temporalio/bridge/Cargo.toml b/temporalio/bridge/Cargo.toml index 681bc7d30..f843d625f 100644 --- a/temporalio/bridge/Cargo.toml +++ b/temporalio/bridge/Cargo.toml @@ -94,7 +94,7 @@ url = "2.2" # crates, so the bridge sees one type of each. [dependencies.temporalio-streams] git = "https://github.com/moetemp/sdk-streams" -rev = "112366b92b069fc925d8c4fe09fce21fcbac51c5" +rev = "d1af72c514330e36bd89bdf93676b7a5ae0d8961" default-features = false optional = true diff --git a/temporalio/contrib/streams/_writes.py b/temporalio/contrib/streams/_writes.py index e73b330c1..fb6eef2ee 100644 --- a/temporalio/contrib/streams/_writes.py +++ b/temporalio/contrib/streams/_writes.py @@ -62,6 +62,11 @@ async def create_stream(request: stream_pb.CreateStreamRequest) -> int: if request.HasField("auto_close") else None, withdraw_run=request.withdraw_run or None, + owner=models.Producer( + kind=request.owner.kind, + id=request.owner.id, + run_id=request.owner.run_id or None, + ), ) ) if response.status == stream_pb.StreamStatus.STREAM_STATUS_CLOSED: diff --git a/temporalio/contrib/streams/system/models.py b/temporalio/contrib/streams/system/models.py index 2304fc581..6a6959e98 100644 --- a/temporalio/contrib/streams/system/models.py +++ b/temporalio/contrib/streams/system/models.py @@ -29,6 +29,9 @@ class CreateStreamRequest: retention: datetime.timedelta | None = None withdraw_run: str | None = None auto_close: datetime.timedelta | None = None + owner: Producer | None = None + close_with_run: bool | None = None + ref: StreamRef | None = None class _CreateStreamRequestTransferTypeConverter( @@ -60,6 +63,17 @@ def from_transfer_type( auto_close=_support.duration_from_proto(value.auto_close) if value.HasField("auto_close") else None, + owner=_ProducerTransferTypeConverter().from_transfer_type( + value.owner, Producer + ) + if value.HasField("owner") + else None, + close_with_run=value.close_with_run if bool(value.close_with_run) else None, + ref=_StreamRefTransferTypeConverter().from_transfer_type( + value.ref, StreamRef + ) + if value.HasField("ref") + else None, ) @typing_extensions.override @@ -77,237 +91,22 @@ def to_transfer_type( message.withdraw_run = value.withdraw_run if value.auto_close is not None: message.auto_close.CopyFrom(_support.duration_to_proto(value.auto_close)) - return message - - -temporalio.converter.transfer_type_convertible( - _CreateStreamRequestTransferTypeConverter -)(CreateStreamRequest) # pyright: ignore[reportUnusedCallResult] - - -@dataclasses.dataclass(slots=True) -class CreateStreamResponse: - stream: StreamRef | None = None - created: bool | None = None - status: ( - temporalio.api.streamservice.v1.stream_service_pb2.StreamStatus.ValueType | None - ) = None - - -class _CreateStreamResponseTransferTypeConverter( - temporalio.converter.TransferTypeConverter[ - CreateStreamResponse, - temporalio.api.streamservice.v1.stream_service_pb2.CreateStreamResponse, - ] -): - transfer_type: ( - type[temporalio.api.streamservice.v1.stream_service_pb2.CreateStreamResponse] - | None - ) = temporalio.api.streamservice.v1.stream_service_pb2.CreateStreamResponse - - @typing_extensions.override - def from_transfer_type( - self, - value: temporalio.api.streamservice.v1.stream_service_pb2.CreateStreamResponse, - type_hint: type[CreateStreamResponse], - ) -> CreateStreamResponse: - return CreateStreamResponse( - stream=_StreamRefTransferTypeConverter().from_transfer_type( - value.stream, StreamRef - ) - if value.HasField("stream") - else None, - created=value.created if bool(value.created) else None, - status=temporalio.api.streamservice.v1.stream_service_pb2.StreamStatus.ValueType( - value.status - ) - if value.status != 0 - else None, - ) - - @typing_extensions.override - def to_transfer_type( - self, - value: CreateStreamResponse, - ) -> temporalio.api.streamservice.v1.stream_service_pb2.CreateStreamResponse: - message = ( - temporalio.api.streamservice.v1.stream_service_pb2.CreateStreamResponse() - ) - if value.stream is not None: - message.stream.CopyFrom( - _StreamRefTransferTypeConverter().to_transfer_type(value.stream) - ) - if value.created is not None: - message.created = value.created - if value.status is not None: - message.status = temporalio.api.streamservice.v1.stream_service_pb2.StreamStatus.ValueType( - value.status - ) - return message - - -temporalio.converter.transfer_type_convertible( - _CreateStreamResponseTransferTypeConverter -)(CreateStreamResponse) # pyright: ignore[reportUnusedCallResult] - - -@dataclasses.dataclass(slots=True) -class StreamRef: - """Identifies one Stream. The id is opaque to callers.""" - - id: str - workflow_id: str | None = None - first_execution_run_id: str | None = None - backend: str | None = None - - -class _StreamRefTransferTypeConverter( - temporalio.converter.TransferTypeConverter[ - StreamRef, temporalio.api.streamservice.v1.stream_service_pb2.StreamRef - ] -): - transfer_type: ( - type[temporalio.api.streamservice.v1.stream_service_pb2.StreamRef] | None - ) = temporalio.api.streamservice.v1.stream_service_pb2.StreamRef - - @typing_extensions.override - def from_transfer_type( - self, - value: temporalio.api.streamservice.v1.stream_service_pb2.StreamRef, - type_hint: type[StreamRef], - ) -> StreamRef: - if not value.id: - raise ValueError("missing required field StreamRef.id") - id = value.id - return StreamRef( - id=id, - workflow_id=value.workflow_id if bool(value.workflow_id) else None, - first_execution_run_id=value.first_execution_run_id - if bool(value.first_execution_run_id) - else None, - backend=value.backend if bool(value.backend) else None, - ) - - @typing_extensions.override - def to_transfer_type( - self, - value: StreamRef, - ) -> temporalio.api.streamservice.v1.stream_service_pb2.StreamRef: - message = temporalio.api.streamservice.v1.stream_service_pb2.StreamRef() - message.id = value.id - if value.workflow_id is not None: - message.workflow_id = value.workflow_id - if value.first_execution_run_id is not None: - message.first_execution_run_id = value.first_execution_run_id - if value.backend is not None: - message.backend = value.backend - return message - - -temporalio.converter.transfer_type_convertible(_StreamRefTransferTypeConverter)( - StreamRef -) # pyright: ignore[reportUnusedCallResult] - - -@dataclasses.dataclass(slots=True) -class AppendRecordsRequest: - """Append. FAILED_PRECONDITION if closed, fenced or format too new. NOT_FOUND if - absent. - """ - - stream: StreamRef | None = None - producer: Producer | None = None - sequence: int | None = None - records: list[AppendRecord] | None = dataclasses.field(default_factory=list) - previous_completed_task: WorkflowTaskEpoch | None = None - failed_tasks: list[int] | None = dataclasses.field(default_factory=list) - withdraw_run: str | None = None - - -class _AppendRecordsRequestTransferTypeConverter( - temporalio.converter.TransferTypeConverter[ - AppendRecordsRequest, - temporalio.api.streamservice.v1.stream_service_pb2.AppendRecordsRequest, - ] -): - transfer_type: ( - type[temporalio.api.streamservice.v1.stream_service_pb2.AppendRecordsRequest] - | None - ) = temporalio.api.streamservice.v1.stream_service_pb2.AppendRecordsRequest - - @typing_extensions.override - def from_transfer_type( - self, - value: temporalio.api.streamservice.v1.stream_service_pb2.AppendRecordsRequest, - type_hint: type[AppendRecordsRequest], - ) -> AppendRecordsRequest: - return AppendRecordsRequest( - stream=_StreamRefTransferTypeConverter().from_transfer_type( - value.stream, StreamRef + if value.owner is not None: + message.owner.CopyFrom( + _ProducerTransferTypeConverter().to_transfer_type(value.owner) ) - if value.HasField("stream") - else None, - producer=_ProducerTransferTypeConverter().from_transfer_type( - value.producer, Producer + if value.close_with_run is not None: + message.close_with_run = value.close_with_run + if value.ref is not None: + message.ref.CopyFrom( + _StreamRefTransferTypeConverter().to_transfer_type(value.ref) ) - if value.HasField("producer") - else None, - sequence=value.sequence if value.sequence != 0 else None, - records=[ - _AppendRecordTransferTypeConverter().from_transfer_type( - item, AppendRecord - ) - for item in value.records - ], - previous_completed_task=_WorkflowTaskEpochTransferTypeConverter().from_transfer_type( - value.previous_completed_task, WorkflowTaskEpoch - ) - if value.HasField("previous_completed_task") - else None, - failed_tasks=list(value.failed_tasks), - withdraw_run=value.withdraw_run if bool(value.withdraw_run) else None, - ) - - @typing_extensions.override - def to_transfer_type( - self, - value: AppendRecordsRequest, - ) -> temporalio.api.streamservice.v1.stream_service_pb2.AppendRecordsRequest: - message = ( - temporalio.api.streamservice.v1.stream_service_pb2.AppendRecordsRequest() - ) - if value.stream is not None: - message.stream.CopyFrom( - _StreamRefTransferTypeConverter().to_transfer_type(value.stream) - ) - if value.producer is not None: - message.producer.CopyFrom( - _ProducerTransferTypeConverter().to_transfer_type(value.producer) - ) - if value.sequence is not None: - message.sequence = value.sequence - if value.records: - for element in value.records: - item = message.records.add() - item.CopyFrom( - _AppendRecordTransferTypeConverter().to_transfer_type(element) - ) - if value.previous_completed_task is not None: - message.previous_completed_task.CopyFrom( - _WorkflowTaskEpochTransferTypeConverter().to_transfer_type( - value.previous_completed_task - ) - ) - if value.failed_tasks: - message.failed_tasks.extend(value.failed_tasks) - if value.withdraw_run is not None: - message.withdraw_run = value.withdraw_run return message temporalio.converter.transfer_type_convertible( - _AppendRecordsRequestTransferTypeConverter -)(AppendRecordsRequest) # pyright: ignore[reportUnusedCallResult] + _CreateStreamRequestTransferTypeConverter +)(CreateStreamRequest) # pyright: ignore[reportUnusedCallResult] @dataclasses.dataclass(slots=True, kw_only=True) @@ -498,6 +297,231 @@ def to_transfer_type( ) # pyright: ignore[reportUnusedCallResult] +@dataclasses.dataclass(slots=True) +class StreamRef: + """Identifies one Stream. The id is opaque to callers.""" + + id: str + workflow_id: str | None = None + first_execution_run_id: str | None = None + backend: str | None = None + + +class _StreamRefTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + StreamRef, temporalio.api.streamservice.v1.stream_service_pb2.StreamRef + ] +): + transfer_type: ( + type[temporalio.api.streamservice.v1.stream_service_pb2.StreamRef] | None + ) = temporalio.api.streamservice.v1.stream_service_pb2.StreamRef + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.streamservice.v1.stream_service_pb2.StreamRef, + type_hint: type[StreamRef], + ) -> StreamRef: + if not value.id: + raise ValueError("missing required field StreamRef.id") + id = value.id + return StreamRef( + id=id, + workflow_id=value.workflow_id if bool(value.workflow_id) else None, + first_execution_run_id=value.first_execution_run_id + if bool(value.first_execution_run_id) + else None, + backend=value.backend if bool(value.backend) else None, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: StreamRef, + ) -> temporalio.api.streamservice.v1.stream_service_pb2.StreamRef: + message = temporalio.api.streamservice.v1.stream_service_pb2.StreamRef() + message.id = value.id + if value.workflow_id is not None: + message.workflow_id = value.workflow_id + if value.first_execution_run_id is not None: + message.first_execution_run_id = value.first_execution_run_id + if value.backend is not None: + message.backend = value.backend + return message + + +temporalio.converter.transfer_type_convertible(_StreamRefTransferTypeConverter)( + StreamRef +) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class CreateStreamResponse: + stream: StreamRef | None = None + created: bool | None = None + status: ( + temporalio.api.streamservice.v1.stream_service_pb2.StreamStatus.ValueType | None + ) = None + + +class _CreateStreamResponseTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + CreateStreamResponse, + temporalio.api.streamservice.v1.stream_service_pb2.CreateStreamResponse, + ] +): + transfer_type: ( + type[temporalio.api.streamservice.v1.stream_service_pb2.CreateStreamResponse] + | None + ) = temporalio.api.streamservice.v1.stream_service_pb2.CreateStreamResponse + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.streamservice.v1.stream_service_pb2.CreateStreamResponse, + type_hint: type[CreateStreamResponse], + ) -> CreateStreamResponse: + return CreateStreamResponse( + stream=_StreamRefTransferTypeConverter().from_transfer_type( + value.stream, StreamRef + ) + if value.HasField("stream") + else None, + created=value.created if bool(value.created) else None, + status=temporalio.api.streamservice.v1.stream_service_pb2.StreamStatus.ValueType( + value.status + ) + if value.status != 0 + else None, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: CreateStreamResponse, + ) -> temporalio.api.streamservice.v1.stream_service_pb2.CreateStreamResponse: + message = ( + temporalio.api.streamservice.v1.stream_service_pb2.CreateStreamResponse() + ) + if value.stream is not None: + message.stream.CopyFrom( + _StreamRefTransferTypeConverter().to_transfer_type(value.stream) + ) + if value.created is not None: + message.created = value.created + if value.status is not None: + message.status = temporalio.api.streamservice.v1.stream_service_pb2.StreamStatus.ValueType( + value.status + ) + return message + + +temporalio.converter.transfer_type_convertible( + _CreateStreamResponseTransferTypeConverter +)(CreateStreamResponse) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class AppendRecordsRequest: + """Append. FAILED_PRECONDITION if closed, fenced or format too new. NOT_FOUND if + absent. + """ + + stream: StreamRef | None = None + producer: Producer | None = None + sequence: int | None = None + records: list[AppendRecord] | None = dataclasses.field(default_factory=list) + previous_completed_task: WorkflowTaskEpoch | None = None + failed_tasks: list[int] | None = dataclasses.field(default_factory=list) + withdraw_run: str | None = None + + +class _AppendRecordsRequestTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + AppendRecordsRequest, + temporalio.api.streamservice.v1.stream_service_pb2.AppendRecordsRequest, + ] +): + transfer_type: ( + type[temporalio.api.streamservice.v1.stream_service_pb2.AppendRecordsRequest] + | None + ) = temporalio.api.streamservice.v1.stream_service_pb2.AppendRecordsRequest + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.streamservice.v1.stream_service_pb2.AppendRecordsRequest, + type_hint: type[AppendRecordsRequest], + ) -> AppendRecordsRequest: + return AppendRecordsRequest( + stream=_StreamRefTransferTypeConverter().from_transfer_type( + value.stream, StreamRef + ) + if value.HasField("stream") + else None, + producer=_ProducerTransferTypeConverter().from_transfer_type( + value.producer, Producer + ) + if value.HasField("producer") + else None, + sequence=value.sequence if value.sequence != 0 else None, + records=[ + _AppendRecordTransferTypeConverter().from_transfer_type( + item, AppendRecord + ) + for item in value.records + ], + previous_completed_task=_WorkflowTaskEpochTransferTypeConverter().from_transfer_type( + value.previous_completed_task, WorkflowTaskEpoch + ) + if value.HasField("previous_completed_task") + else None, + failed_tasks=list(value.failed_tasks), + withdraw_run=value.withdraw_run if bool(value.withdraw_run) else None, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: AppendRecordsRequest, + ) -> temporalio.api.streamservice.v1.stream_service_pb2.AppendRecordsRequest: + message = ( + temporalio.api.streamservice.v1.stream_service_pb2.AppendRecordsRequest() + ) + if value.stream is not None: + message.stream.CopyFrom( + _StreamRefTransferTypeConverter().to_transfer_type(value.stream) + ) + if value.producer is not None: + message.producer.CopyFrom( + _ProducerTransferTypeConverter().to_transfer_type(value.producer) + ) + if value.sequence is not None: + message.sequence = value.sequence + if value.records: + for element in value.records: + item = message.records.add() + item.CopyFrom( + _AppendRecordTransferTypeConverter().to_transfer_type(element) + ) + if value.previous_completed_task is not None: + message.previous_completed_task.CopyFrom( + _WorkflowTaskEpochTransferTypeConverter().to_transfer_type( + value.previous_completed_task + ) + ) + if value.failed_tasks: + message.failed_tasks.extend(value.failed_tasks) + if value.withdraw_run is not None: + message.withdraw_run = value.withdraw_run + return message + + +temporalio.converter.transfer_type_convertible( + _AppendRecordsRequestTransferTypeConverter +)(AppendRecordsRequest) # pyright: ignore[reportUnusedCallResult] + + @dataclasses.dataclass(slots=True) class AppendRecord: """One Record to append.""" diff --git a/temporalio/contrib/streams/system/operations/create_stream.py b/temporalio/contrib/streams/system/operations/create_stream.py index 39efa09da..f2f30d832 100644 --- a/temporalio/contrib/streams/system/operations/create_stream.py +++ b/temporalio/contrib/streams/system/operations/create_stream.py @@ -13,6 +13,8 @@ from ..models import ( CreateStreamRequest, CreateStreamResponse, + Producer, + StreamRef, ) @@ -46,6 +48,9 @@ async def create_stream( retention: datetime.timedelta | None = None, withdraw_run: str | None = None, auto_close: datetime.timedelta | None = None, + owner: Producer | None = None, + close_with_run: bool | None = None, + ref: StreamRef | None = None, ) -> CreateStreamResult: """ Args: @@ -55,11 +60,18 @@ async def create_stream( withdraw_run: Retry run only. The previous run, whose Records this withdraws. auto_close: Close with no result after this long with no append. Empty never auto-closes. + owner: The Workflow run that owns the Stream. The Workflow transport sets it. + close_with_run: Close with the owner run chain's result when it closes. Native + only. + ref: The Stream's ref, with the owner fields. """ request = CreateStreamRequest( stream_id=stream_id, retention=retention, withdraw_run=withdraw_run, auto_close=auto_close, + owner=owner, + close_with_run=close_with_run, + ref=ref, ) return await _create_stream(request) diff --git a/temporalio/contrib/streams/workflow.py b/temporalio/contrib/streams/workflow.py index c14a2cf1f..87803aa0e 100644 --- a/temporalio/contrib/streams/workflow.py +++ b/temporalio/contrib/streams/workflow.py @@ -442,7 +442,16 @@ def _create( f"The Stream {stream.id} already closes with the run chain, and " "close_with_run=False can't turn that off" ) - request = stream_pb.CreateStreamRequest(stream_id=stream.id) + run_id = temporalio.workflow.info().run_id + # The owner makes the Stream the run chain's, and names a retry run's withdrawal. + request = stream_pb.CreateStreamRequest( + stream_id=stream.id, + owner=stream_pb.Producer( + kind=stream_pb.ProducerKind.PRODUCER_KIND_WORKFLOW, + id=_ids.workflow_producer_id(run_id), + run_id=run_id, + ), + ) if retention is not None: request.retention.FromTimedelta(retention) if auto_close is not None: From 928312c3155ff91401fc0471e5f4c876760a4fcf Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 22 Oct 2026 11:09:30 -0700 Subject: [PATCH 06/11] Pinned crate s19, which drops the Workflow write buffer. --- .../temporal/sdk/streams/v1/extension.proto | 74 +---- temporalio/bridge/Cargo.lock | 2 +- temporalio/bridge/Cargo.toml | 2 +- .../contrib/streams/proto/v1/__init__.py | 14 - .../contrib/streams/proto/v1/extension_pb2.py | 142 ++-------- .../streams/proto/v1/extension_pb2.pyi | 258 ------------------ 6 files changed, 25 insertions(+), 467 deletions(-) diff --git a/scripts/_proto/temporal/sdk/streams/v1/extension.proto b/scripts/_proto/temporal/sdk/streams/v1/extension.proto index 5945df743..64cfc0964 100644 --- a/scripts/_proto/temporal/sdk/streams/v1/extension.proto +++ b/scripts/_proto/temporal/sdk/streams/v1/extension.proto @@ -19,10 +19,9 @@ message RedisStreamConfig { google.protobuf.Duration retention = 4; // How long an open Workflow-owned Stream stays after its last write. Empty is 90 days. google.protobuf.Duration safety_ttl = 5; - // When above zero, each Workflow flush runs `WAIT durable_writes `. - int32 durable_writes = 6; - // Empty is 1 s. - google.protobuf.Duration durable_timeout = 7; + // A write is durable as an Activity write is, so a Workflow write has no replica wait. + reserved 6, 7; + reserved "durable_writes", "durable_timeout"; // Zero is 256. int32 blocking_reads_per_node = 8; // Empty is 5 s. @@ -32,61 +31,6 @@ message RedisStreamConfig { string backend = 10; } -// Method `BufferWorkflowCalls`, synchronous. Lang calls it at the end of each Activation of a -// run, with the calls that Workflow code made in that Activation, in order. On replay, lang -// sends only the creates and the closes. The crate writes the buffer in `before_complete` and -// sends the closes in `after_complete`. -message BufferWorkflowCallsRequest { - string run_id = 1; - // A retry run only: the previous run. The crate puts it on each create and append of the - // run, so the first one on each Stream withdraws the previous run's Records. - string withdraw_run = 2; - repeated WorkflowCall calls = 3; - // Set on the run's last call when the server goes on with the Run Chain, which is a failure - // that the retry policy retries, or any cron run. The crate then skips the closes that have - // `when_run_closes`. - bool chain_continues = 4; -} - -message WorkflowCall { - oneof call { - temporal.api.streamservice.v1.CreateStreamRequest create = 1; - // The crate sets the Producer, the sequence and the History facts. - temporal.api.streamservice.v1.AppendRecordsRequest append = 2; - // The crate sets the Producer and the History facts. - temporal.api.streamservice.v1.CloseStreamRequest close = 3; - } - // For a close only: send it only when the server accepts a completion that closes the run. - // `auto_close` sets it. - bool when_run_closes = 4; -} - -message BufferWorkflowCallsResponse { -} - -// Method `FlushRunBuffer`, asynchronous. Lang calls it before it runs a Local Activity, also -// before each retry that Core runs in the same task. The crate writes the run's buffered -// creates and appends in the epoch of the Workflow Task that dispatched the Local Activity, so -// the run's Records come before the Local Activity's. Closes still wait for an accepted -// completion. The task's completion writes only what came later, with the numbering going on, -// and sends the task's withdrawal facts to each Stream this call wrote. A Local Activity of a -// task that already completed, or of a task older than an earlier flush, writes nothing. When -// the call fails, the run's next completion fails with the same error, so the retry withdraws -// what was written. Lang then fails the Local Activity attempt. -message FlushRunBufferRequest { - string namespace = 1; - string run_id = 2; - // `ActivityTask.Start.workflow_task` (field 20) of the Local Activity. - temporal.api.streamservice.v1.WorkflowTaskEpoch workflow_task = 3; - // How long the write may take. Empty is 10 s. - google.protobuf.Duration timeout = 4; -} - -message FlushRunBufferResponse { - // The Records the call wrote. Zero when the run had nothing buffered. - int32 records = 1; -} - // Method `OpenWriter`, asynchronous. A writer for an Activity or a client Producer, which keeps // its epoch and `sequence` order. A writer of an Activity attempt above 1 first sends an empty // batch, so the new epoch withdraws the earlier attempt's Records. @@ -144,18 +88,6 @@ message CloseWritersResponse { int32 failed = 1; } -// Method `DrainCloses`, asynchronous. Waits for the closes that `after_complete` started, for -// example at Worker shutdown. -message DrainClosesRequest { - // Empty is 10 s. - google.protobuf.Duration timeout = 1; -} - -message DrainClosesResponse { - // The closes still running when the timeout passed. - int32 pending = 1; -} - // The details of a failed call, in the error's details bytes. message StreamErrorDetails { // A token for lang, such as `stream_closed`, `stream_fenced` or `stream_format_too_new`. diff --git a/temporalio/bridge/Cargo.lock b/temporalio/bridge/Cargo.lock index 85886502a..1f5b44507 100644 --- a/temporalio/bridge/Cargo.lock +++ b/temporalio/bridge/Cargo.lock @@ -2879,7 +2879,7 @@ dependencies = [ [[package]] name = "temporalio-streams" version = "0.1.0" -source = "git+https://github.com/moetemp/sdk-streams?rev=d1af72c514330e36bd89bdf93676b7a5ae0d8961#d1af72c514330e36bd89bdf93676b7a5ae0d8961" +source = "git+https://github.com/moetemp/sdk-streams?rev=46a94db1195e3f45cbcb39af6ea7754e9ec470fd#46a94db1195e3f45cbcb39af6ea7754e9ec470fd" dependencies = [ "async-trait", "aws-lc-rs", diff --git a/temporalio/bridge/Cargo.toml b/temporalio/bridge/Cargo.toml index f843d625f..0aa27fa9d 100644 --- a/temporalio/bridge/Cargo.toml +++ b/temporalio/bridge/Cargo.toml @@ -94,7 +94,7 @@ url = "2.2" # crates, so the bridge sees one type of each. [dependencies.temporalio-streams] git = "https://github.com/moetemp/sdk-streams" -rev = "d1af72c514330e36bd89bdf93676b7a5ae0d8961" +rev = "46a94db1195e3f45cbcb39af6ea7754e9ec470fd" default-features = false optional = true diff --git a/temporalio/contrib/streams/proto/v1/__init__.py b/temporalio/contrib/streams/proto/v1/__init__.py index c7e82df4f..5b5a3da2c 100644 --- a/temporalio/contrib/streams/proto/v1/__init__.py +++ b/temporalio/contrib/streams/proto/v1/__init__.py @@ -1,39 +1,25 @@ from .extension_pb2 import ( - BufferWorkflowCallsRequest, - BufferWorkflowCallsResponse, CloseWriterRequest, CloseWriterResponse, CloseWritersRequest, CloseWritersResponse, - DrainClosesRequest, - DrainClosesResponse, - FlushRunBufferRequest, - FlushRunBufferResponse, OpenWriterRequest, OpenWriterResponse, RedisStreamConfig, StreamErrorDetails, - WorkflowCall, WriteRecordsRequest, WriteRecordsResponse, ) __all__ = [ - "BufferWorkflowCallsRequest", - "BufferWorkflowCallsResponse", "CloseWriterRequest", "CloseWriterResponse", "CloseWritersRequest", "CloseWritersResponse", - "DrainClosesRequest", - "DrainClosesResponse", - "FlushRunBufferRequest", - "FlushRunBufferResponse", "OpenWriterRequest", "OpenWriterResponse", "RedisStreamConfig", "StreamErrorDetails", - "WorkflowCall", "WriteRecordsRequest", "WriteRecordsResponse", ] diff --git a/temporalio/contrib/streams/proto/v1/extension_pb2.py b/temporalio/contrib/streams/proto/v1/extension_pb2.py index 18252896e..50554a0b0 100644 --- a/temporalio/contrib/streams/proto/v1/extension_pb2.py +++ b/temporalio/contrib/streams/proto/v1/extension_pb2.py @@ -21,20 +21,11 @@ ) DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n\'temporal/sdk/streams/v1/extension.proto\x12\x17temporal.sdk.streams.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x32temporal/api/streamservice/v1/stream_service.proto"\xd6\x02\n\x11RedisStreamConfig\x12\x0c\n\x04urls\x18\x01 \x03(\t\x12\x0f\n\x07\x63luster\x18\x02 \x01(\x08\x12\x12\n\nkey_prefix\x18\x03 \x01(\t\x12,\n\tretention\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\x12-\n\nsafety_ttl\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x16\n\x0e\x64urable_writes\x18\x06 \x01(\x05\x12\x32\n\x0f\x64urable_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x1f\n\x17\x62locking_reads_per_node\x18\x08 \x01(\x05\x12\x33\n\x10response_timeout\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x0f\n\x07\x62\x61\x63kend\x18\n \x01(\t"\x91\x01\n\x1a\x42ufferWorkflowCallsRequest\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x14\n\x0cwithdraw_run\x18\x02 \x01(\t\x12\x34\n\x05\x63\x61lls\x18\x03 \x03(\x0b\x32%.temporal.sdk.streams.v1.WorkflowCall\x12\x17\n\x0f\x63hain_continues\x18\x04 \x01(\x08"\x80\x02\n\x0cWorkflowCall\x12\x44\n\x06\x63reate\x18\x01 \x01(\x0b\x32\x32.temporal.api.streamservice.v1.CreateStreamRequestH\x00\x12\x45\n\x06\x61ppend\x18\x02 \x01(\x0b\x32\x33.temporal.api.streamservice.v1.AppendRecordsRequestH\x00\x12\x42\n\x05\x63lose\x18\x03 \x01(\x0b\x32\x31.temporal.api.streamservice.v1.CloseStreamRequestH\x00\x12\x17\n\x0fwhen_run_closes\x18\x04 \x01(\x08\x42\x06\n\x04\x63\x61ll"\x1d\n\x1b\x42ufferWorkflowCallsResponse"\xaf\x01\n\x15\x46lushRunBufferRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x0e\n\x06run_id\x18\x02 \x01(\t\x12G\n\rworkflow_task\x18\x03 \x01(\x0b\x32\x30.temporal.api.streamservice.v1.WorkflowTaskEpoch\x12*\n\x07timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration")\n\x16\x46lushRunBufferResponse\x12\x0f\n\x07records\x18\x01 \x01(\x05"\x9c\x02\n\x11OpenWriterRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\x06stream\x18\x02 \x01(\x0b\x32(.temporal.api.streamservice.v1.StreamRef\x12\x39\n\x08producer\x18\x03 \x01(\x0b\x32\'.temporal.api.streamservice.v1.Producer\x12\x16\n\x0emax_batch_size\x18\x05 \x01(\x05\x12\x35\n\x12max_retry_duration\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationJ\x04\x08\x04\x10\x05J\x04\x08\x07\x10\x08R\x0e\x62\x61tch_intervalR\x14max_buffered_records"\'\n\x12OpenWriterResponse\x12\x11\n\twriter_id\x18\x01 \x01(\x04"f\n\x13WriteRecordsRequest\x12\x11\n\twriter_id\x18\x01 \x01(\x04\x12<\n\x07records\x18\x02 \x03(\x0b\x32+.temporal.api.streamservice.v1.AppendRecord"A\n\x14WriteRecordsResponse\x12\x14\n\x0c\x66irst_offset\x18\x01 \x01(\x03\x12\x13\n\x0blast_offset\x18\x02 \x01(\x03"\'\n\x12\x43loseWriterRequest\x12\x11\n\twriter_id\x18\x01 \x01(\x04"*\n\x13\x43loseWriterResponse\x12\x13\n\x0blast_offset\x18\x01 \x01(\x03"\x15\n\x13\x43loseWritersRequest"&\n\x14\x43loseWritersResponse\x12\x0e\n\x06\x66\x61iled\x18\x01 \x01(\x05"@\n\x12\x44rainClosesRequest\x12*\n\x07timeout\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration"&\n\x13\x44rainClosesResponse\x12\x0f\n\x07pending\x18\x01 \x01(\x05"$\n\x12StreamErrorDetails\x12\x0e\n\x06reason\x18\x01 \x01(\tb\x06proto3' + b'\n\'temporal/sdk/streams/v1/extension.proto\x12\x17temporal.sdk.streams.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x32temporal/api/streamservice/v1/stream_service.proto"\xb7\x02\n\x11RedisStreamConfig\x12\x0c\n\x04urls\x18\x01 \x03(\t\x12\x0f\n\x07\x63luster\x18\x02 \x01(\x08\x12\x12\n\nkey_prefix\x18\x03 \x01(\t\x12,\n\tretention\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\x12-\n\nsafety_ttl\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x1f\n\x17\x62locking_reads_per_node\x18\x08 \x01(\x05\x12\x33\n\x10response_timeout\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x0f\n\x07\x62\x61\x63kend\x18\n \x01(\tJ\x04\x08\x06\x10\x07J\x04\x08\x07\x10\x08R\x0e\x64urable_writesR\x0f\x64urable_timeout"\x9c\x02\n\x11OpenWriterRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\x06stream\x18\x02 \x01(\x0b\x32(.temporal.api.streamservice.v1.StreamRef\x12\x39\n\x08producer\x18\x03 \x01(\x0b\x32\'.temporal.api.streamservice.v1.Producer\x12\x16\n\x0emax_batch_size\x18\x05 \x01(\x05\x12\x35\n\x12max_retry_duration\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationJ\x04\x08\x04\x10\x05J\x04\x08\x07\x10\x08R\x0e\x62\x61tch_intervalR\x14max_buffered_records"\'\n\x12OpenWriterResponse\x12\x11\n\twriter_id\x18\x01 \x01(\x04"f\n\x13WriteRecordsRequest\x12\x11\n\twriter_id\x18\x01 \x01(\x04\x12<\n\x07records\x18\x02 \x03(\x0b\x32+.temporal.api.streamservice.v1.AppendRecord"A\n\x14WriteRecordsResponse\x12\x14\n\x0c\x66irst_offset\x18\x01 \x01(\x03\x12\x13\n\x0blast_offset\x18\x02 \x01(\x03"\'\n\x12\x43loseWriterRequest\x12\x11\n\twriter_id\x18\x01 \x01(\x04"*\n\x13\x43loseWriterResponse\x12\x13\n\x0blast_offset\x18\x01 \x01(\x03"\x15\n\x13\x43loseWritersRequest"&\n\x14\x43loseWritersResponse\x12\x0e\n\x06\x66\x61iled\x18\x01 \x01(\x05"$\n\x12StreamErrorDetails\x12\x0e\n\x06reason\x18\x01 \x01(\tb\x06proto3' ) _REDISSTREAMCONFIG = DESCRIPTOR.message_types_by_name["RedisStreamConfig"] -_BUFFERWORKFLOWCALLSREQUEST = DESCRIPTOR.message_types_by_name[ - "BufferWorkflowCallsRequest" -] -_WORKFLOWCALL = DESCRIPTOR.message_types_by_name["WorkflowCall"] -_BUFFERWORKFLOWCALLSRESPONSE = DESCRIPTOR.message_types_by_name[ - "BufferWorkflowCallsResponse" -] -_FLUSHRUNBUFFERREQUEST = DESCRIPTOR.message_types_by_name["FlushRunBufferRequest"] -_FLUSHRUNBUFFERRESPONSE = DESCRIPTOR.message_types_by_name["FlushRunBufferResponse"] _OPENWRITERREQUEST = DESCRIPTOR.message_types_by_name["OpenWriterRequest"] _OPENWRITERRESPONSE = DESCRIPTOR.message_types_by_name["OpenWriterResponse"] _WRITERECORDSREQUEST = DESCRIPTOR.message_types_by_name["WriteRecordsRequest"] @@ -43,8 +34,6 @@ _CLOSEWRITERRESPONSE = DESCRIPTOR.message_types_by_name["CloseWriterResponse"] _CLOSEWRITERSREQUEST = DESCRIPTOR.message_types_by_name["CloseWritersRequest"] _CLOSEWRITERSRESPONSE = DESCRIPTOR.message_types_by_name["CloseWritersResponse"] -_DRAINCLOSESREQUEST = DESCRIPTOR.message_types_by_name["DrainClosesRequest"] -_DRAINCLOSESRESPONSE = DESCRIPTOR.message_types_by_name["DrainClosesResponse"] _STREAMERRORDETAILS = DESCRIPTOR.message_types_by_name["StreamErrorDetails"] RedisStreamConfig = _reflection.GeneratedProtocolMessageType( "RedisStreamConfig", @@ -57,61 +46,6 @@ ) _sym_db.RegisterMessage(RedisStreamConfig) -BufferWorkflowCallsRequest = _reflection.GeneratedProtocolMessageType( - "BufferWorkflowCallsRequest", - (_message.Message,), - { - "DESCRIPTOR": _BUFFERWORKFLOWCALLSREQUEST, - "__module__": "temporalio.contrib.streams.proto.v1.extension_pb2", - # @@protoc_insertion_point(class_scope:temporal.sdk.streams.v1.BufferWorkflowCallsRequest) - }, -) -_sym_db.RegisterMessage(BufferWorkflowCallsRequest) - -WorkflowCall = _reflection.GeneratedProtocolMessageType( - "WorkflowCall", - (_message.Message,), - { - "DESCRIPTOR": _WORKFLOWCALL, - "__module__": "temporalio.contrib.streams.proto.v1.extension_pb2", - # @@protoc_insertion_point(class_scope:temporal.sdk.streams.v1.WorkflowCall) - }, -) -_sym_db.RegisterMessage(WorkflowCall) - -BufferWorkflowCallsResponse = _reflection.GeneratedProtocolMessageType( - "BufferWorkflowCallsResponse", - (_message.Message,), - { - "DESCRIPTOR": _BUFFERWORKFLOWCALLSRESPONSE, - "__module__": "temporalio.contrib.streams.proto.v1.extension_pb2", - # @@protoc_insertion_point(class_scope:temporal.sdk.streams.v1.BufferWorkflowCallsResponse) - }, -) -_sym_db.RegisterMessage(BufferWorkflowCallsResponse) - -FlushRunBufferRequest = _reflection.GeneratedProtocolMessageType( - "FlushRunBufferRequest", - (_message.Message,), - { - "DESCRIPTOR": _FLUSHRUNBUFFERREQUEST, - "__module__": "temporalio.contrib.streams.proto.v1.extension_pb2", - # @@protoc_insertion_point(class_scope:temporal.sdk.streams.v1.FlushRunBufferRequest) - }, -) -_sym_db.RegisterMessage(FlushRunBufferRequest) - -FlushRunBufferResponse = _reflection.GeneratedProtocolMessageType( - "FlushRunBufferResponse", - (_message.Message,), - { - "DESCRIPTOR": _FLUSHRUNBUFFERRESPONSE, - "__module__": "temporalio.contrib.streams.proto.v1.extension_pb2", - # @@protoc_insertion_point(class_scope:temporal.sdk.streams.v1.FlushRunBufferResponse) - }, -) -_sym_db.RegisterMessage(FlushRunBufferResponse) - OpenWriterRequest = _reflection.GeneratedProtocolMessageType( "OpenWriterRequest", (_message.Message,), @@ -200,28 +134,6 @@ ) _sym_db.RegisterMessage(CloseWritersResponse) -DrainClosesRequest = _reflection.GeneratedProtocolMessageType( - "DrainClosesRequest", - (_message.Message,), - { - "DESCRIPTOR": _DRAINCLOSESREQUEST, - "__module__": "temporalio.contrib.streams.proto.v1.extension_pb2", - # @@protoc_insertion_point(class_scope:temporal.sdk.streams.v1.DrainClosesRequest) - }, -) -_sym_db.RegisterMessage(DrainClosesRequest) - -DrainClosesResponse = _reflection.GeneratedProtocolMessageType( - "DrainClosesResponse", - (_message.Message,), - { - "DESCRIPTOR": _DRAINCLOSESRESPONSE, - "__module__": "temporalio.contrib.streams.proto.v1.extension_pb2", - # @@protoc_insertion_point(class_scope:temporal.sdk.streams.v1.DrainClosesResponse) - }, -) -_sym_db.RegisterMessage(DrainClosesResponse) - StreamErrorDetails = _reflection.GeneratedProtocolMessageType( "StreamErrorDetails", (_message.Message,), @@ -236,37 +148,23 @@ if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None _REDISSTREAMCONFIG._serialized_start = 153 - _REDISSTREAMCONFIG._serialized_end = 495 - _BUFFERWORKFLOWCALLSREQUEST._serialized_start = 498 - _BUFFERWORKFLOWCALLSREQUEST._serialized_end = 643 - _WORKFLOWCALL._serialized_start = 646 - _WORKFLOWCALL._serialized_end = 902 - _BUFFERWORKFLOWCALLSRESPONSE._serialized_start = 904 - _BUFFERWORKFLOWCALLSRESPONSE._serialized_end = 933 - _FLUSHRUNBUFFERREQUEST._serialized_start = 936 - _FLUSHRUNBUFFERREQUEST._serialized_end = 1111 - _FLUSHRUNBUFFERRESPONSE._serialized_start = 1113 - _FLUSHRUNBUFFERRESPONSE._serialized_end = 1154 - _OPENWRITERREQUEST._serialized_start = 1157 - _OPENWRITERREQUEST._serialized_end = 1441 - _OPENWRITERRESPONSE._serialized_start = 1443 - _OPENWRITERRESPONSE._serialized_end = 1482 - _WRITERECORDSREQUEST._serialized_start = 1484 - _WRITERECORDSREQUEST._serialized_end = 1586 - _WRITERECORDSRESPONSE._serialized_start = 1588 - _WRITERECORDSRESPONSE._serialized_end = 1653 - _CLOSEWRITERREQUEST._serialized_start = 1655 - _CLOSEWRITERREQUEST._serialized_end = 1694 - _CLOSEWRITERRESPONSE._serialized_start = 1696 - _CLOSEWRITERRESPONSE._serialized_end = 1738 - _CLOSEWRITERSREQUEST._serialized_start = 1740 - _CLOSEWRITERSREQUEST._serialized_end = 1761 - _CLOSEWRITERSRESPONSE._serialized_start = 1763 - _CLOSEWRITERSRESPONSE._serialized_end = 1801 - _DRAINCLOSESREQUEST._serialized_start = 1803 - _DRAINCLOSESREQUEST._serialized_end = 1867 - _DRAINCLOSESRESPONSE._serialized_start = 1869 - _DRAINCLOSESRESPONSE._serialized_end = 1907 - _STREAMERRORDETAILS._serialized_start = 1909 - _STREAMERRORDETAILS._serialized_end = 1945 + _REDISSTREAMCONFIG._serialized_end = 464 + _OPENWRITERREQUEST._serialized_start = 467 + _OPENWRITERREQUEST._serialized_end = 751 + _OPENWRITERRESPONSE._serialized_start = 753 + _OPENWRITERRESPONSE._serialized_end = 792 + _WRITERECORDSREQUEST._serialized_start = 794 + _WRITERECORDSREQUEST._serialized_end = 896 + _WRITERECORDSRESPONSE._serialized_start = 898 + _WRITERECORDSRESPONSE._serialized_end = 963 + _CLOSEWRITERREQUEST._serialized_start = 965 + _CLOSEWRITERREQUEST._serialized_end = 1004 + _CLOSEWRITERRESPONSE._serialized_start = 1006 + _CLOSEWRITERRESPONSE._serialized_end = 1048 + _CLOSEWRITERSREQUEST._serialized_start = 1050 + _CLOSEWRITERSREQUEST._serialized_end = 1071 + _CLOSEWRITERSRESPONSE._serialized_start = 1073 + _CLOSEWRITERSRESPONSE._serialized_end = 1111 + _STREAMERRORDETAILS._serialized_start = 1113 + _STREAMERRORDETAILS._serialized_end = 1149 # @@protoc_insertion_point(module_scope) diff --git a/temporalio/contrib/streams/proto/v1/extension_pb2.pyi b/temporalio/contrib/streams/proto/v1/extension_pb2.pyi index 72bbb440d..bb0c6fc43 100644 --- a/temporalio/contrib/streams/proto/v1/extension_pb2.pyi +++ b/temporalio/contrib/streams/proto/v1/extension_pb2.pyi @@ -34,8 +34,6 @@ class RedisStreamConfig(google.protobuf.message.Message): KEY_PREFIX_FIELD_NUMBER: builtins.int RETENTION_FIELD_NUMBER: builtins.int SAFETY_TTL_FIELD_NUMBER: builtins.int - DURABLE_WRITES_FIELD_NUMBER: builtins.int - DURABLE_TIMEOUT_FIELD_NUMBER: builtins.int BLOCKING_READS_PER_NODE_FIELD_NUMBER: builtins.int RESPONSE_TIMEOUT_FIELD_NUMBER: builtins.int BACKEND_FIELD_NUMBER: builtins.int @@ -53,11 +51,6 @@ class RedisStreamConfig(google.protobuf.message.Message): @property def safety_ttl(self) -> google.protobuf.duration_pb2.Duration: """How long an open Workflow-owned Stream stays after its last write. Empty is 90 days.""" - durable_writes: builtins.int - """When above zero, each Workflow flush runs `WAIT durable_writes `.""" - @property - def durable_timeout(self) -> google.protobuf.duration_pb2.Duration: - """Empty is 1 s.""" blocking_reads_per_node: builtins.int """Zero is 256.""" @property @@ -75,8 +68,6 @@ class RedisStreamConfig(google.protobuf.message.Message): key_prefix: builtins.str = ..., retention: google.protobuf.duration_pb2.Duration | None = ..., safety_ttl: google.protobuf.duration_pb2.Duration | None = ..., - durable_writes: builtins.int = ..., - durable_timeout: google.protobuf.duration_pb2.Duration | None = ..., blocking_reads_per_node: builtins.int = ..., response_timeout: google.protobuf.duration_pb2.Duration | None = ..., backend: builtins.str = ..., @@ -84,8 +75,6 @@ class RedisStreamConfig(google.protobuf.message.Message): def HasField( self, field_name: typing_extensions.Literal[ - "durable_timeout", - b"durable_timeout", "response_timeout", b"response_timeout", "retention", @@ -103,10 +92,6 @@ class RedisStreamConfig(google.protobuf.message.Message): b"blocking_reads_per_node", "cluster", b"cluster", - "durable_timeout", - b"durable_timeout", - "durable_writes", - b"durable_writes", "key_prefix", b"key_prefix", "response_timeout", @@ -122,207 +107,6 @@ class RedisStreamConfig(google.protobuf.message.Message): global___RedisStreamConfig = RedisStreamConfig -class BufferWorkflowCallsRequest(google.protobuf.message.Message): - """Method `BufferWorkflowCalls`, synchronous. Lang calls it at the end of each Activation of a - run, with the calls that Workflow code made in that Activation, in order. On replay, lang - sends only the creates and the closes. The crate writes the buffer in `before_complete` and - sends the closes in `after_complete`. - """ - - DESCRIPTOR: google.protobuf.descriptor.Descriptor - - RUN_ID_FIELD_NUMBER: builtins.int - WITHDRAW_RUN_FIELD_NUMBER: builtins.int - CALLS_FIELD_NUMBER: builtins.int - CHAIN_CONTINUES_FIELD_NUMBER: builtins.int - run_id: builtins.str - withdraw_run: builtins.str - """A retry run only: the previous run. The crate puts it on each create and append of the - run, so the first one on each Stream withdraws the previous run's Records. - """ - @property - def calls( - self, - ) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[ - global___WorkflowCall - ]: ... - chain_continues: builtins.bool - """Set on the run's last call when the server goes on with the Run Chain, which is a failure - that the retry policy retries, or any cron run. The crate then skips the closes that have - `when_run_closes`. - """ - def __init__( - self, - *, - run_id: builtins.str = ..., - withdraw_run: builtins.str = ..., - calls: collections.abc.Iterable[global___WorkflowCall] | None = ..., - chain_continues: builtins.bool = ..., - ) -> None: ... - def ClearField( - self, - field_name: typing_extensions.Literal[ - "calls", - b"calls", - "chain_continues", - b"chain_continues", - "run_id", - b"run_id", - "withdraw_run", - b"withdraw_run", - ], - ) -> None: ... - -global___BufferWorkflowCallsRequest = BufferWorkflowCallsRequest - -class WorkflowCall(google.protobuf.message.Message): - DESCRIPTOR: google.protobuf.descriptor.Descriptor - - CREATE_FIELD_NUMBER: builtins.int - APPEND_FIELD_NUMBER: builtins.int - CLOSE_FIELD_NUMBER: builtins.int - WHEN_RUN_CLOSES_FIELD_NUMBER: builtins.int - @property - def create( - self, - ) -> temporalio.api.streamservice.v1.stream_service_pb2.CreateStreamRequest: ... - @property - def append( - self, - ) -> temporalio.api.streamservice.v1.stream_service_pb2.AppendRecordsRequest: - """The crate sets the Producer, the sequence and the History facts.""" - @property - def close( - self, - ) -> temporalio.api.streamservice.v1.stream_service_pb2.CloseStreamRequest: - """The crate sets the Producer and the History facts.""" - when_run_closes: builtins.bool - """For a close only: send it only when the server accepts a completion that closes the run. - `auto_close` sets it. - """ - def __init__( - self, - *, - create: temporalio.api.streamservice.v1.stream_service_pb2.CreateStreamRequest - | None = ..., - append: temporalio.api.streamservice.v1.stream_service_pb2.AppendRecordsRequest - | None = ..., - close: temporalio.api.streamservice.v1.stream_service_pb2.CloseStreamRequest - | None = ..., - when_run_closes: builtins.bool = ..., - ) -> None: ... - def HasField( - self, - field_name: typing_extensions.Literal[ - "append", b"append", "call", b"call", "close", b"close", "create", b"create" - ], - ) -> builtins.bool: ... - def ClearField( - self, - field_name: typing_extensions.Literal[ - "append", - b"append", - "call", - b"call", - "close", - b"close", - "create", - b"create", - "when_run_closes", - b"when_run_closes", - ], - ) -> None: ... - def WhichOneof( - self, oneof_group: typing_extensions.Literal["call", b"call"] - ) -> typing_extensions.Literal["create", "append", "close"] | None: ... - -global___WorkflowCall = WorkflowCall - -class BufferWorkflowCallsResponse(google.protobuf.message.Message): - DESCRIPTOR: google.protobuf.descriptor.Descriptor - - def __init__( - self, - ) -> None: ... - -global___BufferWorkflowCallsResponse = BufferWorkflowCallsResponse - -class FlushRunBufferRequest(google.protobuf.message.Message): - """Method `FlushRunBuffer`, asynchronous. Lang calls it before it runs a Local Activity, also - before each retry that Core runs in the same task. The crate writes the run's buffered - creates and appends in the epoch of the Workflow Task that dispatched the Local Activity, so - the run's Records come before the Local Activity's. Closes still wait for an accepted - completion. The task's completion writes only what came later, with the numbering going on, - and sends the task's withdrawal facts to each Stream this call wrote. A Local Activity of a - task that already completed, or of a task older than an earlier flush, writes nothing. When - the call fails, the run's next completion fails with the same error, so the retry withdraws - what was written. Lang then fails the Local Activity attempt. - """ - - DESCRIPTOR: google.protobuf.descriptor.Descriptor - - NAMESPACE_FIELD_NUMBER: builtins.int - RUN_ID_FIELD_NUMBER: builtins.int - WORKFLOW_TASK_FIELD_NUMBER: builtins.int - TIMEOUT_FIELD_NUMBER: builtins.int - namespace: builtins.str - run_id: builtins.str - @property - def workflow_task( - self, - ) -> temporalio.api.streamservice.v1.stream_service_pb2.WorkflowTaskEpoch: - """`ActivityTask.Start.workflow_task` (field 20) of the Local Activity.""" - @property - def timeout(self) -> google.protobuf.duration_pb2.Duration: - """How long the write may take. Empty is 10 s.""" - def __init__( - self, - *, - namespace: builtins.str = ..., - run_id: builtins.str = ..., - workflow_task: temporalio.api.streamservice.v1.stream_service_pb2.WorkflowTaskEpoch - | None = ..., - timeout: google.protobuf.duration_pb2.Duration | None = ..., - ) -> None: ... - def HasField( - self, - field_name: typing_extensions.Literal[ - "timeout", b"timeout", "workflow_task", b"workflow_task" - ], - ) -> builtins.bool: ... - def ClearField( - self, - field_name: typing_extensions.Literal[ - "namespace", - b"namespace", - "run_id", - b"run_id", - "timeout", - b"timeout", - "workflow_task", - b"workflow_task", - ], - ) -> None: ... - -global___FlushRunBufferRequest = FlushRunBufferRequest - -class FlushRunBufferResponse(google.protobuf.message.Message): - DESCRIPTOR: google.protobuf.descriptor.Descriptor - - RECORDS_FIELD_NUMBER: builtins.int - records: builtins.int - """The Records the call wrote. Zero when the run had nothing buffered.""" - def __init__( - self, - *, - records: builtins.int = ..., - ) -> None: ... - def ClearField( - self, field_name: typing_extensions.Literal["records", b"records"] - ) -> None: ... - -global___FlushRunBufferResponse = FlushRunBufferResponse - class OpenWriterRequest(google.protobuf.message.Message): """Method `OpenWriter`, asynchronous. A writer for an Activity or a client Producer, which keeps its epoch and `sequence` order. A writer of an Activity attempt above 1 first sends an empty @@ -533,48 +317,6 @@ class CloseWritersResponse(google.protobuf.message.Message): global___CloseWritersResponse = CloseWritersResponse -class DrainClosesRequest(google.protobuf.message.Message): - """Method `DrainCloses`, asynchronous. Waits for the closes that `after_complete` started, for - example at Worker shutdown. - """ - - DESCRIPTOR: google.protobuf.descriptor.Descriptor - - TIMEOUT_FIELD_NUMBER: builtins.int - @property - def timeout(self) -> google.protobuf.duration_pb2.Duration: - """Empty is 10 s.""" - def __init__( - self, - *, - timeout: google.protobuf.duration_pb2.Duration | None = ..., - ) -> None: ... - def HasField( - self, field_name: typing_extensions.Literal["timeout", b"timeout"] - ) -> builtins.bool: ... - def ClearField( - self, field_name: typing_extensions.Literal["timeout", b"timeout"] - ) -> None: ... - -global___DrainClosesRequest = DrainClosesRequest - -class DrainClosesResponse(google.protobuf.message.Message): - DESCRIPTOR: google.protobuf.descriptor.Descriptor - - PENDING_FIELD_NUMBER: builtins.int - pending: builtins.int - """The closes still running when the timeout passed.""" - def __init__( - self, - *, - pending: builtins.int = ..., - ) -> None: ... - def ClearField( - self, field_name: typing_extensions.Literal["pending", b"pending"] - ) -> None: ... - -global___DrainClosesResponse = DrainClosesResponse - class StreamErrorDetails(google.protobuf.message.Message): """The details of a failed call, in the error's details bytes.""" From 5e0f9fcd107f2cebbd2ea0f60570564c767c9a7c Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 22 Oct 2026 11:17:46 -0700 Subject: [PATCH 07/11] Kept the Workflow Task in the epoch of a Workflow write. --- temporalio/contrib/streams/_writes.py | 18 +++++++++-- tests/contrib/streams/test_redis.py | 43 +++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/temporalio/contrib/streams/_writes.py b/temporalio/contrib/streams/_writes.py index fb6eef2ee..f76851425 100644 --- a/temporalio/contrib/streams/_writes.py +++ b/temporalio/contrib/streams/_writes.py @@ -29,14 +29,26 @@ def _context() -> tuple[_activity.ActivityStreams, service_client.StreamServiceC def _producer(streams: _activity.ActivityStreams) -> models.Producer: - # Every attempt writes as one Producer epoch with one sequence, so the backend drops the - # copy that a retry sends. + # Every attempt of one Workflow Task writes as one epoch with one sequence, so the backend + # drops the copy that a retry sends. A later task has a higher epoch, so its write + # withdraws what a failed task wrote under the same call key. producer = streams.producer + epoch = producer.epoch + started = epoch.task_started_time if epoch.HasField("task_started_time") else None return models.Producer( kind=producer.kind, id=producer.id, run_id=producer.run_id or None, - epoch=models.ProducerEpoch(attempt=1), + epoch=models.ProducerEpoch( + task_started_event_id=epoch.task_started_event_id or None, + task_attempt=epoch.task_attempt or None, + task_started_time=models.Timestamp( + seconds=started.seconds, nanos=started.nanos + ) + if started is not None + else None, + attempt=1, + ), ) diff --git a/tests/contrib/streams/test_redis.py b/tests/contrib/streams/test_redis.py index cf997cc57..1162c027b 100644 --- a/tests/contrib/streams/test_redis.py +++ b/tests/contrib/streams/test_redis.py @@ -151,9 +151,12 @@ async def test_failed_workflow_task_is_withdrawn(stream_client: Client): ) await handle.result() items = await read_all(workflow_stream(stream_client, handle)) - # The failed task wrote nothing, so the reader gets each Record once. - assert [record.data for record in records(items)] == ["before", "after"] - assert not any(isinstance(item, streams.Superseded) for item in items) + # The retried task writes "after" again, and withdraws the copy of the failed task. + written = records(items) + superseded = [item for item in items if isinstance(item, streams.Superseded)] + assert [record.data for record in written] == ["before", "after", "after"] + assert len(superseded) == 1 + assert [r for r in written if superseded[0].withdraws(r)] == [written[1]] assert isinstance(items[-1], streams.Closed) @@ -313,6 +316,40 @@ async def test_local_activity_writes_with_the_task_epoch(stream_client: Client): ) +@workflow.defn +class LocalOnlyInFailedTask: + @workflow.run + async def run(self, key: str) -> None: + stream = await stream_workflow.create_stream(Events) + if attempts.setdefault(key, 0) == 0: + attempts[key] = 1 + await workflow.execute_local_activity( + local_tokens, + CallModelInput("0 1", streams.ActivityStream.from_handle(stream)), + start_to_close_timeout=timedelta(seconds=30), + ) + raise RuntimeError("fail this Workflow Task once") + + +async def test_retried_task_reuses_a_local_activity_key(stream_client: Client): + # The retried task skips the Local Activity, so its run-end close takes that call key. + async with new_worker( + stream_client, + LocalOnlyInFailedTask, + activities=[local_tokens], + workflow_runner=UnsandboxedWorkflowRunner(), + ) as worker: + handle = await stream_client.start_workflow( + LocalOnlyInFailedTask.run, + str(uuid.uuid4()), + id=f"wf-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + await handle.result() + items = await read_all(workflow_stream(stream_client, handle)) + assert isinstance(items[-1], streams.Closed) + + @workflow.defn class FailingRun: @workflow.run From 38996e538b0c61c22d02763440b602b2045c8c3b Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 22 Oct 2026 11:22:56 -0700 Subject: [PATCH 08/11] Tested that a rerun Local Activity withdraws its failed task's Records. --- tests/contrib/streams/test_redis.py | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/contrib/streams/test_redis.py b/tests/contrib/streams/test_redis.py index 1162c027b..441c54904 100644 --- a/tests/contrib/streams/test_redis.py +++ b/tests/contrib/streams/test_redis.py @@ -316,6 +316,54 @@ async def test_local_activity_writes_with_the_task_epoch(stream_client: Client): ) +@workflow.defn +class LocalThenFailTask: + @workflow.run + async def run(self, key: str) -> None: + stream = await stream_workflow.create_stream(Events) + first = attempts.setdefault(key, 0) == 0 + await workflow.execute_local_activity( + local_tokens, + CallModelInput( + "0 1" if first else "2 3", streams.ActivityStream.from_handle(stream) + ), + start_to_close_timeout=timedelta(seconds=30), + ) + if first: + attempts[key] = 1 + raise RuntimeError("fail this Workflow Task once") + + +async def test_rerun_local_activity_withdraws_its_failed_task(stream_client: Client): + async with new_worker( + stream_client, + LocalThenFailTask, + activities=[local_tokens], + workflow_runner=UnsandboxedWorkflowRunner(), + ) as worker: + handle = await stream_client.start_workflow( + LocalThenFailTask.run, + str(uuid.uuid4()), + id=f"wf-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + await handle.result() + items = await read_all(workflow_stream(stream_client, handle)) + written = records(items) + superseded = [item for item in items if isinstance(item, streams.Superseded)] + assert len(superseded) == 1 + visible = [r for r in written if not superseded[0].withdraws(r)] + assert [r.data for r in visible] == ["2", "3"] + # The rerun keeps the Producer id and moves to the retry task's epoch. + first, second = written[0].producer, visible[0].producer + assert first is not None and second is not None and first.id == second.id + assert first.epoch is not None and second.epoch is not None + assert (first.epoch.task_attempt, first.epoch.task_started_event_id) < ( + second.epoch.task_attempt, + second.epoch.task_started_event_id, + ) + + @workflow.defn class LocalOnlyInFailedTask: @workflow.run From 3a81b4e1824180a5fe9ff7d68773c78054df7d34 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 22 Oct 2026 11:22:56 -0700 Subject: [PATCH 09/11] Pinned crate s19 f2894246 and Core c5, which drop Workflow Task hooks. --- temporalio/bridge/Cargo.lock | 2 +- temporalio/bridge/Cargo.toml | 2 +- temporalio/bridge/sdk-core | 2 +- temporalio/bridge/src/extension.rs | 75 +++--------------------------- tests/worker/test_extension.py | 31 +++--------- 5 files changed, 16 insertions(+), 96 deletions(-) diff --git a/temporalio/bridge/Cargo.lock b/temporalio/bridge/Cargo.lock index 1f5b44507..6e79147c8 100644 --- a/temporalio/bridge/Cargo.lock +++ b/temporalio/bridge/Cargo.lock @@ -2879,7 +2879,7 @@ dependencies = [ [[package]] name = "temporalio-streams" version = "0.1.0" -source = "git+https://github.com/moetemp/sdk-streams?rev=46a94db1195e3f45cbcb39af6ea7754e9ec470fd#46a94db1195e3f45cbcb39af6ea7754e9ec470fd" +source = "git+https://github.com/moetemp/sdk-streams?rev=f28942468b6dd2ae110737d6fb3c81627f066a9d#f28942468b6dd2ae110737d6fb3c81627f066a9d" dependencies = [ "async-trait", "aws-lc-rs", diff --git a/temporalio/bridge/Cargo.toml b/temporalio/bridge/Cargo.toml index 0aa27fa9d..285d5b141 100644 --- a/temporalio/bridge/Cargo.toml +++ b/temporalio/bridge/Cargo.toml @@ -94,7 +94,7 @@ url = "2.2" # crates, so the bridge sees one type of each. [dependencies.temporalio-streams] git = "https://github.com/moetemp/sdk-streams" -rev = "46a94db1195e3f45cbcb39af6ea7754e9ec470fd" +rev = "f28942468b6dd2ae110737d6fb3c81627f066a9d" default-features = false optional = true diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 3a9203ac7..b5b703ec8 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 3a9203ac7cb15171097d7f58e80b648f517ea658 +Subproject commit b5b703ec8ae4b3c175790219d9cd98ce101860e7 diff --git a/temporalio/bridge/src/extension.rs b/temporalio/bridge/src/extension.rs index d52b94ffb..8b594fb31 100644 --- a/temporalio/bridge/src/extension.rs +++ b/temporalio/bridge/src/extension.rs @@ -102,40 +102,21 @@ pub fn new_worker_extension( /// An extension for the SDK's own tests. Release builds leave it out. #[cfg(debug_assertions)] mod test_extension { - use std::sync::{ - atomic::{AtomicU64, Ordering}, - Arc, - }; - use pyo3::exceptions::PyValueError; use pyo3::PyResult; - use temporalio_sdk_core::{ - ExtensionError, WorkerExtension, WorkflowTaskCompletionInfo, WorkflowTaskInterceptError, - WorkflowTaskInterceptor, - }; + use temporalio_sdk_core::{ExtensionError, WorkerExtension}; pub(super) const NAME: &str = "temporalio-test"; - pub(super) struct TestExtension { - interceptor: Arc, - } + pub(super) struct TestExtension; impl TestExtension { - /// The config is empty, or `fail_before_complete` to fail every workflow task. + /// The config must be empty. pub(super) fn new(config: &[u8]) -> PyResult { - let fail_before_complete = match config { - b"" => false, - b"fail_before_complete" => true, - _ => return Err(PyValueError::new_err("Unknown test extension config")), - }; - Ok(Self { - interceptor: Arc::new(CountingInterceptor { - fail_before_complete, - before: AtomicU64::new(0), - after: AtomicU64::new(0), - accepted: AtomicU64::new(0), - }), - }) + if !config.is_empty() { + return Err(PyValueError::new_err("Unknown test extension config")); + } + Ok(Self) } } @@ -145,23 +126,11 @@ mod test_extension { NAME } - fn workflow_task_interceptor(&self) -> Option> { - Some(self.interceptor.clone()) - } - fn call(&self, method: &str, input: &[u8]) -> Result, ExtensionError> { match method { "Echo" => Ok(input.to_vec()), "Fail" => Err(ExtensionError::new(tonic::Code::NotFound, "test failure") .with_details(input.to_vec())), - // Comma separated, so a test reads it with no proto. - "Counts" => Ok(format!( - "{},{},{}", - self.interceptor.before.load(Ordering::SeqCst), - self.interceptor.after.load(Ordering::SeqCst), - self.interceptor.accepted.load(Ordering::SeqCst) - ) - .into_bytes()), _ => Err(ExtensionError::unknown_method(method)), } } @@ -175,34 +144,4 @@ mod test_extension { self.call(&method, &input) } } - - struct CountingInterceptor { - fail_before_complete: bool, - before: AtomicU64, - after: AtomicU64, - accepted: AtomicU64, - } - - #[async_trait::async_trait] - impl WorkflowTaskInterceptor for CountingInterceptor { - async fn before_complete( - &self, - _task: &WorkflowTaskCompletionInfo, - ) -> Result<(), WorkflowTaskInterceptError> { - self.before.fetch_add(1, Ordering::SeqCst); - if self.fail_before_complete { - return Err(WorkflowTaskInterceptError::new( - "test extension refused the task", - )); - } - Ok(()) - } - - async fn after_complete(&self, _task: &WorkflowTaskCompletionInfo, accepted: bool) { - self.after.fetch_add(1, Ordering::SeqCst); - if accepted { - self.accepted.fetch_add(1, Ordering::SeqCst); - } - } - } } diff --git a/tests/worker/test_extension.py b/tests/worker/test_extension.py index bc8ad9dab..4a8fe3904 100644 --- a/tests/worker/test_extension.py +++ b/tests/worker/test_extension.py @@ -7,15 +7,15 @@ from temporalio.runtime import Runtime, TelemetryConfig from temporalio.service import RPCStatusCode from temporalio.worker import ExtensionError, WorkerExtension -from tests.helpers import assert_task_fail_eventually, new_worker +from tests.helpers import new_worker # Debug bridge builds link this extension for the SDK's tests. TEST_EXTENSION = "temporalio-test" -def load_test_extension(config: bytes = b"") -> WorkerExtension: +def load_test_extension() -> WorkerExtension: try: - return WorkerExtension(TEST_EXTENSION, config) + return WorkerExtension(TEST_EXTENSION) except ValueError: pytest.skip("Bridge build has no test extension") @@ -37,29 +37,15 @@ def test_extension_name(): async def test_extension_lets_workflow_task_complete(client: Client): - worker_extension = load_test_extension() - async with new_worker(client, SimpleWorkflow, extension=worker_extension) as worker: + async with new_worker( + client, SimpleWorkflow, extension=load_test_extension() + ) as worker: result = await client.execute_workflow( SimpleWorkflow.run, id=f"wf-{uuid.uuid4()}", task_queue=worker.task_queue, ) assert result == "done" - before, after, accepted = map(int, extension_counts(worker_extension)) - assert before >= 1 and after == before and accepted == before - - -async def test_extension_failure_fails_workflow_task(client: Client): - extension = load_test_extension(b"fail_before_complete") - async with new_worker(client, SimpleWorkflow, extension=extension) as worker: - handle = await client.start_workflow( - SimpleWorkflow.run, - id=f"wf-{uuid.uuid4()}", - task_queue=worker.task_queue, - ) - await assert_task_fail_eventually( - handle, message_contains="test extension refused the task" - ) async def test_extension_must_use_client_runtime(client: Client): @@ -70,15 +56,10 @@ async def test_extension_must_use_client_runtime(client: Client): new_worker(client, SimpleWorkflow, extension=extension) -def extension_counts(extension: WorkerExtension) -> list[str]: - return extension.call("Counts", b"").decode().split(",") - - async def test_extension_calls(): extension = load_test_extension() assert extension.call("Echo", b"abc") == b"abc" assert await extension.call_async("Echo", b"xyz") == b"xyz" - assert extension_counts(extension) == ["0", "0", "0"] async def test_extension_call_errors(): From e633bdc0e20321f7424b0592e19216ddc71658f5 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 22 Oct 2026 11:22:56 -0700 Subject: [PATCH 10/11] Documented that a skipped Local Activity call keeps its Records. --- temporalio/contrib/streams/_items.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/temporalio/contrib/streams/_items.py b/temporalio/contrib/streams/_items.py index cda16568b..18c33d430 100644 --- a/temporalio/contrib/streams/_items.py +++ b/temporalio/contrib/streams/_items.py @@ -49,6 +49,10 @@ class Superseded: It comes at the same point for every Reader. Use :py:meth:`withdraws` to find the earlier Records that it withdraws. + A Local Activity call of a failed Workflow Task is withdrawn only when the retry task runs + that call again. A call that the retry task skips, for example because a Signal changed + the path, keeps its Records. + .. warning:: This API is experimental. """ From d681d759401b263dad51ac7dab703f38d0d353b2 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 22 Oct 2026 12:04:16 -0700 Subject: [PATCH 11/11] Withdrew a run's orphaned Local Activity Records on its next write. --- .../api/streamservice/v1/stream_service.proto | 3 +- .../temporal/sdk/streams/v1/extension.proto | 4 + scripts/_wit/stream-service.wit | 4 +- scripts/_wit/stream-writer.wit | 8 +- temporalio/activity.py | 6 ++ .../streamservice/v1/stream_service_pb2.pyi | 4 +- temporalio/bridge/Cargo.lock | 2 +- temporalio/bridge/Cargo.toml | 2 +- .../proto/activity_task/activity_task_pb2.py | 18 ++--- .../proto/activity_task/activity_task_pb2.pyi | 19 +++++ temporalio/bridge/sdk-core | 2 +- temporalio/contrib/streams/_activity.py | 5 ++ temporalio/contrib/streams/_items.py | 6 +- temporalio/contrib/streams/_writer.py | 11 +++ temporalio/contrib/streams/_writes.py | 15 ++++ .../contrib/streams/proto/v1/extension_pb2.py | 36 ++++----- .../streams/proto/v1/extension_pb2.pyi | 24 ++++++ temporalio/contrib/streams/redis.py | 10 +++ .../streams/system/_resources/stream.py | 7 +- .../contrib/streams/writer_system/models.py | 76 +++++++++++++++++++ temporalio/worker/_activity.py | 2 + tests/contrib/streams/test_redis.py | 17 +++-- 22 files changed, 233 insertions(+), 48 deletions(-) diff --git a/scripts/_proto/temporal/api/streamservice/v1/stream_service.proto b/scripts/_proto/temporal/api/streamservice/v1/stream_service.proto index 97d860de2..1a268395b 100644 --- a/scripts/_proto/temporal/api/streamservice/v1/stream_service.proto +++ b/scripts/_proto/temporal/api/streamservice/v1/stream_service.proto @@ -160,7 +160,8 @@ message AppendRecordsRequest { // The batch number in this epoch. With the Producer and epoch it is the dedupe key. int64 sequence = 4; repeated AppendRecord records = 5; - // Workflow only. The failed tasks are those since this run's last flush to this Stream. + // Local Activity only. The run's last completed Workflow Task and its failed or timed out + // Workflow Tasks, so the batch withdraws the run's orphaned Local Activity Records. WorkflowTaskEpoch previous_completed_task = 6; repeated int64 failed_tasks = 7; // Workflow only. A retry run's first batch: the previous run, whose Records it withdraws. diff --git a/scripts/_proto/temporal/sdk/streams/v1/extension.proto b/scripts/_proto/temporal/sdk/streams/v1/extension.proto index 64cfc0964..a38cb91a0 100644 --- a/scripts/_proto/temporal/sdk/streams/v1/extension.proto +++ b/scripts/_proto/temporal/sdk/streams/v1/extension.proto @@ -45,6 +45,10 @@ message OpenWriterRequest { int32 max_batch_size = 5; // How long a write retries a Store failure. Empty is 10 s. google.protobuf.Duration max_retry_duration = 6; + // Local Activity only. The run's task facts, which each batch sends as AppendRecords does, so + // the writes withdraw the run's orphaned Local Activity Records. + temporal.api.streamservice.v1.WorkflowTaskEpoch previous_completed_task = 8; + repeated int64 failed_tasks = 9; } message OpenWriterResponse { diff --git a/scripts/_wit/stream-service.wit b/scripts/_wit/stream-service.wit index f32251937..8298f40a3 100644 --- a/scripts/_wit/stream-service.wit +++ b/scripts/_wit/stream-service.wit @@ -192,9 +192,9 @@ interface stream-service { /// @nexus.doc "The batch number in this epoch, 1 or more. It is part of the dedupe key." sequence: option, records: list, - /// @nexus.doc "Workflow only. The previous completed Workflow Task of the run." + /// @nexus.doc "Local Activity only. The run's last completed Workflow Task." previous-completed-task: option, - /// @nexus.doc "Workflow only. Failed or timed out tasks since this run's last flush here." + /// @nexus.doc "Local Activity only. The run's failed or timed out Workflow Tasks." failed-tasks: list, /// @nexus.doc "Retry run only. The previous run, whose Records this withdraws." withdraw-run: option, diff --git a/scripts/_wit/stream-writer.wit b/scripts/_wit/stream-writer.wit index 8f021dab7..a8b8233df 100644 --- a/scripts/_wit/stream-writer.wit +++ b/scripts/_wit/stream-writer.wit @@ -16,7 +16,9 @@ world writer { /// @nexus.experimental interface stream-writer { use nexus:temporal-types/model@1.0.0.{duration, placeholder}; - use temporal:nexus/stream-service@1.0.0.{append-record, producer, stream-ref}; + use temporal:nexus/stream-service@1.0.0.{ + append-record, producer, stream-ref, workflow-task-epoch, + }; /// @nexus.doc "Open a writer for one Producer of one Stream." /// @nexus.proto "temporal.sdk.streams.v1.OpenWriterRequest" @@ -27,6 +29,10 @@ interface stream-writer { max-batch-size: option, /// @nexus.doc "How long a write retries a Store failure. Empty is 10 s." max-retry-duration: option, + /// @nexus.doc "Local Activity only. The run's last completed Workflow Task." + previous-completed-task: option, + /// @nexus.doc "Local Activity only. The run's failed or timed out Workflow Tasks." + failed-tasks: list, /// @nexus.omit namespace: placeholder, } diff --git a/temporalio/activity.py b/temporalio/activity.py index 8269eb868..434f707bd 100644 --- a/temporalio/activity.py +++ b/temporalio/activity.py @@ -108,6 +108,12 @@ class WorkflowTaskAttempt: started_time: datetime | None """When the server started the task. None if the server did not send it.""" + previous_started_event_id: int = 0 + """ID of the ``WorkflowTaskStarted`` event of the run's last completed task. 0 if none.""" + + failed_tasks: Sequence[int] = () + """``WorkflowTaskStarted`` event IDs of the run's failed and timed out tasks, the last 100.""" + @dataclass(frozen=True) class Info: diff --git a/temporalio/api/streamservice/v1/stream_service_pb2.pyi b/temporalio/api/streamservice/v1/stream_service_pb2.pyi index 273ef5d6f..f78c6df1f 100644 --- a/temporalio/api/streamservice/v1/stream_service_pb2.pyi +++ b/temporalio/api/streamservice/v1/stream_service_pb2.pyi @@ -620,7 +620,9 @@ class AppendRecordsRequest(google.protobuf.message.Message): ]: ... @property def previous_completed_task(self) -> global___WorkflowTaskEpoch: - """Workflow only. The failed tasks are those since this run's last flush to this Stream.""" + """Local Activity only. The run's last completed Workflow Task and its failed or timed out + Workflow Tasks, so the batch withdraws the run's orphaned Local Activity Records. + """ @property def failed_tasks( self, diff --git a/temporalio/bridge/Cargo.lock b/temporalio/bridge/Cargo.lock index 6e79147c8..5a40cb59b 100644 --- a/temporalio/bridge/Cargo.lock +++ b/temporalio/bridge/Cargo.lock @@ -2879,7 +2879,7 @@ dependencies = [ [[package]] name = "temporalio-streams" version = "0.1.0" -source = "git+https://github.com/moetemp/sdk-streams?rev=f28942468b6dd2ae110737d6fb3c81627f066a9d#f28942468b6dd2ae110737d6fb3c81627f066a9d" +source = "git+https://github.com/moetemp/sdk-streams?rev=f3c74d24a7cb0e370c1a0222696707d6a0014315#f3c74d24a7cb0e370c1a0222696707d6a0014315" dependencies = [ "async-trait", "aws-lc-rs", diff --git a/temporalio/bridge/Cargo.toml b/temporalio/bridge/Cargo.toml index 285d5b141..24f2158fc 100644 --- a/temporalio/bridge/Cargo.toml +++ b/temporalio/bridge/Cargo.toml @@ -94,7 +94,7 @@ url = "2.2" # crates, so the bridge sees one type of each. [dependencies.temporalio-streams] git = "https://github.com/moetemp/sdk-streams" -rev = "f28942468b6dd2ae110737d6fb3c81627f066a9d" +rev = "f3c74d24a7cb0e370c1a0222696707d6a0014315" default-features = false optional = true diff --git a/temporalio/bridge/proto/activity_task/activity_task_pb2.py b/temporalio/bridge/proto/activity_task/activity_task_pb2.py index f269eb0ca..9b53479e6 100644 --- a/temporalio/bridge/proto/activity_task/activity_task_pb2.py +++ b/temporalio/bridge/proto/activity_task/activity_task_pb2.py @@ -26,7 +26,7 @@ ) DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n3temporal/sdk/core/activity_task/activity_task.proto\x12\x15\x63oresdk.activity_task\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a$temporal/api/common/v1/message.proto\x1a%temporal/sdk/core/common/common.proto"\x8d\x01\n\x0c\x41\x63tivityTask\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12-\n\x05start\x18\x03 \x01(\x0b\x32\x1c.coresdk.activity_task.StartH\x00\x12/\n\x06\x63\x61ncel\x18\x04 \x01(\x0b\x32\x1d.coresdk.activity_task.CancelH\x00\x42\t\n\x07variant"\xf4\x07\n\x05Start\x12\x1a\n\x12workflow_namespace\x18\x01 \x01(\t\x12\x15\n\rworkflow_type\x18\x02 \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x13\n\x0b\x61\x63tivity_id\x18\x04 \x01(\t\x12\x15\n\ractivity_type\x18\x05 \x01(\t\x12\x45\n\rheader_fields\x18\x06 \x03(\x0b\x32..coresdk.activity_task.Start.HeaderFieldsEntry\x12.\n\x05input\x18\x07 \x03(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12:\n\x11heartbeat_details\x18\x08 \x03(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x32\n\x0escheduled_time\x18\t \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x42\n\x1e\x63urrent_attempt_scheduled_time\x18\n \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x30\n\x0cstarted_time\x18\x0b \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07\x61ttempt\x18\x0c \x01(\r\x12<\n\x19schedule_to_close_timeout\x18\r \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\x0e \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x34\n\x11heartbeat_timeout\x18\x0f \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x0cretry_policy\x18\x10 \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12\x32\n\x08priority\x18\x12 \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12\x10\n\x08is_local\x18\x11 \x01(\x08\x12\x0e\n\x06run_id\x18\x13 \x01(\t\x12\x41\n\rworkflow_task\x18\x14 \x01(\x0b\x32*.coresdk.activity_task.WorkflowTaskAttempt\x1aT\n\x11HeaderFieldsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12.\n\x05value\x18\x02 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload:\x02\x38\x01"r\n\x13WorkflowTaskAttempt\x12\x18\n\x10started_event_id\x18\x01 \x01(\x03\x12\x0f\n\x07\x61ttempt\x18\x02 \x01(\r\x12\x30\n\x0cstarted_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp"\x8a\x01\n\x06\x43\x61ncel\x12;\n\x06reason\x18\x01 \x01(\x0e\x32+.coresdk.activity_task.ActivityCancelReason\x12\x43\n\x07\x64\x65tails\x18\x02 \x01(\x0b\x32\x32.coresdk.activity_task.ActivityCancellationDetails"\xa0\x01\n\x1b\x41\x63tivityCancellationDetails\x12\x14\n\x0cis_not_found\x18\x01 \x01(\x08\x12\x14\n\x0cis_cancelled\x18\x02 \x01(\x08\x12\x11\n\tis_paused\x18\x03 \x01(\x08\x12\x14\n\x0cis_timed_out\x18\x04 \x01(\x08\x12\x1a\n\x12is_worker_shutdown\x18\x05 \x01(\x08\x12\x10\n\x08is_reset\x18\x06 \x01(\x08*o\n\x14\x41\x63tivityCancelReason\x12\r\n\tNOT_FOUND\x10\x00\x12\r\n\tCANCELLED\x10\x01\x12\r\n\tTIMED_OUT\x10\x02\x12\x13\n\x0fWORKER_SHUTDOWN\x10\x03\x12\n\n\x06PAUSED\x10\x04\x12\t\n\x05RESET\x10\x05\x42\x32\xea\x02/Temporalio::Internal::Bridge::Api::ActivityTaskb\x06proto3' + b'\n3temporal/sdk/core/activity_task/activity_task.proto\x12\x15\x63oresdk.activity_task\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a$temporal/api/common/v1/message.proto\x1a%temporal/sdk/core/common/common.proto"\x8d\x01\n\x0c\x41\x63tivityTask\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12-\n\x05start\x18\x03 \x01(\x0b\x32\x1c.coresdk.activity_task.StartH\x00\x12/\n\x06\x63\x61ncel\x18\x04 \x01(\x0b\x32\x1d.coresdk.activity_task.CancelH\x00\x42\t\n\x07variant"\xf4\x07\n\x05Start\x12\x1a\n\x12workflow_namespace\x18\x01 \x01(\t\x12\x15\n\rworkflow_type\x18\x02 \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x13\n\x0b\x61\x63tivity_id\x18\x04 \x01(\t\x12\x15\n\ractivity_type\x18\x05 \x01(\t\x12\x45\n\rheader_fields\x18\x06 \x03(\x0b\x32..coresdk.activity_task.Start.HeaderFieldsEntry\x12.\n\x05input\x18\x07 \x03(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12:\n\x11heartbeat_details\x18\x08 \x03(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x32\n\x0escheduled_time\x18\t \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x42\n\x1e\x63urrent_attempt_scheduled_time\x18\n \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x30\n\x0cstarted_time\x18\x0b \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07\x61ttempt\x18\x0c \x01(\r\x12<\n\x19schedule_to_close_timeout\x18\r \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\x0e \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x34\n\x11heartbeat_timeout\x18\x0f \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x0cretry_policy\x18\x10 \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12\x32\n\x08priority\x18\x12 \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12\x10\n\x08is_local\x18\x11 \x01(\x08\x12\x0e\n\x06run_id\x18\x13 \x01(\t\x12\x41\n\rworkflow_task\x18\x14 \x01(\x0b\x32*.coresdk.activity_task.WorkflowTaskAttempt\x1aT\n\x11HeaderFieldsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12.\n\x05value\x18\x02 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload:\x02\x38\x01"\xab\x01\n\x13WorkflowTaskAttempt\x12\x18\n\x10started_event_id\x18\x01 \x01(\x03\x12\x0f\n\x07\x61ttempt\x18\x02 \x01(\r\x12\x30\n\x0cstarted_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12!\n\x19previous_started_event_id\x18\x04 \x01(\x03\x12\x14\n\x0c\x66\x61iled_tasks\x18\x05 \x03(\x03"\x8a\x01\n\x06\x43\x61ncel\x12;\n\x06reason\x18\x01 \x01(\x0e\x32+.coresdk.activity_task.ActivityCancelReason\x12\x43\n\x07\x64\x65tails\x18\x02 \x01(\x0b\x32\x32.coresdk.activity_task.ActivityCancellationDetails"\xa0\x01\n\x1b\x41\x63tivityCancellationDetails\x12\x14\n\x0cis_not_found\x18\x01 \x01(\x08\x12\x14\n\x0cis_cancelled\x18\x02 \x01(\x08\x12\x11\n\tis_paused\x18\x03 \x01(\x08\x12\x14\n\x0cis_timed_out\x18\x04 \x01(\x08\x12\x1a\n\x12is_worker_shutdown\x18\x05 \x01(\x08\x12\x10\n\x08is_reset\x18\x06 \x01(\x08*o\n\x14\x41\x63tivityCancelReason\x12\r\n\tNOT_FOUND\x10\x00\x12\r\n\tCANCELLED\x10\x01\x12\r\n\tTIMED_OUT\x10\x02\x12\x13\n\x0fWORKER_SHUTDOWN\x10\x03\x12\n\n\x06PAUSED\x10\x04\x12\t\n\x05RESET\x10\x05\x42\x32\xea\x02/Temporalio::Internal::Bridge::Api::ActivityTaskb\x06proto3' ) _ACTIVITYCANCELREASON = DESCRIPTOR.enum_types_by_name["ActivityCancelReason"] @@ -119,18 +119,18 @@ ) _START_HEADERFIELDSENTRY._options = None _START_HEADERFIELDSENTRY._serialized_options = b"8\001" - _ACTIVITYCANCELREASON._serialized_start = 1799 - _ACTIVITYCANCELREASON._serialized_end = 1910 + _ACTIVITYCANCELREASON._serialized_start = 1857 + _ACTIVITYCANCELREASON._serialized_end = 1968 _ACTIVITYTASK._serialized_start = 221 _ACTIVITYTASK._serialized_end = 362 _START._serialized_start = 365 _START._serialized_end = 1377 _START_HEADERFIELDSENTRY._serialized_start = 1293 _START_HEADERFIELDSENTRY._serialized_end = 1377 - _WORKFLOWTASKATTEMPT._serialized_start = 1379 - _WORKFLOWTASKATTEMPT._serialized_end = 1493 - _CANCEL._serialized_start = 1496 - _CANCEL._serialized_end = 1634 - _ACTIVITYCANCELLATIONDETAILS._serialized_start = 1637 - _ACTIVITYCANCELLATIONDETAILS._serialized_end = 1797 + _WORKFLOWTASKATTEMPT._serialized_start = 1380 + _WORKFLOWTASKATTEMPT._serialized_end = 1551 + _CANCEL._serialized_start = 1554 + _CANCEL._serialized_end = 1692 + _ACTIVITYCANCELLATIONDETAILS._serialized_start = 1695 + _ACTIVITYCANCELLATIONDETAILS._serialized_end = 1855 # @@protoc_insertion_point(module_scope) diff --git a/temporalio/bridge/proto/activity_task/activity_task_pb2.pyi b/temporalio/bridge/proto/activity_task/activity_task_pb2.pyi index dead4d805..3c70116fa 100644 --- a/temporalio/bridge/proto/activity_task/activity_task_pb2.pyi +++ b/temporalio/bridge/proto/activity_task/activity_task_pb2.pyi @@ -347,18 +347,33 @@ class WorkflowTaskAttempt(google.protobuf.message.Message): STARTED_EVENT_ID_FIELD_NUMBER: builtins.int ATTEMPT_FIELD_NUMBER: builtins.int STARTED_TIME_FIELD_NUMBER: builtins.int + PREVIOUS_STARTED_EVENT_ID_FIELD_NUMBER: builtins.int + FAILED_TASKS_FIELD_NUMBER: builtins.int started_event_id: builtins.int """The id of the task's WorkflowTaskStarted event.""" attempt: builtins.int @property def started_time(self) -> google.protobuf.timestamp_pb2.Timestamp: """When the server started the task.""" + previous_started_event_id: builtins.int + """The started event id of the run's last completed workflow task, from the poll response. + 0 when no task of the run has completed. + """ + @property + def failed_tasks( + self, + ) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.int]: + """The started event ids of the run's failed and timed out workflow tasks in history, in event + order. It holds the last 100 only, to keep each start small. + """ def __init__( self, *, started_event_id: builtins.int = ..., attempt: builtins.int = ..., started_time: google.protobuf.timestamp_pb2.Timestamp | None = ..., + previous_started_event_id: builtins.int = ..., + failed_tasks: collections.abc.Iterable[builtins.int] | None = ..., ) -> None: ... def HasField( self, field_name: typing_extensions.Literal["started_time", b"started_time"] @@ -368,6 +383,10 @@ class WorkflowTaskAttempt(google.protobuf.message.Message): field_name: typing_extensions.Literal[ "attempt", b"attempt", + "failed_tasks", + b"failed_tasks", + "previous_started_event_id", + b"previous_started_event_id", "started_event_id", b"started_event_id", "started_time", diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index b5b703ec8..32ba79d62 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit b5b703ec8ae4b3c175790219d9cd98ce101860e7 +Subproject commit 32ba79d6207385417ef5c5219183e43e7a01f069 diff --git a/temporalio/contrib/streams/_activity.py b/temporalio/contrib/streams/_activity.py index ad6cfe3c0..cf9ab81a7 100644 --- a/temporalio/contrib/streams/_activity.py +++ b/temporalio/contrib/streams/_activity.py @@ -23,6 +23,9 @@ class ActivityStreams: producer: stream_pb.Producer loop: asyncio.AbstractEventLoop backend_name: str = "" + # A Local Activity's run history, which lets its writes withdraw the run's orphaned Records. + previous_completed_task: stream_pb.WorkflowTaskEpoch | None = None + failed_tasks: tuple[int, ...] = () writers: dict[str, StreamWriter] = field(default_factory=dict) # The attempt's BufferedPublishers, which the end of the attempt flushes. publishers: list[Any] = field(default_factory=list) @@ -39,6 +42,8 @@ async def known_id() -> str: writers=self.backend.writers(self.namespace, self.converter), stream_id=known_id, producer=self.producer, + previous_completed_task=self.previous_completed_task, + failed_tasks=self.failed_tasks, ) self.writers[stream_id] = writer return writer diff --git a/temporalio/contrib/streams/_items.py b/temporalio/contrib/streams/_items.py index 18c33d430..79a127c12 100644 --- a/temporalio/contrib/streams/_items.py +++ b/temporalio/contrib/streams/_items.py @@ -49,9 +49,9 @@ class Superseded: It comes at the same point for every Reader. Use :py:meth:`withdraws` to find the earlier Records that it withdraws. - A Local Activity call of a failed Workflow Task is withdrawn only when the retry task runs - that call again. A call that the retry task skips, for example because a Signal changed - the path, keeps its Records. + The next write of a run to a Stream withdraws the Records that the run's Local Activities + wrote there in Workflow Tasks that never completed. A Stream that the run never writes + again keeps them. .. warning:: This API is experimental. diff --git a/temporalio/contrib/streams/_writer.py b/temporalio/contrib/streams/_writer.py index 8b7d6e833..c757e4d0e 100644 --- a/temporalio/contrib/streams/_writer.py +++ b/temporalio/contrib/streams/_writer.py @@ -22,6 +22,8 @@ def __init__( writers: writer_client.StreamWriterClient, stream_id: Callable[[], Awaitable[str]], producer: stream_pb.Producer, + previous_completed_task: stream_pb.WorkflowTaskEpoch | None = None, + failed_tasks: Sequence[int] = (), ) -> None: """Create a writer. It opens on its first write. @@ -30,6 +32,8 @@ def __init__( self._writers = writers self._resolve_id = stream_id self._producer = producer + self._previous_completed_task = previous_completed_task + self._failed_tasks = list(failed_tasks) self._writer: writer_client.Writer | None = None self._open_lock = asyncio.Lock() self._closed = False @@ -60,10 +64,17 @@ async def open(self) -> writer_client.Writer: producer = models._ProducerTransferTypeConverter().from_transfer_type( self._producer, models.Producer ) + previous = self._previous_completed_task self._writer = await self._writers.open_writer( models.OpenWriterRequest( stream=models.StreamRef(id=await self._resolve_id()), producer=producer, + previous_completed_task=models.WorkflowTaskEpoch( + started_event_id=previous.started_event_id + ) + if previous is not None + else None, + failed_tasks=self._failed_tasks, ) ) return self._writer diff --git a/temporalio/contrib/streams/_writes.py b/temporalio/contrib/streams/_writes.py index f76851425..751190a19 100644 --- a/temporalio/contrib/streams/_writes.py +++ b/temporalio/contrib/streams/_writes.py @@ -52,6 +52,19 @@ def _producer(streams: _activity.ActivityStreams) -> models.Producer: ) +def _history(streams: _activity.ActivityStreams) -> dict[str, Any]: + # The run's task history, so the write withdraws the run's orphaned Local Activity Records. + previous = streams.previous_completed_task + return { + "previous_completed_task": models.WorkflowTaskEpoch( + started_event_id=previous.started_event_id + ) + if previous is not None + else None, + "failed_tasks": list(streams.failed_tasks), + } + + def _ref(ref: stream_pb.StreamRef) -> models.StreamRef: return models.StreamRef( id=ref.id, @@ -106,6 +119,7 @@ async def append_records(request: stream_pb.AppendRecordsRequest) -> int: for record in request.records ], withdraw_run=request.withdraw_run or None, + **_history(streams), ) ) return response.last_offset or 0 @@ -128,6 +142,7 @@ async def close_stream(request: stream_pb.CloseStreamRequest) -> int: if request.HasField("result") else None, failure=failure, + **_history(streams), ) ) return 0 diff --git a/temporalio/contrib/streams/proto/v1/extension_pb2.py b/temporalio/contrib/streams/proto/v1/extension_pb2.py index 50554a0b0..83848ff2a 100644 --- a/temporalio/contrib/streams/proto/v1/extension_pb2.py +++ b/temporalio/contrib/streams/proto/v1/extension_pb2.py @@ -21,7 +21,7 @@ ) DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n\'temporal/sdk/streams/v1/extension.proto\x12\x17temporal.sdk.streams.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x32temporal/api/streamservice/v1/stream_service.proto"\xb7\x02\n\x11RedisStreamConfig\x12\x0c\n\x04urls\x18\x01 \x03(\t\x12\x0f\n\x07\x63luster\x18\x02 \x01(\x08\x12\x12\n\nkey_prefix\x18\x03 \x01(\t\x12,\n\tretention\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\x12-\n\nsafety_ttl\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x1f\n\x17\x62locking_reads_per_node\x18\x08 \x01(\x05\x12\x33\n\x10response_timeout\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x0f\n\x07\x62\x61\x63kend\x18\n \x01(\tJ\x04\x08\x06\x10\x07J\x04\x08\x07\x10\x08R\x0e\x64urable_writesR\x0f\x64urable_timeout"\x9c\x02\n\x11OpenWriterRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\x06stream\x18\x02 \x01(\x0b\x32(.temporal.api.streamservice.v1.StreamRef\x12\x39\n\x08producer\x18\x03 \x01(\x0b\x32\'.temporal.api.streamservice.v1.Producer\x12\x16\n\x0emax_batch_size\x18\x05 \x01(\x05\x12\x35\n\x12max_retry_duration\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationJ\x04\x08\x04\x10\x05J\x04\x08\x07\x10\x08R\x0e\x62\x61tch_intervalR\x14max_buffered_records"\'\n\x12OpenWriterResponse\x12\x11\n\twriter_id\x18\x01 \x01(\x04"f\n\x13WriteRecordsRequest\x12\x11\n\twriter_id\x18\x01 \x01(\x04\x12<\n\x07records\x18\x02 \x03(\x0b\x32+.temporal.api.streamservice.v1.AppendRecord"A\n\x14WriteRecordsResponse\x12\x14\n\x0c\x66irst_offset\x18\x01 \x01(\x03\x12\x13\n\x0blast_offset\x18\x02 \x01(\x03"\'\n\x12\x43loseWriterRequest\x12\x11\n\twriter_id\x18\x01 \x01(\x04"*\n\x13\x43loseWriterResponse\x12\x13\n\x0blast_offset\x18\x01 \x01(\x03"\x15\n\x13\x43loseWritersRequest"&\n\x14\x43loseWritersResponse\x12\x0e\n\x06\x66\x61iled\x18\x01 \x01(\x05"$\n\x12StreamErrorDetails\x12\x0e\n\x06reason\x18\x01 \x01(\tb\x06proto3' + b'\n\'temporal/sdk/streams/v1/extension.proto\x12\x17temporal.sdk.streams.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x32temporal/api/streamservice/v1/stream_service.proto"\xb7\x02\n\x11RedisStreamConfig\x12\x0c\n\x04urls\x18\x01 \x03(\t\x12\x0f\n\x07\x63luster\x18\x02 \x01(\x08\x12\x12\n\nkey_prefix\x18\x03 \x01(\t\x12,\n\tretention\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\x12-\n\nsafety_ttl\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x1f\n\x17\x62locking_reads_per_node\x18\x08 \x01(\x05\x12\x33\n\x10response_timeout\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x0f\n\x07\x62\x61\x63kend\x18\n \x01(\tJ\x04\x08\x06\x10\x07J\x04\x08\x07\x10\x08R\x0e\x64urable_writesR\x0f\x64urable_timeout"\x85\x03\n\x11OpenWriterRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\x06stream\x18\x02 \x01(\x0b\x32(.temporal.api.streamservice.v1.StreamRef\x12\x39\n\x08producer\x18\x03 \x01(\x0b\x32\'.temporal.api.streamservice.v1.Producer\x12\x16\n\x0emax_batch_size\x18\x05 \x01(\x05\x12\x35\n\x12max_retry_duration\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12Q\n\x17previous_completed_task\x18\x08 \x01(\x0b\x32\x30.temporal.api.streamservice.v1.WorkflowTaskEpoch\x12\x14\n\x0c\x66\x61iled_tasks\x18\t \x03(\x03J\x04\x08\x04\x10\x05J\x04\x08\x07\x10\x08R\x0e\x62\x61tch_intervalR\x14max_buffered_records"\'\n\x12OpenWriterResponse\x12\x11\n\twriter_id\x18\x01 \x01(\x04"f\n\x13WriteRecordsRequest\x12\x11\n\twriter_id\x18\x01 \x01(\x04\x12<\n\x07records\x18\x02 \x03(\x0b\x32+.temporal.api.streamservice.v1.AppendRecord"A\n\x14WriteRecordsResponse\x12\x14\n\x0c\x66irst_offset\x18\x01 \x01(\x03\x12\x13\n\x0blast_offset\x18\x02 \x01(\x03"\'\n\x12\x43loseWriterRequest\x12\x11\n\twriter_id\x18\x01 \x01(\x04"*\n\x13\x43loseWriterResponse\x12\x13\n\x0blast_offset\x18\x01 \x01(\x03"\x15\n\x13\x43loseWritersRequest"&\n\x14\x43loseWritersResponse\x12\x0e\n\x06\x66\x61iled\x18\x01 \x01(\x05"$\n\x12StreamErrorDetails\x12\x0e\n\x06reason\x18\x01 \x01(\tb\x06proto3' ) @@ -150,21 +150,21 @@ _REDISSTREAMCONFIG._serialized_start = 153 _REDISSTREAMCONFIG._serialized_end = 464 _OPENWRITERREQUEST._serialized_start = 467 - _OPENWRITERREQUEST._serialized_end = 751 - _OPENWRITERRESPONSE._serialized_start = 753 - _OPENWRITERRESPONSE._serialized_end = 792 - _WRITERECORDSREQUEST._serialized_start = 794 - _WRITERECORDSREQUEST._serialized_end = 896 - _WRITERECORDSRESPONSE._serialized_start = 898 - _WRITERECORDSRESPONSE._serialized_end = 963 - _CLOSEWRITERREQUEST._serialized_start = 965 - _CLOSEWRITERREQUEST._serialized_end = 1004 - _CLOSEWRITERRESPONSE._serialized_start = 1006 - _CLOSEWRITERRESPONSE._serialized_end = 1048 - _CLOSEWRITERSREQUEST._serialized_start = 1050 - _CLOSEWRITERSREQUEST._serialized_end = 1071 - _CLOSEWRITERSRESPONSE._serialized_start = 1073 - _CLOSEWRITERSRESPONSE._serialized_end = 1111 - _STREAMERRORDETAILS._serialized_start = 1113 - _STREAMERRORDETAILS._serialized_end = 1149 + _OPENWRITERREQUEST._serialized_end = 856 + _OPENWRITERRESPONSE._serialized_start = 858 + _OPENWRITERRESPONSE._serialized_end = 897 + _WRITERECORDSREQUEST._serialized_start = 899 + _WRITERECORDSREQUEST._serialized_end = 1001 + _WRITERECORDSRESPONSE._serialized_start = 1003 + _WRITERECORDSRESPONSE._serialized_end = 1068 + _CLOSEWRITERREQUEST._serialized_start = 1070 + _CLOSEWRITERREQUEST._serialized_end = 1109 + _CLOSEWRITERRESPONSE._serialized_start = 1111 + _CLOSEWRITERRESPONSE._serialized_end = 1153 + _CLOSEWRITERSREQUEST._serialized_start = 1155 + _CLOSEWRITERSREQUEST._serialized_end = 1176 + _CLOSEWRITERSRESPONSE._serialized_start = 1178 + _CLOSEWRITERSRESPONSE._serialized_end = 1216 + _STREAMERRORDETAILS._serialized_start = 1218 + _STREAMERRORDETAILS._serialized_end = 1254 # @@protoc_insertion_point(module_scope) diff --git a/temporalio/contrib/streams/proto/v1/extension_pb2.pyi b/temporalio/contrib/streams/proto/v1/extension_pb2.pyi index bb0c6fc43..377f07353 100644 --- a/temporalio/contrib/streams/proto/v1/extension_pb2.pyi +++ b/temporalio/contrib/streams/proto/v1/extension_pb2.pyi @@ -120,6 +120,8 @@ class OpenWriterRequest(google.protobuf.message.Message): PRODUCER_FIELD_NUMBER: builtins.int MAX_BATCH_SIZE_FIELD_NUMBER: builtins.int MAX_RETRY_DURATION_FIELD_NUMBER: builtins.int + PREVIOUS_COMPLETED_TASK_FIELD_NUMBER: builtins.int + FAILED_TASKS_FIELD_NUMBER: builtins.int namespace: builtins.str @property def stream( @@ -134,6 +136,19 @@ class OpenWriterRequest(google.protobuf.message.Message): @property def max_retry_duration(self) -> google.protobuf.duration_pb2.Duration: """How long a write retries a Store failure. Empty is 10 s.""" + @property + def previous_completed_task( + self, + ) -> temporalio.api.streamservice.v1.stream_service_pb2.WorkflowTaskEpoch: + """Local Activity only. The run's task facts, which each batch sends as AppendRecords does, so + the writes withdraw the run's orphaned Local Activity Records. + """ + @property + def failed_tasks( + self, + ) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[ + builtins.int + ]: ... def __init__( self, *, @@ -144,12 +159,17 @@ class OpenWriterRequest(google.protobuf.message.Message): | None = ..., max_batch_size: builtins.int = ..., max_retry_duration: google.protobuf.duration_pb2.Duration | None = ..., + previous_completed_task: temporalio.api.streamservice.v1.stream_service_pb2.WorkflowTaskEpoch + | None = ..., + failed_tasks: collections.abc.Iterable[builtins.int] | None = ..., ) -> None: ... def HasField( self, field_name: typing_extensions.Literal[ "max_retry_duration", b"max_retry_duration", + "previous_completed_task", + b"previous_completed_task", "producer", b"producer", "stream", @@ -159,12 +179,16 @@ class OpenWriterRequest(google.protobuf.message.Message): def ClearField( self, field_name: typing_extensions.Literal[ + "failed_tasks", + b"failed_tasks", "max_batch_size", b"max_batch_size", "max_retry_duration", b"max_retry_duration", "namespace", b"namespace", + "previous_completed_task", + b"previous_completed_task", "producer", b"producer", "stream", diff --git a/temporalio/contrib/streams/redis.py b/temporalio/contrib/streams/redis.py index 8f54926d6..11bc6304e 100644 --- a/temporalio/contrib/streams/redis.py +++ b/temporalio/contrib/streams/redis.py @@ -465,9 +465,17 @@ def _activity_streams( key = input.headers.get(_KEY_HEADER) key_text = converter.from_payload(key, str) if key is not None else "" epoch = stream_pb.ProducerEpoch(attempt=info.attempt) + previous: stream_pb.WorkflowTaskEpoch | None = None + failed: tuple[int, ...] = () if info.is_local: task = info.workflow_task if task is not None: + # Always set, with 0 for none completed. An unset one turns off the withdrawal of + # the run's orphaned Local Activity Records. + previous = stream_pb.WorkflowTaskEpoch( + started_event_id=task.previous_started_event_id + ) + failed = tuple(task.failed_tasks) epoch.task_started_event_id = task.started_event_id epoch.task_attempt = task.attempt if task.started_time is not None: @@ -502,4 +510,6 @@ def _activity_streams( producer=producer, loop=asyncio.get_running_loop(), backend_name=plugin.backend_name, + previous_completed_task=previous, + failed_tasks=failed, ) diff --git a/temporalio/contrib/streams/system/_resources/stream.py b/temporalio/contrib/streams/system/_resources/stream.py index 184b846fd..be707c504 100644 --- a/temporalio/contrib/streams/system/_resources/stream.py +++ b/temporalio/contrib/streams/system/_resources/stream.py @@ -214,10 +214,9 @@ async def append_records( producer: Required outside a Workflow. In a Workflow the transport sets it. sequence: The batch number in this epoch, 1 or more. It is part of the dedupe key. - previous_completed_task: Workflow only. The previous completed Workflow Task of - the run. - failed_tasks: Workflow only. Failed or timed out tasks since this run's last - flush here. + previous_completed_task: Local Activity only. The run's last completed Workflow + Task. + failed_tasks: Local Activity only. The run's failed or timed out Workflow Tasks. withdraw_run: Retry run only. The previous run, whose Records this withdraws. """ request = AppendRecordsRequest( diff --git a/temporalio/contrib/streams/writer_system/models.py b/temporalio/contrib/streams/writer_system/models.py index 8a51a6493..0ecc43ba4 100644 --- a/temporalio/contrib/streams/writer_system/models.py +++ b/temporalio/contrib/streams/writer_system/models.py @@ -23,6 +23,8 @@ class OpenWriterRequest: producer: Producer | None = None max_batch_size: int | None = None max_retry_duration: datetime.timedelta | None = None + previous_completed_task: WorkflowTaskEpoch | None = None + failed_tasks: list[int] | None = dataclasses.field(default_factory=list) class _OpenWriterRequestTransferTypeConverter( @@ -56,6 +58,12 @@ def from_transfer_type( max_retry_duration=_support.duration_from_proto(value.max_retry_duration) if value.HasField("max_retry_duration") else None, + previous_completed_task=_WorkflowTaskEpochTransferTypeConverter().from_transfer_type( + value.previous_completed_task, WorkflowTaskEpoch + ) + if value.HasField("previous_completed_task") + else None, + failed_tasks=list(value.failed_tasks), ) @typing_extensions.override @@ -78,6 +86,14 @@ def to_transfer_type( message.max_retry_duration.CopyFrom( _support.duration_to_proto(value.max_retry_duration) ) + if value.previous_completed_task is not None: + message.previous_completed_task.CopyFrom( + _WorkflowTaskEpochTransferTypeConverter().to_transfer_type( + value.previous_completed_task + ) + ) + if value.failed_tasks: + message.failed_tasks.extend(value.failed_tasks) return message @@ -332,6 +348,66 @@ def to_transfer_type( ) # pyright: ignore[reportUnusedCallResult] +@dataclasses.dataclass(slots=True) +class WorkflowTaskEpoch: + """A Workflow Task attempt of the run.""" + + started_event_id: int | None = None + attempt: int | None = None + started_time: Timestamp | None = None + + +class _WorkflowTaskEpochTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + WorkflowTaskEpoch, + temporalio.api.streamservice.v1.stream_service_pb2.WorkflowTaskEpoch, + ] +): + transfer_type: ( + type[temporalio.api.streamservice.v1.stream_service_pb2.WorkflowTaskEpoch] + | None + ) = temporalio.api.streamservice.v1.stream_service_pb2.WorkflowTaskEpoch + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.streamservice.v1.stream_service_pb2.WorkflowTaskEpoch, + type_hint: type[WorkflowTaskEpoch], + ) -> WorkflowTaskEpoch: + return WorkflowTaskEpoch( + started_event_id=value.started_event_id + if value.started_event_id != 0 + else None, + attempt=value.attempt if value.attempt != 0 else None, + started_time=_TimestampTransferTypeConverter().from_transfer_type( + value.started_time, Timestamp + ) + if value.HasField("started_time") + else None, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: WorkflowTaskEpoch, + ) -> temporalio.api.streamservice.v1.stream_service_pb2.WorkflowTaskEpoch: + message = temporalio.api.streamservice.v1.stream_service_pb2.WorkflowTaskEpoch() + if value.started_event_id is not None: + message.started_event_id = value.started_event_id + if value.attempt is not None: + message.attempt = value.attempt + if value.started_time is not None: + message.started_time.CopyFrom( + _TimestampTransferTypeConverter().to_transfer_type(value.started_time) + ) + return message + + +temporalio.converter.transfer_type_convertible(_WorkflowTaskEpochTransferTypeConverter)( + WorkflowTaskEpoch +) # pyright: ignore[reportUnusedCallResult] + + @dataclasses.dataclass(slots=True) class WriterResult: writer_id: int | None = None diff --git a/temporalio/worker/_activity.py b/temporalio/worker/_activity.py index b81893897..ea7c773fa 100644 --- a/temporalio/worker/_activity.py +++ b/temporalio/worker/_activity.py @@ -670,6 +670,8 @@ async def _execute_activity( started_time=_proto_to_datetime(start.workflow_task.started_time) if start.workflow_task.HasField("started_time") else None, + previous_started_event_id=start.workflow_task.previous_started_event_id, + failed_tasks=tuple(start.workflow_task.failed_tasks), ) if start.HasField("workflow_task") else None, diff --git a/tests/contrib/streams/test_redis.py b/tests/contrib/streams/test_redis.py index 441c54904..a20a416d6 100644 --- a/tests/contrib/streams/test_redis.py +++ b/tests/contrib/streams/test_redis.py @@ -155,8 +155,8 @@ async def test_failed_workflow_task_is_withdrawn(stream_client: Client): written = records(items) superseded = [item for item in items if isinstance(item, streams.Superseded)] assert [record.data for record in written] == ["before", "after", "after"] - assert len(superseded) == 1 - assert [r for r in written if superseded[0].withdraws(r)] == [written[1]] + withdrawn = [r for r in written if any(i.withdraws(r) for i in superseded)] + assert withdrawn == [written[1]] assert isinstance(items[-1], streams.Closed) @@ -351,8 +351,7 @@ async def test_rerun_local_activity_withdraws_its_failed_task(stream_client: Cli items = await read_all(workflow_stream(stream_client, handle)) written = records(items) superseded = [item for item in items if isinstance(item, streams.Superseded)] - assert len(superseded) == 1 - visible = [r for r in written if not superseded[0].withdraws(r)] + visible = [r for r in written if not any(i.withdraws(r) for i in superseded)] assert [r.data for r in visible] == ["2", "3"] # The rerun keeps the Producer id and moves to the retry task's epoch. first, second = written[0].producer, visible[0].producer @@ -377,10 +376,12 @@ async def run(self, key: str) -> None: start_to_close_timeout=timedelta(seconds=30), ) raise RuntimeError("fail this Workflow Task once") + await stream.topic(Events.tokens).publish("other") -async def test_retried_task_reuses_a_local_activity_key(stream_client: Client): - # The retried task skips the Local Activity, so its run-end close takes that call key. +async def test_later_write_withdraws_a_skipped_local_activity(stream_client: Client): + # The retry task skips the Local Activity, so only its own write can withdraw "0 1". The + # write also takes the skipped call's key. async with new_worker( stream_client, LocalOnlyInFailedTask, @@ -395,6 +396,10 @@ async def test_retried_task_reuses_a_local_activity_key(stream_client: Client): ) await handle.result() items = await read_all(workflow_stream(stream_client, handle)) + written = records(items) + superseded = [item for item in items if isinstance(item, streams.Superseded)] + visible = [r for r in written if not any(s.withdraws(r) for s in superseded)] + assert [r.data for r in visible] == ["other"] assert isinstance(items[-1], streams.Closed)