From 75de955577d2d937de69869f7d60a6d973751913 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Sat, 5 Sep 2026 20:49:22 -0700 Subject: [PATCH 01/96] Align external stream input and output replay schedules --- CHANGELOG.md | 12 ++++ temporalio/bridge/sdk-core | 2 +- .../external_workflow_streams/_runtime.py | 28 ++++++-- temporalio/worker/_workflow_instance.py | 10 +++ .../test_output_runtime.py | 65 +++++++++++++++++++ 5 files changed, 109 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9ded4a5..61ea270dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,18 @@ to include examples, links to docs, or any other relevant information. ## [Unreleased] +### Fixed + +- Preserve empty activations in the shared External Workflow Streams input and + output replay schedule. Workflows that read input, publish decisions, and + schedule Activities now reproduce that schedule during replay. Inconsistent + prerelease markers are rejected explicitly rather than guessing where omitted + activations belonged. +- Resume external input waits when cold replay encounters a wake in an already + loaded History page, including Workers with workflow caching disabled. +- Avoid an unnecessary output replacement Workflow Task after stream input has + resumed the Workflow and it is waiting on an Activity or timer. + ### Added - Added experimental External Workflow Streams in diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 271c0474f..f88293a9d 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 271c0474fd8ab0459ed5a022923a0fc700525e12 +Subproject commit f88293a9da09cfb8ac3f1988579dc3f98e84cf6b diff --git a/temporalio/contrib/external_workflow_streams/_runtime.py b/temporalio/contrib/external_workflow_streams/_runtime.py index d3e091445..09b38bab0 100644 --- a/temporalio/contrib/external_workflow_streams/_runtime.py +++ b/temporalio/contrib/external_workflow_streams/_runtime.py @@ -327,8 +327,10 @@ def __init__( #: refused for: a fresh annotation is the most room there will ever be, so #: refusing there rolls over to an annotation that refuses identically. self._segments_in_annotation = 0 - #: Set when a subscription is registered or a record delivered, so an - #: activation that changed nothing at all emits nothing. + #: A late first subscription must preserve the earlier empty drains in + #: the same task, without making unrelated workflows write markers. + self._unobserved_segments = 0 + self._segment_pending = True self._observed_this_activation = False #: `wait_id -> Future`, awaited by Workflow code and resolved by the #: readiness activation. It lives here rather than on either side alone @@ -428,12 +430,14 @@ def begin_activation(self, history_floor_event_id: int | None = None) -> None: ) if self._output_history_floor_event_id != history_floor_event_id: self._output_segments = [] + self._unobserved_segments = 0 self._output_history_floor_event_id = history_floor_event_id for waiter in self._output_capacity_waiters: if not waiter.done(): waiter.set_result(None) self._output_capacity_waiters = [] self._output_segments.append({}) + self._segment_pending = True def delivery_budget_remaining(self) -> int: """How many more records this activation may hand to Workflow code. @@ -1638,11 +1642,21 @@ def close_segment(self, reason: SegmentEndReason | None = None) -> None: nothing still ran one event-loop drain, and replay must reproduce that drain or ``wait_condition`` predicates fire a different number of times. """ - if not self._observed_this_activation: + if not self._observed_this_activation and not self._segment_pending: + return + self._segment_pending = False + if not self._observed_this_activation and not self._subscriptions: + self._unobserved_segments += 1 return if reason is None: reason = self._segment_end_reason() accumulator = self._ensure_accumulator() + for _ in range(self._unobserved_segments): + self._pending_deltas.append( + accumulator.add_segment(Segment((), SegmentEndReason.NO_DATA_AVAILABLE)) + ) + self._segments_in_annotation += 1 + self._unobserved_segments = 0 self._pending_deltas.append( accumulator.add_segment(Segment(tuple(self._runs), reason)) ) @@ -1686,10 +1700,9 @@ def _segment_end_reason(self) -> SegmentEndReason: def take_observation_delta(self) -> bytes | None: """The bytes to put on this completion's `WorkflowStreamProgress`. - ``None`` means nothing replay-visible changed, which is the only case - where a completion legitimately carries no progress command -- and a - replay delivery is exactly that case, since everything it delivered is - already recorded in the marker being replayed. + Once an input subscription exists, even an empty activation belongs to + the shared input/output replay schedule. Workflows that never subscribe + still emit nothing, and replay does not rewrite its existing marker. """ if self._replay_ready is not None: return None @@ -1786,6 +1799,7 @@ def start_new_annotation(self) -> None: self._run_sizes = [] self._max_run_bytes = 0 self._segments_in_annotation = 0 + self._unobserved_segments = 0 self._observed_this_activation = False self._annotation_start = { wait_id: state.delivery_cursor diff --git a/temporalio/worker/_workflow_instance.py b/temporalio/worker/_workflow_instance.py index c3db3f3ce..7dcf7cd78 100644 --- a/temporalio/worker/_workflow_instance.py +++ b/temporalio/worker/_workflow_instance.py @@ -936,6 +936,16 @@ def _apply_replay_external_streams( runtime.begin_output_replay(output) self._pending_output_replay_finish = True plan = runtime.take_replay_plan() + if ( + has_output + and plan is not None + and len(plan.segments) != len(output_segments) + ): + raise temporalio.workflow.NondeterminismError( + "External stream History has incompatible input and output " + "activation schedules. This prerelease marker omitted empty " + "input activations, whose positions cannot be recovered safely." + ) if plan is None: if has_output: # An output-only marker deliberately has no input ReplayPlan. diff --git a/tests/contrib/external_workflow_streams/test_output_runtime.py b/tests/contrib/external_workflow_streams/test_output_runtime.py index 1c58854a7..049f78a2a 100644 --- a/tests/contrib/external_workflow_streams/test_output_runtime.py +++ b/tests/contrib/external_workflow_streams/test_output_runtime.py @@ -16,6 +16,7 @@ import temporalio.bridge.proto.workflow_completion import temporalio.converter import temporalio.workflow +from temporalio.contrib.external_workflow_streams._annotation import decode_annotation from temporalio.contrib.external_workflow_streams._backend import StreamKey from temporalio.contrib.external_workflow_streams._errors import ( ExternalStreamCapacityError, @@ -1064,3 +1065,67 @@ def test_input_and_output_share_one_replay_segment_drain_schedule() -> None: "abandon-output", "end-input", ] + + +def test_ambiguous_legacy_combined_schedule_is_rejected_before_drain() -> None: + runtime = _CombinedReplayRuntime() + driver = _CombinedReplayDriver(runtime) + output = SimpleNamespace(segments=(object(), object(), object(), object())) + job = SimpleNamespace(output=output, HasField=lambda field: field == "output") + + apply_replay = cast(Any, _WorkflowInstanceImpl._apply_replay_external_streams) + with pytest.raises( + temporalio.workflow.NondeterminismError, match="incompatible input and output" + ): + apply_replay(driver, job) + + assert runtime.events == ["begin-output"] + + +@pytest.mark.parametrize("subscription_exists", [False, True]) +async def test_combined_marker_preserves_empty_input_activations( + runtime: WorkflowStreamRuntime, subscription_exists: bool +) -> None: + key = runtime.stream_key("inputs") + if subscription_exists: + runtime.begin_activation(1) + runtime.register(wait_id=1, stream_key=key) + runtime.take_observation_delta() + runtime.add_terminal() + + deltas = [] + try: + for index in range(4): + runtime.begin_activation(7) + if index == 1 and not subscription_exists: + runtime.register(wait_id=1, stream_key=key) + if index in (1, 3): + record = StreamRecord( + RecordKind.DATA, b"input", "producer", index + ).placed_at(Offset(f"{index}-0")) + runtime.record_delivery(1, record) + await runtime.publish_output( + topic="events", + value=str(index), + value_type=str, + kind=RecordKind.DATA, + max_publish_latency=timedelta(seconds=1), + max_records=10, + max_logical_bytes=10_000, + ) + delta = runtime.take_observation_delta() + if delta is not None: + deltas.append(delta) + deltas.append(runtime.add_terminal()) + staged = await runtime.stage_output(7) + annotation = decode_annotation(b"".join(deltas)) + + assert [bool(segment.runs) for segment in annotation.segments] == [ + False, + True, + False, + True, + ] + assert staged.segment_record_counts == ((0,), (1,), (0,), (1,)) + finally: + await runtime._manager.shutdown() From c00339e6ea5138c496984ece4da8e4c2c8bcedfc Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Sat, 5 Sep 2026 21:10:22 -0700 Subject: [PATCH 02/96] Preserve stream task retention with zero workflow cache --- CHANGELOG.md | 3 +++ temporalio/bridge/sdk-core | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61ea270dd..6f1441833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ to include examples, links to docs, or any other relevant information. loaded History page, including Workers with workflow caching disabled. - Avoid an unnecessary output replacement Workflow Task after stream input has resumed the Workflow and it is waiting on an Activity or timer. +- Keep an incomplete retained external stream task alive when workflow caching + is disabled; evict it after its normal task boundary instead of repeatedly + interrupting input readiness with shutdown markers. ### Added diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index f88293a9d..975bf0e96 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit f88293a9da09cfb8ac3f1988579dc3f98e84cf6b +Subproject commit 975bf0e96a9951d7279886be7d837bf09376f617 From 632e16f8d698bc67884615bf1ecb3916f53cd579 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 14 Sep 2026 15:51:50 -0700 Subject: [PATCH 03/96] Put the shared stream interface on the repaired external SDK. --- .gitmodules | 4 +- streams_demo/agent_loop.py | 92 ++ streams_demo/provider_setup.py | 50 + streams_demo/results-external/history.json | 1209 +++++++++++++++++ streams_demo/results-external/results.json | 107 ++ streams_demo/run_demo.py | 154 +++ .../contrib/external_workflow_streams/_api.py | 15 +- temporalio/streams/__init__.py | 135 ++ temporalio/streams/_binding.py | 356 +++++ temporalio/streams/_frame.py | 72 + temporalio/streams/_handles.py | 215 +++ temporalio/streams/_policy.py | 56 + temporalio/streams/_record.py | 90 ++ 13 files changed, 2552 insertions(+), 3 deletions(-) create mode 100644 streams_demo/agent_loop.py create mode 100644 streams_demo/provider_setup.py create mode 100644 streams_demo/results-external/history.json create mode 100644 streams_demo/results-external/results.json create mode 100644 streams_demo/run_demo.py create mode 100644 temporalio/streams/__init__.py create mode 100644 temporalio/streams/_binding.py create mode 100644 temporalio/streams/_frame.py create mode 100644 temporalio/streams/_handles.py create mode 100644 temporalio/streams/_policy.py create mode 100644 temporalio/streams/_record.py diff --git a/.gitmodules b/.gitmodules index 0c2f5d5bf..ca2f7cff0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "sdk-core"] path = temporalio/bridge/sdk-core - url = https://github.com/mfateev/sdk-core.git - branch = task/python-sdk-streaming + url = https://github.com/moedash/sdk-rust.git + branch = moe/AI-198-external-core-repairs diff --git a/streams_demo/agent_loop.py b/streams_demo/agent_loop.py new file mode 100644 index 000000000..afbb7ea4c --- /dev/null +++ b/streams_demo/agent_loop.py @@ -0,0 +1,92 @@ +"""One agent loop, written once, run on both stream providers. + +Reads input, decides on it, publishes the decision, and runs an ordinary +Activity in the same workflow task. Also handles the two control records the +contract defines, so a retried producer and a finished topic are exercised +rather than described. + +This file is byte-identical in the server-side tree and the client-side tree. +Only the worker that runs it differs, and only in which provider it is given. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from temporalio import activity, streams, workflow + +DECISIONS = "decisions" +INPUTS = "inputs" + + +@activity.defn(name="AI198RecordDecision") +async def record_decision(decision: dict[str, Any]) -> str: + """An ordinary command in the same task as the publish.""" + return f"recorded:{decision['source']}:{decision['branch']}" + + +def decide(token: dict[str, Any]) -> dict[str, Any]: + """The decision the workflow is here to make.""" + if token["value"] % 2 == 0: + return {"source": token["id"], "branch": "even", "computed": token["value"] * 10} + return {"source": token["id"], "branch": "odd", "computed": token["value"] + 100} + + +@workflow.defn(name="AI198StreamContractDemo", sandboxed=False) +class AgentLoop: + """Read, decide, write, until the producer says it has finished.""" + + @workflow.run + async def run(self, limit: int) -> list[dict[str, Any]]: + inputs = streams.reader( + INPUTS, type=dict, idle_timeout=timedelta(seconds=1) + ) + decisions = streams.writer(DECISIONS) + trace: list[dict[str, Any]] = [] + accepted = 0 + try: + async for record in inputs: + if record.kind is streams.RecordKind.SUPERSEDED: + # A newer attempt of the same producer started writing. The + # decisions already published stand, so the workflow says so + # rather than pretending they can be withdrawn. + trace.append( + { + "kind": "superseded", + "producer": record.producer, + "replaced": record.value.previous_attempt, + "attempt": record.value.attempt, + } + ) + await decisions.publish( + {"retracting_attempt": record.value.previous_attempt} + ) + continue + if record.kind is streams.RecordKind.FINISH: + # The producer says it is done, which is what ends the loop. + # Counting decisions instead would leave the terminal record + # unread and let the workflow finish while its producer is + # still writing. + trace.append({"kind": "finish", "producer": record.producer}) + break + decision = decide(record.value) + await decisions.publish(decision) + receipt = await workflow.execute_activity( + record_decision, + decision, + activity_id=f"decision-{decision['source']}", + start_to_close_timeout=timedelta(seconds=10), + ) + trace.append( + {"kind": "decision", "value": decision, "receipt": receipt} + ) + accepted += 1 + if accepted >= limit: + # A bound so a stuck producer cannot run this forever. The + # terminal record above is the ordinary way out. + break + finally: + inputs.close() + await decisions.finish() + return trace diff --git a/streams_demo/provider_setup.py b/streams_demo/provider_setup.py new file mode 100644 index 000000000..1b9d5c03f --- /dev/null +++ b/streams_demo/provider_setup.py @@ -0,0 +1,50 @@ +"""Choose the client-side provider. + +The store is a Redis the customer runs, so this process constructs the backend +and is responsible for closing it. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import redis.asyncio +from temporalio.contrib.external_workflow_streams._redis import RedisStreamBackend + +NAME = "external" + +WORKFLOW_CACHE = int(os.environ.get("AI198_CACHE", "2")) +"""The smallest cache this provider runs on. + +It holds the workflow task open between records, which a run that can be +evicted cannot do. Below two, a consuming workflow stops with no error and no +failed task. Recovery has to be tested by killing the worker rather than by +shrinking the cache. +""" + +_backend: RedisStreamBackend | None = None + + +async def open() -> tuple[str, dict[str, Any]]: + """The server to connect to, and the provider options.""" + global _backend + ready = Path(__file__).resolve().parents[2] / "verify/ready.json" + url = os.environ.get("AI198_REDIS_URL", "redis://127.0.0.1:6399") + # redis-py defaults to a socket timeout equal to this provider's own + # blocking read, so an idle read would abandon a healthy socket. + _backend = RedisStreamBackend( + client=redis.asyncio.from_url(url, decode_responses=False, socket_timeout=30), + key_prefix=os.environ.get("AI198_REDIS_PREFIX", "ai198-contract"), + ) + return json.loads(ready.read_text())["target"], {"backend": _backend} + + +async def close() -> None: + """Release the store this process opened.""" + global _backend + if _backend is not None: + await _backend.aclose() + _backend = None diff --git a/streams_demo/results-external/history.json b/streams_demo/results-external/history.json new file mode 100644 index 000000000..75295aa6b --- /dev/null +++ b/streams_demo/results-external/history.json @@ -0,0 +1,1209 @@ +{ + "events": [ + { + "eventId": "1", + "eventTime": "2026-09-06T18:11:22.157533Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED", + "taskId": "1050382", + "workflowExecutionStartedEventAttributes": { + "workflowType": { + "name": "AI198StreamContractDemo" + }, + "taskQueue": { + "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "OA==" + } + ] + }, + "workflowTaskTimeout": "10s", + "originalExecutionRunId": "01a077ea-f5ad-781e-b17e-b21618fa7a7f", + "identity": "26823@Mac.lan", + "firstExecutionRunId": "01a077ea-f5ad-781e-b17e-b21618fa7a7f", + "attempt": 1, + "firstWorkflowTaskBackoff": "0s", + "workflowId": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "priority": {} + } + }, + { + "eventId": "2", + "eventTime": "2026-09-06T18:11:22.157567Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1050383", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "3", + "eventTime": "2026-09-06T18:11:22.160155Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1050388", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "2", + "identity": "26823@Mac.lan", + "requestId": "4af9762f-b065-4f3e-bbb0-17a6546a7ee6", + "historySizeBytes": "404", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + } + } + }, + { + "eventId": "4", + "eventTime": "2026-09-06T18:11:22.174162Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1050393", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "2", + "startedEventId": "3", + "identity": "26823@Mac.lan", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + }, + "sdkMetadata": { + "coreUsedFlags": [ + 2, + 1, + 3 + ], + "sdkName": "temporal-python", + "sdkVersion": "1.31.0" + }, + "meteringMetadata": {} + } + }, + { + "eventId": "5", + "eventTime": "2026-09-06T18:11:22.174189Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "taskId": "1050394", + "markerRecordedEventAttributes": { + "markerName": "core_external_stream", + "details": { + "external_stream": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAEQARoECAEQASK+AQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC1hYzMyMGU1N2ZhZmM0ZWFmOTQ5YzFhYTBiNGY4NDZiZSQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YGaW5wdXRzAA1yZWRpcy1zdHJlYW1zAQIAAQIBAQ8xNzg4NzE4MjgyMTcxLTAPMTc4ODcxODI4MjE3MS0wAQABAwEBAQ8xNzg4NzE4MjgyMTcxLTAoBjKdAQgBEAEaIFNsYVBscXRXTmF5Y2VpSDByRlVqRElLVE1XTTVYUk1FIAEqJDAxYTA3N2VhLWY1YWQtNzgxZS1iMTdlLWIyMTYxOGZhN2E3ZjIyCglkZWNpc2lvbnMQARi0ASIgjBxO69u2MphhOmkhkT4NDYD9yThaPpqKHcAWa2oslZg6AwoBADoDCgEBQg1yZWRpcy1zdHJlYW1zSAE=" + } + ] + } + }, + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "6", + "eventTime": "2026-09-06T18:11:22.174198Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1050395", + "activityTaskScheduledEventAttributes": { + "activityId": "decision-r1", + "activityType": { + "name": "AI198RecordDecision" + }, + "taskQueue": { + "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJicmFuY2giOiJvZGQiLCJjb21wdXRlZCI6MTAxLCJzb3VyY2UiOiJyMSJ9" + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "10s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "4", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2.0, + "maximumInterval": "100s" + }, + "useWorkflowBuildId": true, + "priority": {} + } + }, + { + "eventId": "7", + "eventTime": "2026-09-06T18:11:22.174212Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1050399", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "6", + "identity": "26823@Mac.lan", + "requestId": "58663e42-9843-4b02-82cd-bf5176975d5c", + "attempt": 1, + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + } + } + }, + { + "eventId": "8", + "eventTime": "2026-09-06T18:11:22.175897Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1050400", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InJlY29yZGVkOnIxOm9kZCI=" + } + ] + }, + "scheduledEventId": "6", + "startedEventId": "7", + "identity": "26823@Mac.lan" + } + }, + { + "eventId": "9", + "eventTime": "2026-09-06T18:11:22.175903Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1050401", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "10", + "eventTime": "2026-09-06T18:11:22.176609Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1050404", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "9", + "identity": "26823@Mac.lan", + "requestId": "c04f1c35-feda-443d-9764-f1b9d9308366", + "historySizeBytes": "1653", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + } + } + }, + { + "eventId": "11", + "eventTime": "2026-09-06T18:11:22.183152Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1050410", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "9", + "startedEventId": "10", + "identity": "26823@Mac.lan", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "12", + "eventTime": "2026-09-06T18:11:22.183168Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "taskId": "1050411", + "markerRecordedEventAttributes": { + "markerName": "core_external_stream", + "details": { + "external_stream": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAEQARoECAEQAiLOAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC1hYzMyMGU1N2ZhZmM0ZWFmOTQ5YzFhYTBiNGY4NDZiZSQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YGaW5wdXRzAQ8xNzg4NzE4MjgyMTcxLTANcmVkaXMtc3RyZWFtcwECAAECAQEPMTc4ODcxODI4MjE4MS0wDzE3ODg3MTgyODIxODEtMAEAAQMBAQEPMTc4ODcxODI4MjE4MS0wKAYynQEIARABGiB0cFZkaG9qaW0wUTBtLWlkSzNrV3Z3N2poQkU5MmFORSAIKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YyMgoJZGVjaXNpb25zEAEYtAEiIEG2KSpLbvL+t7NXgoIazG7djtT9TCEi2Caa1YjB4xlZOgMKAQA6AwoBAUINcmVkaXMtc3RyZWFtc0gB" + } + ] + } + }, + "workflowTaskCompletedEventId": "11" + } + }, + { + "eventId": "13", + "eventTime": "2026-09-06T18:11:22.183175Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1050412", + "activityTaskScheduledEventAttributes": { + "activityId": "decision-r2", + "activityType": { + "name": "AI198RecordDecision" + }, + "taskQueue": { + "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJicmFuY2giOiJldmVuIiwiY29tcHV0ZWQiOjIwLCJzb3VyY2UiOiJyMiJ9" + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "10s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "11", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2.0, + "maximumInterval": "100s" + }, + "useWorkflowBuildId": true, + "priority": {} + } + }, + { + "eventId": "14", + "eventTime": "2026-09-06T18:11:22.180649Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "taskId": "1050413", + "workflowExecutionSignaledEventAttributes": { + "signalName": "__temporal_external_stream_wake", + "input": { + "payloads": [ + { + "metadata": { + "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAESBmlucHV0cyokMDFhMDc3ZWEtZjVhZC03ODFlLWIxN2UtYjIxNjE4ZmE3YTdmMgdtb2RlbCMx" + } + ] + }, + "identity": "26823@Mac.lan", + "requestId": "1df2f713-fd2d-53fb-baa7-c57b9f5793a8" + } + }, + { + "eventId": "15", + "eventTime": "2026-09-06T18:11:22.183190Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1050414", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "16", + "eventTime": "2026-09-06T18:11:22.183924Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1050418", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "15", + "identity": "26823@Mac.lan", + "requestId": "9efbed6e-9cfb-4854-896d-bac0d1f26f81", + "historySizeBytes": "2936", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + } + } + }, + { + "eventId": "17", + "eventTime": "2026-09-06T18:11:22.188413Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1050424", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "15", + "startedEventId": "16", + "identity": "26823@Mac.lan", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "18", + "eventTime": "2026-09-06T18:11:22.183185Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1050425", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "13", + "identity": "26823@Mac.lan", + "requestId": "2ef67959-882a-4107-8539-d5196556c9f8", + "attempt": 1, + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + } + } + }, + { + "eventId": "19", + "eventTime": "2026-09-06T18:11:22.187585Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "taskId": "1050426", + "workflowExecutionSignaledEventAttributes": { + "signalName": "__temporal_external_stream_wake", + "input": { + "payloads": [ + { + "metadata": { + "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" + } + ] + }, + "identity": "26823@Mac.lan", + "requestId": "e43a9981-456e-5236-9b51-9ab044957092" + } + }, + { + "eventId": "20", + "eventTime": "2026-09-06T18:11:22.184543Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1050427", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InJlY29yZGVkOnIyOmV2ZW4i" + } + ] + }, + "scheduledEventId": "13", + "startedEventId": "18", + "identity": "26823@Mac.lan" + } + }, + { + "eventId": "21", + "eventTime": "2026-09-06T18:11:22.188436Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1050428", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "22", + "eventTime": "2026-09-06T18:11:22.189479Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1050431", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "21", + "identity": "26823@Mac.lan", + "requestId": "a7e422ab-0ccb-400b-b202-9cb14c072dbe", + "historySizeBytes": "3696", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + } + } + }, + { + "eventId": "23", + "eventTime": "2026-09-06T18:11:22.705946Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1050437", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "21", + "startedEventId": "22", + "identity": "26823@Mac.lan", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "24", + "eventTime": "2026-09-06T18:11:22.705975Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "taskId": "1050438", + "markerRecordedEventAttributes": { + "markerName": "core_external_stream", + "details": { + "external_stream": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAEQARoECAEQAyLOAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC1hYzMyMGU1N2ZhZmM0ZWFmOTQ5YzFhYTBiNGY4NDZiZSQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YGaW5wdXRzAQ8xNzg4NzE4MjgyMTgxLTANcmVkaXMtc3RyZWFtcwECAAECAQEPMTc4ODcxODI4MjcwMS0wDzE3ODg3MTgyODI3MDEtMAEAAQMBAQEPMTc4ODcxODI4MjcwMS0wKAYynQEIARABGiB1UENKbTNlcWEwa2dqS25xMHhlTUZYeHlJSHU4bWVHUSAUKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YyMgoJZGVjaXNpb25zEAIY0wIiIMlkAX98qOr68/irYtiKi0b09uWIahOZe6Q558yOXlphOgMKAQA6AwoBAkINcmVkaXMtc3RyZWFtc0gB" + } + ] + } + }, + "workflowTaskCompletedEventId": "23" + } + }, + { + "eventId": "25", + "eventTime": "2026-09-06T18:11:22.705985Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1050439", + "activityTaskScheduledEventAttributes": { + "activityId": "decision-r3", + "activityType": { + "name": "AI198RecordDecision" + }, + "taskQueue": { + "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJicmFuY2giOiJvZGQiLCJjb21wdXRlZCI6MTAzLCJzb3VyY2UiOiJyMyJ9" + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "10s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "23", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2.0, + "maximumInterval": "100s" + }, + "useWorkflowBuildId": true, + "priority": {} + } + }, + { + "eventId": "26", + "eventTime": "2026-09-06T18:11:22.191345Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "taskId": "1050440", + "workflowExecutionSignaledEventAttributes": { + "signalName": "__temporal_external_stream_wake", + "input": { + "payloads": [ + { + "metadata": { + "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAESBmlucHV0cyokMDFhMDc3ZWEtZjVhZC03ODFlLWIxN2UtYjIxNjE4ZmE3YTdmMgdtb2RlbCMx" + } + ] + }, + "identity": "26823@Mac.lan", + "requestId": "37d1b402-38c8-5dda-8645-d585db292a63" + } + }, + { + "eventId": "27", + "eventTime": "2026-09-06T18:11:22.706006Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1050441", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "28", + "eventTime": "2026-09-06T18:11:22.706861Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1050445", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "27", + "identity": "26823@Mac.lan", + "requestId": "25930a39-9163-4f47-8e21-cb2b48d61d84", + "historySizeBytes": "4983", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + } + } + }, + { + "eventId": "29", + "eventTime": "2026-09-06T18:11:22.715797Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1050451", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "27", + "startedEventId": "28", + "identity": "26823@Mac.lan", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "30", + "eventTime": "2026-09-06T18:11:22.705999Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1050452", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "25", + "identity": "26823@Mac.lan", + "requestId": "a725c165-a214-4892-916c-76e1ea839d7c", + "attempt": 1, + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + } + } + }, + { + "eventId": "31", + "eventTime": "2026-09-06T18:11:22.715327Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "taskId": "1050453", + "workflowExecutionSignaledEventAttributes": { + "signalName": "__temporal_external_stream_wake", + "input": { + "payloads": [ + { + "metadata": { + "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" + } + ] + }, + "identity": "26823@Mac.lan", + "requestId": "2fbd7436-ef53-5aea-a0a0-f5580216293b" + } + }, + { + "eventId": "32", + "eventTime": "2026-09-06T18:11:22.707608Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1050454", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InJlY29yZGVkOnIzOm9kZCI=" + } + ] + }, + "scheduledEventId": "25", + "startedEventId": "30", + "identity": "26823@Mac.lan" + } + }, + { + "eventId": "33", + "eventTime": "2026-09-06T18:11:22.715826Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1050455", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "34", + "eventTime": "2026-09-06T18:11:22.716505Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "taskId": "1050458", + "workflowExecutionSignaledEventAttributes": { + "signalName": "__temporal_external_stream_wake", + "input": { + "payloads": [ + { + "metadata": { + "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAESBmlucHV0cyokMDFhMDc3ZWEtZjVhZC03ODFlLWIxN2UtYjIxNjE4ZmE3YTdmMgdtb2RlbCMy" + } + ] + }, + "identity": "26823@Mac.lan", + "requestId": "8399a538-2d53-53ec-8373-46b5609feb7b" + } + }, + { + "eventId": "35", + "eventTime": "2026-09-06T18:11:22.716835Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1050460", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "33", + "identity": "26823@Mac.lan", + "requestId": "b3544f16-128d-491f-8ecf-d7f185abaa08", + "historySizeBytes": "6008", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + } + } + }, + { + "eventId": "36", + "eventTime": "2026-09-06T18:11:22.727247Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1050467", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "33", + "startedEventId": "35", + "identity": "26823@Mac.lan", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "37", + "eventTime": "2026-09-06T18:11:22.727270Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "taskId": "1050468", + "markerRecordedEventAttributes": { + "markerName": "core_external_stream", + "details": { + "external_stream": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAEQARoECAEQBCLOAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC1hYzMyMGU1N2ZhZmM0ZWFmOTQ5YzFhYTBiNGY4NDZiZSQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YGaW5wdXRzAQ8xNzg4NzE4MjgyNzAxLTANcmVkaXMtc3RyZWFtcwECAAECAQEPMTc4ODcxODI4MjcxOC0wDzE3ODg3MTgyODI3MTgtMAEAAQMBAQEPMTc4ODcxODI4MjcxOC0wKAYynQEIARABGiA0WXNrdDcwVHNsUWVLRUdCVVV1Y3NTZDJnZjZIWk10SyAgKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YyMgoJZGVjaXNpb25zEAEYtAEiID5nSwItjUt3xrIwLwmnksCGdUXwRytSYqhf4/HFL9GVOgMKAQA6AwoBAUINcmVkaXMtc3RyZWFtc0gB" + } + ] + } + }, + "workflowTaskCompletedEventId": "36" + } + }, + { + "eventId": "38", + "eventTime": "2026-09-06T18:11:22.727280Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1050469", + "activityTaskScheduledEventAttributes": { + "activityId": "decision-r4", + "activityType": { + "name": "AI198RecordDecision" + }, + "taskQueue": { + "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJicmFuY2giOiJldmVuIiwiY29tcHV0ZWQiOjQwLCJzb3VyY2UiOiJyNCJ9" + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "10s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "36", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2.0, + "maximumInterval": "100s" + }, + "useWorkflowBuildId": true, + "priority": {} + } + }, + { + "eventId": "39", + "eventTime": "2026-09-06T18:11:22.722015Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "taskId": "1050470", + "workflowExecutionSignaledEventAttributes": { + "signalName": "__temporal_external_stream_wake", + "input": { + "payloads": [ + { + "metadata": { + "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" + } + ] + }, + "identity": "26823@Mac.lan", + "requestId": "39da5ac8-0f7a-5872-9d15-165c2b32f2cf" + } + }, + { + "eventId": "40", + "eventTime": "2026-09-06T18:11:22.724282Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "taskId": "1050471", + "workflowExecutionSignaledEventAttributes": { + "signalName": "__temporal_external_stream_wake", + "input": { + "payloads": [ + { + "metadata": { + "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" + } + ] + }, + "identity": "26823@Mac.lan", + "requestId": "46ea13fa-03d0-56b3-971f-658b3c5b2dea" + } + }, + { + "eventId": "41", + "eventTime": "2026-09-06T18:11:22.727305Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1050472", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "42", + "eventTime": "2026-09-06T18:11:22.728358Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1050476", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "41", + "identity": "26823@Mac.lan", + "requestId": "bf6b249a-a171-4571-b49a-2dd98bc0d8a6", + "historySizeBytes": "7543", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + } + } + }, + { + "eventId": "43", + "eventTime": "2026-09-06T18:11:22.737729Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1050485", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "41", + "startedEventId": "42", + "identity": "26823@Mac.lan", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "44", + "eventTime": "2026-09-06T18:11:22.727297Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1050486", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "38", + "identity": "26823@Mac.lan", + "requestId": "e9cf5853-0b8f-425c-92f7-6eccfc1585e3", + "attempt": 1, + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + } + } + }, + { + "eventId": "45", + "eventTime": "2026-09-06T18:11:22.730443Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "taskId": "1050487", + "workflowExecutionSignaledEventAttributes": { + "signalName": "__temporal_external_stream_wake", + "input": { + "payloads": [ + { + "metadata": { + "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAESBmlucHV0cyokMDFhMDc3ZWEtZjVhZC03ODFlLWIxN2UtYjIxNjE4ZmE3YTdmMgdtb2RlbCMy" + } + ] + }, + "identity": "26823@Mac.lan", + "requestId": "46cf9d72-14a4-5d2c-a6df-6ef2fa8f0975" + } + }, + { + "eventId": "46", + "eventTime": "2026-09-06T18:11:22.733340Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "taskId": "1050488", + "workflowExecutionSignaledEventAttributes": { + "signalName": "__temporal_external_stream_wake", + "input": { + "payloads": [ + { + "metadata": { + "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" + } + ] + }, + "identity": "26823@Mac.lan", + "requestId": "4eeaeff4-edb1-5ad1-aa05-14d5fe21efd9" + } + }, + { + "eventId": "47", + "eventTime": "2026-09-06T18:11:22.735277Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "taskId": "1050489", + "workflowExecutionSignaledEventAttributes": { + "signalName": "__temporal_external_stream_wake", + "input": { + "payloads": [ + { + "metadata": { + "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" + } + ] + }, + "identity": "26823@Mac.lan", + "requestId": "c5856df4-2d45-5a5f-a25a-ac5656b800bb" + } + }, + { + "eventId": "48", + "eventTime": "2026-09-06T18:11:22.737445Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "taskId": "1050490", + "workflowExecutionSignaledEventAttributes": { + "signalName": "__temporal_external_stream_wake", + "input": { + "payloads": [ + { + "metadata": { + "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" + } + ] + }, + "identity": "26823@Mac.lan", + "requestId": "c817c883-d225-5657-9320-5685eee70ab4" + } + }, + { + "eventId": "49", + "eventTime": "2026-09-06T18:11:22.729071Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1050491", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InJlY29yZGVkOnI0OmV2ZW4i" + } + ] + }, + "scheduledEventId": "38", + "startedEventId": "44", + "identity": "26823@Mac.lan" + } + }, + { + "eventId": "50", + "eventTime": "2026-09-06T18:11:22.737758Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1050492", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "51", + "eventTime": "2026-09-06T18:11:22.738595Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1050495", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "50", + "identity": "26823@Mac.lan", + "requestId": "8645299a-6b83-48a4-b193-fa6477e6a412", + "historySizeBytes": "9075", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + } + } + }, + { + "eventId": "52", + "eventTime": "2026-09-06T18:11:22.748540Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_FAILED", + "taskId": "1050503", + "workflowTaskFailedEventAttributes": { + "scheduledEventId": "50", + "startedEventId": "51", + "cause": "WORKFLOW_TASK_FAILED_CAUSE_UNHANDLED_COMMAND", + "failure": { + "message": "UnhandledCommand", + "serverFailureInfo": {} + }, + "identity": "26823@Mac.lan" + } + }, + { + "eventId": "53", + "eventTime": "2026-09-06T18:11:22.741945Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "taskId": "1050504", + "workflowExecutionSignaledEventAttributes": { + "signalName": "__temporal_external_stream_wake", + "input": { + "payloads": [ + { + "metadata": { + "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAESBmlucHV0cyokMDFhMDc3ZWEtZjVhZC03ODFlLWIxN2UtYjIxNjE4ZmE3YTdmMgdtb2RlbCMy" + } + ] + }, + "identity": "26823@Mac.lan", + "requestId": "8645d950-0d4c-5660-b6c7-24ffd557507f" + } + }, + { + "eventId": "54", + "eventTime": "2026-09-06T18:11:22.743630Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "taskId": "1050505", + "workflowExecutionSignaledEventAttributes": { + "signalName": "__temporal_external_stream_wake", + "input": { + "payloads": [ + { + "metadata": { + "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" + } + ] + }, + "identity": "26823@Mac.lan", + "requestId": "dbf5475f-1e51-56f5-a094-57adb5e5d7c3" + } + }, + { + "eventId": "55", + "eventTime": "2026-09-06T18:11:22.745447Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "taskId": "1050506", + "workflowExecutionSignaledEventAttributes": { + "signalName": "__temporal_external_stream_wake", + "input": { + "payloads": [ + { + "metadata": { + "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" + } + ] + }, + "identity": "26823@Mac.lan", + "requestId": "44d26222-20e8-5363-b60f-7d315980020d" + } + }, + { + "eventId": "56", + "eventTime": "2026-09-06T18:11:22.747175Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "taskId": "1050507", + "workflowExecutionSignaledEventAttributes": { + "signalName": "__temporal_external_stream_wake", + "input": { + "payloads": [ + { + "metadata": { + "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" + } + ] + }, + "identity": "26823@Mac.lan", + "requestId": "8206e1fa-6e16-5e8c-9467-67fa2a3aa353" + } + }, + { + "eventId": "57", + "eventTime": "2026-09-06T18:11:22.748568Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1050508", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "58", + "eventTime": "2026-09-06T18:11:22.749316Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1050511", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "57", + "identity": "26823@Mac.lan", + "requestId": "8beba935-535b-42df-970d-a1ad3d0a885f", + "historySizeBytes": "10377", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + } + } + }, + { + "eventId": "59", + "eventTime": "2026-09-06T18:11:27.755216Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1050515", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "57", + "startedEventId": "58", + "identity": "26823@Mac.lan", + "workerVersion": { + "buildId": "5876163392d8546a76682b853febd140" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "60", + "eventTime": "2026-09-06T18:11:27.755239Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "taskId": "1050516", + "markerRecordedEventAttributes": { + "markerName": "core_external_stream", + "details": { + "external_stream": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAEQBRoECAEQBSLaAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC1hYzMyMGU1N2ZhZmM0ZWFmOTQ5YzFhYTBiNGY4NDZiZSQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YGaW5wdXRzAQ8xNzg4NzE4MjgyNzE4LTANcmVkaXMtc3RyZWFtcwECAAECAAECAAECAAECAAECAQEPMTc4ODcxODI4MjczMS0wDzE3ODg3MTgyODI3MzEtMAEAAQMBAQEPMTc4ODcxODI4MjczMS0wKAcysAEIARABGiB1S0JESWJiX3pEYnlqUFNZZXloXzlJSDAxeVZhbnR2XyA4KiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YyMQoJZGVjaXNpb25zEAEYbSIg3ClteUJUVTZweyuvvwlOF9DoWSLrxmpqEsPbZESWN6I6AwoBADoDCgEAOgMKAQA6AwoBADoDCgEAOgMKAQFCDXJlZGlzLXN0cmVhbXNIAQ==" + } + ] + } + }, + "workflowTaskCompletedEventId": "59" + } + }, + { + "eventId": "61", + "eventTime": "2026-09-06T18:11:27.755243Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED", + "taskId": "1050517", + "workflowExecutionCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "W3sia2luZCI6ImRlY2lzaW9uIiwicmVjZWlwdCI6InJlY29yZGVkOnIxOm9kZCIsInZhbHVlIjp7ImJyYW5jaCI6Im9kZCIsImNvbXB1dGVkIjoxMDEsInNvdXJjZSI6InIxIn19LHsia2luZCI6ImRlY2lzaW9uIiwicmVjZWlwdCI6InJlY29yZGVkOnIyOmV2ZW4iLCJ2YWx1ZSI6eyJicmFuY2giOiJldmVuIiwiY29tcHV0ZWQiOjIwLCJzb3VyY2UiOiJyMiJ9fSx7ImF0dGVtcHQiOjIsImtpbmQiOiJzdXBlcnNlZGVkIiwicHJvZHVjZXIiOiJtb2RlbCIsInJlcGxhY2VkIjoxfSx7ImtpbmQiOiJkZWNpc2lvbiIsInJlY2VpcHQiOiJyZWNvcmRlZDpyMzpvZGQiLCJ2YWx1ZSI6eyJicmFuY2giOiJvZGQiLCJjb21wdXRlZCI6MTAzLCJzb3VyY2UiOiJyMyJ9fSx7ImtpbmQiOiJkZWNpc2lvbiIsInJlY2VpcHQiOiJyZWNvcmRlZDpyNDpldmVuIiwidmFsdWUiOnsiYnJhbmNoIjoiZXZlbiIsImNvbXB1dGVkIjo0MCwic291cmNlIjoicjQifX0seyJraW5kIjoiZmluaXNoIiwicHJvZHVjZXIiOiJtb2RlbCJ9XQ==" + } + ] + }, + "workflowTaskCompletedEventId": "59" + } + } + ] +} \ No newline at end of file diff --git a/streams_demo/results-external/results.json b/streams_demo/results-external/results.json new file mode 100644 index 000000000..e902198c8 --- /dev/null +++ b/streams_demo/results-external/results.json @@ -0,0 +1,107 @@ +{ + "provider": "external", + "workflow_id": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "target": "127.0.0.1:53266", + "max_cached_workflows": 0, + "outcome": "completed", + "trace": [ + { + "kind": "decision", + "receipt": "recorded:r1:odd", + "value": { + "branch": "odd", + "computed": 101, + "source": "r1" + } + }, + { + "kind": "decision", + "receipt": "recorded:r2:even", + "value": { + "branch": "even", + "computed": 20, + "source": "r2" + } + }, + { + "attempt": 2, + "kind": "superseded", + "producer": "model", + "replaced": 1 + }, + { + "kind": "decision", + "receipt": "recorded:r3:odd", + "value": { + "branch": "odd", + "computed": 103, + "source": "r3" + } + }, + { + "kind": "decision", + "receipt": "recorded:r4:even", + "value": { + "branch": "even", + "computed": 40, + "source": "r4" + } + }, + { + "kind": "finish", + "producer": "model" + } + ], + "task_failures": [ + { + "event_id": 52, + "cause": 1, + "message": "UnhandledCommand" + } + ], + "observed_output": [ + { + "kind": "DATA", + "value": { + "branch": "odd", + "computed": 101, + "source": "r1" + } + }, + { + "kind": "DATA", + "value": { + "branch": "even", + "computed": 20, + "source": "r2" + } + }, + { + "kind": "DATA", + "value": { + "retracting_attempt": 1 + } + }, + { + "kind": "DATA", + "value": { + "branch": "odd", + "computed": 103, + "source": "r3" + } + }, + { + "kind": "DATA", + "value": { + "branch": "even", + "computed": 40, + "source": "r4" + } + }, + { + "kind": "FINISH", + "value": null + } + ], + "history_events": 61 +} diff --git a/streams_demo/run_demo.py b/streams_demo/run_demo.py new file mode 100644 index 000000000..027a95103 --- /dev/null +++ b/streams_demo/run_demo.py @@ -0,0 +1,154 @@ +"""Run the shared agent loop against whichever provider is configured. + +Three cases, the same on both providers: + +- read, decide, publish and an ordinary Activity in the same workflow task, + with the smallest workflow cache the provider supports, so that as much of + the run as it allows is rebuilt rather than remembered; +- a producer whose second attempt supersedes its first, which the reader has + to report and the workflow has to act on; +- an outside consumer reading what the workflow published. + +Byte-identical in both trees. ``provider_setup`` is what differs, and it is +the only import here that names a provider. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +import sys +import time +import uuid +from typing import Any + +from temporalio import streams +from temporalio.api.enums.v1 import EventType +from temporalio.client import Client +from temporalio.worker import Worker + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from agent_loop import INPUTS, AgentLoop, record_decision # noqa: E402 +import provider_setup # noqa: E402 + +DECISION_LIMIT = 8 +EXPECTED_OUTPUT = 6 + + +async def collect_output(client: Client, workflow_id: str, want: int) -> list[dict]: + reader = await streams.consumer(client, workflow_id=workflow_id) + seen: list[dict] = [] + async for record in reader.read(type=dict, topic="decisions"): + seen.append({"kind": record.kind.name, "value": record.value}) + if len(seen) >= want: + break + return seen + + +async def main() -> int: + out = Path(__file__).resolve().parent / f"results-{provider_setup.NAME}" + out.mkdir(exist_ok=True) + target, options = await provider_setup.open() + client = await Client.connect(target, namespace="default") + streams.configure(**options) + + uid = f"ai198-contract-{provider_setup.NAME}-" + uuid.uuid4().hex + record: dict[str, Any] = { + "provider": provider_setup.NAME, + "workflow_id": uid, + "target": target, + "max_cached_workflows": provider_setup.WORKFLOW_CACHE, + } + + async with Worker( + client, + task_queue=uid, + workflows=[AgentLoop], + activities=[record_decision], + max_cached_workflows=provider_setup.WORKFLOW_CACHE, + **streams.worker_options(), + ): + handle = await client.start_workflow( + AgentLoop.run, DECISION_LIMIT, id=uid, task_queue=uid + ) + output = asyncio.create_task(collect_output(client, uid, EXPECTED_OUTPUT)) + + # The first attempt writes two records and then stops, as a failed + # activity would. The second writes different inputs under the same + # logical producer, which is what the reader has to report. + first = await streams.producer( + client, workflow_id=uid, stream=INPUTS, producer_id="model", attempt=1 + ) + await first.append({"id": "r1", "value": 1}, {"id": "r2", "value": 2}) + await asyncio.sleep(0.5) + second = await streams.producer( + client, workflow_id=uid, stream=INPUTS, producer_id="model", attempt=2 + ) + await second.append({"id": "r3", "value": 3}, {"id": "r4", "value": 4}) + await second.finish() + + # A failed Workflow Task is not an outcome. The server rejects a + # completion that raced newly buffered events, and the retry usually + # gets through, so reading the first failure as the result reports a + # working run as a broken one. Wait for a terminal event, then report + # the retries separately so they are neither the headline nor hidden. + deadline = time.monotonic() + 120 + # Taken from the enum rather than written out, because guessing these + # numbers is how a run that completed gets reported as terminated. + terminal = { + EventType.EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED: "completed", + EventType.EVENT_TYPE_WORKFLOW_EXECUTION_FAILED: "workflow_failed", + EventType.EVENT_TYPE_WORKFLOW_EXECUTION_TIMED_OUT: "execution_timed_out", + EventType.EVENT_TYPE_WORKFLOW_EXECUTION_CANCELED: "canceled", + EventType.EVENT_TYPE_WORKFLOW_EXECUTION_TERMINATED: "terminated", + EventType.EVENT_TYPE_WORKFLOW_EXECUTION_CONTINUED_AS_NEW: "continued_as_new", + } + while time.monotonic() < deadline: + history = await handle.fetch_history() + reached = [ + terminal[e.event_type] + for e in history.events + if e.event_type in terminal + ] + if reached: + record["outcome"] = reached[-1] + if record["outcome"] == "completed": + record["trace"] = await handle.result() + break + await asyncio.sleep(0.1) + else: + record["outcome"] = "timed_out_waiting" + history = await handle.fetch_history() + + retried = [ + e + for e in history.events + if e.event_type == EventType.EVENT_TYPE_WORKFLOW_TASK_FAILED + ] + record["task_failures"] = [ + { + "event_id": e.event_id, + "cause": int(e.workflow_task_failed_event_attributes.cause), + "message": e.workflow_task_failed_event_attributes.failure.message, + } + for e in retried + ] + + try: + record["observed_output"] = await asyncio.wait_for(output, timeout=20) + except asyncio.TimeoutError: + output.cancel() + record["observed_output"] = "timed_out" + + history = await handle.fetch_history() + record["history_events"] = len(history.events) + (out / "history.json").write_text(history.to_json()) + await provider_setup.close() + (out / "results.json").write_text(json.dumps(record, indent=2) + "\n") + print(json.dumps(record, indent=2)) + return 0 if record["outcome"] == "completed" else 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/temporalio/contrib/external_workflow_streams/_api.py b/temporalio/contrib/external_workflow_streams/_api.py index 2ae02d391..af33bed81 100644 --- a/temporalio/contrib/external_workflow_streams/_api.py +++ b/temporalio/contrib/external_workflow_streams/_api.py @@ -35,6 +35,7 @@ classify_read_failure, ) from temporalio.contrib.external_workflow_streams._record import ( + Offset, StreamRecord, ) from temporalio.types import AnyType @@ -594,6 +595,16 @@ def __aiter__(self) -> AsyncIterator[AnyType]: return self._iterate() async def _iterate(self) -> AsyncIterator[AnyType]: + async for _offset, value in self.records(): + yield value + + async def records(self) -> AsyncIterator[tuple[Offset, AnyType]]: + """Each value with the provider offset it was read from. + + Same delivery, same consumption, same commit order as iterating values. + A reader that has to name where it got to, or hand a position to + something outside the Workflow, cannot do it from the values alone. + """ while not self._finished: # Re-filled before *every* record rather than once per batch. A # record buffered while Workflow code was doing something else -- a @@ -623,8 +634,10 @@ async def _iterate(self) -> AsyncIterator[AnyType]: # becomes a value or raises, and neither outcome can leave the # ready list half-consumed. value = self._decode(record) + offset = record.offset self._commit(record) - yield value + assert offset is not None + yield offset, value continue await self._await_readiness() diff --git a/temporalio/streams/__init__.py b/temporalio/streams/__init__.py new file mode 100644 index 000000000..14fcd4e41 --- /dev/null +++ b/temporalio/streams/__init__.py @@ -0,0 +1,135 @@ +"""A stream a workflow can read, decide on, and write. + +The contract, in five statements: + +1. **A workflow publishes only to its own stream, and it publishes + transactionally.** :meth:`StreamWriter.publish` returns without the record + being visible. The record appears when the workflow task is accepted, and + never if the task fails, so no reader can see a decision the workflow did + not commit. +2. **Reading is an observation, and the SDK records it.** What + :class:`StreamReader` handed to workflow code, including the boundary where + it found nothing, is committed with the commands that reading produced. + Recovery re-supplies the same records in the same order. +3. **Anything that does I/O publishes on its own account.** An activity, a + local activity, or an outside process writes through :func:`producer`, and + its records are visible as soon as they are written. It carries a producer + id, an attempt and a sequence so a retry can be told from a new generation. +4. **A cursor is opaque and belongs to its provider.** Hand it back to resume. + Do not compare two cursors or do arithmetic on one: one provider numbers + records with integers and another with a millisecond-and-sequence pair. +5. **A workflow names its streams relative to itself, and the provider + resolves them.** ``reader("inputs")`` is this workflow's inbound stream + called ``inputs``; ``writer("decisions")`` is a topic it publishes on. One + provider stores that as a namespace-level stream id and the other as a name + under the run's chain key, and workflow code does not have to know which. + The provider itself is chosen when the worker is built. + +Outside a workflow there is no worker to carry the choice, so a process calls +:func:`configure` once before it opens a :func:`producer` or a +:func:`consumer`, and passes :func:`worker_options` to its ``Worker`` and +``Replayer``. Those two calls are where a provider is named. Nothing else in +this module mentions one. + +Reading somebody else's stream is out of scope for the first release. It is +the topology neither prototype has evidence for, and leaving it out is what +lets both of them implement the rest. + +What the contract does not promise: that a :attr:`RecordKind.FINISH` record +means the writing activity succeeded, that a superseded attempt's records can +be withdrawn, or that a stream outlives the retention its provider is +configured for. + +Prototype support for AI-198. The public names are the proposal; the module +behind them (``_binding``) is the only file that differs between the +server-side and the client-side implementation. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any, TypeVar + +from temporalio.streams._binding import ( + Consumer, + Producer, + configure, + consumer, + open_read, + open_write, + producer, + worker_options, +) +from temporalio.streams._handles import ReadSource, StreamReader, StreamWriter, WriteSink +from temporalio.streams._record import ( + BEGINNING, + Cursor, + RecordKind, + StreamRecord, + Supersession, +) + +__all__ = [ + "BEGINNING", + "Consumer", + "Cursor", + "Producer", + "ReadSource", + "RecordKind", + "StreamReader", + "StreamRecord", + "StreamWriter", + "Supersession", + "WriteSink", + "configure", + "consumer", + "producer", + "worker_options", + "reader", + "writer", +] + +T = TypeVar("T") + + +def reader( + stream: str, + *, + type: type | None = None, + topic: str | None = None, + start: Cursor = BEGINNING, + idle_timeout: timedelta | None = None, +) -> StreamReader[Any]: + """Subscribe this workflow to its inbound stream ``stream``. + + Each call is a separate subscription with its own cursor, so adding, + removing or reordering one renumbers the waits after it. Gate a change + behind :func:`temporalio.workflow.patched` as you would for a timer. + + Args: + stream: The inbound stream's name, relative to this workflow. + type: The value type, used as the decode hint. + topic: Only records on this topic, or every topic when omitted. + start: Where to start. Only honoured on the first subscription of a + run, because after that the recorded cursor decides. + idle_timeout: How long the workflow waits with nothing arriving before + the provider is allowed to release the worker. ``None`` takes the + provider's default. + """ + return StreamReader( + open_read(stream, start=start, idle_timeout=idle_timeout), + topic=topic, + type=type, + ) + + +def writer(topic: str, *, type: type | None = None) -> StreamWriter[Any]: + """Publish to ``topic`` on the stream this workflow owns. + + Args: + topic: The topic name. + type: Declared for symmetry with :func:`reader` and for documentation. + Encoding follows the value. + """ + del type + return StreamWriter(open_write(topic), topic) diff --git a/temporalio/streams/_binding.py b/temporalio/streams/_binding.py new file mode 100644 index 000000000..879ee632d --- /dev/null +++ b/temporalio/streams/_binding.py @@ -0,0 +1,356 @@ +"""The client-side binding of the stream interface. + +Streams live in a store the customer runs, Redis here. A workflow's publish is +buffered by the worker, staged invisibly under a token, and promoted only once +a marker in History proves the workflow task that produced it was accepted. +Consumption is recorded the same way, as ranges and boundaries in History. + +This is the only module of the package that differs between the two +implementations. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from datetime import timedelta +from typing import Any + +from temporalio import activity, workflow +from temporalio.api.common.v1 import Payload +from temporalio.client import Client +from temporalio.contrib.external_workflow_streams import ( + BEGINNING as PROVIDER_BEGINNING, + AFTER, + ExternalOutputStreamClient, + ExternalStreamProducer, + WakeNotAcknowledgedError, + WorkflowChainKey, + external_output_stream, + external_stream, +) +from temporalio.streams import _frame +from temporalio.streams._handles import ReadSource, WriteSink +from temporalio.streams._policy import AttemptTracker +from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord + +__all__ = [ + "Consumer", + "Producer", + "configure", + "consumer", + "open_read", + "open_write", + "producer", + "worker_options", +] + +PROVIDER = "external" + +_backend: Any = None + + +def configure(**options: Any) -> None: + """Name the provider for this process. + + Args: + backend: A ``StreamBackend`` instance, such as ``RedisStreamBackend``. + There is no registry to name one from out here, so the caller + constructs it and hands it over. + """ + global _backend + unknown = sorted(set(options) - {"backend"}) + if unknown: + raise TypeError(f"unknown provider options {unknown}") + if "backend" not in options: + raise TypeError("the client-side provider needs a backend") + _backend = options["backend"] + + +def worker_options() -> dict[str, Any]: + """What a ``Worker`` or ``Replayer`` needs to serve this provider.""" + return {"external_stream_backend": _require_backend()} + + +def _require_backend() -> Any: + if _backend is None: + raise RuntimeError( + "no stream provider configured; call temporalio.streams.configure(" + "backend=...) before opening a producer, a consumer or a worker" + ) + return _backend + + +class _ExternalReadSource: + def __init__(self, subscription: Any) -> None: + self._subscription = subscription + self._records = subscription.records() + + async def next_batch(self) -> list[tuple[Cursor, bytes]]: + # One record per batch. The provider reports readiness per record, so a + # batch here would be this binding inventing a boundary the provider + # never observed, and replay records boundaries. + offset, body = await self._records.__anext__() + return [(Cursor(str(offset)), body)] + + def close(self) -> None: + self._subscription.close() + + +class _ExternalWriteSink: + def __init__(self, topic: Any) -> None: + self._topic = topic + + async def publish(self, frame: bytes) -> None: + # Awaited for real here. The worker holds a bounded output batch, and a + # publisher that has filled it waits for room rather than growing it. + await self._topic.publish(frame) + + +def open_read( + stream: str, + *, + start: Cursor = BEGINNING, + idle_timeout: timedelta | None = None, +) -> ReadSource: + """Subscribe the running workflow to its inbound stream ``stream``.""" + # The provider resolves the name under this run's chain key, so a start + # position is only meaningful to a reader that already has one, and this + # provider's first subscription always starts at the beginning of the + # stream it minted. + if start is not BEGINNING and start.token: + raise ValueError( + "the client-side provider starts a new subscription at the " + "beginning of the stream it owns; resuming elsewhere is not " + "supported yet" + ) + options = external_stream + if idle_timeout is not None: + options = options.with_options(idle_timeout=idle_timeout) + return _ExternalReadSource(options.topic(stream, type=bytes).subscribe()) + + +def open_write(topic: str) -> WriteSink: + """Bind ``topic`` on the stream the running workflow owns.""" + return _ExternalWriteSink(external_output_stream.topic(topic, type=bytes)) + + +async def _chain_key(client: Client, workflow_id: str) -> WorkflowChainKey: + description = await client.get_workflow_handle(workflow_id).describe() + return WorkflowChainKey( + client.namespace, + workflow_id, + description.raw_description.workflow_execution_info.first_run_id, + ) + + +class Producer: + """Appends to a stream from outside workflow code. + + Every append is visible as soon as it is written. That is the point for an + activity streaming model output, and it is why an activity carries its own + identity: the retry of a failed attempt has no commit boundary to sort it + out afterwards. + """ + + def __init__( + self, + topic: Any, + converter: Any, + stream: str, + producer_id: str, + attempt: int, + client: Client | None = None, + workflow_id: str = "", + ) -> None: + """Prefer :func:`producer`.""" + self._topic = topic + self._converter = converter + self._stream = stream + self._producer_id = producer_id + self._attempt = attempt + self._client = client + self._workflow_id = workflow_id + self._sequence = 0 + + @property + def attempt(self) -> int: + """The generation this producer is writing.""" + return self._attempt + + async def append(self, *values: Any) -> Cursor: + """Append values and return where the first one landed.""" + first: Any = None + for value in values: + frame = _frame.encode( + topic=self._stream, + kind=RecordKind.DATA, + producer=self._producer_id, + attempt=self._attempt, + sequence=self._sequence, + body=self._encode(value), + ) + self._sequence += 1 + offset = await self._topic.publish(frame) + if first is None: + first = offset + return Cursor(str(first)) + + async def finish(self) -> None: + """Declare this stream complete.""" + frame = _frame.encode( + topic=self._stream, + kind=RecordKind.FINISH, + producer=self._producer_id, + attempt=self._attempt, + sequence=self._sequence, + body=b"", + ) + self._sequence += 1 + try: + await self._topic.publish(frame) + except WakeNotAcknowledgedError: + # The record is appended; what failed is telling a consumer that is + # no longer there to be told. A terminal record most often races the + # consumer acting on it, so treating an absent consumer as a failed + # publish would make the ordinary ending look like an error. + if not await self._consumer_has_gone(): + raise + + async def _consumer_has_gone(self) -> bool: + """Whether the consuming execution has closed. Asked, not assumed.""" + if self._client is None or not self._workflow_id: + return False + description = await self._client.get_workflow_handle( + self._workflow_id + ).describe() + return description.status is not None and description.status.value != 1 + + def _encode(self, value: Any) -> bytes: + payload = ( + value + if isinstance(value, Payload) + else self._converter.to_payloads([value])[0] + ) + return payload.SerializeToString() + + +async def producer( + client: Client, + *, + workflow_id: str, + stream: str, + producer_id: str = "", + attempt: int = 0, +) -> Producer: + """Open a producer for the inbound stream ``stream`` of ``workflow_id``. + + Inside an activity, leave ``producer_id`` and ``attempt`` unset: the + activity's own id and attempt are the right answer and are what let a + reader tell a retry from a new generation. + """ + if not producer_id: + producer_id = activity.info().activity_id + if not attempt: + attempt = activity.info().attempt + session = await ExternalStreamProducer.connect( + backend=_require_backend(), + workflow=await _chain_key(client, workflow_id), + client=client, + # The attempt is part of it. Deduplication answers "is this the same + # append again", and a second attempt writing different words at the + # same sequence is not: this provider rejects that outright. Folding + # the attempt in keeps a retried append idempotent without letting a + # new generation be refused as a conflicting duplicate. + session_id=f"{producer_id}#{attempt}", + ) + return Producer( + session.topic(stream, type=bytes), + client.data_converter.payload_converter, + stream, + producer_id, + attempt, + client, + workflow_id, + ) + + +class Consumer: + """Reads a stream from outside workflow code, resumably.""" + + def __init__(self, client: Any, converter: Any) -> None: + """Prefer :func:`consumer`.""" + self._client = client + self._converter = converter + + async def read( + self, + *, + start: Cursor = BEGINNING, + topic: str | None = None, + type: type | None = None, + ) -> AsyncIterator[StreamRecord[Any]]: + """Yield records from ``start`` as they arrive. + + Applies the same supersession rule as a workflow reader, so a browser + and a workflow watching one activity agree on which attempt is current. + """ + if topic is None: + raise ValueError( + "this provider stores each topic separately, so an outside " + "reader has to name one" + ) + attempts = AttemptTracker() + after = PROVIDER_BEGINNING if not start.token else AFTER(start.token) + handle = self._client.topic(topic, type=bytes) + async for item in handle.subscribe(after=after): + cursor = Cursor(str(item.offset)) + try: + kind, frame_topic, source, attempt, sequence, body = _frame.decode( + item.data + ) + except ValueError: + continue + superseded = attempts.note(source, attempt, cursor) + if superseded is not None: + yield superseded + yield StreamRecord( + value=self._decode(body, type) if kind is RecordKind.DATA else None, + cursor=cursor, + kind=kind, + topic=frame_topic, + producer=source, + attempt=attempt, + sequence=sequence, + ) + + def _decode(self, body: bytes, as_type: type | None) -> Any: + payload = Payload() + payload.ParseFromString(body) + if as_type is None: + return self._converter.from_payloads([payload])[0] + return self._converter.from_payloads([payload], [as_type])[0] + + +async def consumer( + client: Client, *, workflow_id: str, stream: str = "" +) -> Consumer: + """Open a reader for what ``workflow_id`` publishes. + + An empty ``stream`` reads what the workflow wrote through + :func:`temporalio.streams.writer`. This provider keeps inbound and outbound + records in separate stores, so naming an inbound stream is not supported + from here yet. + """ + if stream: + raise ValueError( + "this provider separates inbound and outbound storage, so an " + "outside reader cannot follow an inbound stream yet" + ) + return Consumer( + await ExternalOutputStreamClient.connect( + backend=_require_backend(), + workflow=await _chain_key(client, workflow_id), + client=client, + ), + client.data_converter.payload_converter, + ) diff --git a/temporalio/streams/_frame.py b/temporalio/streams/_frame.py new file mode 100644 index 000000000..c32811d32 --- /dev/null +++ b/temporalio/streams/_frame.py @@ -0,0 +1,72 @@ +"""The record envelope both bindings put on the wire. + +A provider stores an opaque body. The contract needs more than a body: which +topic a record belongs to, whether it is data or a terminal marker, and which +producer attempt wrote it. Neither prototype carries all of that in a channel +of its own, so the interface carries it here and each binding stores the result +as an ordinary body. + +Encoding is synchronous and deterministic because a workflow publishes on the +workflow thread. The header is JSON with sorted keys for that reason, and +because a stored record that a person can read is worth more during an +incident than a few saved bytes. +""" + +from __future__ import annotations + +import json +from typing import Any + +from temporalio.streams._record import RecordKind + +__all__ = ["decode", "encode"] + +_MAGIC = b"tsf1" + + +def encode( + *, + topic: str, + kind: RecordKind, + producer: str, + attempt: int, + sequence: int, + body: bytes, +) -> bytes: + """Wrap one already-encoded body with the contract's metadata.""" + header: dict[str, Any] = {"k": int(kind), "t": topic} + # Omitted rather than written as a default, so a workflow's own publish + # does not carry three fields that only mean something for a producer. + if producer: + header["p"] = producer + if attempt: + header["a"] = attempt + if sequence >= 0: + header["s"] = sequence + raw = json.dumps(header, sort_keys=True, separators=(",", ":")).encode() + return _MAGIC + len(raw).to_bytes(4, "big") + raw + body + + +def decode(frame: bytes) -> tuple[RecordKind, str, str, int, int, bytes]: + """Split a frame into ``(kind, topic, producer, attempt, sequence, body)``. + + Raises: + ValueError: The bytes are not a frame this interface wrote. Reading a + stream someone else populated is a real case, so it fails with a + name rather than an index error. + """ + if len(frame) < 8 or frame[:4] != _MAGIC: + raise ValueError( + "this record was not written through the stream interface, so its " + "topic and producer are unknown" + ) + size = int.from_bytes(frame[4:8], "big") + header = json.loads(frame[8 : 8 + size]) + return ( + RecordKind(header.get("k", int(RecordKind.DATA))), + header.get("t", ""), + header.get("p", ""), + int(header.get("a", 0)), + int(header.get("s", -1)), + frame[8 + size :], + ) diff --git a/temporalio/streams/_handles.py b/temporalio/streams/_handles.py new file mode 100644 index 000000000..a6dc9a073 --- /dev/null +++ b/temporalio/streams/_handles.py @@ -0,0 +1,215 @@ +"""The handles workflow code holds. + +Everything provider-specific sits behind the two protocols at the top. A +handle converts values, frames records, filters topics and synthesizes +supersession; a binding only moves bytes. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any, Generic, Protocol, TypeVar + +from temporalio import workflow +from temporalio.api.common.v1 import Payload +from temporalio.streams import _frame +from temporalio.streams._policy import AttemptTracker +from temporalio.streams._record import Cursor, RecordKind, StreamRecord + +__all__ = ["ReadSource", "StreamReader", "StreamWriter", "WriteSink"] + +T = TypeVar("T") + + +class ReadSource(Protocol): + """One subscription, as a provider supplies it.""" + + async def next_batch(self) -> list[tuple[Cursor, bytes]]: + """The next records, waiting until there is at least one. + + A batch rather than a record because delivery boundaries are what a + provider actually records, and flattening them here keeps that out of + the contract. + + Raises: + StopAsyncIteration: This subscription has ended. + """ + ... + + def close(self) -> None: + """End the subscription. Idempotent.""" + ... + + +class WriteSink(Protocol): + """One topic on the stream the running workflow owns.""" + + async def publish(self, frame: bytes) -> None: + """Accept one framed record into this workflow task's output. + + Awaitable because a provider may hold the publisher back when its + batch is full. It does not mean the record is visible: it becomes + visible when the workflow task is accepted. + """ + ... + + +def _encode_value(value: Any) -> bytes: + converter = workflow.payload_converter() + payload = value if isinstance(value, Payload) else converter.to_payloads([value])[0] + return payload.SerializeToString() + + +def _decode_value(body: bytes, as_type: type | None) -> Any: + converter = workflow.payload_converter() + payload = Payload() + payload.ParseFromString(body) + if as_type is None: + return converter.from_payloads([payload])[0] + return converter.from_payloads([payload], [as_type])[0] + + +class StreamWriter(Generic[T]): + """Publishes to a topic on the stream this workflow owns. + + A workflow can only publish transactionally to a stream it owns, on both + providers. Writing to somebody else's stream is an activity's job, and it + gets the weaker guarantee that goes with doing I/O. + """ + + def __init__(self, sink: WriteSink, topic: str) -> None: + """Prefer :func:`temporalio.streams.writer`.""" + self._sink = sink + self._topic = topic + self._finished = False + + @property + def topic(self) -> str: + """The topic this writer is bound to.""" + return self._topic + + async def publish(self, value: T) -> None: + """Append ``value`` to this topic. + + Returning does not mean the record is visible. It becomes visible when + this workflow task is accepted, and never at all if the task fails, so + a reader cannot see a decision the workflow did not commit. + """ + if self._finished: + raise RuntimeError(f"topic {self._topic!r} was already finished") + await self._sink.publish( + _frame.encode( + topic=self._topic, + kind=RecordKind.DATA, + producer="", + attempt=0, + sequence=-1, + body=_encode_value(value), + ) + ) + + async def finish(self) -> None: + """Declare this topic complete. + + Says the writer has nothing more to send. It does not say the workflow + or the activity behind it succeeded, and a reader that treats it that + way will accept output from an attempt that later timed out. + """ + if self._finished: + return + self._finished = True + await self._sink.publish( + _frame.encode( + topic=self._topic, + kind=RecordKind.FINISH, + producer="", + attempt=0, + sequence=-1, + body=b"", + ) + ) + + +class StreamReader(Generic[T]): + """Reads a stream from inside workflow code. + + Iterating yields every kind of record, including the supersession the + reader synthesizes when a producer's newer attempt appears. Check + ``record.kind``, or use :meth:`values` when the workflow genuinely only + wants data. + """ + + def __init__( + self, + source: ReadSource, + *, + topic: str | None = None, + type: type | None = None, + ) -> None: + """Prefer :func:`temporalio.streams.reader`.""" + self._source = source + self._topic = topic + self._type = type + self._pending: list[StreamRecord[Any]] = [] + self._attempts = AttemptTracker() + self._closed = False + + def __aiter__(self) -> AsyncIterator[StreamRecord[T]]: + """Iterate records until the reader is closed.""" + return self._iterate() + + async def _iterate(self) -> AsyncIterator[StreamRecord[T]]: + while not self._closed: + try: + record = await self.next() + except StopAsyncIteration: + # The provider ended the subscription. Iteration stops rather + # than raising, so a workflow that reads to the end of a + # finished stream leaves the loop instead of failing its task. + return + yield record + + async def values(self) -> AsyncIterator[T]: + """Iterate the data values, dropping control records.""" + async for record in self: + if record.kind is RecordKind.DATA: + yield record.value + + async def next(self) -> StreamRecord[T]: + """The next record, waiting for one if there is none buffered. + + Raises: + StopAsyncIteration: The provider ended the subscription. + """ + while not self._pending: + await self._fill() + return self._pending.pop(0) + + async def _fill(self) -> None: + for cursor, frame in await self._source.next_batch(): + kind, topic, producer, attempt, sequence, body = _frame.decode(frame) + if self._topic is not None and topic != self._topic: + continue + superseded = self._attempts.note(producer, attempt, cursor) + if superseded is not None: + self._pending.append(superseded) + self._pending.append( + StreamRecord( + value=( + _decode_value(body, self._type) + if kind is RecordKind.DATA + else None + ), + cursor=cursor, + kind=kind, + topic=topic, + producer=producer, + attempt=attempt, + sequence=sequence, + ) + ) + + def close(self) -> None: + """End the subscription. Idempotent.""" + self._closed = True + self._source.close() diff --git a/temporalio/streams/_policy.py b/temporalio/streams/_policy.py new file mode 100644 index 000000000..24e20ab2f --- /dev/null +++ b/temporalio/streams/_policy.py @@ -0,0 +1,56 @@ +"""Turning a producer's newer attempt into something a reader can act on. + +An activity that streams half an answer and then fails leaves those records in +the stream. Its retry calls the model again and writes different words. No +provider can undo the first half, and a workflow that already acted on it has +committed that decision, so the honest thing is to tell the reader that a new +attempt began and let the application decide. + +This runs in the reader over records it already observed, so it costs no round +trip and replays without the provider being involved. +""" + +from __future__ import annotations + +from temporalio.streams._record import ( + Cursor, + RecordKind, + StreamRecord, + Supersession, +) + +__all__ = ["AttemptTracker"] + + +class AttemptTracker: + """Watches producer attempts on one subscription.""" + + def __init__(self) -> None: + self._attempts: dict[str, int] = {} + + def note( + self, producer: str, attempt: int, cursor: Cursor + ) -> StreamRecord[Supersession] | None: + """A supersession record when this record starts a newer attempt. + + A producer that declares no attempt supersedes nothing, because there + is no generation to compare. That is the same answer as an unnumbered + record: the interface reports what it was told and invents nothing. + """ + if not producer or attempt <= 0: + return None + previous = self._attempts.get(producer, 0) + if attempt <= previous: + return None + self._attempts[producer] = attempt + if previous == 0: + return None + return StreamRecord( + value=Supersession( + producer=producer, previous_attempt=previous, attempt=attempt + ), + cursor=cursor, + kind=RecordKind.SUPERSEDED, + producer=producer, + attempt=attempt, + ) diff --git a/temporalio/streams/_record.py b/temporalio/streams/_record.py new file mode 100644 index 000000000..d458ef0c4 --- /dev/null +++ b/temporalio/streams/_record.py @@ -0,0 +1,90 @@ +"""The value types the stream contract is expressed in. + +Nothing here touches Temporal or a provider, so both bindings share it +unchanged. +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass +from typing import Generic, TypeVar + +__all__ = [ + "BEGINNING", + "Cursor", + "RecordKind", + "StreamRecord", + "Supersession", +] + +T = TypeVar("T") + + +@enum.unique +class RecordKind(enum.IntEnum): + """What a record is.""" + + DATA = 1 + """Carries a value published by a workflow or a producer.""" + + FINISH = 2 + """The writer of this topic declared it complete. + + Separate from the activity or workflow that wrote it having succeeded. A + reader that treats it as proof of success is wrong: the activity can still + time out after writing it. + """ + + SUPERSEDED = 3 + """A later attempt of the same producer started writing. + + Synthesized by the reader from what it observed, so both providers deliver + it identically and replay reproduces it without the provider's help. + """ + + +@dataclass(frozen=True) +class Cursor: + """A position in a stream, ordered by its provider rather than by value. + + Opaque on purpose. One provider numbers records with integers and another + with a millisecond-and-sequence pair, so comparing tokens here would be + right for one and wrong for the other. Hand a cursor back to resume from + it; ask the reader to compare two. + """ + + token: str + + def __str__(self) -> str: + """The provider's position token.""" + return self.token + + +BEGINNING = Cursor("") +"""Read from the oldest record the stream still retains.""" + + +@dataclass(frozen=True) +class Supersession: + """The body of a :attr:`RecordKind.SUPERSEDED` record.""" + + producer: str + previous_attempt: int + attempt: int + + +@dataclass(frozen=True) +class StreamRecord(Generic[T]): + """One record as workflow code sees it.""" + + value: T + cursor: Cursor + kind: RecordKind = RecordKind.DATA + topic: str = "" + producer: str = "" + """Who wrote it, or empty when the owning workflow wrote it itself.""" + attempt: int = 0 + """The producer's attempt, or 0 when it did not declare one.""" + sequence: int = -1 + """The producer's position within its attempt, or -1 when unnumbered.""" From 66f353c75d330adaeccb5a97e6c132e48913c634 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Sun, 6 Sep 2026 11:26:03 -0700 Subject: [PATCH 04/96] Let a terminal record land on a consumer that is closing. The server refuses the wake while the execution is closing, and at that moment its status is still the running one, so a single look read "running" and turned the ordinary ending into an error. --- streams_demo/results-external/history.json | 813 ++++++++------------- streams_demo/results-external/results.json | 8 +- temporalio/streams/_binding.py | 26 +- 3 files changed, 346 insertions(+), 501 deletions(-) diff --git a/streams_demo/results-external/history.json b/streams_demo/results-external/history.json index 75295aa6b..1013bf0e7 100644 --- a/streams_demo/results-external/history.json +++ b/streams_demo/results-external/history.json @@ -2,15 +2,15 @@ "events": [ { "eventId": "1", - "eventTime": "2026-09-06T18:11:22.157533Z", + "eventTime": "2026-09-06T18:23:04.307989Z", "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED", - "taskId": "1050382", + "taskId": "1052794", "workflowExecutionStartedEventAttributes": { "workflowType": { "name": "AI198StreamContractDemo" }, "taskQueue": { - "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "name": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", "kind": "TASK_QUEUE_KIND_NORMAL" }, "input": { @@ -24,23 +24,23 @@ ] }, "workflowTaskTimeout": "10s", - "originalExecutionRunId": "01a077ea-f5ad-781e-b17e-b21618fa7a7f", - "identity": "26823@Mac.lan", - "firstExecutionRunId": "01a077ea-f5ad-781e-b17e-b21618fa7a7f", + "originalExecutionRunId": "01a077f5-ac73-7f13-9fa6-ebc5c0821a69", + "identity": "34059@Mac.lan", + "firstExecutionRunId": "01a077f5-ac73-7f13-9fa6-ebc5c0821a69", "attempt": 1, "firstWorkflowTaskBackoff": "0s", - "workflowId": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "workflowId": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", "priority": {} } }, { "eventId": "2", - "eventTime": "2026-09-06T18:11:22.157567Z", + "eventTime": "2026-09-06T18:23:04.308024Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1050383", + "taskId": "1052795", "workflowTaskScheduledEventAttributes": { "taskQueue": { - "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "name": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", "kind": "TASK_QUEUE_KIND_NORMAL" }, "startToCloseTimeout": "10s", @@ -49,35 +49,35 @@ }, { "eventId": "3", - "eventTime": "2026-09-06T18:11:22.160155Z", + "eventTime": "2026-09-06T18:23:04.310387Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1050388", + "taskId": "1052800", "workflowTaskStartedEventAttributes": { "scheduledEventId": "2", - "identity": "26823@Mac.lan", - "requestId": "4af9762f-b065-4f3e-bbb0-17a6546a7ee6", - "historySizeBytes": "404", + "identity": "34059@Mac.lan", + "requestId": "26ca0dc2-9c92-4899-9736-a16e48687a91", + "historySizeBytes": "406", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" } } }, { "eventId": "4", - "eventTime": "2026-09-06T18:11:22.174162Z", + "eventTime": "2026-09-06T18:23:04.324822Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1050393", + "taskId": "1052805", "workflowTaskCompletedEventAttributes": { "scheduledEventId": "2", "startedEventId": "3", - "identity": "26823@Mac.lan", + "identity": "34059@Mac.lan", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" }, "sdkMetadata": { "coreUsedFlags": [ - 2, 1, + 2, 3 ], "sdkName": "temporal-python", @@ -88,9 +88,9 @@ }, { "eventId": "5", - "eventTime": "2026-09-06T18:11:22.174189Z", + "eventTime": "2026-09-06T18:23:04.324847Z", "eventType": "EVENT_TYPE_MARKER_RECORDED", - "taskId": "1050394", + "taskId": "1052806", "markerRecordedEventAttributes": { "markerName": "core_external_stream", "details": { @@ -100,7 +100,7 @@ "metadata": { "encoding": "YmluYXJ5L3Byb3RvYnVm" }, - "data": "CAEQARoECAEQASK+AQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC1hYzMyMGU1N2ZhZmM0ZWFmOTQ5YzFhYTBiNGY4NDZiZSQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YGaW5wdXRzAA1yZWRpcy1zdHJlYW1zAQIAAQIBAQ8xNzg4NzE4MjgyMTcxLTAPMTc4ODcxODI4MjE3MS0wAQABAwEBAQ8xNzg4NzE4MjgyMTcxLTAoBjKdAQgBEAEaIFNsYVBscXRXTmF5Y2VpSDByRlVqRElLVE1XTTVYUk1FIAEqJDAxYTA3N2VhLWY1YWQtNzgxZS1iMTdlLWIyMTYxOGZhN2E3ZjIyCglkZWNpc2lvbnMQARi0ASIgjBxO69u2MphhOmkhkT4NDYD9yThaPpqKHcAWa2oslZg6AwoBADoDCgEBQg1yZWRpcy1zdHJlYW1zSAE=" + "data": "CAEQARoECAEQASK+AQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAA1yZWRpcy1zdHJlYW1zAQIAAQIBAQ8xNzg4NzE4OTg0MzIyLTAPMTc4ODcxODk4NDMyMi0wAQABAwEBAQ8xNzg4NzE4OTg0MzIyLTAoBjKdAQgBEAEaIGV1MFlKZjgxdXJfTDZ0YU9lZUlxRVVJUzNsQnVGNzBaIAEqJDAxYTA3N2Y1LWFjNzMtN2YxMy05ZmE2LWViYzVjMDgyMWE2OTIyCglkZWNpc2lvbnMQARi0ASIgjBxO69u2MphhOmkhkT4NDYD9yThaPpqKHcAWa2oslZg6AwoBADoDCgEBQg1yZWRpcy1zdHJlYW1zSAE=" } ] } @@ -110,16 +110,16 @@ }, { "eventId": "6", - "eventTime": "2026-09-06T18:11:22.174198Z", + "eventTime": "2026-09-06T18:23:04.324856Z", "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", - "taskId": "1050395", + "taskId": "1052807", "activityTaskScheduledEventAttributes": { "activityId": "decision-r1", "activityType": { "name": "AI198RecordDecision" }, "taskQueue": { - "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "name": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", "kind": "TASK_QUEUE_KIND_NORMAL" }, "header": {}, @@ -149,24 +149,24 @@ }, { "eventId": "7", - "eventTime": "2026-09-06T18:11:22.174212Z", + "eventTime": "2026-09-06T18:23:04.324868Z", "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", - "taskId": "1050399", + "taskId": "1052811", "activityTaskStartedEventAttributes": { "scheduledEventId": "6", - "identity": "26823@Mac.lan", - "requestId": "58663e42-9843-4b02-82cd-bf5176975d5c", + "identity": "34059@Mac.lan", + "requestId": "47dd481e-e301-4a50-b342-23ff777038be", "attempt": 1, "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" } } }, { "eventId": "8", - "eventTime": "2026-09-06T18:11:22.175897Z", + "eventTime": "2026-09-06T18:23:04.326319Z", "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", - "taskId": "1050400", + "taskId": "1052812", "activityTaskCompletedEventAttributes": { "result": { "payloads": [ @@ -180,18 +180,19 @@ }, "scheduledEventId": "6", "startedEventId": "7", - "identity": "26823@Mac.lan" + "identity": "34059@Mac.lan" } }, { "eventId": "9", - "eventTime": "2026-09-06T18:11:22.175903Z", + "eventTime": "2026-09-06T18:23:04.326325Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1050401", + "taskId": "1052813", "workflowTaskScheduledEventAttributes": { "taskQueue": { - "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", - "kind": "TASK_QUEUE_KIND_NORMAL" + "name": "34059@Mac.lan-37fc3e6910fe473798ebf07f75c25409", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9" }, "startToCloseTimeout": "10s", "attempt": 1 @@ -199,30 +200,30 @@ }, { "eventId": "10", - "eventTime": "2026-09-06T18:11:22.176609Z", + "eventTime": "2026-09-06T18:23:04.326970Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1050404", + "taskId": "1052817", "workflowTaskStartedEventAttributes": { "scheduledEventId": "9", - "identity": "26823@Mac.lan", - "requestId": "c04f1c35-feda-443d-9764-f1b9d9308366", - "historySizeBytes": "1653", + "identity": "34059@Mac.lan", + "requestId": "bd8c44cb-8931-4f15-be7f-9c0ef23bebf0", + "historySizeBytes": "1711", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" } } }, { "eventId": "11", - "eventTime": "2026-09-06T18:11:22.183152Z", + "eventTime": "2026-09-06T18:23:04.334247Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1050410", + "taskId": "1052823", "workflowTaskCompletedEventAttributes": { "scheduledEventId": "9", "startedEventId": "10", - "identity": "26823@Mac.lan", + "identity": "34059@Mac.lan", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" }, "sdkMetadata": {}, "meteringMetadata": {} @@ -230,9 +231,9 @@ }, { "eventId": "12", - "eventTime": "2026-09-06T18:11:22.183168Z", + "eventTime": "2026-09-06T18:23:04.334266Z", "eventType": "EVENT_TYPE_MARKER_RECORDED", - "taskId": "1050411", + "taskId": "1052824", "markerRecordedEventAttributes": { "markerName": "core_external_stream", "details": { @@ -242,7 +243,7 @@ "metadata": { "encoding": "YmluYXJ5L3Byb3RvYnVm" }, - "data": "CAEQARoECAEQAiLOAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC1hYzMyMGU1N2ZhZmM0ZWFmOTQ5YzFhYTBiNGY4NDZiZSQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YGaW5wdXRzAQ8xNzg4NzE4MjgyMTcxLTANcmVkaXMtc3RyZWFtcwECAAECAQEPMTc4ODcxODI4MjE4MS0wDzE3ODg3MTgyODIxODEtMAEAAQMBAQEPMTc4ODcxODI4MjE4MS0wKAYynQEIARABGiB0cFZkaG9qaW0wUTBtLWlkSzNrV3Z3N2poQkU5MmFORSAIKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YyMgoJZGVjaXNpb25zEAEYtAEiIEG2KSpLbvL+t7NXgoIazG7djtT9TCEi2Caa1YjB4xlZOgMKAQA6AwoBAUINcmVkaXMtc3RyZWFtc0gB" + "data": "CAEQAhoECAEQAiLOAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAQ8xNzg4NzE4OTg0MzIyLTANcmVkaXMtc3RyZWFtcwECAAECAQEPMTc4ODcxODk4NDMzMi0wDzE3ODg3MTg5ODQzMzItMAEAAQMBAQEPMTc4ODcxODk4NDMzMi0wKAYynQEIARABGiAxUTNvYU1sREF6aXVQNkNwR3l5U1BRa3J2bVhuNGotUSAIKiQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkyMgoJZGVjaXNpb25zEAEYtAEiIEG2KSpLbvL+t7NXgoIazG7djtT9TCEi2Caa1YjB4xlZOgMKAQA6AwoBAUINcmVkaXMtc3RyZWFtc0gB" } ] } @@ -252,16 +253,16 @@ }, { "eventId": "13", - "eventTime": "2026-09-06T18:11:22.183175Z", + "eventTime": "2026-09-06T18:23:04.334272Z", "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", - "taskId": "1050412", + "taskId": "1052825", "activityTaskScheduledEventAttributes": { "activityId": "decision-r2", "activityType": { "name": "AI198RecordDecision" }, "taskQueue": { - "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "name": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", "kind": "TASK_QUEUE_KIND_NORMAL" }, "header": {}, @@ -291,9 +292,9 @@ }, { "eventId": "14", - "eventTime": "2026-09-06T18:11:22.180649Z", + "eventTime": "2026-09-06T18:23:04.331874Z", "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1050413", + "taskId": "1052826", "workflowExecutionSignaledEventAttributes": { "signalName": "__temporal_external_stream_wake", "input": { @@ -303,23 +304,24 @@ "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", "encoding": "YmluYXJ5L3Byb3RvYnVm" }, - "data": "CAESBmlucHV0cyokMDFhMDc3ZWEtZjVhZC03ODFlLWIxN2UtYjIxNjE4ZmE3YTdmMgdtb2RlbCMx" + "data": "CAESBmlucHV0cyokMDFhMDc3ZjUtYWM3My03ZjEzLTlmYTYtZWJjNWMwODIxYTY5Mgdtb2RlbCMx" } ] }, - "identity": "26823@Mac.lan", - "requestId": "1df2f713-fd2d-53fb-baa7-c57b9f5793a8" + "identity": "34059@Mac.lan", + "requestId": "c70d6ed3-a072-5673-8c23-4d7fb2864d3f" } }, { "eventId": "15", - "eventTime": "2026-09-06T18:11:22.183190Z", + "eventTime": "2026-09-06T18:23:04.334287Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1050414", + "taskId": "1052827", "workflowTaskScheduledEventAttributes": { "taskQueue": { - "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", - "kind": "TASK_QUEUE_KIND_NORMAL" + "name": "34059@Mac.lan-37fc3e6910fe473798ebf07f75c25409", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9" }, "startToCloseTimeout": "10s", "attempt": 1 @@ -327,30 +329,30 @@ }, { "eventId": "16", - "eventTime": "2026-09-06T18:11:22.183924Z", + "eventTime": "2026-09-06T18:23:04.334301Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1050418", + "taskId": "1052828", "workflowTaskStartedEventAttributes": { "scheduledEventId": "15", - "identity": "26823@Mac.lan", - "requestId": "9efbed6e-9cfb-4854-896d-bac0d1f26f81", - "historySizeBytes": "2936", + "identity": "34059@Mac.lan", + "requestId": "request-from-RespondWorkflowTaskCompleted", + "historySizeBytes": "1831", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" } } }, { "eventId": "17", - "eventTime": "2026-09-06T18:11:22.188413Z", + "eventTime": "2026-09-06T18:23:04.336066Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1050424", + "taskId": "1052833", "workflowTaskCompletedEventAttributes": { "scheduledEventId": "15", "startedEventId": "16", - "identity": "26823@Mac.lan", + "identity": "34059@Mac.lan", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" }, "sdkMetadata": {}, "meteringMetadata": {} @@ -358,46 +360,46 @@ }, { "eventId": "18", - "eventTime": "2026-09-06T18:11:22.183185Z", + "eventTime": "2026-09-06T18:23:04.336077Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "taskId": "1052834", + "markerRecordedEventAttributes": { + "markerName": "core_external_stream", + "details": { + "external_stream": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAEQAhoECAEQAiKoAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAQ8xNzg4NzE4OTg0MzMyLTANcmVkaXMtc3RyZWFtcwECAAEDAQEBDzE3ODg3MTg5ODQzMzItMCgI" + } + ] + } + }, + "workflowTaskCompletedEventId": "17" + } + }, + { + "eventId": "19", + "eventTime": "2026-09-06T18:23:04.334282Z", "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", - "taskId": "1050425", + "taskId": "1052835", "activityTaskStartedEventAttributes": { "scheduledEventId": "13", - "identity": "26823@Mac.lan", - "requestId": "2ef67959-882a-4107-8539-d5196556c9f8", + "identity": "34059@Mac.lan", + "requestId": "9fc0cd1b-60de-4c07-b64d-b2b9e8cd3c8e", "attempt": 1, "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" } } }, - { - "eventId": "19", - "eventTime": "2026-09-06T18:11:22.187585Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1050426", - "workflowExecutionSignaledEventAttributes": { - "signalName": "__temporal_external_stream_wake", - "input": { - "payloads": [ - { - "metadata": { - "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" - } - ] - }, - "identity": "26823@Mac.lan", - "requestId": "e43a9981-456e-5236-9b51-9ab044957092" - } - }, { "eventId": "20", - "eventTime": "2026-09-06T18:11:22.184543Z", + "eventTime": "2026-09-06T18:23:04.335841Z", "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", - "taskId": "1050427", + "taskId": "1052836", "activityTaskCompletedEventAttributes": { "result": { "payloads": [ @@ -410,19 +412,20 @@ ] }, "scheduledEventId": "13", - "startedEventId": "18", - "identity": "26823@Mac.lan" + "startedEventId": "19", + "identity": "34059@Mac.lan" } }, { "eventId": "21", - "eventTime": "2026-09-06T18:11:22.188436Z", + "eventTime": "2026-09-06T18:23:04.336080Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1050428", + "taskId": "1052837", "workflowTaskScheduledEventAttributes": { "taskQueue": { - "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", - "kind": "TASK_QUEUE_KIND_NORMAL" + "name": "34059@Mac.lan-37fc3e6910fe473798ebf07f75c25409", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9" }, "startToCloseTimeout": "10s", "attempt": 1 @@ -430,30 +433,30 @@ }, { "eventId": "22", - "eventTime": "2026-09-06T18:11:22.189479Z", + "eventTime": "2026-09-06T18:23:04.336092Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1050431", + "taskId": "1052838", "workflowTaskStartedEventAttributes": { "scheduledEventId": "21", - "identity": "26823@Mac.lan", - "requestId": "a7e422ab-0ccb-400b-b202-9cb14c072dbe", - "historySizeBytes": "3696", + "identity": "34059@Mac.lan", + "requestId": "request-from-RespondWorkflowTaskCompleted", + "historySizeBytes": "3174", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" } } }, { "eventId": "23", - "eventTime": "2026-09-06T18:11:22.705946Z", + "eventTime": "2026-09-06T18:23:04.856009Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1050437", + "taskId": "1052843", "workflowTaskCompletedEventAttributes": { "scheduledEventId": "21", "startedEventId": "22", - "identity": "26823@Mac.lan", + "identity": "34059@Mac.lan", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" }, "sdkMetadata": {}, "meteringMetadata": {} @@ -461,9 +464,9 @@ }, { "eventId": "24", - "eventTime": "2026-09-06T18:11:22.705975Z", + "eventTime": "2026-09-06T18:23:04.856043Z", "eventType": "EVENT_TYPE_MARKER_RECORDED", - "taskId": "1050438", + "taskId": "1052844", "markerRecordedEventAttributes": { "markerName": "core_external_stream", "details": { @@ -473,7 +476,7 @@ "metadata": { "encoding": "YmluYXJ5L3Byb3RvYnVm" }, - "data": "CAEQARoECAEQAyLOAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC1hYzMyMGU1N2ZhZmM0ZWFmOTQ5YzFhYTBiNGY4NDZiZSQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YGaW5wdXRzAQ8xNzg4NzE4MjgyMTgxLTANcmVkaXMtc3RyZWFtcwECAAECAQEPMTc4ODcxODI4MjcwMS0wDzE3ODg3MTgyODI3MDEtMAEAAQMBAQEPMTc4ODcxODI4MjcwMS0wKAYynQEIARABGiB1UENKbTNlcWEwa2dqS25xMHhlTUZYeHlJSHU4bWVHUSAUKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YyMgoJZGVjaXNpb25zEAIY0wIiIMlkAX98qOr68/irYtiKi0b09uWIahOZe6Q558yOXlphOgMKAQA6AwoBAkINcmVkaXMtc3RyZWFtc0gB" + "data": "CAEQAxoECAEQAyLOAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAQ8xNzg4NzE4OTg0MzMyLTANcmVkaXMtc3RyZWFtcwECAAECAQEPMTc4ODcxODk4NDg1MS0wDzE3ODg3MTg5ODQ4NTEtMAEAAQMBAQEPMTc4ODcxODk4NDg1MS0wKAYynQEIARABGiBhekMwV1J5bktxbVRMUjNMLWFLdDZNRjVuc25jTzRMdCAUKiQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkyMgoJZGVjaXNpb25zEAIY0wIiIMlkAX98qOr68/irYtiKi0b09uWIahOZe6Q558yOXlphOgMKAQA6AwoBAkINcmVkaXMtc3RyZWFtc0gB" } ] } @@ -483,16 +486,16 @@ }, { "eventId": "25", - "eventTime": "2026-09-06T18:11:22.705985Z", + "eventTime": "2026-09-06T18:23:04.856056Z", "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", - "taskId": "1050439", + "taskId": "1052845", "activityTaskScheduledEventAttributes": { "activityId": "decision-r3", "activityType": { "name": "AI198RecordDecision" }, "taskQueue": { - "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "name": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", "kind": "TASK_QUEUE_KIND_NORMAL" }, "header": {}, @@ -522,9 +525,9 @@ }, { "eventId": "26", - "eventTime": "2026-09-06T18:11:22.191345Z", + "eventTime": "2026-09-06T18:23:04.341085Z", "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1050440", + "taskId": "1052846", "workflowExecutionSignaledEventAttributes": { "signalName": "__temporal_external_stream_wake", "input": { @@ -534,23 +537,24 @@ "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", "encoding": "YmluYXJ5L3Byb3RvYnVm" }, - "data": "CAESBmlucHV0cyokMDFhMDc3ZWEtZjVhZC03ODFlLWIxN2UtYjIxNjE4ZmE3YTdmMgdtb2RlbCMx" + "data": "CAESBmlucHV0cyokMDFhMDc3ZjUtYWM3My03ZjEzLTlmYTYtZWJjNWMwODIxYTY5Mgdtb2RlbCMx" } ] }, - "identity": "26823@Mac.lan", - "requestId": "37d1b402-38c8-5dda-8645-d585db292a63" + "identity": "34059@Mac.lan", + "requestId": "0da6d15a-59e2-5ce9-89c3-fdb107a4d620" } }, { "eventId": "27", - "eventTime": "2026-09-06T18:11:22.706006Z", + "eventTime": "2026-09-06T18:23:04.856092Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1050441", + "taskId": "1052847", "workflowTaskScheduledEventAttributes": { "taskQueue": { - "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", - "kind": "TASK_QUEUE_KIND_NORMAL" + "name": "34059@Mac.lan-37fc3e6910fe473798ebf07f75c25409", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9" }, "startToCloseTimeout": "10s", "attempt": 1 @@ -558,30 +562,30 @@ }, { "eventId": "28", - "eventTime": "2026-09-06T18:11:22.706861Z", + "eventTime": "2026-09-06T18:23:04.856126Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1050445", + "taskId": "1052848", "workflowTaskStartedEventAttributes": { "scheduledEventId": "27", - "identity": "26823@Mac.lan", - "requestId": "25930a39-9163-4f47-8e21-cb2b48d61d84", - "historySizeBytes": "4983", + "identity": "34059@Mac.lan", + "requestId": "request-from-RespondWorkflowTaskCompleted", + "historySizeBytes": "4035", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" } } }, { "eventId": "29", - "eventTime": "2026-09-06T18:11:22.715797Z", + "eventTime": "2026-09-06T18:23:04.860129Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1050451", + "taskId": "1052853", "workflowTaskCompletedEventAttributes": { "scheduledEventId": "27", "startedEventId": "28", - "identity": "26823@Mac.lan", + "identity": "34059@Mac.lan", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" }, "sdkMetadata": {}, "meteringMetadata": {} @@ -589,46 +593,46 @@ }, { "eventId": "30", - "eventTime": "2026-09-06T18:11:22.705999Z", + "eventTime": "2026-09-06T18:23:04.860156Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "taskId": "1052854", + "markerRecordedEventAttributes": { + "markerName": "core_external_stream", + "details": { + "external_stream": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAEQAxoECAEQAyKoAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAQ8xNzg4NzE4OTg0ODUxLTANcmVkaXMtc3RyZWFtcwECAAEDAQEBDzE3ODg3MTg5ODQ4NTEtMCgI" + } + ] + } + }, + "workflowTaskCompletedEventId": "29" + } + }, + { + "eventId": "31", + "eventTime": "2026-09-06T18:23:04.856080Z", "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", - "taskId": "1050452", + "taskId": "1052855", "activityTaskStartedEventAttributes": { "scheduledEventId": "25", - "identity": "26823@Mac.lan", - "requestId": "a725c165-a214-4892-916c-76e1ea839d7c", + "identity": "34059@Mac.lan", + "requestId": "ae70d169-fcae-4c8b-b56f-24ef10977f72", "attempt": 1, "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" } } }, - { - "eventId": "31", - "eventTime": "2026-09-06T18:11:22.715327Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1050453", - "workflowExecutionSignaledEventAttributes": { - "signalName": "__temporal_external_stream_wake", - "input": { - "payloads": [ - { - "metadata": { - "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" - } - ] - }, - "identity": "26823@Mac.lan", - "requestId": "2fbd7436-ef53-5aea-a0a0-f5580216293b" - } - }, { "eventId": "32", - "eventTime": "2026-09-06T18:11:22.707608Z", + "eventTime": "2026-09-06T18:23:04.859154Z", "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", - "taskId": "1050454", + "taskId": "1052856", "activityTaskCompletedEventAttributes": { "result": { "payloads": [ @@ -641,19 +645,20 @@ ] }, "scheduledEventId": "25", - "startedEventId": "30", - "identity": "26823@Mac.lan" + "startedEventId": "31", + "identity": "34059@Mac.lan" } }, { "eventId": "33", - "eventTime": "2026-09-06T18:11:22.715826Z", + "eventTime": "2026-09-06T18:23:04.860164Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1050455", + "taskId": "1052857", "workflowTaskScheduledEventAttributes": { "taskQueue": { - "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", - "kind": "TASK_QUEUE_KIND_NORMAL" + "name": "34059@Mac.lan-37fc3e6910fe473798ebf07f75c25409", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9" }, "startToCloseTimeout": "10s", "attempt": 1 @@ -661,62 +666,40 @@ }, { "eventId": "34", - "eventTime": "2026-09-06T18:11:22.716505Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1050458", - "workflowExecutionSignaledEventAttributes": { - "signalName": "__temporal_external_stream_wake", - "input": { - "payloads": [ - { - "metadata": { - "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAESBmlucHV0cyokMDFhMDc3ZWEtZjVhZC03ODFlLWIxN2UtYjIxNjE4ZmE3YTdmMgdtb2RlbCMy" - } - ] - }, - "identity": "26823@Mac.lan", - "requestId": "8399a538-2d53-53ec-8373-46b5609feb7b" - } - }, - { - "eventId": "35", - "eventTime": "2026-09-06T18:11:22.716835Z", + "eventTime": "2026-09-06T18:23:04.860195Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1050460", + "taskId": "1052858", "workflowTaskStartedEventAttributes": { "scheduledEventId": "33", - "identity": "26823@Mac.lan", - "requestId": "b3544f16-128d-491f-8ecf-d7f185abaa08", - "historySizeBytes": "6008", + "identity": "34059@Mac.lan", + "requestId": "request-from-RespondWorkflowTaskCompleted", + "historySizeBytes": "5378", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" } } }, { - "eventId": "36", - "eventTime": "2026-09-06T18:11:22.727247Z", + "eventId": "35", + "eventTime": "2026-09-06T18:23:04.873800Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1050467", + "taskId": "1052863", "workflowTaskCompletedEventAttributes": { "scheduledEventId": "33", - "startedEventId": "35", - "identity": "26823@Mac.lan", + "startedEventId": "34", + "identity": "34059@Mac.lan", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" }, "sdkMetadata": {}, "meteringMetadata": {} } }, { - "eventId": "37", - "eventTime": "2026-09-06T18:11:22.727270Z", + "eventId": "36", + "eventTime": "2026-09-06T18:23:04.873824Z", "eventType": "EVENT_TYPE_MARKER_RECORDED", - "taskId": "1050468", + "taskId": "1052864", "markerRecordedEventAttributes": { "markerName": "core_external_stream", "details": { @@ -726,26 +709,26 @@ "metadata": { "encoding": "YmluYXJ5L3Byb3RvYnVm" }, - "data": "CAEQARoECAEQBCLOAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC1hYzMyMGU1N2ZhZmM0ZWFmOTQ5YzFhYTBiNGY4NDZiZSQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YGaW5wdXRzAQ8xNzg4NzE4MjgyNzAxLTANcmVkaXMtc3RyZWFtcwECAAECAQEPMTc4ODcxODI4MjcxOC0wDzE3ODg3MTgyODI3MTgtMAEAAQMBAQEPMTc4ODcxODI4MjcxOC0wKAYynQEIARABGiA0WXNrdDcwVHNsUWVLRUdCVVV1Y3NTZDJnZjZIWk10SyAgKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YyMgoJZGVjaXNpb25zEAEYtAEiID5nSwItjUt3xrIwLwmnksCGdUXwRytSYqhf4/HFL9GVOgMKAQA6AwoBAUINcmVkaXMtc3RyZWFtc0gB" + "data": "CAEQBBoECAEQBCLOAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAQ8xNzg4NzE4OTg0ODUxLTANcmVkaXMtc3RyZWFtcwECAAECAQEPMTc4ODcxODk4NDg3MC0wDzE3ODg3MTg5ODQ4NzAtMAEAAQMBAQEPMTc4ODcxODk4NDg3MC0wKAYynQEIARABGiBBU25qczFkZkowaTZZdHdBSkt0RW9XTlZUek9jcUxTbCAgKiQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkyMgoJZGVjaXNpb25zEAEYtAEiID5nSwItjUt3xrIwLwmnksCGdUXwRytSYqhf4/HFL9GVOgMKAQA6AwoBAUINcmVkaXMtc3RyZWFtc0gB" } ] } }, - "workflowTaskCompletedEventId": "36" + "workflowTaskCompletedEventId": "35" } }, { - "eventId": "38", - "eventTime": "2026-09-06T18:11:22.727280Z", + "eventId": "37", + "eventTime": "2026-09-06T18:23:04.873833Z", "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", - "taskId": "1050469", + "taskId": "1052865", "activityTaskScheduledEventAttributes": { "activityId": "decision-r4", "activityType": { "name": "AI198RecordDecision" }, "taskQueue": { - "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "name": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", "kind": "TASK_QUEUE_KIND_NORMAL" }, "header": {}, @@ -763,7 +746,7 @@ "scheduleToStartTimeout": "0s", "startToCloseTimeout": "10s", "heartbeatTimeout": "0s", - "workflowTaskCompletedEventId": "36", + "workflowTaskCompletedEventId": "35", "retryPolicy": { "initialInterval": "1s", "backoffCoefficient": 2.0, @@ -774,32 +757,10 @@ } }, { - "eventId": "39", - "eventTime": "2026-09-06T18:11:22.722015Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1050470", - "workflowExecutionSignaledEventAttributes": { - "signalName": "__temporal_external_stream_wake", - "input": { - "payloads": [ - { - "metadata": { - "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" - } - ] - }, - "identity": "26823@Mac.lan", - "requestId": "39da5ac8-0f7a-5872-9d15-165c2b32f2cf" - } - }, - { - "eventId": "40", - "eventTime": "2026-09-06T18:11:22.724282Z", + "eventId": "38", + "eventTime": "2026-09-06T18:23:04.869822Z", "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1050471", + "taskId": "1052866", "workflowExecutionSignaledEventAttributes": { "signalName": "__temporal_external_stream_wake", "input": { @@ -809,167 +770,102 @@ "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", "encoding": "YmluYXJ5L3Byb3RvYnVm" }, - "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" + "data": "CAESBmlucHV0cyokMDFhMDc3ZjUtYWM3My03ZjEzLTlmYTYtZWJjNWMwODIxYTY5Mgdtb2RlbCMy" } ] }, - "identity": "26823@Mac.lan", - "requestId": "46ea13fa-03d0-56b3-971f-658b3c5b2dea" + "identity": "34059@Mac.lan", + "requestId": "5d10fc28-9e67-5c92-8e99-a7efb1f1d9cb" } }, { - "eventId": "41", - "eventTime": "2026-09-06T18:11:22.727305Z", + "eventId": "39", + "eventTime": "2026-09-06T18:23:04.873860Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1050472", + "taskId": "1052867", "workflowTaskScheduledEventAttributes": { "taskQueue": { - "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", - "kind": "TASK_QUEUE_KIND_NORMAL" + "name": "34059@Mac.lan-37fc3e6910fe473798ebf07f75c25409", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9" }, "startToCloseTimeout": "10s", "attempt": 1 } }, { - "eventId": "42", - "eventTime": "2026-09-06T18:11:22.728358Z", + "eventId": "40", + "eventTime": "2026-09-06T18:23:04.873883Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1050476", + "taskId": "1052868", "workflowTaskStartedEventAttributes": { - "scheduledEventId": "41", - "identity": "26823@Mac.lan", - "requestId": "bf6b249a-a171-4571-b49a-2dd98bc0d8a6", - "historySizeBytes": "7543", + "scheduledEventId": "39", + "identity": "34059@Mac.lan", + "requestId": "request-from-RespondWorkflowTaskCompleted", + "historySizeBytes": "6238", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" } } }, { - "eventId": "43", - "eventTime": "2026-09-06T18:11:22.737729Z", + "eventId": "41", + "eventTime": "2026-09-06T18:23:04.876306Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1050485", + "taskId": "1052873", "workflowTaskCompletedEventAttributes": { - "scheduledEventId": "41", - "startedEventId": "42", - "identity": "26823@Mac.lan", + "scheduledEventId": "39", + "startedEventId": "40", + "identity": "34059@Mac.lan", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" }, "sdkMetadata": {}, "meteringMetadata": {} } }, { - "eventId": "44", - "eventTime": "2026-09-06T18:11:22.727297Z", + "eventId": "42", + "eventTime": "2026-09-06T18:23:04.876327Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "taskId": "1052874", + "markerRecordedEventAttributes": { + "markerName": "core_external_stream", + "details": { + "external_stream": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L3Byb3RvYnVm" + }, + "data": "CAEQBBoECAEQBCKoAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAQ8xNzg4NzE4OTg0ODcwLTANcmVkaXMtc3RyZWFtcwECAAEDAQEBDzE3ODg3MTg5ODQ4NzAtMCgI" + } + ] + } + }, + "workflowTaskCompletedEventId": "41" + } + }, + { + "eventId": "43", + "eventTime": "2026-09-06T18:23:04.873853Z", "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", - "taskId": "1050486", + "taskId": "1052875", "activityTaskStartedEventAttributes": { - "scheduledEventId": "38", - "identity": "26823@Mac.lan", - "requestId": "e9cf5853-0b8f-425c-92f7-6eccfc1585e3", + "scheduledEventId": "37", + "identity": "34059@Mac.lan", + "requestId": "23c51461-54a6-4666-9081-7a1f0ab7dfd2", "attempt": 1, "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" } } }, { - "eventId": "45", - "eventTime": "2026-09-06T18:11:22.730443Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1050487", - "workflowExecutionSignaledEventAttributes": { - "signalName": "__temporal_external_stream_wake", - "input": { - "payloads": [ - { - "metadata": { - "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAESBmlucHV0cyokMDFhMDc3ZWEtZjVhZC03ODFlLWIxN2UtYjIxNjE4ZmE3YTdmMgdtb2RlbCMy" - } - ] - }, - "identity": "26823@Mac.lan", - "requestId": "46cf9d72-14a4-5d2c-a6df-6ef2fa8f0975" - } - }, - { - "eventId": "46", - "eventTime": "2026-09-06T18:11:22.733340Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1050488", - "workflowExecutionSignaledEventAttributes": { - "signalName": "__temporal_external_stream_wake", - "input": { - "payloads": [ - { - "metadata": { - "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" - } - ] - }, - "identity": "26823@Mac.lan", - "requestId": "4eeaeff4-edb1-5ad1-aa05-14d5fe21efd9" - } - }, - { - "eventId": "47", - "eventTime": "2026-09-06T18:11:22.735277Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1050489", - "workflowExecutionSignaledEventAttributes": { - "signalName": "__temporal_external_stream_wake", - "input": { - "payloads": [ - { - "metadata": { - "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" - } - ] - }, - "identity": "26823@Mac.lan", - "requestId": "c5856df4-2d45-5a5f-a25a-ac5656b800bb" - } - }, - { - "eventId": "48", - "eventTime": "2026-09-06T18:11:22.737445Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1050490", - "workflowExecutionSignaledEventAttributes": { - "signalName": "__temporal_external_stream_wake", - "input": { - "payloads": [ - { - "metadata": { - "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" - } - ] - }, - "identity": "26823@Mac.lan", - "requestId": "c817c883-d225-5657-9320-5685eee70ab4" - } - }, - { - "eventId": "49", - "eventTime": "2026-09-06T18:11:22.729071Z", + "eventId": "44", + "eventTime": "2026-09-06T18:23:04.875977Z", "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", - "taskId": "1050491", + "taskId": "1052876", "activityTaskCompletedEventAttributes": { "result": { "payloads": [ @@ -981,127 +877,62 @@ } ] }, - "scheduledEventId": "38", - "startedEventId": "44", - "identity": "26823@Mac.lan" + "scheduledEventId": "37", + "startedEventId": "43", + "identity": "34059@Mac.lan" } }, { - "eventId": "50", - "eventTime": "2026-09-06T18:11:22.737758Z", + "eventId": "45", + "eventTime": "2026-09-06T18:23:04.876333Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1050492", + "taskId": "1052877", "workflowTaskScheduledEventAttributes": { "taskQueue": { - "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", - "kind": "TASK_QUEUE_KIND_NORMAL" + "name": "34059@Mac.lan-37fc3e6910fe473798ebf07f75c25409", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9" }, "startToCloseTimeout": "10s", "attempt": 1 } }, { - "eventId": "51", - "eventTime": "2026-09-06T18:11:22.738595Z", + "eventId": "46", + "eventTime": "2026-09-06T18:23:04.876353Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1050495", + "taskId": "1052878", "workflowTaskStartedEventAttributes": { - "scheduledEventId": "50", - "identity": "26823@Mac.lan", - "requestId": "8645299a-6b83-48a4-b193-fa6477e6a412", - "historySizeBytes": "9075", + "scheduledEventId": "45", + "identity": "34059@Mac.lan", + "requestId": "request-from-RespondWorkflowTaskCompleted", + "historySizeBytes": "7581", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" } } }, { - "eventId": "52", - "eventTime": "2026-09-06T18:11:22.748540Z", + "eventId": "47", + "eventTime": "2026-09-06T18:23:04.886126Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_FAILED", - "taskId": "1050503", + "taskId": "1052882", "workflowTaskFailedEventAttributes": { - "scheduledEventId": "50", - "startedEventId": "51", + "scheduledEventId": "45", + "startedEventId": "46", "cause": "WORKFLOW_TASK_FAILED_CAUSE_UNHANDLED_COMMAND", "failure": { "message": "UnhandledCommand", "serverFailureInfo": {} }, - "identity": "26823@Mac.lan" + "identity": "34059@Mac.lan" } }, { - "eventId": "53", - "eventTime": "2026-09-06T18:11:22.741945Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1050504", - "workflowExecutionSignaledEventAttributes": { - "signalName": "__temporal_external_stream_wake", - "input": { - "payloads": [ - { - "metadata": { - "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAESBmlucHV0cyokMDFhMDc3ZWEtZjVhZC03ODFlLWIxN2UtYjIxNjE4ZmE3YTdmMgdtb2RlbCMy" - } - ] - }, - "identity": "26823@Mac.lan", - "requestId": "8645d950-0d4c-5660-b6c7-24ffd557507f" - } - }, - { - "eventId": "54", - "eventTime": "2026-09-06T18:11:22.743630Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1050505", - "workflowExecutionSignaledEventAttributes": { - "signalName": "__temporal_external_stream_wake", - "input": { - "payloads": [ - { - "metadata": { - "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" - } - ] - }, - "identity": "26823@Mac.lan", - "requestId": "dbf5475f-1e51-56f5-a094-57adb5e5d7c3" - } - }, - { - "eventId": "55", - "eventTime": "2026-09-06T18:11:22.745447Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1050506", - "workflowExecutionSignaledEventAttributes": { - "signalName": "__temporal_external_stream_wake", - "input": { - "payloads": [ - { - "metadata": { - "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" - } - ] - }, - "identity": "26823@Mac.lan", - "requestId": "44d26222-20e8-5363-b60f-7d315980020d" - } - }, - { - "eventId": "56", - "eventTime": "2026-09-06T18:11:22.747175Z", + "eventId": "48", + "eventTime": "2026-09-06T18:23:04.883008Z", "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1050507", + "taskId": "1052883", "workflowExecutionSignaledEventAttributes": { "signalName": "__temporal_external_stream_wake", "input": { @@ -1111,22 +942,22 @@ "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", "encoding": "YmluYXJ5L3Byb3RvYnVm" }, - "data": "CAESBmlucHV0cxgBKiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2Y=" + "data": "CAESBmlucHV0cyokMDFhMDc3ZjUtYWM3My03ZjEzLTlmYTYtZWJjNWMwODIxYTY5Mgdtb2RlbCMy" } ] }, - "identity": "26823@Mac.lan", - "requestId": "8206e1fa-6e16-5e8c-9467-67fa2a3aa353" + "identity": "34059@Mac.lan", + "requestId": "da1874cc-ecf7-5813-8e2d-807bf5d43193" } }, { - "eventId": "57", - "eventTime": "2026-09-06T18:11:22.748568Z", + "eventId": "49", + "eventTime": "2026-09-06T18:23:04.886152Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1050508", + "taskId": "1052884", "workflowTaskScheduledEventAttributes": { "taskQueue": { - "name": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "name": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", "kind": "TASK_QUEUE_KIND_NORMAL" }, "startToCloseTimeout": "10s", @@ -1134,41 +965,41 @@ } }, { - "eventId": "58", - "eventTime": "2026-09-06T18:11:22.749316Z", + "eventId": "50", + "eventTime": "2026-09-06T18:23:04.886996Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1050511", + "taskId": "1052887", "workflowTaskStartedEventAttributes": { - "scheduledEventId": "57", - "identity": "26823@Mac.lan", - "requestId": "8beba935-535b-42df-970d-a1ad3d0a885f", - "historySizeBytes": "10377", + "scheduledEventId": "49", + "identity": "34059@Mac.lan", + "requestId": "40f8ac65-5593-41c0-8cd7-a618c78e8bea", + "historySizeBytes": "8865", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" } } }, { - "eventId": "59", - "eventTime": "2026-09-06T18:11:27.755216Z", + "eventId": "51", + "eventTime": "2026-09-06T18:23:05.303033Z", "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1050515", + "taskId": "1052891", "workflowTaskCompletedEventAttributes": { - "scheduledEventId": "57", - "startedEventId": "58", - "identity": "26823@Mac.lan", + "scheduledEventId": "49", + "startedEventId": "50", + "identity": "34059@Mac.lan", "workerVersion": { - "buildId": "5876163392d8546a76682b853febd140" + "buildId": "bea38d1926cc20b3f7972bbc95740895" }, "sdkMetadata": {}, "meteringMetadata": {} } }, { - "eventId": "60", - "eventTime": "2026-09-06T18:11:27.755239Z", + "eventId": "52", + "eventTime": "2026-09-06T18:23:05.303054Z", "eventType": "EVENT_TYPE_MARKER_RECORDED", - "taskId": "1050516", + "taskId": "1052892", "markerRecordedEventAttributes": { "markerName": "core_external_stream", "details": { @@ -1178,19 +1009,19 @@ "metadata": { "encoding": "YmluYXJ5L3Byb3RvYnVm" }, - "data": "CAEQBRoECAEQBSLaAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC1hYzMyMGU1N2ZhZmM0ZWFmOTQ5YzFhYTBiNGY4NDZiZSQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YGaW5wdXRzAQ8xNzg4NzE4MjgyNzE4LTANcmVkaXMtc3RyZWFtcwECAAECAAECAAECAAECAAECAQEPMTc4ODcxODI4MjczMS0wDzE3ODg3MTgyODI3MzEtMAEAAQMBAQEPMTc4ODcxODI4MjczMS0wKAcysAEIARABGiB1S0JESWJiX3pEYnlqUFNZZXloXzlJSDAxeVZhbnR2XyA4KiQwMWEwNzdlYS1mNWFkLTc4MWUtYjE3ZS1iMjE2MThmYTdhN2YyMQoJZGVjaXNpb25zEAEYbSIg3ClteUJUVTZweyuvvwlOF9DoWSLrxmpqEsPbZESWN6I6AwoBADoDCgEAOgMKAQA6AwoBADoDCgEAOgMKAQFCDXJlZGlzLXN0cmVhbXNIAQ==" + "data": "CAEQARoECAEQBSLOAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAQ8xNzg4NzE4OTg0ODcwLTANcmVkaXMtc3RyZWFtcwECAAECAQEPMTc4ODcxODk4NDg4My0wDzE3ODg3MTg5ODQ4ODMtMAEAAQMBAQEPMTc4ODcxODk4NDg4My0wKAcynAEIARABGiBxeUxQdjZoN0NUZUpfd3IxZzUwOE82NEtPeTRXX0FWOCAwKiQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkyMQoJZGVjaXNpb25zEAEYbSIg3ClteUJUVTZweyuvvwlOF9DoWSLrxmpqEsPbZESWN6I6AwoBADoDCgEBQg1yZWRpcy1zdHJlYW1zSAE=" } ] } }, - "workflowTaskCompletedEventId": "59" + "workflowTaskCompletedEventId": "51" } }, { - "eventId": "61", - "eventTime": "2026-09-06T18:11:27.755243Z", + "eventId": "53", + "eventTime": "2026-09-06T18:23:05.303058Z", "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED", - "taskId": "1050517", + "taskId": "1052893", "workflowExecutionCompletedEventAttributes": { "result": { "payloads": [ @@ -1202,7 +1033,7 @@ } ] }, - "workflowTaskCompletedEventId": "59" + "workflowTaskCompletedEventId": "51" } } ] diff --git a/streams_demo/results-external/results.json b/streams_demo/results-external/results.json index e902198c8..149ce1ef9 100644 --- a/streams_demo/results-external/results.json +++ b/streams_demo/results-external/results.json @@ -1,8 +1,8 @@ { "provider": "external", - "workflow_id": "ai198-contract-external-ac320e57fafc4eaf949c1aa0b4f846be", + "workflow_id": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", "target": "127.0.0.1:53266", - "max_cached_workflows": 0, + "max_cached_workflows": 2, "outcome": "completed", "trace": [ { @@ -54,7 +54,7 @@ ], "task_failures": [ { - "event_id": 52, + "event_id": 47, "cause": 1, "message": "UnhandledCommand" } @@ -103,5 +103,5 @@ "value": null } ], - "history_events": 61 + "history_events": 53 } diff --git a/temporalio/streams/_binding.py b/temporalio/streams/_binding.py index 879ee632d..45903b8c6 100644 --- a/temporalio/streams/_binding.py +++ b/temporalio/streams/_binding.py @@ -11,13 +11,15 @@ from __future__ import annotations +import asyncio +import time from collections.abc import AsyncIterator from datetime import timedelta from typing import Any from temporalio import activity, workflow from temporalio.api.common.v1 import Payload -from temporalio.client import Client +from temporalio.client import Client, WorkflowExecutionStatus from temporalio.contrib.external_workflow_streams import ( BEGINNING as PROVIDER_BEGINNING, AFTER, @@ -217,13 +219,25 @@ async def finish(self) -> None: raise async def _consumer_has_gone(self) -> bool: - """Whether the consuming execution has closed. Asked, not assumed.""" + """Whether the consuming execution is closing or closed. + + Asked rather than assumed, and asked for a few seconds rather than + once: the server refuses the wake while the execution is closing, and + at that moment its status is still the running one. A single look would + read "running" and turn the ordinary ending into an error. + """ if self._client is None or not self._workflow_id: return False - description = await self._client.get_workflow_handle( - self._workflow_id - ).describe() - return description.status is not None and description.status.value != 1 + handle = self._client.get_workflow_handle(self._workflow_id) + deadline = time.monotonic() + 5 + while True: + description = await handle.describe() + status = description.status + if status is not None and status != WorkflowExecutionStatus.RUNNING: + return True + if time.monotonic() >= deadline: + return False + await asyncio.sleep(0.1) def _encode(self, value: Any) -> bytes: payload = ( From f06271eb6682884d3e2b5f9aae2a9a0ea0b811a0 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Tue, 15 Sep 2026 15:35:51 -0700 Subject: [PATCH 05/96] Added the temporalio.streams interface with a provider registry. --- temporalio/streams/__init__.py | 20 ++- temporalio/streams/_provider.py | 216 +++++++++++++++++++++++ temporalio/streams/providers/__init__.py | 19 ++ 3 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 temporalio/streams/_provider.py create mode 100644 temporalio/streams/providers/__init__.py diff --git a/temporalio/streams/__init__.py b/temporalio/streams/__init__.py index 14fcd4e41..a25c2bdca 100644 --- a/temporalio/streams/__init__.py +++ b/temporalio/streams/__init__.py @@ -21,7 +21,7 @@ 5. **A workflow names its streams relative to itself, and the provider resolves them.** ``reader("inputs")`` is this workflow's inbound stream called ``inputs``; ``writer("decisions")`` is a topic it publishes on. One - provider stores that as a namespace-level stream id and the other as a name + provider stores that as a namespace-level stream id and another as a name under the run's chain key, and workflow code does not have to know which. The provider itself is chosen when the worker is built. @@ -40,9 +40,9 @@ be withdrawn, or that a stream outlives the retention its provider is configured for. -Prototype support for AI-198. The public names are the proposal; the module -behind them (``_binding``) is the only file that differs between the -server-side and the client-side implementation. +Prototype support for AI-198. The public names are the proposal; providers +live under :mod:`temporalio.streams.providers`, one module each, registered +by name and chosen by :func:`configure`. Everything else is shared. """ from __future__ import annotations @@ -50,9 +50,11 @@ from datetime import timedelta from typing import Any, TypeVar -from temporalio.streams._binding import ( +from temporalio.streams._handles import ReadSource, StreamReader, StreamWriter, WriteSink +from temporalio.streams._provider import ( Consumer, Producer, + StreamProvider, configure, consumer, open_read, @@ -60,7 +62,6 @@ producer, worker_options, ) -from temporalio.streams._handles import ReadSource, StreamReader, StreamWriter, WriteSink from temporalio.streams._record import ( BEGINNING, Cursor, @@ -76,6 +77,7 @@ "Producer", "ReadSource", "RecordKind", + "StreamProvider", "StreamReader", "StreamRecord", "StreamWriter", @@ -133,3 +135,9 @@ def writer(topic: str, *, type: type | None = None) -> StreamWriter[Any]: """ del type return StreamWriter(open_write(topic), topic) + + +# Importing the package registers every provider whose dependencies are +# present in this tree. Import order matters: the registry above must exist +# before a provider module can register with it. +from temporalio.streams import providers as _providers # noqa: E402,F401 diff --git a/temporalio/streams/_provider.py b/temporalio/streams/_provider.py new file mode 100644 index 000000000..58282cc55 --- /dev/null +++ b/temporalio/streams/_provider.py @@ -0,0 +1,216 @@ +"""The provider registry: where a process names its stream provider. + +A provider only moves bytes. The handles, records, framing and supersession +around it are shared, so every provider module is small: it implements +:class:`StreamProvider` and registers a factory under a short name when it is +imported. One :func:`configure` call decides which factory serves this +process; when exactly one provider is registered, it is the default and the +call can be omitted. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from datetime import timedelta +from typing import Any, Callable, Protocol + +from temporalio.streams._handles import ReadSource, WriteSink +from temporalio.streams._record import BEGINNING, Cursor, StreamRecord + +__all__ = [ + "Consumer", + "Producer", + "StreamProvider", + "configure", + "consumer", + "open_read", + "open_write", + "producer", + "register", + "registered", + "worker_options", +] + + +class Producer(Protocol): + """Appends to a stream from outside workflow code. + + Every append is visible as soon as the provider accepts it, and carries + the producer id, attempt and sequence that let a reader tell a retried + append from a new generation. + """ + + @property + def attempt(self) -> int: + """The generation this producer is writing.""" + ... + + async def append(self, *values: Any) -> Cursor: + """Append values and return where the first one landed.""" + ... + + async def finish(self) -> None: + """Declare this producer's output complete.""" + ... + + +class Consumer(Protocol): + """Reads a stream from outside workflow code, resumably.""" + + def read( + self, + *, + start: Cursor = BEGINNING, + topic: str | None = None, + type: type | None = None, + ) -> AsyncIterator[StreamRecord[Any]]: + """Yield records from ``start`` as they arrive.""" + ... + + +class StreamProvider(Protocol): + """What a provider module implements. + + The workflow-side pair (:meth:`open_read`, :meth:`open_write`) runs inside + workflow code and must keep the contract's first two rules: publishes + commit with the workflow task, and reads are recorded observations. The + outside pair (:meth:`producer`, :meth:`consumer`) runs anywhere and moves + framed bytes. A transport-only provider may serve just the outside pair + and raise on the workflow side, naming the provider a worker should use. + """ + + name: str + + def configure(self, **options: Any) -> None: + """Accept this provider's process-level options.""" + ... + + def worker_options(self) -> dict[str, Any]: + """What a ``Worker`` or ``Replayer`` needs to serve this provider.""" + ... + + def open_read( + self, + stream: str, + *, + start: Cursor = BEGINNING, + idle_timeout: timedelta | None = None, + ) -> ReadSource: + """Subscribe the running workflow to its inbound stream ``stream``.""" + ... + + def open_write(self, topic: str) -> WriteSink: + """Bind ``topic`` on the stream the running workflow owns.""" + ... + + async def producer( + self, + client: Any, + *, + workflow_id: str, + stream: str, + producer_id: str = "", + attempt: int = 0, + ) -> Producer: + """Open a producer for ``workflow_id``'s inbound stream ``stream``.""" + ... + + async def consumer( + self, client: Any, *, workflow_id: str, stream: str = "" + ) -> Consumer: + """Open a reader for what ``workflow_id`` publishes.""" + ... + + +_factories: dict[str, Callable[[], StreamProvider]] = {} +_active: StreamProvider | None = None + + +def register(name: str, factory: Callable[[], StreamProvider]) -> None: + """Make ``factory`` selectable as ``configure(provider=name)``.""" + _factories[name] = factory + + +def registered() -> list[str]: + """The provider names this process can configure.""" + return sorted(_factories) + + +def _make(name: str | None) -> StreamProvider: + if name is None: + if len(_factories) == 1: + name = next(iter(_factories)) + else: + raise RuntimeError( + "name a provider: configure(provider=...) with one of " + f"{registered()}" + ) + factory = _factories.get(name) + if factory is None: + raise RuntimeError( + f"no stream provider {name!r} is registered; available: {registered()}" + ) + return factory() + + +def configure(provider: str | None = None, **options: Any) -> None: + """Name the provider for this process and hand it its options. + + ``provider`` may be omitted when exactly one provider is registered. + """ + global _active + chosen = _make(provider) + chosen.configure(**options) + _active = chosen + + +def _current() -> StreamProvider: + global _active + if _active is None: + chosen = _make(None) + chosen.configure() + _active = chosen + return _active + + +def worker_options() -> dict[str, Any]: + """What a ``Worker`` or ``Replayer`` needs to serve this provider.""" + return _current().worker_options() + + +def open_read( + stream: str, + *, + start: Cursor = BEGINNING, + idle_timeout: timedelta | None = None, +) -> ReadSource: + """Subscribe the running workflow to its inbound stream ``stream``.""" + return _current().open_read(stream, start=start, idle_timeout=idle_timeout) + + +def open_write(topic: str) -> WriteSink: + """Bind ``topic`` on the stream the running workflow owns.""" + return _current().open_write(topic) + + +async def producer( + client: Any, + *, + workflow_id: str, + stream: str, + producer_id: str = "", + attempt: int = 0, +) -> Producer: + """Open a producer for the inbound stream ``stream`` of ``workflow_id``.""" + return await _current().producer( + client, + workflow_id=workflow_id, + stream=stream, + producer_id=producer_id, + attempt=attempt, + ) + + +async def consumer(client: Any, *, workflow_id: str, stream: str = "") -> Consumer: + """Open a reader for what ``workflow_id`` publishes.""" + return await _current().consumer(client, workflow_id=workflow_id, stream=stream) diff --git a/temporalio/streams/providers/__init__.py b/temporalio/streams/providers/__init__.py new file mode 100644 index 000000000..dff94b250 --- /dev/null +++ b/temporalio/streams/providers/__init__.py @@ -0,0 +1,19 @@ +"""Stream providers, one module each. + +Importing this package registers every provider whose dependencies are +present. A tree that lacks a provider's dependencies simply does not offer +that name; nothing else changes, because everything above the provider is +shared. +""" + +from __future__ import annotations + +_KNOWN = ("memory", "workflow_streams", "native", "redis", "nexus") + +for _name in _KNOWN: + try: + __import__(f"{__name__}.{_name}") + except ImportError: + # This tree does not carry that provider. The registry error message + # lists what is actually available. + pass From 3dd101f06028fdb9080a28a3d10d9f386423f1c7 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Tue, 15 Sep 2026 15:35:51 -0700 Subject: [PATCH 06/96] Added the in-memory reference provider and the conformance tests. --- temporalio/streams/providers/memory.py | 315 ++++++++++++++++++++++ tests/streams/__init__.py | 0 tests/streams/test_streams_conformance.py | 164 +++++++++++ 3 files changed, 479 insertions(+) create mode 100644 temporalio/streams/providers/memory.py create mode 100644 tests/streams/__init__.py create mode 100644 tests/streams/test_streams_conformance.py diff --git a/temporalio/streams/providers/memory.py b/temporalio/streams/providers/memory.py new file mode 100644 index 000000000..808a84f13 --- /dev/null +++ b/temporalio/streams/providers/memory.py @@ -0,0 +1,315 @@ +"""The in-process reference provider. + +Exists so the conformance suite can exercise the whole surface without a +store, and to document in one file what a provider owes. Two honest limits, +both stated so nobody mistakes this for evidence: + +- It is not replay-safe. Workflow-side state lives in plain process memory, + so run it with a warm workflow cache and do not use it to demonstrate + recovery. +- A workflow's publish becomes visible at ``publish`` time rather than at + task acceptance, so it only approximates rule 1 of the contract. + +The outside surface (producer, consumer, dedupe, supersession, cursors) is +faithful, which is what the conformance tests lean on. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from datetime import timedelta +from typing import Any + +import temporalio.converter +from temporalio import workflow +from temporalio.api.common.v1 import Payload +from temporalio.streams import _frame, _provider +from temporalio.streams._handles import ReadSource, WriteSink +from temporalio.streams._policy import AttemptTracker +from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord + +_DEFAULT_POLL = timedelta(milliseconds=100) + + +class _MemoryStream: + def __init__(self) -> None: + self.frames: list[bytes] = [] + # Dedupe identity is (producer#attempt, first sequence of the append), + # the same pair the storage providers use. + self.seen: dict[tuple[str, int], int] = {} + self._arrival = asyncio.Event() + + def append(self, frames: list[bytes], *, producer_id: str, sequence: int) -> int: + key = (producer_id, sequence) + already = self.seen.get(key) + if already is not None: + return already + offset = len(self.frames) + self.frames.extend(frames) + self.seen[key] = offset + arrival, self._arrival = self._arrival, asyncio.Event() + arrival.set() + return offset + + async def wait_past(self, offset: int) -> None: + while len(self.frames) <= offset: + await self._arrival.wait() + + +_streams: dict[str, _MemoryStream] = {} + + +def _stream(stream_id: str) -> _MemoryStream: + found = _streams.get(stream_id) + if found is None: + found = _streams[stream_id] = _MemoryStream() + return found + + +def reset() -> None: + """Drop every stream. For tests.""" + _streams.clear() + + +def _inbound_id(workflow_id: str, stream: str) -> str: + return f"{workflow_id}:{stream}" if stream else workflow_id + + +def _converter(client: Any) -> Any: + if client is None: + return temporalio.converter.DataConverter.default.payload_converter + return client.data_converter.payload_converter + + +class _MemReadSource: + """Workflow-side read that wakes by polling a timer. + + A real provider wakes the workflow by delivering; polling is the price of + having no delivery path, and it is why this provider is for tests. + """ + + def __init__(self, store: _MemoryStream, start: int, poll: timedelta) -> None: + self._store = store + self._cursor = start + self._poll = poll + self._closed = False + + async def next_batch(self) -> list[tuple[Cursor, bytes]]: + while not self._closed: + frames = self._store.frames + if len(frames) > self._cursor: + batch = [ + (Cursor(str(offset)), frames[offset]) + for offset in range(self._cursor, len(frames)) + ] + self._cursor = len(frames) + return batch + await workflow.sleep(self._poll) + raise StopAsyncIteration + + def close(self) -> None: + self._closed = True + + +class _MemWriteSink: + def __init__(self, store: _MemoryStream, topic: str) -> None: + self._store = store + self._topic = topic + self._sequence = 0 + + async def publish(self, frame: bytes) -> None: + self._store.append( + [frame], + producer_id=f"__workflow__:{self._topic}", + sequence=self._sequence, + ) + self._sequence += 1 + + +class MemoryProducer: + """The outside producer, faithful to the contract.""" + + def __init__( + self, store: _MemoryStream, converter: Any, topic: str, producer_id: str, attempt: int + ) -> None: + self._store = store + self._converter = converter + self._topic = topic + self._producer_id = producer_id + self._attempt = attempt + self._sequence = 0 + + @property + def attempt(self) -> int: + return self._attempt + + @property + def _provider_id(self) -> str: + return ( + f"{self._producer_id}#{self._attempt}" if self._attempt else self._producer_id + ) + + async def append(self, *values: Any) -> Cursor: + frames = [] + for value in values: + frames.append( + _frame.encode( + topic=self._topic, + kind=RecordKind.DATA, + producer=self._producer_id, + attempt=self._attempt, + sequence=self._sequence, + body=self._encode(value), + ) + ) + self._sequence += 1 + offset = self._store.append( + frames, + producer_id=self._provider_id, + sequence=self._sequence - len(frames), + ) + return Cursor(str(offset)) + + async def finish(self) -> None: + frame = _frame.encode( + topic=self._topic, + kind=RecordKind.FINISH, + producer=self._producer_id, + attempt=self._attempt, + sequence=self._sequence, + body=b"", + ) + self._sequence += 1 + self._store.append( + [frame], producer_id=self._provider_id, sequence=self._sequence - 1 + ) + + def _encode(self, value: Any) -> bytes: + payload = ( + value + if isinstance(value, Payload) + else self._converter.to_payloads([value])[0] + ) + return payload.SerializeToString() + + +class MemoryConsumer: + """The outside reader, with the shared supersession rule.""" + + def __init__(self, store: _MemoryStream, converter: Any) -> None: + self._store = store + self._converter = converter + + async def read( + self, + *, + start: Cursor = BEGINNING, + topic: str | None = None, + type: type | None = None, + ) -> AsyncIterator[StreamRecord[Any]]: + attempts = AttemptTracker() + offset = int(start.token) if start.token else 0 + while True: + await self._store.wait_past(offset) + frames = self._store.frames + while offset < len(frames): + cursor = Cursor(str(offset)) + frame = frames[offset] + offset += 1 + try: + kind, frame_topic, source, attempt, sequence, body = _frame.decode( + frame + ) + except ValueError: + continue + if topic is not None and frame_topic != topic: + continue + superseded = attempts.note(source, attempt, cursor) + if superseded is not None: + yield superseded + yield StreamRecord( + value=self._decode(body, type) if kind is RecordKind.DATA else None, + cursor=cursor, + kind=kind, + topic=frame_topic, + producer=source, + attempt=attempt, + sequence=sequence, + ) + + def _decode(self, body: bytes, as_type: type | None) -> Any: + payload = Payload() + payload.ParseFromString(body) + if as_type is None: + return self._converter.from_payloads([payload])[0] + return self._converter.from_payloads([payload], [as_type])[0] + + +class _MemoryProvider: + name = "memory" + + def __init__(self) -> None: + self._poll = _DEFAULT_POLL + + def configure(self, **options: Any) -> None: + poll = options.pop("poll_interval", None) + if poll is not None: + self._poll = poll + if options: + raise TypeError( + f"the memory provider takes only poll_interval, got {sorted(options)}" + ) + + def worker_options(self) -> dict[str, Any]: + return {} + + def open_read( + self, + stream: str, + *, + start: Cursor = BEGINNING, + idle_timeout: timedelta | None = None, + ) -> ReadSource: + store = _stream(_inbound_id(workflow.info().workflow_id, stream)) + return _MemReadSource( + store, + int(start.token) if start.token else 0, + idle_timeout or self._poll, + ) + + def open_write(self, topic: str) -> WriteSink: + store = _stream(_inbound_id(workflow.info().workflow_id, "")) + return _MemWriteSink(store, topic) + + async def producer( + self, + client: Any, + *, + workflow_id: str, + stream: str, + producer_id: str = "", + attempt: int = 0, + ) -> MemoryProducer: + if not producer_id: + from temporalio import activity + + producer_id = activity.info().activity_id + attempt = attempt or activity.info().attempt + return MemoryProducer( + _stream(_inbound_id(workflow_id, stream)), + _converter(client), + stream, + producer_id, + attempt, + ) + + async def consumer( + self, client: Any, *, workflow_id: str, stream: str = "" + ) -> MemoryConsumer: + return MemoryConsumer( + _stream(_inbound_id(workflow_id, stream)), _converter(client) + ) + + +_provider.register("memory", _MemoryProvider) diff --git a/tests/streams/__init__.py b/tests/streams/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py new file mode 100644 index 000000000..d01132851 --- /dev/null +++ b/tests/streams/test_streams_conformance.py @@ -0,0 +1,164 @@ +"""Conformance tests for the stream contract. + +Written against the public surface plus the in-memory reference provider, so +they run with no server and no store. A storage provider reuses the same +expectations through its own fixtures; what this file pins down is the +contract: framing, producer identity, retry deduplication, supersession, +topic filtering, and cursor resumption. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from temporalio import streams +from temporalio.streams import _frame +from temporalio.streams._policy import AttemptTracker +from temporalio.streams._record import Cursor, RecordKind +from temporalio.streams.providers import memory + + +@pytest.fixture(autouse=True) +def _fresh_memory_provider(): + memory.reset() + streams.configure(provider="memory") + yield + memory.reset() + + +async def take(records: Any, count: int, timeout: float = 5.0) -> list: + out: list = [] + + async def _collect() -> None: + async for record in records: + out.append(record) + if len(out) >= count: + return + + await asyncio.wait_for(_collect(), timeout) + return out + + +def test_frame_roundtrip(): + frame = _frame.encode( + topic="decisions", + kind=RecordKind.DATA, + producer="model", + attempt=3, + sequence=7, + body=b"payload-bytes", + ) + kind, topic, producer, attempt, sequence, body = _frame.decode(frame) + assert (kind, topic, producer, attempt, sequence, body) == ( + RecordKind.DATA, + "decisions", + "model", + 3, + 7, + b"payload-bytes", + ) + + +def test_supersession_is_synthesized_from_observations(): + attempts = AttemptTracker() + assert attempts.note("model", 1, Cursor("0")) is None + superseded = attempts.note("model", 2, Cursor("1")) + assert superseded is not None + assert superseded.kind is RecordKind.SUPERSEDED + assert superseded.value.previous_attempt == 1 + # The same attempt again is not a new generation. + assert attempts.note("model", 2, Cursor("2")) is None + + +async def test_append_read_roundtrip(): + producer = await streams.producer( + None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 + ) + await producer.append({"id": "r1"}, {"id": "r2"}) + await producer.finish() + + consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") + records = await take(consumer.read(type=dict), 3) + assert [r.kind for r in records] == [ + RecordKind.DATA, + RecordKind.DATA, + RecordKind.FINISH, + ] + assert [r.value for r in records[:2]] == [{"id": "r1"}, {"id": "r2"}] + assert all(r.producer == "model" and r.attempt == 1 for r in records) + assert [r.sequence for r in records] == [0, 1, 2] + + +async def test_retried_append_is_deduplicated(): + first = await streams.producer( + None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 + ) + await first.append({"id": "r1"}) + # The retry of the same attempt starts its sequence over and appends the + # same record. The provider must not store it twice. + retry = await streams.producer( + None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 + ) + await retry.append({"id": "r1"}) + + consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") + records = await take(consumer.read(type=dict), 1) + assert records[0].value == {"id": "r1"} + with pytest.raises(asyncio.TimeoutError): + await take(consumer.read(type=dict), 2, timeout=0.2) + + +async def test_new_attempt_supersedes_the_old_one(): + first = await streams.producer( + None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 + ) + await first.append({"text": "The capital of"}) + second = await streams.producer( + None, workflow_id="wf", stream="inputs", producer_id="model", attempt=2 + ) + await second.append({"text": "Paris is the capital"}) + + consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") + records = await take(consumer.read(type=dict), 3) + assert records[0].kind is RecordKind.DATA and records[0].attempt == 1 + assert records[1].kind is RecordKind.SUPERSEDED + assert records[1].value.previous_attempt == 1 + assert records[2].kind is RecordKind.DATA and records[2].attempt == 2 + + +async def test_topic_filter(): + producer = await streams.producer( + None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 + ) + await producer.append({"n": 1}) + other = await streams.producer( + None, workflow_id="wf", stream="other", producer_id="model", attempt=1 + ) + await other.append({"n": 2}) + + consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") + records = await take(consumer.read(type=dict, topic="inputs"), 1) + assert records[0].value == {"n": 1} + + +async def test_cursor_resumes_where_it_points(): + producer = await streams.producer( + None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 + ) + await producer.append({"n": 1}, {"n": 2}, {"n": 3}) + + consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") + records = await take(consumer.read(type=dict), 3) + checkpoint = records[1].cursor + + resumed = await streams.consumer(None, workflow_id="wf", stream="inputs") + again = await take(resumed.read(type=dict, start=checkpoint), 2) + assert [r.value for r in again] == [{"n": 2}, {"n": 3}] + + +def test_unknown_provider_is_a_clear_error(): + with pytest.raises(RuntimeError, match="no stream provider 'nope'"): + streams.configure(provider="nope") From 62be804709a5f6e3d5b60c11c9dc4021e759c44f Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Tue, 15 Sep 2026 15:35:51 -0700 Subject: [PATCH 07/96] Added the shared demo loop. --- streams_demo/provider_setup.py | 51 ++++++++-------------------------- 1 file changed, 11 insertions(+), 40 deletions(-) diff --git a/streams_demo/provider_setup.py b/streams_demo/provider_setup.py index 1b9d5c03f..81e7108e5 100644 --- a/streams_demo/provider_setup.py +++ b/streams_demo/provider_setup.py @@ -1,50 +1,21 @@ -"""Choose the client-side provider. +"""Pick the provider for a demo run from the environment. -The store is a Redis the customer runs, so this process constructs the backend -and is responsible for closing it. +``STREAMS_PROVIDER`` names any registered provider; this base tree carries +only ``memory``, and each provider branch adds its own name. The demo needs a +Temporal server to run the workflow either way; ``TEMPORAL_ADDRESS`` points +at it. """ from __future__ import annotations -import json import os -from pathlib import Path -from typing import Any -import redis.asyncio -from temporalio.contrib.external_workflow_streams._redis import RedisStreamBackend +NAME = os.environ.get("STREAMS_PROVIDER", "memory") -NAME = "external" +# The memory provider is not replay-safe, so its demo keeps the cache warm. +# Storage providers run with the smallest cache they support instead. +WORKFLOW_CACHE = int(os.environ.get("STREAMS_WORKFLOW_CACHE", "512")) -WORKFLOW_CACHE = int(os.environ.get("AI198_CACHE", "2")) -"""The smallest cache this provider runs on. -It holds the workflow task open between records, which a run that can be -evicted cannot do. Below two, a consuming workflow stops with no error and no -failed task. Recovery has to be tested by killing the worker rather than by -shrinking the cache. -""" - -_backend: RedisStreamBackend | None = None - - -async def open() -> tuple[str, dict[str, Any]]: - """The server to connect to, and the provider options.""" - global _backend - ready = Path(__file__).resolve().parents[2] / "verify/ready.json" - url = os.environ.get("AI198_REDIS_URL", "redis://127.0.0.1:6399") - # redis-py defaults to a socket timeout equal to this provider's own - # blocking read, so an idle read would abandon a healthy socket. - _backend = RedisStreamBackend( - client=redis.asyncio.from_url(url, decode_responses=False, socket_timeout=30), - key_prefix=os.environ.get("AI198_REDIS_PREFIX", "ai198-contract"), - ) - return json.loads(ready.read_text())["target"], {"backend": _backend} - - -async def close() -> None: - """Release the store this process opened.""" - global _backend - if _backend is not None: - await _backend.aclose() - _backend = None +async def open() -> tuple[str, dict]: + return os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), {"provider": NAME} From 7964e4927519980aa84f2b3d985a8af25b16ea56 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Tue, 15 Sep 2026 16:00:15 -0700 Subject: [PATCH 08/96] Moved the client-side binding into the provider registry. --- .../{_binding.py => providers/redis.py} | 284 +++++++++--------- 1 file changed, 143 insertions(+), 141 deletions(-) rename temporalio/streams/{_binding.py => providers/redis.py} (59%) diff --git a/temporalio/streams/_binding.py b/temporalio/streams/providers/redis.py similarity index 59% rename from temporalio/streams/_binding.py rename to temporalio/streams/providers/redis.py index 45903b8c6..7931e9fd4 100644 --- a/temporalio/streams/_binding.py +++ b/temporalio/streams/providers/redis.py @@ -1,17 +1,19 @@ -"""The client-side binding of the stream interface. +"""The client-side (Redis) provider. Streams live in a store the customer runs, Redis here. A workflow's publish is buffered by the worker, staged invisibly under a token, and promoted only once a marker in History proves the workflow task that produced it was accepted. Consumption is recorded the same way, as ranges and boundaries in History. -This is the only module of the package that differs between the two -implementations. +``configure`` builds the backend from ``url`` and ``key_prefix`` (defaulting +to ``AI198_REDIS_URL`` and ``AI198_REDIS_PREFIX``), or takes a constructed +``backend`` when the caller wants to own its lifecycle. """ from __future__ import annotations import asyncio +import os import time from collections.abc import AsyncIterator from datetime import timedelta @@ -30,57 +32,11 @@ external_output_stream, external_stream, ) -from temporalio.streams import _frame +from temporalio.streams import _frame, _provider from temporalio.streams._handles import ReadSource, WriteSink from temporalio.streams._policy import AttemptTracker from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord -__all__ = [ - "Consumer", - "Producer", - "configure", - "consumer", - "open_read", - "open_write", - "producer", - "worker_options", -] - -PROVIDER = "external" - -_backend: Any = None - - -def configure(**options: Any) -> None: - """Name the provider for this process. - - Args: - backend: A ``StreamBackend`` instance, such as ``RedisStreamBackend``. - There is no registry to name one from out here, so the caller - constructs it and hands it over. - """ - global _backend - unknown = sorted(set(options) - {"backend"}) - if unknown: - raise TypeError(f"unknown provider options {unknown}") - if "backend" not in options: - raise TypeError("the client-side provider needs a backend") - _backend = options["backend"] - - -def worker_options() -> dict[str, Any]: - """What a ``Worker`` or ``Replayer`` needs to serve this provider.""" - return {"external_stream_backend": _require_backend()} - - -def _require_backend() -> Any: - if _backend is None: - raise RuntimeError( - "no stream provider configured; call temporalio.streams.configure(" - "backend=...) before opening a producer, a consumer or a worker" - ) - return _backend - class _ExternalReadSource: def __init__(self, subscription: Any) -> None: @@ -108,34 +64,6 @@ async def publish(self, frame: bytes) -> None: await self._topic.publish(frame) -def open_read( - stream: str, - *, - start: Cursor = BEGINNING, - idle_timeout: timedelta | None = None, -) -> ReadSource: - """Subscribe the running workflow to its inbound stream ``stream``.""" - # The provider resolves the name under this run's chain key, so a start - # position is only meaningful to a reader that already has one, and this - # provider's first subscription always starts at the beginning of the - # stream it minted. - if start is not BEGINNING and start.token: - raise ValueError( - "the client-side provider starts a new subscription at the " - "beginning of the stream it owns; resuming elsewhere is not " - "supported yet" - ) - options = external_stream - if idle_timeout is not None: - options = options.with_options(idle_timeout=idle_timeout) - return _ExternalReadSource(options.topic(stream, type=bytes).subscribe()) - - -def open_write(topic: str) -> WriteSink: - """Bind ``topic`` on the stream the running workflow owns.""" - return _ExternalWriteSink(external_output_stream.topic(topic, type=bytes)) - - async def _chain_key(client: Client, workflow_id: str) -> WorkflowChainKey: description = await client.get_workflow_handle(workflow_id).describe() return WorkflowChainKey( @@ -145,7 +73,7 @@ async def _chain_key(client: Client, workflow_id: str) -> WorkflowChainKey: ) -class Producer: +class RedisProducer: """Appends to a stream from outside workflow code. Every append is visible as soon as it is written. That is the point for an @@ -164,7 +92,6 @@ def __init__( client: Client | None = None, workflow_id: str = "", ) -> None: - """Prefer :func:`producer`.""" self._topic = topic self._converter = converter self._stream = stream @@ -248,51 +175,10 @@ def _encode(self, value: Any) -> bytes: return payload.SerializeToString() -async def producer( - client: Client, - *, - workflow_id: str, - stream: str, - producer_id: str = "", - attempt: int = 0, -) -> Producer: - """Open a producer for the inbound stream ``stream`` of ``workflow_id``. - - Inside an activity, leave ``producer_id`` and ``attempt`` unset: the - activity's own id and attempt are the right answer and are what let a - reader tell a retry from a new generation. - """ - if not producer_id: - producer_id = activity.info().activity_id - if not attempt: - attempt = activity.info().attempt - session = await ExternalStreamProducer.connect( - backend=_require_backend(), - workflow=await _chain_key(client, workflow_id), - client=client, - # The attempt is part of it. Deduplication answers "is this the same - # append again", and a second attempt writing different words at the - # same sequence is not: this provider rejects that outright. Folding - # the attempt in keeps a retried append idempotent without letting a - # new generation be refused as a conflicting duplicate. - session_id=f"{producer_id}#{attempt}", - ) - return Producer( - session.topic(stream, type=bytes), - client.data_converter.payload_converter, - stream, - producer_id, - attempt, - client, - workflow_id, - ) - - -class Consumer: +class RedisConsumer: """Reads a stream from outside workflow code, resumably.""" def __init__(self, client: Any, converter: Any) -> None: - """Prefer :func:`consumer`.""" self._client = client self._converter = converter @@ -345,26 +231,142 @@ def _decode(self, body: bytes, as_type: type | None) -> Any: return self._converter.from_payloads([payload], [as_type])[0] -async def consumer( - client: Client, *, workflow_id: str, stream: str = "" -) -> Consumer: - """Open a reader for what ``workflow_id`` publishes. +class _RedisProvider: + name = "redis" - An empty ``stream`` reads what the workflow wrote through - :func:`temporalio.streams.writer`. This provider keeps inbound and outbound - records in separate stores, so naming an inbound stream is not supported - from here yet. - """ - if stream: - raise ValueError( - "this provider separates inbound and outbound storage, so an " - "outside reader cannot follow an inbound stream yet" + def __init__(self) -> None: + self._backend: Any = None + + def configure(self, **options: Any) -> None: + backend = options.pop("backend", None) + url = options.pop("url", None) + key_prefix = options.pop("key_prefix", None) + if options: + raise TypeError( + "the redis provider takes backend, url and key_prefix, got " + f"{sorted(options)}" + ) + if backend is not None: + self._backend = backend + return + import redis.asyncio + + from temporalio.contrib.external_workflow_streams._redis import ( + RedisStreamBackend, ) - return Consumer( - await ExternalOutputStreamClient.connect( - backend=_require_backend(), + + # redis-py defaults to a socket timeout equal to this provider's own + # blocking read, so an idle read would abandon a healthy socket. + self._backend = RedisStreamBackend( + client=redis.asyncio.from_url( + url or os.environ.get("AI198_REDIS_URL", "redis://127.0.0.1:6399"), + decode_responses=False, + socket_timeout=30, + ), + key_prefix=key_prefix + or os.environ.get("AI198_REDIS_PREFIX", "ai198-contract"), + ) + + def _require_backend(self) -> Any: + if self._backend is None: + raise RuntimeError( + "no store configured; call temporalio.streams.configure(" + 'provider="redis", ...) before opening a producer, a consumer ' + "or a worker" + ) + return self._backend + + def worker_options(self) -> dict[str, Any]: + return {"external_stream_backend": self._require_backend()} + + def open_read( + self, + stream: str, + *, + start: Cursor = BEGINNING, + idle_timeout: timedelta | None = None, + ) -> ReadSource: + # The provider resolves the name under this run's chain key, so a start + # position is only meaningful to a reader that already has one, and this + # provider's first subscription always starts at the beginning of the + # stream it minted. + if start is not BEGINNING and start.token: + raise ValueError( + "the client-side provider starts a new subscription at the " + "beginning of the stream it owns; resuming elsewhere is not " + "supported yet" + ) + options = external_stream + if idle_timeout is not None: + options = options.with_options(idle_timeout=idle_timeout) + return _ExternalReadSource(options.topic(stream, type=bytes).subscribe()) + + def open_write(self, topic: str) -> WriteSink: + return _ExternalWriteSink(external_output_stream.topic(topic, type=bytes)) + + async def producer( + self, + client: Client, + *, + workflow_id: str, + stream: str, + producer_id: str = "", + attempt: int = 0, + ) -> RedisProducer: + """Open a producer for the inbound stream ``stream`` of ``workflow_id``. + + Inside an activity, leave ``producer_id`` and ``attempt`` unset: the + activity's own id and attempt are the right answer and are what let a + reader tell a retry from a new generation. + """ + if not producer_id: + producer_id = activity.info().activity_id + if not attempt: + attempt = activity.info().attempt + session = await ExternalStreamProducer.connect( + backend=self._require_backend(), workflow=await _chain_key(client, workflow_id), client=client, - ), - client.data_converter.payload_converter, - ) + # The attempt is part of it. Deduplication answers "is this the + # same append again", and a second attempt writing different words + # at the same sequence is not: this provider rejects that outright. + # Folding the attempt in keeps a retried append idempotent without + # letting a new generation be refused as a conflicting duplicate. + session_id=f"{producer_id}#{attempt}", + ) + return RedisProducer( + session.topic(stream, type=bytes), + client.data_converter.payload_converter, + stream, + producer_id, + attempt, + client, + workflow_id, + ) + + async def consumer( + self, client: Client, *, workflow_id: str, stream: str = "" + ) -> RedisConsumer: + """Open a reader for what ``workflow_id`` publishes. + + An empty ``stream`` reads what the workflow wrote through + :func:`temporalio.streams.writer`. This provider keeps inbound and + outbound records in separate stores, so naming an inbound stream is + not supported from here yet. + """ + if stream: + raise ValueError( + "this provider separates inbound and outbound storage, so an " + "outside reader cannot follow an inbound stream yet" + ) + return RedisConsumer( + await ExternalOutputStreamClient.connect( + backend=self._require_backend(), + workflow=await _chain_key(client, workflow_id), + client=client, + ), + client.data_converter.payload_converter, + ) + + +_provider.register("redis", _RedisProvider) From af2483dc22ee1ff29ba9e221ed6296f3f927d492 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Tue, 15 Sep 2026 17:58:03 -0700 Subject: [PATCH 09/96] Added the prepare and drain hooks the Workflow Streams transport needs. A provider that answers outside readers through handlers on the workflow has to register them before the first task completes, and one that parks a long-poll update against the run has to let go before the workflow returns. Both are no-ops on the storage providers, so workflow code calls them unconditionally and stays portable. --- temporalio/streams/__init__.py | 4 +++ temporalio/streams/_provider.py | 57 ++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/temporalio/streams/__init__.py b/temporalio/streams/__init__.py index a25c2bdca..699919ffe 100644 --- a/temporalio/streams/__init__.py +++ b/temporalio/streams/__init__.py @@ -57,8 +57,10 @@ StreamProvider, configure, consumer, + drain, open_read, open_write, + prepare, producer, worker_options, ) @@ -85,6 +87,8 @@ "WriteSink", "configure", "consumer", + "drain", + "prepare", "producer", "worker_options", "reader", diff --git a/temporalio/streams/_provider.py b/temporalio/streams/_provider.py index 58282cc55..48439d754 100644 --- a/temporalio/streams/_provider.py +++ b/temporalio/streams/_provider.py @@ -23,8 +23,10 @@ "StreamProvider", "configure", "consumer", + "drain", "open_read", "open_write", + "prepare", "producer", "register", "registered", @@ -121,6 +123,24 @@ async def consumer( """Open a reader for what ``workflow_id`` publishes.""" ... + def prepare(self) -> None: + """Install whatever this provider needs before the workflow runs. + + Optional. A provider that serves outside readers through handlers on + the workflow itself has to register them before the first task + completes, or a reader that arrives early finds nothing to talk to. + """ + ... + + def drain(self) -> None: + """Release anything this provider parked on the workflow's behalf. + + Optional. A provider that parks an outside reader against the running + workflow, as the Workflow Streams transport does with its long-poll + update, has to let go before the workflow can return. + """ + ... + _factories: dict[str, Callable[[], StreamProvider]] = {} _active: StreamProvider | None = None @@ -159,9 +179,18 @@ def configure(provider: str | None = None, **options: Any) -> None: ``provider`` may be omitted when exactly one provider is registered. """ global _active + _active = instance(provider, **options) + + +def instance(provider: str | None = None, **options: Any) -> StreamProvider: + """A configured provider that is not installed as the process default. + + For code that serves one provider while the process is configured with + another, such as a Nexus stream handler delegating to its store. + """ chosen = _make(provider) chosen.configure(**options) - _active = chosen + return chosen def _current() -> StreamProvider: @@ -193,6 +222,32 @@ def open_write(topic: str) -> WriteSink: return _current().open_write(topic) +def prepare() -> None: + """Let the provider install what it needs, before the workflow runs. + + Call it from the workflow's constructor. It is a no-op on providers that + need nothing, so workflow code can call it unconditionally and stay + portable. + """ + provider = _current() + install = getattr(provider, "prepare", None) + if install is not None: + install() + + +def drain() -> None: + """Release anything the provider parked on this workflow's behalf. + + Call it before a workflow that read a stream returns. It is a no-op on + providers that park nothing, so workflow code can call it unconditionally + and stay portable. + """ + provider = _current() + release = getattr(provider, "drain", None) + if release is not None: + release() + + async def producer( client: Any, *, From 07413452d138887c49fc763f51972b747d037c9c Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Tue, 15 Sep 2026 18:01:04 -0700 Subject: [PATCH 10/96] Made cursors exclusive, added latest(), and allowed topic appends. Porting the agent harness onto the interface surfaced three things the small example never needed. A reader resumes after the record a cursor names, so it can store the last cursor it handled without advancing an opaque token. A consumer reports its latest position, so a client can follow a turn it is about to start without the workflow reporting a position. And a producer can append onto a topic of the stream the workflow publishes, so an activity's live output lands next to the workflow's own records for one outside reader. --- temporalio/streams/__init__.py | 25 ++++++----- temporalio/streams/_provider.py | 55 +++++++++++++++++++---- temporalio/streams/_record.py | 4 +- temporalio/streams/providers/memory.py | 20 ++++++--- tests/streams/test_streams_conformance.py | 40 ++++++++++++++++- 5 files changed, 115 insertions(+), 29 deletions(-) diff --git a/temporalio/streams/__init__.py b/temporalio/streams/__init__.py index 699919ffe..aaf0a444d 100644 --- a/temporalio/streams/__init__.py +++ b/temporalio/streams/__init__.py @@ -12,12 +12,16 @@ it found nothing, is committed with the commands that reading produced. Recovery re-supplies the same records in the same order. 3. **Anything that does I/O publishes on its own account.** An activity, a - local activity, or an outside process writes through :func:`producer`, and - its records are visible as soon as they are written. It carries a producer - id, an attempt and a sequence so a retry can be told from a new generation. -4. **A cursor is opaque and belongs to its provider.** Hand it back to resume. - Do not compare two cursors or do arithmetic on one: one provider numbers - records with integers and another with a millisecond-and-sequence pair. + local activity, or an outside process writes through :func:`producer`, + either into one of the workflow's inbound streams or onto a topic of the + stream the workflow publishes, and its records are visible as soon as they + are written. It carries a producer id, an attempt and a sequence so a retry + can be told from a new generation. +4. **A cursor is opaque and belongs to its provider.** Hand it back to resume + after the record it names. Do not compare two cursors or do arithmetic on + one: one provider numbers records with integers and another with a + millisecond-and-sequence pair. A reader that wants to follow from now asks + the consumer for :meth:`Consumer.latest` instead of guessing a position. 5. **A workflow names its streams relative to itself, and the provider resolves them.** ``reader("inputs")`` is this workflow's inbound stream called ``inputs``; ``writer("decisions")`` is a topic it publishes on. One @@ -103,7 +107,7 @@ def reader( *, type: type | None = None, topic: str | None = None, - start: Cursor = BEGINNING, + after: Cursor = BEGINNING, idle_timeout: timedelta | None = None, ) -> StreamReader[Any]: """Subscribe this workflow to its inbound stream ``stream``. @@ -116,14 +120,15 @@ def reader( stream: The inbound stream's name, relative to this workflow. type: The value type, used as the decode hint. topic: Only records on this topic, or every topic when omitted. - start: Where to start. Only honoured on the first subscription of a - run, because after that the recorded cursor decides. + after: Resume after this record. Only honoured on the first + subscription of a run, because after that the recorded cursor + decides. idle_timeout: How long the workflow waits with nothing arriving before the provider is allowed to release the worker. ``None`` takes the provider's default. """ return StreamReader( - open_read(stream, start=start, idle_timeout=idle_timeout), + open_read(stream, after=after, idle_timeout=idle_timeout), topic=topic, type=type, ) diff --git a/temporalio/streams/_provider.py b/temporalio/streams/_provider.py index 48439d754..c224b47bf 100644 --- a/temporalio/streams/_provider.py +++ b/temporalio/streams/_provider.py @@ -62,11 +62,28 @@ class Consumer(Protocol): def read( self, *, - start: Cursor = BEGINNING, + after: Cursor = BEGINNING, topic: str | None = None, type: type | None = None, ) -> AsyncIterator[StreamRecord[Any]]: - """Yield records from ``start`` as they arrive.""" + """Yield the records after ``after`` as they arrive. + + ``BEGINNING`` yields everything the stream retains. Any other cursor + came from a record this or another reader saw, and reading resumes + just past it, so a reader that stores the last cursor it handled and + hands it back sees every record exactly once. + """ + ... + + async def latest(self, *, topic: str | None = None) -> Cursor: + """The cursor of the newest record, or ``BEGINNING`` when there is none. + + For a reader that wants to follow from now: ``read(after=latest())`` + yields only what is published after this call returned, which is how + a client that is about to send a message positions itself before + sending, without the workflow having to report a position. ``topic`` + is for the provider that keeps each topic in its own store. + """ ... @@ -95,7 +112,7 @@ def open_read( self, stream: str, *, - start: Cursor = BEGINNING, + after: Cursor = BEGINNING, idle_timeout: timedelta | None = None, ) -> ReadSource: """Subscribe the running workflow to its inbound stream ``stream``.""" @@ -110,11 +127,18 @@ async def producer( client: Any, *, workflow_id: str, - stream: str, + stream: str = "", + topic: str = "", producer_id: str = "", attempt: int = 0, ) -> Producer: - """Open a producer for ``workflow_id``'s inbound stream ``stream``.""" + """Open a producer that appends on ``workflow_id``'s account. + + ``stream`` names one of the workflow's inbound streams. With no + ``stream``, ``topic`` names a topic on the stream the workflow itself + publishes, which is how an activity puts its live output next to the + workflow's own records for the same outside reader. + """ ... async def consumer( @@ -210,11 +234,11 @@ def worker_options() -> dict[str, Any]: def open_read( stream: str, *, - start: Cursor = BEGINNING, + after: Cursor = BEGINNING, idle_timeout: timedelta | None = None, ) -> ReadSource: """Subscribe the running workflow to its inbound stream ``stream``.""" - return _current().open_read(stream, start=start, idle_timeout=idle_timeout) + return _current().open_read(stream, after=after, idle_timeout=idle_timeout) def open_write(topic: str) -> WriteSink: @@ -252,15 +276,28 @@ async def producer( client: Any, *, workflow_id: str, - stream: str, + stream: str = "", + topic: str = "", producer_id: str = "", attempt: int = 0, ) -> Producer: - """Open a producer for the inbound stream ``stream`` of ``workflow_id``.""" + """Open a producer that appends on ``workflow_id``'s account. + + Name either an inbound ``stream`` of the workflow, or a ``topic`` on the + stream the workflow publishes. Inside an activity, leave ``producer_id`` + and ``attempt`` unset: the activity's own id and attempt are the right + answer, and they are what let a reader tell a retry from a new generation. + """ + if bool(stream) == bool(topic): + raise ValueError( + "name exactly one of stream (an inbound stream of the workflow) or " + "topic (a topic on the stream the workflow publishes)" + ) return await _current().producer( client, workflow_id=workflow_id, stream=stream, + topic=topic, producer_id=producer_id, attempt=attempt, ) diff --git a/temporalio/streams/_record.py b/temporalio/streams/_record.py index d458ef0c4..4fd322189 100644 --- a/temporalio/streams/_record.py +++ b/temporalio/streams/_record.py @@ -50,8 +50,8 @@ class Cursor: Opaque on purpose. One provider numbers records with integers and another with a millisecond-and-sequence pair, so comparing tokens here would be - right for one and wrong for the other. Hand a cursor back to resume from - it; ask the reader to compare two. + right for one and wrong for the other. Hand a cursor back to resume after + the record it names; nothing here advances one. """ token: str diff --git a/temporalio/streams/providers/memory.py b/temporalio/streams/providers/memory.py index 808a84f13..477a17b68 100644 --- a/temporalio/streams/providers/memory.py +++ b/temporalio/streams/providers/memory.py @@ -204,12 +204,12 @@ def __init__(self, store: _MemoryStream, converter: Any) -> None: async def read( self, *, - start: Cursor = BEGINNING, + after: Cursor = BEGINNING, topic: str | None = None, type: type | None = None, ) -> AsyncIterator[StreamRecord[Any]]: attempts = AttemptTracker() - offset = int(start.token) if start.token else 0 + offset = int(after.token) + 1 if after.token else 0 while True: await self._store.wait_past(offset) frames = self._store.frames @@ -238,6 +238,11 @@ async def read( sequence=sequence, ) + async def latest(self, *, topic: str | None = None) -> Cursor: + del topic # one store per stream, so the position is topic-independent + count = len(self._store.frames) + return Cursor(str(count - 1)) if count else BEGINNING + def _decode(self, body: bytes, as_type: type | None) -> Any: payload = Payload() payload.ParseFromString(body) @@ -268,13 +273,13 @@ def open_read( self, stream: str, *, - start: Cursor = BEGINNING, + after: Cursor = BEGINNING, idle_timeout: timedelta | None = None, ) -> ReadSource: store = _stream(_inbound_id(workflow.info().workflow_id, stream)) return _MemReadSource( store, - int(start.token) if start.token else 0, + int(after.token) + 1 if after.token else 0, idle_timeout or self._poll, ) @@ -287,7 +292,8 @@ async def producer( client: Any, *, workflow_id: str, - stream: str, + stream: str = "", + topic: str = "", producer_id: str = "", attempt: int = 0, ) -> MemoryProducer: @@ -296,10 +302,12 @@ async def producer( producer_id = activity.info().activity_id attempt = attempt or activity.info().attempt + # With no inbound stream named, the target is the store the workflow's + # own writer appends to, and the frame carries the topic instead. return MemoryProducer( _stream(_inbound_id(workflow_id, stream)), _converter(client), - stream, + stream or topic, producer_id, attempt, ) diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index d01132851..978ef5ca1 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -152,13 +152,49 @@ async def test_cursor_resumes_where_it_points(): consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") records = await take(consumer.read(type=dict), 3) - checkpoint = records[1].cursor + checkpoint = records[0].cursor + # Resuming after a record hands back everything past it and nothing + # twice, without the reader ever advancing a cursor itself. resumed = await streams.consumer(None, workflow_id="wf", stream="inputs") - again = await take(resumed.read(type=dict, start=checkpoint), 2) + again = await take(resumed.read(type=dict, after=checkpoint), 2) assert [r.value for r in again] == [{"n": 2}, {"n": 3}] +async def test_latest_positions_a_reader_at_the_end(): + producer = await streams.producer( + None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 + ) + consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") + assert await consumer.latest() == streams.BEGINNING + + await producer.append({"n": 1}, {"n": 2}) + since = await consumer.latest() + await producer.append({"n": 3}) + + # A reader that positioned itself before the last append sees only what + # came after, which is how a client follows a turn it is about to start. + records = await take(consumer.read(type=dict, after=since), 1) + assert [r.value for r in records] == [{"n": 3}] + + +async def test_producer_appends_onto_the_owners_topic(): + # An activity's live output lands on the same topic the workflow + # publishes, so one outside reader follows both. + producer = await streams.producer( + None, workflow_id="wf", topic="events", producer_id="model", attempt=1 + ) + await producer.append({"delta": "hel"}, {"delta": "lo"}) + + consumer = await streams.consumer(None, workflow_id="wf") + records = await take(consumer.read(type=dict, topic="events"), 2) + assert [r.value["delta"] for r in records] == ["hel", "lo"] + assert {r.producer for r in records} == {"model"} + + with pytest.raises(ValueError): + await streams.producer(None, workflow_id="wf", producer_id="model", attempt=1) + + def test_unknown_provider_is_a_clear_error(): with pytest.raises(RuntimeError, match="no stream provider 'nope'"): streams.configure(provider="nope") From fb74e7901cc4bd3a380d76b78acb2e0a5b5de7d0 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Tue, 15 Sep 2026 18:01:04 -0700 Subject: [PATCH 11/96] Made cursors exclusive, added latest(), and allowed topic appends. Porting the agent harness onto the interface surfaced three things the small example never needed. A reader resumes after the record a cursor names, so it can store the last cursor it handled without advancing an opaque token. A consumer reports its latest position, so a client can follow a turn it is about to start without the workflow reporting a position. And a producer can append onto a topic of the stream the workflow publishes, so an activity's live output lands next to the workflow's own records for one outside reader. --- temporalio/streams/providers/redis.py | 87 ++++++++++++++++++--------- 1 file changed, 57 insertions(+), 30 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 7931e9fd4..ec5742bf2 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -26,7 +26,9 @@ BEGINNING as PROVIDER_BEGINNING, AFTER, ExternalOutputStreamClient, + ExternalOutputStreamProducer, ExternalStreamProducer, + Offset, WakeNotAcknowledgedError, WorkflowChainKey, external_output_stream, @@ -185,24 +187,21 @@ def __init__(self, client: Any, converter: Any) -> None: async def read( self, *, - start: Cursor = BEGINNING, + after: Cursor = BEGINNING, topic: str | None = None, type: type | None = None, ) -> AsyncIterator[StreamRecord[Any]]: - """Yield records from ``start`` as they arrive. + """Yield the records after ``after`` as they arrive. Applies the same supersession rule as a workflow reader, so a browser and a workflow watching one activity agree on which attempt is current. """ - if topic is None: - raise ValueError( - "this provider stores each topic separately, so an outside " - "reader has to name one" - ) attempts = AttemptTracker() - after = PROVIDER_BEGINNING if not start.token else AFTER(start.token) - handle = self._client.topic(topic, type=bytes) - async for item in handle.subscribe(after=after): + boundary = ( + AFTER(Offset(after.token)) if after.token else PROVIDER_BEGINNING + ) + handle = self._client.topic(self._require_topic(topic), type=bytes) + async for item in handle.subscribe(after=boundary): cursor = Cursor(str(item.offset)) try: kind, frame_topic, source, attempt, sequence, body = _frame.decode( @@ -223,6 +222,21 @@ async def read( sequence=sequence, ) + async def latest(self, *, topic: str | None = None) -> Cursor: + tail = await self._client.topic(self._require_topic(topic), type=bytes).tail() + if tail.is_beginning: + return BEGINNING + return Cursor(tail.offset.token) + + @staticmethod + def _require_topic(topic: str | None) -> str: + if topic is None: + raise ValueError( + "this provider stores each topic separately, so an outside " + "reader has to name one" + ) + return topic + def _decode(self, body: bytes, as_type: type | None) -> Any: payload = Payload() payload.ParseFromString(body) @@ -283,14 +297,14 @@ def open_read( self, stream: str, *, - start: Cursor = BEGINNING, + after: Cursor = BEGINNING, idle_timeout: timedelta | None = None, ) -> ReadSource: # The provider resolves the name under this run's chain key, so a start # position is only meaningful to a reader that already has one, and this # provider's first subscription always starts at the beginning of the # stream it minted. - if start is not BEGINNING and start.token: + if after.token: raise ValueError( "the client-side provider starts a new subscription at the " "beginning of the stream it owns; resuming elsewhere is not " @@ -309,35 +323,48 @@ async def producer( client: Client, *, workflow_id: str, - stream: str, + stream: str = "", + topic: str = "", producer_id: str = "", attempt: int = 0, ) -> RedisProducer: - """Open a producer for the inbound stream ``stream`` of ``workflow_id``. + """Open a producer on ``workflow_id``'s account. - Inside an activity, leave ``producer_id`` and ``attempt`` unset: the - activity's own id and attempt are the right answer and are what let a - reader tell a retry from a new generation. + ``stream`` names an inbound stream; with none, ``topic`` names a topic + on the workflow's output stream, appended directly rather than through + the workflow's staged commit. Inside an activity, leave ``producer_id`` + and ``attempt`` unset: the activity's own id and attempt are the right + answer and are what let a reader tell a retry from a new generation. """ if not producer_id: producer_id = activity.info().activity_id if not attempt: attempt = activity.info().attempt - session = await ExternalStreamProducer.connect( - backend=self._require_backend(), - workflow=await _chain_key(client, workflow_id), - client=client, - # The attempt is part of it. Deduplication answers "is this the - # same append again", and a second attempt writing different words - # at the same sequence is not: this provider rejects that outright. - # Folding the attempt in keeps a retried append idempotent without - # letting a new generation be refused as a conflicting duplicate. - session_id=f"{producer_id}#{attempt}", - ) + # The attempt is part of it. Deduplication answers "is this the + # same append again", and a second attempt writing different words + # at the same sequence is not: this provider rejects that outright. + # Folding the attempt in keeps a retried append idempotent without + # letting a new generation be refused as a conflicting duplicate. + session_id = f"{producer_id}#{attempt}" + chain = await _chain_key(client, workflow_id) + if stream: + session: Any = await ExternalStreamProducer.connect( + backend=self._require_backend(), + workflow=chain, + client=client, + session_id=session_id, + ) + else: + session = await ExternalOutputStreamProducer.connect( + backend=self._require_backend(), + workflow=chain, + client=client, + session_id=session_id, + ) return RedisProducer( - session.topic(stream, type=bytes), + session.topic(stream or topic, type=bytes), client.data_converter.payload_converter, - stream, + stream or topic, producer_id, attempt, client, From 295fe3fc409dd584b05804550ea96132be01dc6d Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Tue, 15 Sep 2026 19:08:41 -0700 Subject: [PATCH 12/96] Applied an external stream replay marker before the task's first drain. A replay marker carries what its Workflow Task published, and installing it resets the publish records. The activation applied it with the non-query jobs, after the signal and update set had already run and published, so a task whose publishes a signal woke replayed as zero records against a manifest of several, and any query against the completed run failed. The marker now goes in with the first set that drains, after that set's other jobs and before the drain. --- temporalio/worker/_workflow_instance.py | 23 ++++++- tests/streams/test_redis_replay.py | 92 +++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 tests/streams/test_redis_replay.py diff --git a/temporalio/worker/_workflow_instance.py b/temporalio/worker/_workflow_instance.py index 7dcf7cd78..88e7a891b 100644 --- a/temporalio/worker/_workflow_instance.py +++ b/temporalio/worker/_workflow_instance.py @@ -528,11 +528,21 @@ def activate( job_sets: list[ list[temporalio.bridge.proto.workflow_activation.WorkflowActivationJob] ] = [[], [], [], []] + # An external stream replay marker is what this Workflow Task recorded, + # so it has to be installed before any of the task's code runs and + # publishes, or the install wipes those publishes and the manifest + # check fails them. It is applied right before the first drain of the + # activation, after every job that drain will act on. + replay_jobs: list[ + temporalio.bridge.proto.workflow_activation.WorkflowActivationJob + ] = [] for job in act.jobs: if job.HasField("notify_has_patch"): job_sets[0].append(job) elif job.HasField("signal_workflow") or job.HasField("do_update"): job_sets[1].append(job) + elif job.HasField("replay_external_streams"): + replay_jobs.append(job) elif not job.HasField("query_workflow"): if job.HasField("initialize_workflow"): start_job = job.initialize_workflow @@ -558,24 +568,35 @@ def activate( for job in job_set: # Let errors bubble out of these to the caller to fail the task self._apply(job) - if any(job_sets): + for job in replay_jobs: + self._apply(job) + if any(job_sets) or replay_jobs: self._run_once( check_conditions=bool(job_sets[1] or job_sets[2]) ) else: # Preserve the legacy scheduling order for histories which do # not contain the single-batch workflow logic flag. + replay_pending = bool(replay_jobs) for index, job_set in enumerate(job_sets): if not job_set: continue for job in job_set: # Let errors bubble out of these to the caller to fail the task self._apply(job) + if replay_pending and index >= 1: + for job in replay_jobs: + self._apply(job) + replay_pending = False # Run one iteration of the loop. We do not allow conditions to # be checked in patch jobs (first index) or query jobs (last # index). self._run_once(check_conditions=index == 1 or index == 2) + if replay_pending: + for job in replay_jobs: + self._apply(job) + self._run_once(check_conditions=True) except BaseException: # An error is already on its way out, so the replay is *abandoned* # rather than closed. Closing runs `verify_replay_consumed`, which diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py new file mode 100644 index 000000000..988b1305d --- /dev/null +++ b/tests/streams/test_redis_replay.py @@ -0,0 +1,92 @@ +"""A query against a completed run replays it on the client-side provider. + +Gated behind ``STREAMS_LIVE=redis``; needs a dev server (``TEMPORAL_ADDRESS``) +and a Redis (``AI198_REDIS_URL``). +""" + +from __future__ import annotations + +import asyncio +import os +import uuid +from typing import Any + +import pytest + +from temporalio import streams, workflow +from temporalio.client import Client +from temporalio.worker import Worker + +pytestmark = pytest.mark.skipif( + os.environ.get("STREAMS_LIVE") != "redis", + reason="needs a live server and redis; run with STREAMS_LIVE=redis", +) + + +async def take(records: Any, count: int, timeout: float = 5.0) -> list: + out: list = [] + + async def _collect() -> None: + async for record in records: + out.append(record) + if len(out) >= count: + return + + await asyncio.wait_for(_collect(), timeout) + return out + + +@workflow.defn +class SignalWokenPublish: + """Publishes in the task a signal wakes, then completes in that same task.""" + + def __init__(self) -> None: + self._closed = False + + @workflow.signal + def close(self) -> None: + self._closed = True + + @workflow.run + async def run(self) -> int: + out = streams.writer("out") + await out.publish({"n": 0}) + await workflow.wait_condition(lambda: self._closed) + for n in range(1, 4): + await out.publish({"n": n}) + return 4 + + @workflow.query + def probe(self) -> int: + return 1 + + +async def test_query_after_completion_replays_the_final_task(): + # The final task's publishes were woken by a signal, which the activation + # applies before the replay marker, so the marker's expectations have to be + # installed before that task's code runs or the replay records nothing + # against a manifest of three. + streams.configure( + provider="redis", + url=os.environ.get("AI198_REDIS_URL", "redis://127.0.0.1:6399"), + key_prefix=f"streams-redis-replay-{uuid.uuid4().hex}", + ) + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233") + ) + workflow_id = f"streams-redis-replay-{uuid.uuid4().hex}" + + async with Worker( + client, + task_queue=f"tq-{workflow_id}", + workflows=[SignalWokenPublish], + **streams.worker_options(), + ): + handle = await client.start_workflow( + SignalWokenPublish.run, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + consumer = await streams.consumer(client, workflow_id=workflow_id) + await take(consumer.read(type=dict, topic="out"), 1, timeout=60) + await handle.signal(SignalWokenPublish.close) + assert await handle.result() == 4 + assert await handle.query(SignalWokenPublish.probe) == 1 From 668e4324c4664d0861c969b21a094807f7227aca Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 16 Sep 2026 14:37:39 -0700 Subject: [PATCH 13/96] Sorted and formatted the streams package for ruff. CI runs `ruff check --select I` and `ruff format --check` over the whole tree, and the new files were written by hand. --- streams_demo/agent_loop.py | 10 ++++++---- streams_demo/run_demo.py | 4 ++-- temporalio/streams/__init__.py | 7 ++++++- temporalio/streams/_provider.py | 3 +-- temporalio/streams/providers/memory.py | 11 +++++++++-- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/streams_demo/agent_loop.py b/streams_demo/agent_loop.py index afbb7ea4c..e68bb0a58 100644 --- a/streams_demo/agent_loop.py +++ b/streams_demo/agent_loop.py @@ -29,7 +29,11 @@ async def record_decision(decision: dict[str, Any]) -> str: def decide(token: dict[str, Any]) -> dict[str, Any]: """The decision the workflow is here to make.""" if token["value"] % 2 == 0: - return {"source": token["id"], "branch": "even", "computed": token["value"] * 10} + return { + "source": token["id"], + "branch": "even", + "computed": token["value"] * 10, + } return {"source": token["id"], "branch": "odd", "computed": token["value"] + 100} @@ -39,9 +43,7 @@ class AgentLoop: @workflow.run async def run(self, limit: int) -> list[dict[str, Any]]: - inputs = streams.reader( - INPUTS, type=dict, idle_timeout=timedelta(seconds=1) - ) + inputs = streams.reader(INPUTS, type=dict, idle_timeout=timedelta(seconds=1)) decisions = streams.writer(DECISIONS) trace: list[dict[str, Any]] = [] accepted = 0 diff --git a/streams_demo/run_demo.py b/streams_demo/run_demo.py index 027a95103..687dc1a05 100644 --- a/streams_demo/run_demo.py +++ b/streams_demo/run_demo.py @@ -17,10 +17,10 @@ import asyncio import json -from pathlib import Path import sys import time import uuid +from pathlib import Path from typing import Any from temporalio import streams @@ -29,8 +29,8 @@ from temporalio.worker import Worker sys.path.insert(0, str(Path(__file__).resolve().parent)) -from agent_loop import INPUTS, AgentLoop, record_decision # noqa: E402 import provider_setup # noqa: E402 +from agent_loop import INPUTS, AgentLoop, record_decision # noqa: E402 DECISION_LIMIT = 8 EXPECTED_OUTPUT = 6 diff --git a/temporalio/streams/__init__.py b/temporalio/streams/__init__.py index aaf0a444d..caaa03ffb 100644 --- a/temporalio/streams/__init__.py +++ b/temporalio/streams/__init__.py @@ -54,7 +54,12 @@ from datetime import timedelta from typing import Any, TypeVar -from temporalio.streams._handles import ReadSource, StreamReader, StreamWriter, WriteSink +from temporalio.streams._handles import ( + ReadSource, + StreamReader, + StreamWriter, + WriteSink, +) from temporalio.streams._provider import ( Consumer, Producer, diff --git a/temporalio/streams/_provider.py b/temporalio/streams/_provider.py index c224b47bf..a36339a97 100644 --- a/temporalio/streams/_provider.py +++ b/temporalio/streams/_provider.py @@ -186,8 +186,7 @@ def _make(name: str | None) -> StreamProvider: name = next(iter(_factories)) else: raise RuntimeError( - "name a provider: configure(provider=...) with one of " - f"{registered()}" + f"name a provider: configure(provider=...) with one of {registered()}" ) factory = _factories.get(name) if factory is None: diff --git a/temporalio/streams/providers/memory.py b/temporalio/streams/providers/memory.py index 477a17b68..fb2712350 100644 --- a/temporalio/streams/providers/memory.py +++ b/temporalio/streams/providers/memory.py @@ -131,7 +131,12 @@ class MemoryProducer: """The outside producer, faithful to the contract.""" def __init__( - self, store: _MemoryStream, converter: Any, topic: str, producer_id: str, attempt: int + self, + store: _MemoryStream, + converter: Any, + topic: str, + producer_id: str, + attempt: int, ) -> None: self._store = store self._converter = converter @@ -147,7 +152,9 @@ def attempt(self) -> int: @property def _provider_id(self) -> str: return ( - f"{self._producer_id}#{self._attempt}" if self._attempt else self._producer_id + f"{self._producer_id}#{self._attempt}" + if self._attempt + else self._producer_id ) async def append(self, *values: Any) -> Cursor: From 0877fe4e6c45ea4964b042cd8ebbc972abfab7c9 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 16 Sep 2026 14:48:04 -0700 Subject: [PATCH 14/96] Made the streams package pass the type and doc linters. `prepare` and `drain` are hooks only a transport that parks something against the running workflow needs, so they move to their own protocol rather than making every provider carry two empty methods. The demo's teardown called a `close` that no provider setup defined. --- streams_demo/agent_loop.py | 1 + streams_demo/provider_setup.py | 10 +++++++++ streams_demo/run_demo.py | 2 ++ temporalio/streams/__init__.py | 6 +++++- temporalio/streams/_policy.py | 1 + temporalio/streams/_provider.py | 25 +++++++++++++++++------ temporalio/streams/providers/memory.py | 7 +++++++ tests/streams/test_streams_conformance.py | 2 +- 8 files changed, 46 insertions(+), 8 deletions(-) diff --git a/streams_demo/agent_loop.py b/streams_demo/agent_loop.py index e68bb0a58..f3f39dca9 100644 --- a/streams_demo/agent_loop.py +++ b/streams_demo/agent_loop.py @@ -43,6 +43,7 @@ class AgentLoop: @workflow.run async def run(self, limit: int) -> list[dict[str, Any]]: + """Decide on at most ``limit`` inputs, then return the trace.""" inputs = streams.reader(INPUTS, type=dict, idle_timeout=timedelta(seconds=1)) decisions = streams.writer(DECISIONS) trace: list[dict[str, Any]] = [] diff --git a/streams_demo/provider_setup.py b/streams_demo/provider_setup.py index 81e7108e5..52ebbd266 100644 --- a/streams_demo/provider_setup.py +++ b/streams_demo/provider_setup.py @@ -18,4 +18,14 @@ async def open() -> tuple[str, dict]: + """The server to connect to and the options :func:`configure` takes.""" return os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), {"provider": NAME} + + +async def close() -> None: + """Let go of whatever :func:`open` acquired. + + Nothing here: the memory provider holds no connection. A provider branch + that opens one closes it here, so the demo's teardown reads the same on + every provider. + """ diff --git a/streams_demo/run_demo.py b/streams_demo/run_demo.py index 687dc1a05..9d6a62cb5 100644 --- a/streams_demo/run_demo.py +++ b/streams_demo/run_demo.py @@ -37,6 +37,7 @@ async def collect_output(client: Client, workflow_id: str, want: int) -> list[dict]: + """Read ``want`` decisions off the workflow's stream from outside it.""" reader = await streams.consumer(client, workflow_id=workflow_id) seen: list[dict] = [] async for record in reader.read(type=dict, topic="decisions"): @@ -47,6 +48,7 @@ async def collect_output(client: Client, workflow_id: str, want: int) -> list[di async def main() -> int: + """Run the demo once and write what happened next to this file.""" out = Path(__file__).resolve().parent / f"results-{provider_setup.NAME}" out.mkdir(exist_ok=True) target, options = await provider_setup.open() diff --git a/temporalio/streams/__init__.py b/temporalio/streams/__init__.py index caaa03ffb..cd55ee9d4 100644 --- a/temporalio/streams/__init__.py +++ b/temporalio/streams/__init__.py @@ -64,6 +64,7 @@ Consumer, Producer, StreamProvider, + StreamProviderLifecycle, configure, consumer, drain, @@ -89,6 +90,7 @@ "ReadSource", "RecordKind", "StreamProvider", + "StreamProviderLifecycle", "StreamReader", "StreamRecord", "StreamWriter", @@ -154,4 +156,6 @@ def writer(topic: str, *, type: type | None = None) -> StreamWriter[Any]: # Importing the package registers every provider whose dependencies are # present in this tree. Import order matters: the registry above must exist # before a provider module can register with it. -from temporalio.streams import providers as _providers # noqa: E402,F401 +from temporalio.streams import ( + providers as _providers, # noqa: E402,F401 # pyright: ignore[reportUnusedImport] +) diff --git a/temporalio/streams/_policy.py b/temporalio/streams/_policy.py index 24e20ab2f..73114bbdb 100644 --- a/temporalio/streams/_policy.py +++ b/temporalio/streams/_policy.py @@ -26,6 +26,7 @@ class AttemptTracker: """Watches producer attempts on one subscription.""" def __init__(self) -> None: + """Start with no producer seen.""" self._attempts: dict[str, int] = {} def note( diff --git a/temporalio/streams/_provider.py b/temporalio/streams/_provider.py index a36339a97..18e2bb983 100644 --- a/temporalio/streams/_provider.py +++ b/temporalio/streams/_provider.py @@ -21,6 +21,7 @@ "Consumer", "Producer", "StreamProvider", + "StreamProviderLifecycle", "configure", "consumer", "drain", @@ -96,6 +97,9 @@ class StreamProvider(Protocol): outside pair (:meth:`producer`, :meth:`consumer`) runs anywhere and moves framed bytes. A transport-only provider may serve just the outside pair and raise on the workflow side, naming the provider a worker should use. + + A provider whose transport parks something against the running workflow + also implements :class:`StreamProviderLifecycle`. """ name: str @@ -147,21 +151,30 @@ async def consumer( """Open a reader for what ``workflow_id`` publishes.""" ... + +class StreamProviderLifecycle(Protocol): + """The hooks a provider adds when it needs the workflow's own lifetime. + + Separate from :class:`StreamProvider` because most transports need + neither, and a provider is not asked to carry a pair of empty methods to + say so. :func:`prepare` and :func:`drain` call whichever half is present. + """ + def prepare(self) -> None: """Install whatever this provider needs before the workflow runs. - Optional. A provider that serves outside readers through handlers on - the workflow itself has to register them before the first task - completes, or a reader that arrives early finds nothing to talk to. + A provider that serves outside readers through handlers on the + workflow itself has to register them before the first task completes, + or a reader that arrives early finds nothing to talk to. """ ... def drain(self) -> None: """Release anything this provider parked on the workflow's behalf. - Optional. A provider that parks an outside reader against the running - workflow, as the Workflow Streams transport does with its long-poll - update, has to let go before the workflow can return. + A provider that parks an outside reader against the running workflow, + as the Workflow Streams transport does with its long-poll update, has + to let go before the workflow can return. """ ... diff --git a/temporalio/streams/providers/memory.py b/temporalio/streams/providers/memory.py index fb2712350..f7e58ef44 100644 --- a/temporalio/streams/providers/memory.py +++ b/temporalio/streams/providers/memory.py @@ -138,6 +138,7 @@ def __init__( producer_id: str, attempt: int, ) -> None: + """Bind this producer to ``topic`` on ``store``.""" self._store = store self._converter = converter self._topic = topic @@ -147,6 +148,7 @@ def __init__( @property def attempt(self) -> int: + """The generation this producer is writing.""" return self._attempt @property @@ -158,6 +160,7 @@ def _provider_id(self) -> str: ) async def append(self, *values: Any) -> Cursor: + """Append ``values`` and return the cursor of the last one.""" frames = [] for value in values: frames.append( @@ -179,6 +182,7 @@ async def append(self, *values: Any) -> Cursor: return Cursor(str(offset)) async def finish(self) -> None: + """Mark this producer done, so a reader stops waiting on it.""" frame = _frame.encode( topic=self._topic, kind=RecordKind.FINISH, @@ -205,6 +209,7 @@ class MemoryConsumer: """The outside reader, with the shared supersession rule.""" def __init__(self, store: _MemoryStream, converter: Any) -> None: + """Read whatever ``store`` holds, now and as it grows.""" self._store = store self._converter = converter @@ -215,6 +220,7 @@ async def read( topic: str | None = None, type: type | None = None, ) -> AsyncIterator[StreamRecord[Any]]: + """Yield records after ``after``, waiting for ones not written yet.""" attempts = AttemptTracker() offset = int(after.token) + 1 if after.token else 0 while True: @@ -246,6 +252,7 @@ async def read( ) async def latest(self, *, topic: str | None = None) -> Cursor: + """The cursor of the last record written, for following from now.""" del topic # one store per stream, so the position is topic-independent count = len(self._store.frames) return Cursor(str(count - 1)) if count else BEGINNING diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index 978ef5ca1..0c7818064 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -22,7 +22,7 @@ @pytest.fixture(autouse=True) -def _fresh_memory_provider(): +def _fresh_memory_provider(): # pyright: ignore[reportUnusedFunction] memory.reset() streams.configure(provider="memory") yield From 29cc1cb51ce887c2ab6dd70e0c6014f6d582734e Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 16 Sep 2026 15:18:02 -0700 Subject: [PATCH 15/96] Made the Redis provider pass the linters. The provider imports nothing from `workflow`, and its producer, consumer and `latest` need the docstrings the doc linter asks every public method for. --- temporalio/streams/providers/redis.py | 13 ++++++++----- tests/streams/test_redis_replay.py | 4 +--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index ec5742bf2..ad5fbcd9c 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -19,11 +19,10 @@ from datetime import timedelta from typing import Any -from temporalio import activity, workflow +from temporalio import activity from temporalio.api.common.v1 import Payload from temporalio.client import Client, WorkflowExecutionStatus from temporalio.contrib.external_workflow_streams import ( - BEGINNING as PROVIDER_BEGINNING, AFTER, ExternalOutputStreamClient, ExternalOutputStreamProducer, @@ -34,6 +33,9 @@ external_output_stream, external_stream, ) +from temporalio.contrib.external_workflow_streams import ( + BEGINNING as PROVIDER_BEGINNING, +) from temporalio.streams import _frame, _provider from temporalio.streams._handles import ReadSource, WriteSink from temporalio.streams._policy import AttemptTracker @@ -94,6 +96,7 @@ def __init__( client: Client | None = None, workflow_id: str = "", ) -> None: + """Bind this producer to ``topic`` on the external store.""" self._topic = topic self._converter = converter self._stream = stream @@ -181,6 +184,7 @@ class RedisConsumer: """Reads a stream from outside workflow code, resumably.""" def __init__(self, client: Any, converter: Any) -> None: + """Read what ``client`` reaches in the external store.""" self._client = client self._converter = converter @@ -197,9 +201,7 @@ async def read( and a workflow watching one activity agree on which attempt is current. """ attempts = AttemptTracker() - boundary = ( - AFTER(Offset(after.token)) if after.token else PROVIDER_BEGINNING - ) + boundary = AFTER(Offset(after.token)) if after.token else PROVIDER_BEGINNING handle = self._client.topic(self._require_topic(topic), type=bytes) async for item in handle.subscribe(after=boundary): cursor = Cursor(str(item.offset)) @@ -223,6 +225,7 @@ async def read( ) async def latest(self, *, topic: str | None = None) -> Cursor: + """The cursor of the last record written, for following from now.""" tail = await self._client.topic(self._require_topic(topic), type=bytes).tail() if tail.is_beginning: return BEGINNING diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 988b1305d..f47e6937b 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -71,9 +71,7 @@ async def test_query_after_completion_replays_the_final_task(): url=os.environ.get("AI198_REDIS_URL", "redis://127.0.0.1:6399"), key_prefix=f"streams-redis-replay-{uuid.uuid4().hex}", ) - client = await Client.connect( - os.environ.get("TEMPORAL_ADDRESS", "localhost:7233") - ) + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) workflow_id = f"streams-redis-replay-{uuid.uuid4().hex}" async with Worker( From 89c1a372b76571fa99b7c25d343db3cced0b8527 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 16 Sep 2026 15:28:32 -0700 Subject: [PATCH 16/96] Gave the external stream test fakes the shapes the real path has. A record only reaches a reader after a provider placed it, so the fake runtime hands out placed records and the reader can name where each one came from. The subscription manager captures the loop it is built on, which is the Worker's, so its fixture has to build it on one. --- .../external_workflow_streams/test_api.py | 21 ++++++++++++++++++- .../test_output_runtime.py | 4 +++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/contrib/external_workflow_streams/test_api.py b/tests/contrib/external_workflow_streams/test_api.py index 08d725761..236d0b614 100644 --- a/tests/contrib/external_workflow_streams/test_api.py +++ b/tests/contrib/external_workflow_streams/test_api.py @@ -30,6 +30,7 @@ ) from temporalio.contrib.external_workflow_streams._manager import PreparedRecord from temporalio.contrib.external_workflow_streams._record import ( + Offset, RecordKind, StreamRecord, ) @@ -58,6 +59,9 @@ def __init__(self) -> None: #: Overrides what `codec_for` hands back, so a test can control when -- #: and whether -- decoding a record succeeds. self.codec: Any = None + #: Offsets handed out by `drain`, so buffered records come back in the + #: read-path shape whatever a test put in. + self._placed = 0 def stream_key(self, stream_name: str) -> StreamKey: return StreamKey("ns", "wf", "first-run", stream_name) @@ -81,7 +85,22 @@ def drain(self, wait_id: int, max_records: int | None = None) -> list[StreamReco ) else: self.buffers[wait_id] = [] - return buffered + # A provider places a record when it appends it, so nothing without an + # offset ever reaches a reader. Place what a test left bare rather than + # let the fake deliver a shape the real path cannot produce. + placed = [] + for record in buffered: + if record.offset is not None: + placed.append(record) + continue + self._placed += 1 + at = record.placed_at(Offset(f"{self._placed:020d}")) + if isinstance(record, PreparedRecord): + at = PreparedRecord.of( + at, record.prepared_payload, record.prepare_error + ) + placed.append(at) + return placed def delivery_budget_remaining(self) -> int: return self.budget diff --git a/tests/contrib/external_workflow_streams/test_output_runtime.py b/tests/contrib/external_workflow_streams/test_output_runtime.py index 049f78a2a..56d0993d2 100644 --- a/tests/contrib/external_workflow_streams/test_output_runtime.py +++ b/tests/contrib/external_workflow_streams/test_output_runtime.py @@ -168,7 +168,9 @@ def backend() -> _OutputMemoryBackend: @pytest.fixture -def runtime(backend: _OutputMemoryBackend) -> WorkflowStreamRuntime: +async def runtime(backend: _OutputMemoryBackend) -> WorkflowStreamRuntime: + # The manager captures the loop it is built on, which in the Worker is the + # Worker's own. A sync fixture has none to capture. manager = StreamSubscriptionManager( backend=backend, notify_ready=_notify, From 97849a8d1aab2f3bc4568c2612e575917223592c Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 16 Sep 2026 15:38:08 -0700 Subject: [PATCH 17/96] Snapshotted the markers where the abandoned task begins. Every Workflow Task that closes commits its own marker, and empty input activations now close one too, so a baseline read three steps earlier counts markers that belong to tasks this case says nothing about. --- .../external_workflow_streams/test_worker_handoff.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/contrib/external_workflow_streams/test_worker_handoff.py b/tests/contrib/external_workflow_streams/test_worker_handoff.py index 3e52796c0..cf6b212a0 100644 --- a/tests/contrib/external_workflow_streams/test_worker_handoff.py +++ b/tests/contrib/external_workflow_streams/test_worker_handoff.py @@ -704,9 +704,6 @@ async def test_a_finalization_that_cannot_be_answered_writes_no_marker( "no marker committed the first record, so there is no previous " "marker for the retry to replay from", ) - before = markers(await history(handle)) - committed_before = committed_boundary(before[-1], wait_id=1) - marker_before = marker_bytes(before[-1]) # Fed from here, and each record is *followed* to the Workflow rather # than merely published on a schedule. The precondition this case needs @@ -761,6 +758,13 @@ async def test_a_finalization_that_cannot_be_answered_writes_no_marker( "since the last delivery is expected to fit inside" ) + # Read here rather than at the first marker: every Workflow Task that + # closes between the two commits its own marker, and the claim this + # case makes is about the one task that is open right now. + before = markers(await history(handle)) + committed_before = committed_boundary(before[-1], wait_id=1) + marker_before = marker_bytes(before[-1]) + # The Run's entry disappears underneath the finalization, exactly once. original = workflow_worker_impl._handle_external_stream_jobs sabotaged: list[str] = [] From d963173e7af2365b5c43b0ad5965eb9caf69f07c Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 16 Sep 2026 15:38:26 -0700 Subject: [PATCH 18/96] Added the changelog entry for the shared stream interface. The changelog checkpoint requires an entry for any user-facing change. --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f1441833..73c8bc320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,15 @@ to include examples, links to docs, or any other relevant information. ### Added +- **Experimental**: `temporalio.streams` defines one stream interface a workflow + can read, decide on, and write, with a registry that picks the provider when + the worker is built. `temporalio.streams.providers.memory` is the in-memory + reference provider the conformance tests run against, and + `temporalio.streams.providers.redis` serves the same interface over External + Workflow Streams. +- `ExternalStreamSubscription.records()` yields each value with the provider + offset it was read from, for a reader that has to name where it got to. + - Added experimental External Workflow Streams in `temporalio.contrib.external_workflow_streams`. Workflow stream payloads are stored in a configured external backend instead of Temporal History, with a From 5bf0094eec0fac082800afaac403a466d31f3072 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 16 Sep 2026 16:22:04 -0700 Subject: [PATCH 19/96] Made the demo call the provider lifecycle hooks. A transport that serves outside readers through handlers on the workflow registers them in `prepare`, so without the call an outside reader finds no handler and the demo's follow fails against that provider. --- streams_demo/agent_loop.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/streams_demo/agent_loop.py b/streams_demo/agent_loop.py index f3f39dca9..79d92c367 100644 --- a/streams_demo/agent_loop.py +++ b/streams_demo/agent_loop.py @@ -41,6 +41,15 @@ def decide(token: dict[str, Any]) -> dict[str, Any]: class AgentLoop: """Read, decide, write, until the producer says it has finished.""" + def __init__(self) -> None: + """Let the provider install what it needs before the first task. + + A no-op except on a transport that serves outside readers through + handlers on this workflow, which has to register them before the + first task completes or an early reader finds nothing to talk to. + """ + streams.prepare() + @workflow.run async def run(self, limit: int) -> list[dict[str, Any]]: """Decide on at most ``limit`` inputs, then return the trace.""" @@ -92,4 +101,7 @@ async def run(self, limit: int) -> list[dict[str, Any]]: finally: inputs.close() await decisions.finish() + # Lets go of anything the provider parked against this run, so a + # transport that holds a long poll open can let the workflow return. + streams.drain() return trace From 105ad9be663064cd7d68f6e22cdf6dd33359e34d Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Mon, 14 Sep 2026 17:34:56 -0700 Subject: [PATCH 20/96] Support google-genai 2.21 file downloads (#1865) --- CHANGELOG.md | 78 +++++++++++++++++++ pyproject.toml | 2 +- .../contrib/google_genai/_gemini_activity.py | 6 +- temporalio/contrib/google_genai/_models.py | 1 + .../google_genai/_temporal_async_client.py | 8 +- .../contrib/google_genai/_temporal_files.py | 64 +++++++++++++-- tests/contrib/google_genai/test_gemini.py | 28 +++++++ uv.lock | 8 +- 8 files changed, 179 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c8bc320..796548c8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,84 @@ to include examples, links to docs, or any other relevant information. through `ExternalOutputStreamClient`. Workflow output is staged outside History and becomes readable only after its compact Workflow Task marker is committed. +### Changed + +- Standalone Activities are now generally available (GA). (Standalone Activities as Nexus operations + and Standalone Activities operator commands remain experimental. Operator commands are `pause`, + `unpause`, `updateOptions`, `restoreOriginal`.) +- System Nexus Signal-with-Start Workflow operations now use the typed + `WorkflowOutboundInterceptor.start_signal_with_start_workflow` interception point instead of + the generic `WorkflowOutboundInterceptor.start_nexus_operation` method. +- System Nexus Signal-with-Start Workflow operations now invoke + `WorkflowOutboundInterceptor.start_system_nexus_operation` after their typed interception + point. They continue not to invoke `WorkflowOutboundInterceptor.start_nexus_operation`. +- The experimental `GetNexusOperationResultInput` now includes the Nexus endpoint, service, and + operation. + +### :boom: Breaking Changes + +- Experimental external storage: `ExternalStorage.driver_selector` is now called with a + `StorageDriverSelectContext` instead of a `StorageDriverStoreContext`. Update the annotation; + the new type carries the same `target` field. Since selectors are plain callables, a stale + annotation fails type checking rather than at runtime. +- `client.ActivityExecution` and `client.ActivityExecutionDescription` had some fields removed or renamed + to match RPC API. + - Dataclass parameters for these types were changed to `frozen=True, eq=False, kw_only=True`. + - `scheduled_time` was renamed `schedule_time`. + - `last_failure` was changed from field to method that runs data converter on demand. + - `state_transition_count`, `eager_execution_requested`, `paused` and `long_poll_token` were removed. +- ActivityHandle.describe() long-poll token was removed. The functionality can still be used manually + through raw gRPC API. + +### Fixed + +- `temporalio.contrib.google_genai` now requires `google-genai` 2.21.0 or later + and supports its file download API, including video inputs and download + destinations. +- `temporalio.contrib.deepagents` no longer dedups repeated identical tool, + model, and backend-op calls: each dispatch runs its own Activity, and the + continue-as-new result cache is retired for new executions (a continued run + resumes from the carried transcript and never re-executes prior dispatches, + so a carried cache entry could only serve stale results). Patch-gated + (`deepagents.retire-result-cache`), so histories recorded before this change + replay unchanged; note that deferring the patch keeps the full legacy dedup + cache — including the stale-result behavior this entry describes — and that + a chain upgraded mid-continue-as-new re-executes rather than reuses a + repeated identical call (the conservative direction). +- `contrib.deepagents`: summarization middleware configured with a model name string now routes its LLM calls through Activities instead of running them in the Workflow. + +- **Experimental**: External storage metrics now report the wall-clock time storage was in flight. + Previously each batch's duration was summed, over-reporting the time whenever storage operations + ran concurrently. +- System Nexus Signal-with-Start workflow operations now give custom payload + converters the target workflow's serialization context when encoding their + inner request payloads. +- Cancelling an activity from a signal while the workflow itself is cancelled + no longer causes a nondeterminism error from duplicate activity-cancellation + commands. +- `StrandsPlugin` now disables Botocore retries for its default Bedrock model so + model request retries are handled exclusively by Temporal. +- `temporalio.contrib.openai_agents` now honors the `retry-after-ms` and + `retry-after` headers when OpenAI returns `x-should-retry: true`. Previously + the delay the server asked for was discarded on that path and the activity + retried on its configured interval instead. +- Nexus-context workflow/activity starts no longer set `on_conflict_options` when there are no links + or callbacks to attach. +- The workflow sandbox now passes `pydantic_core` through by default, alongside `pydantic`. + +## [1.32.0] - 2026-08-24 + +### Added + +- Added `temporalio.converter.create_payload_validation_error` to create the + non-retryable application error used when a converted payload fails validation. +- Added experimental `temporalio.contrib.opentelemetry.ReplaySafeMeterProvider` and + `ReplaySafeLoggerProvider` (and exported `ReplaySafeTracerProvider`): wrap an + OpenTelemetry provider so metrics and log events recorded from workflow code (e.g. by + Google ADK) are not duplicated on replay. `GoogleAdkPlugin` warns when a global OTel + provider is not replay-safe. +- Added `LoggingConfig.format` to select compact, pretty, or newline-delimited JSON output for + Core logs written to the console. - Added the `Runtime(disable_environment_info=...)` option to control whether runtime, hosting, and platform information is included in worker heartbeats. diff --git a/pyproject.toml b/pyproject.toml index a6ef530b9..b3f69c9c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ lambda-worker-otel = [ "opentelemetry-sdk-extension-aws>=2.0.0,<3", ] aioboto3 = ["aioboto3>=10.4.0", "types-aioboto3[s3]>=10.4.0"] -google-genai = ["google-genai>=2.10.0,<3.0.0"] +google-genai = ["google-genai>=2.21.0,<3.0.0"] strands-agents = ["strands-agents>=1.39.0"] [project.urls] diff --git a/temporalio/contrib/google_genai/_gemini_activity.py b/temporalio/contrib/google_genai/_gemini_activity.py index 496bbf1ab..dde83bfff 100644 --- a/temporalio/contrib/google_genai/_gemini_activity.py +++ b/temporalio/contrib/google_genai/_gemini_activity.py @@ -198,10 +198,12 @@ async def gemini_files_upload( @activity.defn async def gemini_files_download( req: _GeminiDownloadFileRequest, - ) -> bytes: + ) -> bytes | None: """Download a file using the real genai.Client on the worker.""" return await self._client.aio.files.download( - file=req.file, config=req.config + file=req.file, + destination=req.destination, + config=req.config, ) @activity.defn diff --git a/temporalio/contrib/google_genai/_models.py b/temporalio/contrib/google_genai/_models.py index 2f70d9b4d..21caa8d39 100644 --- a/temporalio/contrib/google_genai/_models.py +++ b/temporalio/contrib/google_genai/_models.py @@ -100,6 +100,7 @@ class _GeminiDownloadFileRequest(BaseModel): """Serializable activity input for a file download.""" file: str + destination: str | None = None config: types.DownloadFileConfig | None = None diff --git a/temporalio/contrib/google_genai/_temporal_async_client.py b/temporalio/contrib/google_genai/_temporal_async_client.py index c77cd5cee..c9abfd988 100644 --- a/temporalio/contrib/google_genai/_temporal_async_client.py +++ b/temporalio/contrib/google_genai/_temporal_async_client.py @@ -255,13 +255,19 @@ def __init__( # Closure-wrap bound-method tools so google-genai's internal # config deep-copy (>= 2.8.0) can't clone the workflow instance. self._models = _TemporalAsyncModels(api_client) - self._files = TemporalAsyncFiles(api_client, activity_config) + self._temporal_files = TemporalAsyncFiles(api_client, activity_config) + self._files = self._temporal_files self._file_search_stores = TemporalAsyncFileSearchStores( api_client, activity_config ) self._temporal_interactions = TemporalAsyncInteractions(activity_config) self._temporal_agents = TemporalAsyncAgents(activity_config) + @property + def files(self) -> TemporalAsyncFiles: + """Temporal-aware files resource; operations run as activities.""" + return self._temporal_files + @property def interactions( # type: ignore[override] self, diff --git a/temporalio/contrib/google_genai/_temporal_files.py b/temporalio/contrib/google_genai/_temporal_files.py index f785c00cb..c2f3a3f53 100644 --- a/temporalio/contrib/google_genai/_temporal_files.py +++ b/temporalio/contrib/google_genai/_temporal_files.py @@ -11,7 +11,7 @@ import io import os from datetime import timedelta -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, overload if TYPE_CHECKING: import google.auth.credentials @@ -100,12 +100,31 @@ async def upload( **act_config, ) + @overload async def download( self, *, - file: str | types.File, + file: str | types.File | types.Video | types.GeneratedVideo, + destination: None = None, config: types.DownloadFileConfigOrDict | None = None, - ) -> bytes: + ) -> bytes: ... + + @overload + async def download( + self, + *, + file: str | types.File | types.Video | types.GeneratedVideo, + destination: str | os.PathLike[str] | io.IOBase, + config: types.DownloadFileConfigOrDict | None = None, + ) -> None: ... + + async def download( + self, + *, + file: str | types.File | types.Video | types.GeneratedVideo, + destination: str | os.PathLike[str] | io.IOBase | None = None, + config: types.DownloadFileConfigOrDict | None = None, + ) -> bytes | None: """Download a file via a Temporal activity.""" act_config: ActivityConfig = {**self._activity_config} if "summary" not in act_config: @@ -119,19 +138,48 @@ async def download( download_config = config _validate_http_options(download_config.http_options) - if isinstance(file, types.File): - if not file.name: - raise ValueError("File object must have a name to download.") + if isinstance(file, types.GeneratedVideo): + file_video = file.video + file_name = file_video.uri if file_video is not None else None + elif isinstance(file, types.Video): + file_name = file.uri + elif isinstance(file, types.File): file_name = file.name else: file_name = file + if not file_name: + raise ValueError("File name is required.") + + if isinstance(destination, io.IOBase): + destination_stream = destination + destination_path = None + elif destination is not None: + destination_stream = None + destination_path = os.fspath(destination) + else: + destination_stream = None + destination_path = None - return await temporal_workflow.execute_activity( + data = await temporal_workflow.execute_activity( "gemini_files_download", - _GeminiDownloadFileRequest(file=file_name, config=download_config), + _GeminiDownloadFileRequest( + file=file_name, + destination=destination_path, + config=download_config, + ), result_type=bytes, **act_config, ) + if destination_stream is not None: + destination_stream.write(data) + return None + if destination_path is not None: + return None + if isinstance(file, types.Video): + file.video_bytes = data + elif isinstance(file, types.GeneratedVideo) and file.video is not None: + file.video.video_bytes = data + return data async def register_files( self, diff --git a/tests/contrib/google_genai/test_gemini.py b/tests/contrib/google_genai/test_gemini.py index 0881f63b3..d48e554e9 100644 --- a/tests/contrib/google_genai/test_gemini.py +++ b/tests/contrib/google_genai/test_gemini.py @@ -742,6 +742,16 @@ async def run(self, file_name: str) -> bytes: return await client.files.download(file=file_name) +@workflow.defn +class FileDownloadToPathWorkflow: + """Workflow that downloads a file to a path on the activity worker.""" + + @workflow.run + async def run(self, file_name: str, destination: str) -> None: + client = TemporalAsyncClient() + await client.files.download(file=file_name, destination=destination) + + @workflow.defn class FileSearchStoreUploadWorkflow: """Workflow that uploads to a file search store.""" @@ -1251,6 +1261,24 @@ async def test_file_download(client: Client): assert result == b"fake file content" +async def test_file_download_to_path(client: Client): + """Download destinations are passed to the activity worker.""" + new_client, api_tracker = apply_plugin(client, []) + + async with new_worker(new_client, FileDownloadToPathWorkflow) as worker: + await new_client.execute_workflow( + FileDownloadToPathWorkflow.run, + args=["files/some-file", "/tmp/downloaded-file"], + id=f"gemini-file-download-to-path-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + + assert len(api_tracker.file_download_requests) == 1 + request = api_tracker.file_download_requests[0] + assert request.file == "files/some-file" + assert request.destination == "/tmp/downloaded-file" + + # =========================================================================== # File search store upload tests # =========================================================================== diff --git a/uv.lock b/uv.lock index 9c625ed14..df077d1e8 100644 --- a/uv.lock +++ b/uv.lock @@ -1354,7 +1354,7 @@ requests = [ [[package]] name = "google-genai" -version = "2.11.0" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1368,9 +1368,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/01/e7b5f3aac89200c78318ed7643401e7f5ed3131b0cd353c07483606b1e61/google_genai-2.11.0.tar.gz", hash = "sha256:4c5e524d24b145c96be327f9a7f8f04b0fe4efee0533877795e9848afed01749", size = 622366, upload-time = "2026-07-09T17:49:43.862Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/a7/a45f64f22ab9302b55fcbeb32acb6f313690a7748629b01e451aad1817a3/google_genai-2.21.0.tar.gz", hash = "sha256:0ecc11c6a5b9f5e3cc58e77ae5fead00c6719f8a1b2b654b803f514a9a6b64c0", size = 677301, upload-time = "2026-08-31T21:49:14.508Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/ef/d296c23390160a8b0b1dafb36dd3cb36a39ed40c81cd27e04e6233334186/google_genai-2.11.0-py3-none-any.whl", hash = "sha256:5bc8186100e1d34d691fbe0cba392b7e04e98d286ca952323a6672d054accf95", size = 984162, upload-time = "2026-07-09T17:49:42.15Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/c2419dd5fedd5803ce09810e4e39561a0a13a960b3f48d2581c12e42af3e/google_genai-2.21.0-py3-none-any.whl", hash = "sha256:36b575034be46a03acd603a852e22a6359f2cdd6b26bb1d65d9b7e0cc7ab3648", size = 1080223, upload-time = "2026-08-31T21:49:12.699Z" }, ] [[package]] @@ -4820,7 +4820,7 @@ requires-dist = [ { name = "aioboto3", marker = "extra == 'aioboto3'", specifier = ">=10.4.0" }, { name = "deepagents", marker = "python_full_version >= '3.11' and extra == 'deepagents'", specifier = ">=0.6.12,<0.7" }, { name = "google-adk", marker = "extra == 'google-adk'", specifier = ">=2.2.0,<3" }, - { name = "google-genai", marker = "extra == 'google-genai'", specifier = ">=2.10.0,<3.0.0" }, + { name = "google-genai", marker = "extra == 'google-genai'", specifier = ">=2.21.0,<3.0.0" }, { name = "grpcio", marker = "extra == 'grpc'", specifier = ">=1.48.2,<2" }, { name = "langchain", marker = "python_full_version >= '3.11' and extra == 'deepagents'", specifier = ">=1.3.11,<2" }, { name = "langchain-core", marker = "python_full_version >= '3.11' and extra == 'deepagents'", specifier = ">=1.4.8,<2" }, From e69ad055a5b302e5c34bfb645e22cc14fd04f0f5 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Mon, 31 Aug 2026 08:17:37 -0700 Subject: [PATCH 21/96] Fix tests with latest dependencies (#1798) * Support Google ADK 2.7 streaming import * Fix tests with latest dependencies * Apply suggestion from @brianstrauch --- tests/contrib/google_adk_agents/test_adk_streaming.py | 7 ++++++- tests/contrib/google_genai/test_gemini.py | 8 ++++---- tests/nexus/test_temporal_system_nexus.py | 11 ++++------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/contrib/google_adk_agents/test_adk_streaming.py b/tests/contrib/google_adk_agents/test_adk_streaming.py index 30aecd9f4..d426990bb 100644 --- a/tests/contrib/google_adk_agents/test_adk_streaming.py +++ b/tests/contrib/google_adk_agents/test_adk_streaming.py @@ -12,7 +12,12 @@ import pytest from google.adk import Agent -from google.adk.agents.run_config import RunConfig, StreamingMode + +# ADK 2.7 marks StreamingMode as a private re-export. +from google.adk.agents.run_config import ( + RunConfig, + StreamingMode, # pyright: ignore[reportPrivateImportUsage] +) from google.adk.models import BaseLlm, LLMRegistry from google.adk.models.llm_request import LlmRequest from google.adk.models.llm_response import LlmResponse diff --git a/tests/contrib/google_genai/test_gemini.py b/tests/contrib/google_genai/test_gemini.py index d48e554e9..d87d37675 100644 --- a/tests/contrib/google_genai/test_gemini.py +++ b/tests/contrib/google_genai/test_gemini.py @@ -292,8 +292,8 @@ async def gemini_file_search_stores_upload( self, req: _GeminiUploadToFileSearchStoreRequest ) -> types.UploadToFileSearchStoreOperation: self.file_search_store_upload_requests.append(req) - return types.UploadToFileSearchStoreOperation.model_construct( - name="operations/test-op", + return types.UploadToFileSearchStoreOperation.model_validate( + {"name": "operations/test-op"} ) @activity.defn @@ -1415,8 +1415,8 @@ async def _gen(): ) gemini.aio.files.download = AsyncMock(return_value=b"mock download content") # type: ignore[method-assign] gemini.aio.file_search_stores.upload_to_file_search_store = AsyncMock( # type: ignore[method-assign] - return_value=types.UploadToFileSearchStoreOperation.model_construct( - name="operations/mock-op" + return_value=types.UploadToFileSearchStoreOperation.model_validate( + {"name": "operations/mock-op"} ) ) diff --git a/tests/nexus/test_temporal_system_nexus.py b/tests/nexus/test_temporal_system_nexus.py index ff6b36e41..b6c711128 100644 --- a/tests/nexus/test_temporal_system_nexus.py +++ b/tests/nexus/test_temporal_system_nexus.py @@ -343,13 +343,10 @@ def _proto_scalar_sample(field: FieldDescriptor, *, path: str) -> Any: def _field_is_repeated(field: FieldDescriptor) -> bool: - return bool( - getattr( - field, - "is_repeated", - getattr(field, "label") == FieldDescriptor.LABEL_REPEATED, - ) - ) + is_repeated = getattr(field, "is_repeated", None) + if is_repeated is not None: + return bool(is_repeated) + return getattr(field, "label") == FieldDescriptor.LABEL_REPEATED @pytest.mark.parametrize( From 914ee40b73bb008a2348ec3ad648c2f82a94e8d0 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 16 Sep 2026 19:57:16 -0700 Subject: [PATCH 22/96] Adopted main's Nexus system API generator script. The branch's own copy shells out to a binary name the nexgen crate does not install, so generation died before it could diff anything. --- scripts/gen_nexus_system_api.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/scripts/gen_nexus_system_api.py b/scripts/gen_nexus_system_api.py index 7ac1bd2de..e57ca03b3 100644 --- a/scripts/gen_nexus_system_api.py +++ b/scripts/gen_nexus_system_api.py @@ -35,15 +35,28 @@ / "v1" / "request_response.proto" ) +NEX_GEN_VERSION = "0.2.4" def nex_gen_command() -> list[str]: if bin_path := os.environ.get("NEX_GEN_BIN"): return [bin_path] - if shutil.which("nex-gen") is None: - subprocess.check_call(["cargo", "install", "--locked", "nex-gen", "--force"]) - return ["nex-gen"] + if shutil.which("nexgen") is None: + subprocess.check_call( + [ + "cargo", + "install", + "--locked", + "nexgen", + "--version", + NEX_GEN_VERSION, + "--features", + "advanced", + "--force", + ] + ) + return ["nexgen"] def build_descriptor_set(descriptor_path: Path) -> None: @@ -114,13 +127,11 @@ def generate_nexus_system_api() -> None: subprocess.check_call( [ *command, - "generate", - "--lang", "python", - "--input", str(wit_path), - "--input", str(wit_deps_dir), + "--native-api", + "--system-nexus", "--support-file", str(python_support_path), "--descriptors", From 14b504284a61de65780ada199cdb7e47c4ab9e14 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 16 Sep 2026 21:12:17 -0700 Subject: [PATCH 23/96] Loosened the status error's response annotation. Under the latest dependency set openai carries its own httpx as httpx2, so pyright will not accept the httpx.Response the test builds. Going through the class object was tried first and did not silence it. --- tests/contrib/openai_agents/test_openai.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 25597ee55..0baae0e6f 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -1431,8 +1431,16 @@ def status_error(status: int): import httpx raise APIStatusError( message="Something went wrong.", - response=httpx.Response( - status_code=status, request=httpx.Request("GET", url="") + # Under the latest dependency set openai carries its own httpx + # as `httpx2`, so the parameter's `Response` is a different + # class from the one built here and the two are not assignable. + # Only the annotation differs; openai reads the object the same + # way either way. + response=cast( + Any, + httpx.Response( + status_code=status, request=httpx.Request("GET", url="") + ), ), body=None, ) From c4bed86bad249faad85031ac7b897f5ed87bc904 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 16 Sep 2026 21:36:27 -0700 Subject: [PATCH 24/96] Held the external stream suite off the time-skipping server. The same suite on the same Core is 673 passed and 0 failed against a dev server, and fails under time skipping. Which cases fail drifts between fourteen and seventeen across runs, so the suite is held as a whole rather than by a list of names that was never stable. --- .../external_workflow_streams/conftest.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/contrib/external_workflow_streams/conftest.py b/tests/contrib/external_workflow_streams/conftest.py index d6e8483c0..dd1128b9f 100644 --- a/tests/contrib/external_workflow_streams/conftest.py +++ b/tests/contrib/external_workflow_streams/conftest.py @@ -16,6 +16,25 @@ import pytest import pytest_asyncio +from temporalio.testing import WorkflowEnvironment + + +@pytest.fixture(autouse=True) +def skip_under_time_skipping(env: WorkflowEnvironment) -> None: + """Hold this suite to a server whose clock the tests can reason about. + + These cases measure real-server timing: how long a Workflow Task was held + open, the interval a wake sweep runs on, the deadline a shutdown waits out. + The time-skipping server advances the clock whenever workers go idle, which + removes exactly the quantities being measured, so the failures it produces + say nothing about the feature. Which cases fail drifts run to run, between + fourteen and seventeen of them, which is why the whole suite is held rather + than a list of names. The ordinary dev-server step still runs all of it. + """ + if env.supports_time_skipping: + pytest.skip("this suite measures real-server timing; see conftest") + + DEFAULT_REDIS_URL = "redis://127.0.0.1:6379" #: Every key this suite creates starts with this, so a leaked key is From 86dc631d4a8ab62b9a7423bcf11b75829177ddac Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 16 Sep 2026 23:08:51 -0700 Subject: [PATCH 25/96] Gave each mock tool call its own id. A script that drives two tool calls handed both invocations the same completed id, which the Agents SDK now rejects outright rather than tolerating. This diverges from upstream, which still hands out a constant there; unique ids are strictly more correct for a builder handing out completed call ids, so the right long-term home is upstream rather than here. --- temporalio/contrib/openai_agents/testing.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/temporalio/contrib/openai_agents/testing.py b/temporalio/contrib/openai_agents/testing.py index 110ca20b7..3367a81e1 100644 --- a/temporalio/contrib/openai_agents/testing.py +++ b/temporalio/contrib/openai_agents/testing.py @@ -1,5 +1,6 @@ """Testing utilities for OpenAI agents.""" +import uuid from collections.abc import AsyncIterator, Callable, Sequence from typing import Any @@ -73,7 +74,9 @@ def tool_call(arguments: str, name: str) -> ModelResponse: return ResponseBuilders.model_response( ResponseFunctionToolCall( arguments=arguments, - call_id="call", + # A script that calls two tools hands the same completed id to + # two invocations, which the Agents SDK now rejects. + call_id=f"call-{uuid.uuid4()}", name=name, type="function_call", id="id", From c27c62070f9ef99d24396b5034867cd3e5f1b30f Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 16 Sep 2026 23:21:03 -0700 Subject: [PATCH 26/96] Repinned Core to the finalized external repairs branch. That branch now matches upstream's nexus model WIT for the workflow-id policies, which is what the current generator needs. --- temporalio/bridge/sdk-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 975bf0e96..31af05354 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 975bf0e96a9951d7279886be7d837bf09376f617 +Subproject commit 31af05354330bb88a8b1e1f598bde1ddddae1d85 From 6b34091892a7a3bba5b2bbc4dabfc7ac74b2ab74 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 16 Sep 2026 23:54:00 -0700 Subject: [PATCH 27/96] Regenerated the Nexus system API from the repinned Core. The generator needs its matching support file and the upstream lint exclusions, so both came across with it. One test asserted the old model's field names; the only consumers of that model outside the generated package are a generated re-export in temporalio.workflow and three package-level helpers, none of which touch those fields. --- pyproject.toml | 5 + scripts/nex_gen_support.py | 93 +++++- .../nexus/system/workflow_service/__init__.py | 37 ++- .../workflow_service/_resources/__init__.py | 5 - .../workflow_service/_support/__init__.py | 2 +- .../_support/nex_gen_support.py | 93 +++++- .../_system_nexus_interceptor.py | 95 ++++++ .../nexus/system/workflow_service/models.py | 297 +++++++++++++++--- .../workflow_service/operations/__init__.py | 2 +- .../operations/signal_with_start_workflow.py | 209 ++++++------ .../{service.py => services.py} | 11 +- tests/nexus/test_temporal_system_nexus.py | 9 +- 12 files changed, 669 insertions(+), 189 deletions(-) delete mode 100644 temporalio/nexus/system/workflow_service/_resources/__init__.py create mode 100644 temporalio/nexus/system/workflow_service/_system_nexus_interceptor.py rename temporalio/nexus/system/workflow_service/{service.py => services.py} (58%) diff --git a/pyproject.toml b/pyproject.toml index b3f69c9c2..70f3d2c97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -188,8 +188,13 @@ exclude = [ # Ignore generated code 'temporalio/api', 'temporalio/bridge/proto', + 'temporalio/nexus/system/workflow_service', ] +[[tool.mypy.overrides]] +module = "temporalio.nexus.system.workflow_service.*" +ignore_errors = true + [tool.pydocstyle] convention = "google" # https://github.com/PyCQA/pydocstyle/issues/363#issuecomment-625563088 diff --git a/scripts/nex_gen_support.py b/scripts/nex_gen_support.py index 58b51e263..fad21151e 100644 --- a/scripts/nex_gen_support.py +++ b/scripts/nex_gen_support.py @@ -10,6 +10,12 @@ import temporalio.api.workflow.v1 import temporalio.common import temporalio.converter +import temporalio.nexus.system + + +class SignalWithStartWorkflowModelRequest(typing.Protocol): + namespace: str + id: str def retry_policy_from_proto( @@ -52,6 +58,12 @@ def workflow_type_to_proto( return common_pb2.WorkflowType(name=workflow_function_name(workflow_type)) +def workflow_type_from_proto( + proto: common_pb2.WorkflowType, +) -> str: + return proto.name + + def task_queue_from_proto( proto: taskqueue_pb2.TaskQueue, ) -> str: @@ -70,12 +82,33 @@ def workflow_namespace() -> str: return info().namespace +def signal_with_start_workflow_serialization_context( + request: SignalWithStartWorkflowModelRequest, +) -> temporalio.converter.WorkflowSerializationContext: + return temporalio.converter.WorkflowSerializationContext( + namespace=request.namespace, + workflow_id=request.id, + ) + + def payloads_to_proto( values: collections.abc.Sequence[typing.Any], ) -> common_pb2.Payloads: - from temporalio.workflow import payload_converter + return ( + temporalio.nexus.system._current_user_payload_converter().to_payloads_wrapper( + values + ) + ) - return payload_converter().to_payloads_wrapper(values) + +def payloads_from_proto( + proto: common_pb2.Payloads, +) -> list[object]: + return list( + temporalio.nexus.system._current_user_payload_converter().from_payloads_wrapper( + proto + ) + ) def _clone_payload(payload: common_pb2.Payload) -> common_pb2.Payload: @@ -87,20 +120,23 @@ def _clone_payload(payload: common_pb2.Payload) -> common_pb2.Payload: def _value_to_payload(value: object | common_pb2.Payload) -> common_pb2.Payload: if isinstance(value, common_pb2.Payload): return _clone_payload(value) - from temporalio.workflow import payload_converter - - payloads = payload_converter().to_payloads_wrapper([value]) + payloads = ( + temporalio.nexus.system._current_user_payload_converter().to_payloads_wrapper( + [value] + ) + ) return _clone_payload(payloads.payloads[0]) def _payload_to_value(payload: common_pb2.Payload) -> object: wrapper = common_pb2.Payloads() wrapper.payloads.add().CopyFrom(payload) - from temporalio.workflow import payload_converter return typing.cast( object, - payload_converter().from_payloads_wrapper(wrapper)[0], + temporalio.nexus.system._current_user_payload_converter().from_payloads_wrapper( + wrapper + )[0], ) @@ -131,6 +167,21 @@ def memo_to_proto( return message +def header_from_proto( + proto: common_pb2.Header, +) -> collections.abc.Mapping[str, object]: + return {key: _payload_to_value(value) for key, value in proto.fields.items()} + + +def header_to_proto( + header: collections.abc.Mapping[str, object], +) -> common_pb2.Header: + message = common_pb2.Header() + for key, value in header.items(): + message.fields[key].CopyFrom(_value_to_payload(value)) + return message + + def duration_from_proto(proto: google.protobuf.duration_pb2.Duration) -> timedelta: return proto.ToTimedelta() @@ -177,6 +228,12 @@ def search_attributes_to_proto( return proto +def search_attributes_from_proto( + proto: common_pb2.SearchAttributes, +) -> temporalio.common.TypedSearchAttributes: + return temporalio.converter.decode_typed_search_attributes(proto) + + def priority_from_proto( proto: common_pb2.Priority, ) -> temporalio.common.Priority: @@ -193,3 +250,25 @@ def versioning_override_to_proto( versioning_override: temporalio.common.VersioningOverride, ) -> temporalio.api.workflow.v1.VersioningOverride: return versioning_override._to_proto() # pyright: ignore[reportPrivateUsage] + + +def versioning_override_from_proto( + proto: temporalio.api.workflow.v1.VersioningOverride, +) -> temporalio.common.VersioningOverride: + if proto.HasField("pinned") and proto.pinned.HasField("version"): + version = proto.pinned.version + return temporalio.common.PinnedVersioningOverride( + temporalio.common.WorkerDeploymentVersion( + deployment_name=version.deployment_name, + build_id=version.build_id, + ) + ) + if proto.pinned_version: + return temporalio.common.PinnedVersioningOverride( + temporalio.common.WorkerDeploymentVersion.from_canonical_string( + proto.pinned_version + ) + ) + if proto.auto_upgrade: + return temporalio.common.AutoUpgradeVersioningOverride() + raise ValueError("unknown versioning override proto shape") diff --git a/temporalio/nexus/system/workflow_service/__init__.py b/temporalio/nexus/system/workflow_service/__init__.py index 7c24fa125..8397ef186 100644 --- a/temporalio/nexus/system/workflow_service/__init__.py +++ b/temporalio/nexus/system/workflow_service/__init__.py @@ -1,8 +1,15 @@ -# Generated by nex-gen. DO NOT EDIT! +# Generated by nexgen v0.2.4. DO NOT EDIT! from __future__ import annotations -from . import service as _service +import collections.abc +import typing + +import nexusrpc + +import temporalio.converter + +from . import services as _services from .operations.signal_with_start_workflow import signal_with_start_workflow __all__ = [ @@ -10,9 +17,33 @@ ] +_InputT = typing.TypeVar("_InputT") +_OutputT = typing.TypeVar("_OutputT") + + +_SerializationContextFactory = collections.abc.Callable[ + [_InputT], temporalio.converter.SerializationContext +] + + +class _NexusOperationInfo(typing.Generic[_InputT, _OutputT]): + def __init__( + self, + *, + operation: nexusrpc.Operation[_InputT, _OutputT], + serialization_context: _SerializationContextFactory[_InputT] | None = None, + ) -> None: + self.operation: nexusrpc.Operation[_InputT, _OutputT] = operation + self.serialization_context: _SerializationContextFactory[_InputT] | None = ( + serialization_context + ) + + __nexus_operation_registry__ = { ( "temporal.api.workflowservice.v1.WorkflowService", "SignalWithStartWorkflowExecution", - ): _service.WorkflowService.signal_with_start_workflow, + ): _NexusOperationInfo( + operation=_services.WorkflowService.signal_with_start_workflow, + ), } diff --git a/temporalio/nexus/system/workflow_service/_resources/__init__.py b/temporalio/nexus/system/workflow_service/_resources/__init__.py deleted file mode 100644 index 373efbd33..000000000 --- a/temporalio/nexus/system/workflow_service/_resources/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# Generated by nex-gen. DO NOT EDIT! - -from __future__ import annotations - -__all__ = [] diff --git a/temporalio/nexus/system/workflow_service/_support/__init__.py b/temporalio/nexus/system/workflow_service/_support/__init__.py index 530c33e80..6af0c0c56 100644 --- a/temporalio/nexus/system/workflow_service/_support/__init__.py +++ b/temporalio/nexus/system/workflow_service/_support/__init__.py @@ -1,4 +1,4 @@ -# Generated by nex-gen. DO NOT EDIT! +# Generated by nexgen v0.2.4. DO NOT EDIT! from __future__ import annotations diff --git a/temporalio/nexus/system/workflow_service/_support/nex_gen_support.py b/temporalio/nexus/system/workflow_service/_support/nex_gen_support.py index 58b51e263..fad21151e 100644 --- a/temporalio/nexus/system/workflow_service/_support/nex_gen_support.py +++ b/temporalio/nexus/system/workflow_service/_support/nex_gen_support.py @@ -10,6 +10,12 @@ import temporalio.api.workflow.v1 import temporalio.common import temporalio.converter +import temporalio.nexus.system + + +class SignalWithStartWorkflowModelRequest(typing.Protocol): + namespace: str + id: str def retry_policy_from_proto( @@ -52,6 +58,12 @@ def workflow_type_to_proto( return common_pb2.WorkflowType(name=workflow_function_name(workflow_type)) +def workflow_type_from_proto( + proto: common_pb2.WorkflowType, +) -> str: + return proto.name + + def task_queue_from_proto( proto: taskqueue_pb2.TaskQueue, ) -> str: @@ -70,12 +82,33 @@ def workflow_namespace() -> str: return info().namespace +def signal_with_start_workflow_serialization_context( + request: SignalWithStartWorkflowModelRequest, +) -> temporalio.converter.WorkflowSerializationContext: + return temporalio.converter.WorkflowSerializationContext( + namespace=request.namespace, + workflow_id=request.id, + ) + + def payloads_to_proto( values: collections.abc.Sequence[typing.Any], ) -> common_pb2.Payloads: - from temporalio.workflow import payload_converter + return ( + temporalio.nexus.system._current_user_payload_converter().to_payloads_wrapper( + values + ) + ) - return payload_converter().to_payloads_wrapper(values) + +def payloads_from_proto( + proto: common_pb2.Payloads, +) -> list[object]: + return list( + temporalio.nexus.system._current_user_payload_converter().from_payloads_wrapper( + proto + ) + ) def _clone_payload(payload: common_pb2.Payload) -> common_pb2.Payload: @@ -87,20 +120,23 @@ def _clone_payload(payload: common_pb2.Payload) -> common_pb2.Payload: def _value_to_payload(value: object | common_pb2.Payload) -> common_pb2.Payload: if isinstance(value, common_pb2.Payload): return _clone_payload(value) - from temporalio.workflow import payload_converter - - payloads = payload_converter().to_payloads_wrapper([value]) + payloads = ( + temporalio.nexus.system._current_user_payload_converter().to_payloads_wrapper( + [value] + ) + ) return _clone_payload(payloads.payloads[0]) def _payload_to_value(payload: common_pb2.Payload) -> object: wrapper = common_pb2.Payloads() wrapper.payloads.add().CopyFrom(payload) - from temporalio.workflow import payload_converter return typing.cast( object, - payload_converter().from_payloads_wrapper(wrapper)[0], + temporalio.nexus.system._current_user_payload_converter().from_payloads_wrapper( + wrapper + )[0], ) @@ -131,6 +167,21 @@ def memo_to_proto( return message +def header_from_proto( + proto: common_pb2.Header, +) -> collections.abc.Mapping[str, object]: + return {key: _payload_to_value(value) for key, value in proto.fields.items()} + + +def header_to_proto( + header: collections.abc.Mapping[str, object], +) -> common_pb2.Header: + message = common_pb2.Header() + for key, value in header.items(): + message.fields[key].CopyFrom(_value_to_payload(value)) + return message + + def duration_from_proto(proto: google.protobuf.duration_pb2.Duration) -> timedelta: return proto.ToTimedelta() @@ -177,6 +228,12 @@ def search_attributes_to_proto( return proto +def search_attributes_from_proto( + proto: common_pb2.SearchAttributes, +) -> temporalio.common.TypedSearchAttributes: + return temporalio.converter.decode_typed_search_attributes(proto) + + def priority_from_proto( proto: common_pb2.Priority, ) -> temporalio.common.Priority: @@ -193,3 +250,25 @@ def versioning_override_to_proto( versioning_override: temporalio.common.VersioningOverride, ) -> temporalio.api.workflow.v1.VersioningOverride: return versioning_override._to_proto() # pyright: ignore[reportPrivateUsage] + + +def versioning_override_from_proto( + proto: temporalio.api.workflow.v1.VersioningOverride, +) -> temporalio.common.VersioningOverride: + if proto.HasField("pinned") and proto.pinned.HasField("version"): + version = proto.pinned.version + return temporalio.common.PinnedVersioningOverride( + temporalio.common.WorkerDeploymentVersion( + deployment_name=version.deployment_name, + build_id=version.build_id, + ) + ) + if proto.pinned_version: + return temporalio.common.PinnedVersioningOverride( + temporalio.common.WorkerDeploymentVersion.from_canonical_string( + proto.pinned_version + ) + ) + if proto.auto_upgrade: + return temporalio.common.AutoUpgradeVersioningOverride() + raise ValueError("unknown versioning override proto shape") diff --git a/temporalio/nexus/system/workflow_service/_system_nexus_interceptor.py b/temporalio/nexus/system/workflow_service/_system_nexus_interceptor.py new file mode 100644 index 000000000..c70b106db --- /dev/null +++ b/temporalio/nexus/system/workflow_service/_system_nexus_interceptor.py @@ -0,0 +1,95 @@ +# Generated by nexgen v0.2.4. DO NOT EDIT! + +from __future__ import annotations + +import abc +import typing + +from . import models + +if typing.TYPE_CHECKING: + import temporalio.workflow + from temporalio.worker._interceptor import StartNexusOperationInput + + +__all__ = [ + "_start_system_nexus_operation", + "_SystemNexusWorkflowOutboundInterceptorBase", + "_SystemNexusWorkflowOutboundInterceptorTerminal", +] + + +_InputT = typing.TypeVar("_InputT") +_OutputT = typing.TypeVar("_OutputT") + + +async def _start_system_nexus_operation( + interceptor: _SystemNexusWorkflowOutboundInterceptorBase, + input: StartNexusOperationInput[_InputT, _OutputT], +) -> temporalio.workflow.NexusOperationHandle[_OutputT]: + if ( + input.service == "temporal.api.workflowservice.v1.WorkflowService" + and input.operation_name == "SignalWithStartWorkflowExecution" + ): + typed_input = typing.cast( + "StartNexusOperationInput[models.SignalWithStartWorkflowRequest, models.SignalWithStartWorkflowResponse]", + input, + ) + # The dispatch check above establishes that this operation's response type is _OutputT. + return typing.cast( + "temporalio.workflow.NexusOperationHandle[_OutputT]", + await interceptor.start_signal_with_start_workflow(typed_input.input), + ) + raise ValueError( + f"unsupported System Nexus operation: {input.service}/{input.operation_name}" + ) + + +class _SystemNexusWorkflowOutboundInterceptorBase(abc.ABC): + @abc.abstractmethod + def _next_system_nexus_interceptor( + self, + ) -> _SystemNexusWorkflowOutboundInterceptorBase: ... + + async def start_signal_with_start_workflow( + self, request: models.SignalWithStartWorkflowRequest + ) -> temporalio.workflow.NexusOperationHandle[ + models.SignalWithStartWorkflowResponse + ]: + """Intercept the SignalWithStartWorkflow operation.""" + return await self._next_system_nexus_interceptor().start_signal_with_start_workflow( + request + ) + + +class _SystemNexusWorkflowOutboundInterceptorTerminal(abc.ABC): + @abc.abstractmethod + async def _intercept_system_nexus_operation( + self, + input: StartNexusOperationInput[_InputT, _OutputT], + ) -> temporalio.workflow.NexusOperationHandle[_OutputT]: ... + + async def start_signal_with_start_workflow( + self, request: models.SignalWithStartWorkflowRequest + ) -> temporalio.workflow.NexusOperationHandle[ + models.SignalWithStartWorkflowResponse + ]: + from temporalio.nexus.system import TEMPORAL_SYSTEM_ENDPOINT + from temporalio.worker._interceptor import StartNexusOperationInput + from temporalio.workflow import NexusOperationCancellationType + + return await self._intercept_system_nexus_operation( + StartNexusOperationInput( + endpoint=TEMPORAL_SYSTEM_ENDPOINT, + service="temporal.api.workflowservice.v1.WorkflowService", + operation="SignalWithStartWorkflowExecution", + input=request, + output_type=models.SignalWithStartWorkflowResponse, + schedule_to_close_timeout=None, + schedule_to_start_timeout=None, + start_to_close_timeout=None, + cancellation_type=NexusOperationCancellationType.WAIT_COMPLETED, + headers=None, + summary=None, + ) + ) diff --git a/temporalio/nexus/system/workflow_service/models.py b/temporalio/nexus/system/workflow_service/models.py index 05e1e3088..df3694863 100644 --- a/temporalio/nexus/system/workflow_service/models.py +++ b/temporalio/nexus/system/workflow_service/models.py @@ -1,4 +1,4 @@ -# Generated by nex-gen. DO NOT EDIT! +# Generated by nexgen v0.2.4. DO NOT EDIT! from __future__ import annotations @@ -7,25 +7,39 @@ import datetime import typing +import typing_extensions + import temporalio.api.sdk.v1.user_metadata_pb2 import temporalio.api.workflowservice.v1.request_response_pb2 import temporalio.common +import temporalio.converter from ._support import ( + duration_from_proto, duration_to_proto, + memo_from_proto, memo_to_proto, payload_from_proto, payload_to_proto, + payloads_from_proto, payloads_to_proto, + priority_from_proto, priority_to_proto, + retry_policy_from_proto, retry_policy_to_proto, + search_attributes_from_proto, search_attributes_to_proto, signal_function_to_proto, + task_queue_from_proto, task_queue_to_proto, + versioning_override_from_proto, versioning_override_to_proto, + workflow_id_conflict_policy_from_proto, workflow_id_conflict_policy_to_proto, + workflow_id_reuse_policy_from_proto, workflow_id_reuse_policy_to_proto, workflow_namespace, + workflow_type_from_proto, workflow_type_to_proto, ) @@ -59,83 +73,262 @@ class SignalWithStartWorkflowRequest: versioning_override: temporalio.common.VersioningOverride | None = None start_delay: datetime.timedelta | None = None user_metadata: UserMetadata | None = None + namespace: str = dataclasses.field(default_factory=workflow_namespace) + - def to_proto( +class _SignalWithStartWorkflowRequestTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + SignalWithStartWorkflowRequest, + temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionRequest, + ] +): + transfer_type: ( + type[ + temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionRequest + ] + | None + ) = temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionRequest + + @typing_extensions.override + def from_transfer_type( self, + value: temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionRequest, + type_hint: type[SignalWithStartWorkflowRequest], + ) -> SignalWithStartWorkflowRequest: + if not value.HasField("workflow_type"): + raise ValueError( + "missing required field SignalWithStartWorkflowRequest.workflow" + ) + workflow = workflow_type_from_proto(value.workflow_type) + if not value.workflow_id: + raise ValueError("missing required field SignalWithStartWorkflowRequest.id") + id = value.workflow_id + if not value.HasField("task_queue"): + raise ValueError( + "missing required field SignalWithStartWorkflowRequest.task_queue" + ) + task_queue = task_queue_from_proto(value.task_queue) + if not value.signal_name: + raise ValueError( + "missing required field SignalWithStartWorkflowRequest.signal" + ) + signal = value.signal_name + return SignalWithStartWorkflowRequest( + workflow=workflow, + args=payloads_from_proto(value.input) if value.HasField("input") else None, + id=id, + task_queue=task_queue, + signal=signal, + signal_args=payloads_from_proto(value.signal_input) + if value.HasField("signal_input") + else None, + execution_timeout=duration_from_proto(value.workflow_execution_timeout) + if value.HasField("workflow_execution_timeout") + else None, + run_timeout=duration_from_proto(value.workflow_run_timeout) + if value.HasField("workflow_run_timeout") + else None, + task_timeout=duration_from_proto(value.workflow_task_timeout) + if value.HasField("workflow_task_timeout") + else None, + request_id=value.request_id if bool(value.request_id) else None, + id_reuse_policy=workflow_id_reuse_policy_from_proto( + value.workflow_id_reuse_policy + ), + id_conflict_policy=workflow_id_conflict_policy_from_proto( + value.workflow_id_conflict_policy + ) + if value.workflow_id_conflict_policy != 0 + else None, + retry_policy=retry_policy_from_proto(value.retry_policy) + if value.HasField("retry_policy") + else None, + cron_schedule=value.cron_schedule if bool(value.cron_schedule) else None, + memo=memo_from_proto(value.memo) if value.HasField("memo") else None, + search_attributes=search_attributes_from_proto(value.search_attributes) + if value.HasField("search_attributes") + else None, + priority=priority_from_proto(value.priority) + if value.HasField("priority") + else None, + versioning_override=versioning_override_from_proto( + value.versioning_override + ) + if value.HasField("versioning_override") + else None, + start_delay=duration_from_proto(value.workflow_start_delay) + if value.HasField("workflow_start_delay") + else None, + user_metadata=_UserMetadataTransferTypeConverter().from_transfer_type( + value.user_metadata, UserMetadata + ) + if value.HasField("user_metadata") + else None, + namespace=value.namespace, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: SignalWithStartWorkflowRequest, ) -> temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionRequest: message = temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionRequest() - message.workflow_type.CopyFrom(workflow_type_to_proto(self.workflow)) - if self.args is not None: - message.input.CopyFrom(payloads_to_proto(self.args)) - message.workflow_id = self.id - message.task_queue.CopyFrom(task_queue_to_proto(self.task_queue)) - message.signal_name = signal_function_to_proto(self.signal) - if self.signal_args is not None: - message.signal_input.CopyFrom(payloads_to_proto(self.signal_args)) - if self.execution_timeout is not None: + message.workflow_type.CopyFrom(workflow_type_to_proto(value.workflow)) + if value.args is not None: + message.input.CopyFrom(payloads_to_proto(value.args)) + message.workflow_id = value.id + message.task_queue.CopyFrom(task_queue_to_proto(value.task_queue)) + message.signal_name = signal_function_to_proto(value.signal) + if value.signal_args is not None: + message.signal_input.CopyFrom(payloads_to_proto(value.signal_args)) + if value.execution_timeout is not None: message.workflow_execution_timeout.CopyFrom( - duration_to_proto(self.execution_timeout) + duration_to_proto(value.execution_timeout) + ) + if value.run_timeout is not None: + message.workflow_run_timeout.CopyFrom(duration_to_proto(value.run_timeout)) + if value.task_timeout is not None: + message.workflow_task_timeout.CopyFrom( + duration_to_proto(value.task_timeout) ) - if self.run_timeout is not None: - message.workflow_run_timeout.CopyFrom(duration_to_proto(self.run_timeout)) - if self.task_timeout is not None: - message.workflow_task_timeout.CopyFrom(duration_to_proto(self.task_timeout)) - if self.request_id is not None: - message.request_id = self.request_id + if value.request_id is not None: + message.request_id = value.request_id message.workflow_id_reuse_policy = workflow_id_reuse_policy_to_proto( - self.id_reuse_policy + value.id_reuse_policy ) - if self.id_conflict_policy is not None: + if value.id_conflict_policy is not None: message.workflow_id_conflict_policy = workflow_id_conflict_policy_to_proto( - self.id_conflict_policy + value.id_conflict_policy ) - if self.retry_policy is not None: - message.retry_policy.CopyFrom(retry_policy_to_proto(self.retry_policy)) - if self.cron_schedule is not None: - message.cron_schedule = self.cron_schedule - if self.memo is not None: - message.memo.CopyFrom(memo_to_proto(self.memo)) - if self.search_attributes is not None: + if value.retry_policy is not None: + message.retry_policy.CopyFrom(retry_policy_to_proto(value.retry_policy)) + if value.cron_schedule is not None: + message.cron_schedule = value.cron_schedule + if value.memo is not None: + message.memo.CopyFrom(memo_to_proto(value.memo)) + if value.search_attributes is not None: message.search_attributes.CopyFrom( - search_attributes_to_proto(self.search_attributes) + search_attributes_to_proto(value.search_attributes) ) - if self.priority is not None: - message.priority.CopyFrom(priority_to_proto(self.priority)) - if self.versioning_override is not None: + if value.priority is not None: + message.priority.CopyFrom(priority_to_proto(value.priority)) + if value.versioning_override is not None: message.versioning_override.CopyFrom( - versioning_override_to_proto(self.versioning_override) + versioning_override_to_proto(value.versioning_override) ) - if self.start_delay is not None: - message.workflow_start_delay.CopyFrom(duration_to_proto(self.start_delay)) - if self.user_metadata is not None: - message.user_metadata.CopyFrom(self.user_metadata.to_proto()) - message.namespace = workflow_namespace() + if value.start_delay is not None: + message.workflow_start_delay.CopyFrom(duration_to_proto(value.start_delay)) + if value.user_metadata is not None: + message.user_metadata.CopyFrom( + _UserMetadataTransferTypeConverter().to_transfer_type( + value.user_metadata + ) + ) + message.namespace = value.namespace return message +_ = temporalio.converter.transfer_type_convertible( + _SignalWithStartWorkflowRequestTransferTypeConverter +)(SignalWithStartWorkflowRequest) + + @dataclasses.dataclass(slots=True) class UserMetadata: static_summary: typing.Any | None = None static_details: typing.Any | None = None - @classmethod - def from_proto( - cls, - proto: temporalio.api.sdk.v1.user_metadata_pb2.UserMetadata, + +class _UserMetadataTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + UserMetadata, temporalio.api.sdk.v1.user_metadata_pb2.UserMetadata + ] +): + transfer_type: type[temporalio.api.sdk.v1.user_metadata_pb2.UserMetadata] | None = ( + temporalio.api.sdk.v1.user_metadata_pb2.UserMetadata + ) + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.sdk.v1.user_metadata_pb2.UserMetadata, + type_hint: type[UserMetadata], ) -> UserMetadata: - return cls( - static_summary=payload_from_proto(proto.summary) - if proto.HasField("summary") + return UserMetadata( + static_summary=payload_from_proto(value.summary) + if value.HasField("summary") else None, - static_details=payload_from_proto(proto.details) - if proto.HasField("details") + static_details=payload_from_proto(value.details) + if value.HasField("details") else None, ) - def to_proto(self) -> temporalio.api.sdk.v1.user_metadata_pb2.UserMetadata: + @typing_extensions.override + def to_transfer_type( + self, + value: UserMetadata, + ) -> temporalio.api.sdk.v1.user_metadata_pb2.UserMetadata: message = temporalio.api.sdk.v1.user_metadata_pb2.UserMetadata() - if self.static_summary is not None: - message.summary.CopyFrom(payload_to_proto(self.static_summary)) - if self.static_details is not None: - message.details.CopyFrom(payload_to_proto(self.static_details)) + if value.static_summary is not None: + message.summary.CopyFrom(payload_to_proto(value.static_summary)) + if value.static_details is not None: + message.details.CopyFrom(payload_to_proto(value.static_details)) + return message + + +_ = temporalio.converter.transfer_type_convertible(_UserMetadataTransferTypeConverter)( + UserMetadata +) + + +@dataclasses.dataclass(slots=True) +class SignalWithStartWorkflowResponse: + """ + .. warning:: + This API is experimental and subject to change. + """ + + run_id: str | None = None + started: bool | None = None + + +class _SignalWithStartWorkflowResponseTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + SignalWithStartWorkflowResponse, + temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionResponse, + ] +): + transfer_type: ( + type[ + temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionResponse + ] + | None + ) = temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionResponse + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionResponse, + type_hint: type[SignalWithStartWorkflowResponse], + ) -> SignalWithStartWorkflowResponse: + return SignalWithStartWorkflowResponse( + run_id=value.run_id if bool(value.run_id) else None, + started=value.started if bool(value.started) else None, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: SignalWithStartWorkflowResponse, + ) -> temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionResponse: + message = temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionResponse() + if value.run_id is not None: + message.run_id = value.run_id + if value.started is not None: + message.started = value.started return message + + +_ = temporalio.converter.transfer_type_convertible( + _SignalWithStartWorkflowResponseTransferTypeConverter +)(SignalWithStartWorkflowResponse) diff --git a/temporalio/nexus/system/workflow_service/operations/__init__.py b/temporalio/nexus/system/workflow_service/operations/__init__.py index 67c9cc56b..28d71c338 100644 --- a/temporalio/nexus/system/workflow_service/operations/__init__.py +++ b/temporalio/nexus/system/workflow_service/operations/__init__.py @@ -1,3 +1,3 @@ -# Generated by nex-gen. DO NOT EDIT! +# Generated by nexgen v0.2.4. DO NOT EDIT! from __future__ import annotations diff --git a/temporalio/nexus/system/workflow_service/operations/signal_with_start_workflow.py b/temporalio/nexus/system/workflow_service/operations/signal_with_start_workflow.py index 0865e5a88..9060fc11c 100644 --- a/temporalio/nexus/system/workflow_service/operations/signal_with_start_workflow.py +++ b/temporalio/nexus/system/workflow_service/operations/signal_with_start_workflow.py @@ -1,4 +1,4 @@ -# Generated by nex-gen. DO NOT EDIT! +# Generated by nexgen v0.2.4. DO NOT EDIT! from __future__ import annotations @@ -8,7 +8,6 @@ import typing_extensions -import temporalio.api.workflowservice.v1.request_response_pb2 import temporalio.common if typing.TYPE_CHECKING: @@ -16,6 +15,7 @@ from ..models import ( SignalWithStartWorkflowRequest, + SignalWithStartWorkflowResponse, UserMetadata, ) @@ -33,15 +33,14 @@ async def _signal_with_start_workflow( get_external_workflow_handle, ) - request_proto = request.to_proto() nexus_client = create_nexus_client( service="temporal.api.workflowservice.v1.WorkflowService", endpoint="__temporal_system", ) handle = await nexus_client.start_operation( operation="SignalWithStartWorkflowExecution", - input=request_proto, - output_type=temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionResponse, + input=request, + output_type=SignalWithStartWorkflowResponse, ) result = await handle return get_external_workflow_handle(request.id, run_id=result.run_id) @@ -77,17 +76,17 @@ async def signal_with_start_workflow( # Overload case: -# - workflow name with optional list-form workflow arguments -# - signal name with optional list-form signal arguments +# - workflow name with positional workflow arguments +# - signal method callable with no signal arguments @typing.overload async def signal_with_start_workflow( workflow: str, - *, - args: list[typing.Any] | None = ..., + *args: object, id: str, task_queue: str, - signal: str, - signal_args: list[typing.Any] | None = ..., + signal: collections.abc.Callable[ + [SelfType], None | collections.abc.Awaitable[None] + ], execution_timeout: datetime.timedelta | None = ..., run_timeout: datetime.timedelta | None = ..., task_timeout: datetime.timedelta | None = ..., @@ -103,23 +102,22 @@ async def signal_with_start_workflow( start_delay: datetime.timedelta | None = ..., static_summary: str | None = ..., static_details: str | None = ..., -) -> ExternalWorkflowHandle[object]: ... +) -> ExternalWorkflowHandle[SelfType]: ... # Overload case: -# - workflow method callable with typed positional workflow arguments -# - signal name with optional list-form signal arguments +# - workflow name with positional workflow arguments +# - signal method callable with a typed single signal arguments @typing.overload async def signal_with_start_workflow( - workflow: collections.abc.Callable[ - [SelfType, typing_extensions.Unpack[WorkflowArgs]], - collections.abc.Awaitable[WorkflowResult], - ], - *args: typing_extensions.Unpack[WorkflowArgs], + workflow: str, + *args: object, id: str, task_queue: str, - signal: str, - signal_args: list[typing.Any] | None = ..., + signal: collections.abc.Callable[ + [SelfType, SignalArg], None | collections.abc.Awaitable[None] + ], + signal_args: SignalArg, execution_timeout: datetime.timedelta | None = ..., run_timeout: datetime.timedelta | None = ..., task_timeout: datetime.timedelta | None = ..., @@ -139,20 +137,16 @@ async def signal_with_start_workflow( # Overload case: -# - workflow method callable with list-form workflow arguments -# - signal name with optional list-form signal arguments +# - workflow name with positional workflow arguments +# - signal callable with list-form signal arguments @typing.overload async def signal_with_start_workflow( - workflow: collections.abc.Callable[ - [SelfType, typing_extensions.Unpack[WorkflowArgs]], - collections.abc.Awaitable[WorkflowResult], - ], - *, - args: list[typing.Any], + workflow: str, + *args: object, id: str, task_queue: str, - signal: str, - signal_args: list[typing.Any] | None = ..., + signal: collections.abc.Callable[..., None | collections.abc.Awaitable[None]], + signal_args: list[typing.Any], execution_timeout: datetime.timedelta | None = ..., run_timeout: datetime.timedelta | None = ..., task_timeout: datetime.timedelta | None = ..., @@ -168,21 +162,21 @@ async def signal_with_start_workflow( start_delay: datetime.timedelta | None = ..., static_summary: str | None = ..., static_details: str | None = ..., -) -> ExternalWorkflowHandle[SelfType]: ... +) -> ExternalWorkflowHandle[object]: ... # Overload case: -# - workflow name with positional workflow arguments -# - signal method callable with no signal arguments +# - workflow name with optional list-form workflow arguments +# - signal name with optional list-form signal arguments @typing.overload async def signal_with_start_workflow( workflow: str, - *args: object, + *, + args: list[typing.Any] | None = ..., id: str, task_queue: str, - signal: collections.abc.Callable[ - [SelfType], None | collections.abc.Awaitable[None] - ], + signal: str, + signal_args: list[typing.Any] | None = ..., execution_timeout: datetime.timedelta | None = ..., run_timeout: datetime.timedelta | None = ..., task_timeout: datetime.timedelta | None = ..., @@ -198,7 +192,7 @@ async def signal_with_start_workflow( start_delay: datetime.timedelta | None = ..., static_summary: str | None = ..., static_details: str | None = ..., -) -> ExternalWorkflowHandle[SelfType]: ... +) -> ExternalWorkflowHandle[object]: ... # Overload case: @@ -233,20 +227,19 @@ async def signal_with_start_workflow( # Overload case: -# - workflow method callable with typed positional workflow arguments -# - signal method callable with no signal arguments +# - workflow name with optional list-form workflow arguments +# - signal method callable with a typed single signal arguments @typing.overload async def signal_with_start_workflow( - workflow: collections.abc.Callable[ - [SelfType, typing_extensions.Unpack[WorkflowArgs]], - collections.abc.Awaitable[WorkflowResult], - ], - *args: typing_extensions.Unpack[WorkflowArgs], + workflow: str, + *, + args: list[typing.Any] | None = ..., id: str, task_queue: str, signal: collections.abc.Callable[ - [SelfType], None | collections.abc.Awaitable[None] + [SelfType, SignalArg], None | collections.abc.Awaitable[None] ], + signal_args: SignalArg, execution_timeout: datetime.timedelta | None = ..., run_timeout: datetime.timedelta | None = ..., task_timeout: datetime.timedelta | None = ..., @@ -266,21 +259,17 @@ async def signal_with_start_workflow( # Overload case: -# - workflow method callable with list-form workflow arguments -# - signal method callable with no signal arguments +# - workflow name with optional list-form workflow arguments +# - signal callable with list-form signal arguments @typing.overload async def signal_with_start_workflow( - workflow: collections.abc.Callable[ - [SelfType, typing_extensions.Unpack[WorkflowArgs]], - collections.abc.Awaitable[WorkflowResult], - ], + workflow: str, *, - args: list[typing.Any], + args: list[typing.Any] | None = ..., id: str, task_queue: str, - signal: collections.abc.Callable[ - [SelfType], None | collections.abc.Awaitable[None] - ], + signal: collections.abc.Callable[..., None | collections.abc.Awaitable[None]], + signal_args: list[typing.Any], execution_timeout: datetime.timedelta | None = ..., run_timeout: datetime.timedelta | None = ..., task_timeout: datetime.timedelta | None = ..., @@ -296,22 +285,23 @@ async def signal_with_start_workflow( start_delay: datetime.timedelta | None = ..., static_summary: str | None = ..., static_details: str | None = ..., -) -> ExternalWorkflowHandle[SelfType]: ... +) -> ExternalWorkflowHandle[object]: ... # Overload case: -# - workflow name with positional workflow arguments -# - signal method callable with a typed single signal arguments +# - workflow method callable with typed positional workflow arguments +# - signal name with optional list-form signal arguments @typing.overload async def signal_with_start_workflow( - workflow: str, - *args: object, + workflow: collections.abc.Callable[ + [SelfType, typing_extensions.Unpack[WorkflowArgs]], + collections.abc.Awaitable[WorkflowResult], + ], + *args: typing_extensions.Unpack[WorkflowArgs], id: str, task_queue: str, - signal: collections.abc.Callable[ - [SelfType, SignalArg], None | collections.abc.Awaitable[None] - ], - signal_args: SignalArg, + signal: str, + signal_args: list[typing.Any] | None = ..., execution_timeout: datetime.timedelta | None = ..., run_timeout: datetime.timedelta | None = ..., task_timeout: datetime.timedelta | None = ..., @@ -331,19 +321,20 @@ async def signal_with_start_workflow( # Overload case: -# - workflow name with optional list-form workflow arguments -# - signal method callable with a typed single signal arguments +# - workflow method callable with typed positional workflow arguments +# - signal method callable with no signal arguments @typing.overload async def signal_with_start_workflow( - workflow: str, - *, - args: list[typing.Any] | None = ..., + workflow: collections.abc.Callable[ + [SelfType, typing_extensions.Unpack[WorkflowArgs]], + collections.abc.Awaitable[WorkflowResult], + ], + *args: typing_extensions.Unpack[WorkflowArgs], id: str, task_queue: str, signal: collections.abc.Callable[ - [SelfType, SignalArg], None | collections.abc.Awaitable[None] + [SelfType], None | collections.abc.Awaitable[None] ], - signal_args: SignalArg, execution_timeout: datetime.timedelta | None = ..., run_timeout: datetime.timedelta | None = ..., task_timeout: datetime.timedelta | None = ..., @@ -397,22 +388,19 @@ async def signal_with_start_workflow( # Overload case: -# - workflow method callable with list-form workflow arguments -# - signal method callable with a typed single signal arguments +# - workflow method callable with typed positional workflow arguments +# - signal callable with list-form signal arguments @typing.overload async def signal_with_start_workflow( workflow: collections.abc.Callable[ [SelfType, typing_extensions.Unpack[WorkflowArgs]], collections.abc.Awaitable[WorkflowResult], ], - *, - args: list[typing.Any], + *args: typing_extensions.Unpack[WorkflowArgs], id: str, task_queue: str, - signal: collections.abc.Callable[ - [SelfType, SignalArg], None | collections.abc.Awaitable[None] - ], - signal_args: SignalArg, + signal: collections.abc.Callable[..., None | collections.abc.Awaitable[None]], + signal_args: list[typing.Any], execution_timeout: datetime.timedelta | None = ..., run_timeout: datetime.timedelta | None = ..., task_timeout: datetime.timedelta | None = ..., @@ -432,16 +420,20 @@ async def signal_with_start_workflow( # Overload case: -# - workflow name with positional workflow arguments -# - signal callable with list-form signal arguments +# - workflow method callable with list-form workflow arguments +# - signal name with optional list-form signal arguments @typing.overload async def signal_with_start_workflow( - workflow: str, - *args: object, + workflow: collections.abc.Callable[ + [SelfType, typing_extensions.Unpack[WorkflowArgs]], + collections.abc.Awaitable[WorkflowResult], + ], + *, + args: list[typing.Any], id: str, task_queue: str, - signal: collections.abc.Callable[..., None | collections.abc.Awaitable[None]], - signal_args: list[typing.Any], + signal: str, + signal_args: list[typing.Any] | None = ..., execution_timeout: datetime.timedelta | None = ..., run_timeout: datetime.timedelta | None = ..., task_timeout: datetime.timedelta | None = ..., @@ -457,21 +449,25 @@ async def signal_with_start_workflow( start_delay: datetime.timedelta | None = ..., static_summary: str | None = ..., static_details: str | None = ..., -) -> ExternalWorkflowHandle[object]: ... +) -> ExternalWorkflowHandle[SelfType]: ... # Overload case: -# - workflow name with optional list-form workflow arguments -# - signal callable with list-form signal arguments +# - workflow method callable with list-form workflow arguments +# - signal method callable with no signal arguments @typing.overload async def signal_with_start_workflow( - workflow: str, + workflow: collections.abc.Callable[ + [SelfType, typing_extensions.Unpack[WorkflowArgs]], + collections.abc.Awaitable[WorkflowResult], + ], *, - args: list[typing.Any] | None = ..., + args: list[typing.Any], id: str, task_queue: str, - signal: collections.abc.Callable[..., None | collections.abc.Awaitable[None]], - signal_args: list[typing.Any], + signal: collections.abc.Callable[ + [SelfType], None | collections.abc.Awaitable[None] + ], execution_timeout: datetime.timedelta | None = ..., run_timeout: datetime.timedelta | None = ..., task_timeout: datetime.timedelta | None = ..., @@ -487,23 +483,26 @@ async def signal_with_start_workflow( start_delay: datetime.timedelta | None = ..., static_summary: str | None = ..., static_details: str | None = ..., -) -> ExternalWorkflowHandle[object]: ... +) -> ExternalWorkflowHandle[SelfType]: ... # Overload case: -# - workflow method callable with typed positional workflow arguments -# - signal callable with list-form signal arguments +# - workflow method callable with list-form workflow arguments +# - signal method callable with a typed single signal arguments @typing.overload async def signal_with_start_workflow( workflow: collections.abc.Callable[ [SelfType, typing_extensions.Unpack[WorkflowArgs]], collections.abc.Awaitable[WorkflowResult], ], - *args: typing_extensions.Unpack[WorkflowArgs], + *, + args: list[typing.Any], id: str, task_queue: str, - signal: collections.abc.Callable[..., None | collections.abc.Awaitable[None]], - signal_args: list[typing.Any], + signal: collections.abc.Callable[ + [SelfType, SignalArg], None | collections.abc.Awaitable[None] + ], + signal_args: SignalArg, execution_timeout: datetime.timedelta | None = ..., run_timeout: datetime.timedelta | None = ..., task_timeout: datetime.timedelta | None = ..., @@ -629,6 +628,11 @@ async def signal_with_start_workflow( Returns: A workflow handle to the started workflow. """ + if positional_args and args is not None: + raise TypeError("cannot specify both positional arguments and args") + normalized_args: list[typing.Any] | None = ( + list(positional_args) if positional_args else args + ) normalized_signal_args: list[typing.Any] | None if signal_args is None: normalized_signal_args = None @@ -636,11 +640,6 @@ async def signal_with_start_workflow( normalized_signal_args = typing.cast(list[typing.Any], signal_args) else: normalized_signal_args = [signal_args] - if positional_args and args is not None: - raise TypeError("cannot specify both positional arguments and args") - normalized_args: list[typing.Any] | None = ( - list(positional_args) if positional_args else args - ) user_metadata = ( None if static_summary is None and static_details is None diff --git a/temporalio/nexus/system/workflow_service/service.py b/temporalio/nexus/system/workflow_service/services.py similarity index 58% rename from temporalio/nexus/system/workflow_service/service.py rename to temporalio/nexus/system/workflow_service/services.py index 7ce5849ca..c538493e1 100644 --- a/temporalio/nexus/system/workflow_service/service.py +++ b/temporalio/nexus/system/workflow_service/services.py @@ -1,10 +1,13 @@ -# Generated by nex-gen. DO NOT EDIT! +# Generated by nexgen v0.2.4. DO NOT EDIT! from __future__ import annotations from nexusrpc import Operation, service -import temporalio.api.workflowservice.v1.request_response_pb2 +from .models import ( + SignalWithStartWorkflowRequest, + SignalWithStartWorkflowResponse, +) @service(name="temporal.api.workflowservice.v1.WorkflowService") @@ -16,6 +19,6 @@ class WorkflowService: # .. warning:: This API is experimental and subject to change. signal_with_start_workflow: Operation[ - temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionRequest, - temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionResponse, + SignalWithStartWorkflowRequest, + SignalWithStartWorkflowResponse, ] = Operation(name="SignalWithStartWorkflowExecution") diff --git a/tests/nexus/test_temporal_system_nexus.py b/tests/nexus/test_temporal_system_nexus.py index b6c711128..dfa466500 100644 --- a/tests/nexus/test_temporal_system_nexus.py +++ b/tests/nexus/test_temporal_system_nexus.py @@ -14,6 +14,7 @@ import temporalio.api.workflowservice.v1.request_response_pb2 as workflowservice_pb2 import temporalio.converter import temporalio.nexus.system as nexus_system +import temporalio.nexus.system.workflow_service.models as workflow_service_models from temporalio import workflow from temporalio.bridge._visitor import PayloadVisitor from temporalio.bridge._visitor_functions import VisitorFunctions @@ -137,12 +138,12 @@ def _assert_start_nexus_operation_interceptor_trace() -> None: assert trace_name == "workflow.start_nexus_operation" trace_input = cast(StartNexusOperationInput[Any, Any], trace_value) request = cast( - workflowservice_pb2.SignalWithStartWorkflowExecutionRequest, + workflow_service_models.SignalWithStartWorkflowRequest, trace_input.input, ) - assert request.workflow_id == "system-nexus-workflow-id" - assert request.signal_name == "test-signal" - assert request.workflow_type.name == "test-workflow" + assert request.id == "system-nexus-workflow-id" + assert request.signal == "test-signal" + assert request.workflow == "test-workflow" class _MarkingPayloadVisitor(VisitorFunctions): From 9437d75ccfa0554bd2e4d4974dea8d62cdb60708 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 17 Sep 2026 00:12:12 -0700 Subject: [PATCH 28/96] Adopted main's payload visitor generator. The generator renames the service module, and the visitor script still looked for the old filename, so the generation sequence died after the model was written. --- scripts/gen_payload_visitor.py | 37 +++++++++++++++++++++++----------- temporalio/bridge/_visitor.py | 5 +++++ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/scripts/gen_payload_visitor.py b/scripts/gen_payload_visitor.py index 0001659f7..7fab85dcd 100644 --- a/scripts/gen_payload_visitor.py +++ b/scripts/gen_payload_visitor.py @@ -2,7 +2,7 @@ import sys from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path -from typing import cast +from typing import cast, get_args, get_origin, get_type_hints import google.protobuf.message import nexusrpc @@ -19,14 +19,15 @@ from temporalio.bridge.proto.workflow_completion.workflow_completion_pb2 import ( WorkflowActivationCompletion, ) +from temporalio.converter._payload_converter import _get_transfer_type_converter def discover_system_nexus_roots() -> list[Descriptor]: - module_path = ( - base_dir / "temporalio" / "nexus" / "system" / "workflow_service" / "service.py" - ) + module_path = base_dir / "temporalio" / "nexus" / "system" / "workflow_service" spec = spec_from_file_location( - "temporalio_nexus_system_workflow_service", module_path + "temporalio_nexus_system_workflow_service", + module_path / "__init__.py", + submodule_search_locations=[str(module_path)], ) if spec is None or spec.loader is None: raise RuntimeError(f"Cannot load generated system service from {module_path}") @@ -35,10 +36,14 @@ def discover_system_nexus_roots() -> list[Descriptor]: spec.loader.exec_module(module) roots: list[Descriptor] = [] - for operation in vars(module.WorkflowService).values(): - if not isinstance(operation, nexusrpc.Operation): + for annotation in get_type_hints(module._services.WorkflowService).values(): + if get_origin(annotation) is not nexusrpc.Operation: continue - for proto_type in (operation.input_type, operation.output_type): + for operation_type in get_args(annotation): + converter = _get_transfer_type_converter(operation_type) + proto_type = ( + converter.transfer_type if converter is not None else operation_type + ) if isinstance(proto_type, type) and issubclass( proto_type, google.protobuf.message.Message ): @@ -119,12 +124,20 @@ def generate(self, roots: list[Descriptor]) -> str: The generated code defines async visitor functions for each reachable protobuf message type starting from WorkflowActivation, including support - for repeated fields and map entries, and a convenience entrypoint - function `visit`. + for repeated fields and map entries. Payload-free roots get no-op methods + so the `visit` entrypoint recognizes them as supported. """ - for r in roots: - self.walk(r) + for root in roots: + if not self.walk(root): + self.methods.append( + f"""\ + async def _visit_{name_for(root)}( + self, fs: VisitorFunctions, o: Any + ) -> None: + pass +""" + ) header = """ from __future__ import annotations diff --git a/temporalio/bridge/_visitor.py b/temporalio/bridge/_visitor.py index ef1e21dbd..f26fc390f 100644 --- a/temporalio/bridge/_visitor.py +++ b/temporalio/bridge/_visitor.py @@ -621,3 +621,8 @@ async def _visit_temporal_api_workflowservice_v1_SignalWithStartWorkflowExecutio await self._visit_temporal_api_common_v1_Header(fs, o.header) if o.HasField("user_metadata"): await self._visit_temporal_api_sdk_v1_UserMetadata(fs, o.user_metadata) + + async def _visit_temporal_api_workflowservice_v1_SignalWithStartWorkflowExecutionResponse( + self, fs: VisitorFunctions, o: Any + ) -> None: + pass From e6007d30c7179d241139050cb72a3104dbaed3f3 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 17 Sep 2026 09:13:45 -0700 Subject: [PATCH 29/96] Made the external stream docstrings build under pydoctor. pydoctor resolves single-backtick prose as symbol references, so shell words, tuples and file names were read as missing targets. Two simple tables also lost their column alignment because pydoctor rewrites role references before parsing, so one is now a literal block. --- .../external_workflow_streams/_annotation.py | 2 +- .../contrib/external_workflow_streams/_api.py | 6 +-- .../external_workflow_streams/_errors.py | 32 ++++++------ .../external_workflow_streams/_manager.py | 50 +++++++++---------- .../_output_backend.py | 2 +- .../external_workflow_streams/_producer.py | 12 ++--- .../external_workflow_streams/_redis.py | 26 +++++----- .../external_workflow_streams/_runtime.py | 10 ++-- temporalio/worker/_workflow.py | 4 +- 9 files changed, 73 insertions(+), 71 deletions(-) diff --git a/temporalio/contrib/external_workflow_streams/_annotation.py b/temporalio/contrib/external_workflow_streams/_annotation.py index d5c55bb53..a55cfb0f8 100644 --- a/temporalio/contrib/external_workflow_streams/_annotation.py +++ b/temporalio/contrib/external_workflow_streams/_annotation.py @@ -154,7 +154,7 @@ class AnnotationBudgetExceeded(temporalio.exceptions.ApplicationError): terminal could not fit an empty annotation, at the point the Workflow makes it. - An :py:class:`~temporalio.exceptions.ApplicationError` marked + An :py:class:`temporalio.exceptions.ApplicationError` marked non-retryable, and that is the substance of this class rather than a detail. A plain exception here fails the *Workflow Task*, and the server retries Workflow Task failures forever: the encoding that overflowed overflows again diff --git a/temporalio/contrib/external_workflow_streams/_api.py b/temporalio/contrib/external_workflow_streams/_api.py index af33bed81..0a373b10b 100644 --- a/temporalio/contrib/external_workflow_streams/_api.py +++ b/temporalio/contrib/external_workflow_streams/_api.py @@ -73,7 +73,7 @@ """ #: Where per-Run subscription state hangs off the Workflow instance. Reserved, -#: and deliberately not in the `__temporal_workflow_stream*` namespace the +#: and deliberately not in the ``__temporal_workflow_stream*`` namespace the #: shipped contrib feature already owns. _RUN_STATE_ATTR = "__temporal_external_stream_state" @@ -183,7 +183,7 @@ class _RunState: runtime: ExternalStreamRuntime | None = None next_wait_id: int = 1 - #: `wait_id -> Future`, resolved by the readiness activation handler. + #: ``wait_id -> Future``, resolved by the readiness activation handler. pending: dict[int, Any] = field(default_factory=dict) @@ -539,7 +539,7 @@ class ExternalStreamSubscription(Generic[AnyType]): **One consumer.** The cursor, the readiness future, and the blocked flag are all the subscription's rather than an iterator's, so two coroutines waiting on it at once is refused with - :class:`~temporalio.contrib.external_workflow_streams._errors.ConcurrentStreamConsumerError` + :class:`temporalio.contrib.external_workflow_streams.ConcurrentStreamConsumerError` rather than served -- see :meth:`_refuse_a_second_waiter` for what sharing them would do. Two consumers of the same *stream* is a supported shape and the way to ask for it is a second ``subscribe()``: delivery is a broadcast diff --git a/temporalio/contrib/external_workflow_streams/_errors.py b/temporalio/contrib/external_workflow_streams/_errors.py index c9450d235..6025ca3f7 100644 --- a/temporalio/contrib/external_workflow_streams/_errors.py +++ b/temporalio/contrib/external_workflow_streams/_errors.py @@ -11,20 +11,22 @@ key on the metrics here rather than on Workflow Task failure counts, which a transient backend outage also increments. -============================ ========================== ==================== -Condition Error type Operator response -============================ ========================== ==================== -Backend unreachable/erroring :class:`StreamStorageError` None -- clears when - the backend recovers -Recorded offset missing, :class:`StreamIntegrityError` Repair or restore -expired, reordered, or the backend, or -miscounted terminate the Run -Bytes intact but undecodable :class:`StreamDecodeError` Align the consumer's - converter with the - producer's -Annotation does not match ordinary nondeterminism Fix or version the -the subscriptions made Workflow code -============================ ========================== ==================== +:: + + ============================ ========================== ==================== + Condition Error type Operator response + ============================ ========================== ==================== + Backend unreachable/erroring ``StreamStorageError`` None -- clears when + the backend recovers + Recorded offset missing, ``StreamIntegrityError`` Repair or restore + expired, reordered, or the backend, or + miscounted terminate the Run + Bytes intact but undecodable ``StreamDecodeError`` Align the consumer's + converter with the + producer's + Annotation does not match ordinary nondeterminism Fix or version the + the subscriptions made Workflow code + ============================ ========================== ==================== There is deliberately **no** ``workflow_failure_exception_types`` registration here: integrity loss *blocks* a Workflow rather than terminating it (ADR-014). @@ -83,7 +85,7 @@ class StreamIntegrityError(StreamError): class StreamDecodeError(StreamError): """A record was present and intact but could not be decoded. - Separated from :class:`StreamIntegrityError` because it is a configuration + Separated from ``StreamIntegrityError`` because it is a configuration error on the *consumer* -- a DataConverter or codec that does not match the producer's -- and reporting it as integrity loss sends an operator to restore a backend that was never damaged (ADR-015). diff --git a/temporalio/contrib/external_workflow_streams/_manager.py b/temporalio/contrib/external_workflow_streams/_manager.py index 86b521e8d..2323b42e1 100644 --- a/temporalio/contrib/external_workflow_streams/_manager.py +++ b/temporalio/contrib/external_workflow_streams/_manager.py @@ -28,13 +28,13 @@ Three cursors, and conflating them is how a speculative read becomes a durable claim: -=================== =================== =========== ================== -Cursor Advances on Owner Survives eviction -=================== =================== =========== ================== -``committed`` marker commit only the marker yes -``delivery`` hand-off to workflow the instance no -``prefetch`` buffering the manager no -=================== =================== =========== ================== +============= ==================== ============ ================= +Cursor Advances on Owner Survives eviction +============= ==================== ============ ================= +``committed`` marker commit only the marker yes +``delivery`` hand-off to workflow the instance no +``prefetch`` buffering the manager no +============= ==================== ============ ================= ``prefetch`` is speculative: reading a record is not consuming it, and consuming it is not committing it. The manager may only move it *backwards* to @@ -227,7 +227,7 @@ class RunStatus: out is only the first thing the activation then has to do. Holds an activation takes *itself* are deliberately not bounded. Those are the -same backend exposure the park handshake already has -- `install_park_intent` +same backend exposure the park handshake already has -- ``install_park_intent`` can hang exactly as a removal can -- so a bound there would move the wait rather than remove it. @@ -316,7 +316,7 @@ class Subscription: _has_room: asyncio.Event = field(default_factory=asyncio.Event, repr=False) _watcher: asyncio.Task[None] | None = field(default=None, repr=False) _cancelled: bool = field(default=False, repr=False) - #: Bumped whenever every speculative read is discarded. A `read_after` + #: Bumped whenever every speculative read is discarded. A ``read_after`` #: already in flight started from the cursor being discarded, so its records #: are speculative too; the watcher compares this across the await and drops #: them rather than appending them on top of the reset -- which would put the @@ -747,7 +747,7 @@ def __init__( self._pending_wake_runs: dict[str, _PendingWake] = {} self._activation_sequences: dict[str, int] = {} #: Wakes the shutdown sweep could not get acknowledged. Reported through - #: `external_stream_shutdown_wake_failed`; kept here so a test can tell + #: ``external_stream_shutdown_wake_failed``; kept here so a test can tell #: "no wake was needed" from "a wake was needed and lost". self.shutdown_wake_failures = 0 self._buffer_size = buffer_size @@ -780,7 +780,7 @@ def __init__( #: Strong references to the in-flight registration-time reconciliations. self._reconciliations: set[asyncio.Task[None]] = set() #: Removals this manager decided on and did not get confirmed, per Run - #: and then per `(stream key, wait_id)`. The durable half of the intent + #: and then per ``(stream key, wait_id)``. The durable half of the intent #: invariant: a removal that failed is a claim about the *backend*, and #: recording it anywhere that a close or an eviction takes away is the #: same as not recording it at all. Drained under `_park_lock`. @@ -858,7 +858,7 @@ def _cancel_watcher(self, subscription: Subscription) -> None: def _start_watcher(self, subscription: Subscription) -> None: """Starts a watcher, and reconciles the park state it inherited. - Both on the manager's own loop, because both are `create_task` calls and + Both on the manager's own loop, because both are ``create_task`` calls and `register` runs on the Workflow executor thread. """ if subscription._cancelled or subscription._watcher is not None: @@ -886,10 +886,10 @@ async def _reconcile_inherited_park(self, subscription: Subscription) -> None: Registration is where a Worker learns such an intent exists, and it is also the moment its status is unambiguous. A subscription is registered by user Workflow code running, and no user code runs inside a park - (`wft-lifecycle.md`), so an intent found here belongs to a park that is + (``wft-lifecycle.md``), so an intent found here belongs to a park that is over: the Core that confirmed it has either moved on or gone with the Worker that held it. What leaving it costs is the invariant's whole - point -- `current_park_generation` keeps answering a generation Core has + point -- ``current_park_generation`` keeps answering a generation Core has discarded, every producer wake names that generation and Core discards it as stale, and because a parked wake's request ID ignores sender identity the second such wake is byte-identical to the first and the @@ -1379,7 +1379,7 @@ def _reannounce_after_cleanup( A stale intent does not only leak. While it was installed every wake for this stream named the generation behind it -- the producer reads - `current_park_generation`, and so does the Worker's own sender -- and + ``current_park_generation``, and so does the Worker's own sender -- and Core discards a non-zero generation that is not the park it is holding. A record that arrived in that window was therefore announced to nobody, and *removing the intent does not announce it*: `_report_ready` counted @@ -1390,12 +1390,12 @@ def _reannounce_after_cleanup( create a Workflow Task. Both outcomes that clear the key announce, and that is why the provider - contract does not answer with a Boolean. `ABSENT` is not "someone else's + contract does not answer with a Boolean. ``ABSENT`` is not "someone else's intent is in the way", it is "the intent this entry named is gone" -- which is what a retry sees after the reply to a delete that in fact succeeded was lost, and what one cleanup owner sees after another finished the job. Reading that as a mismatch would leave the record the - intent silenced silent for good. `MISMATCH` announces nothing: an intent + intent silenced silent for good. ``MISMATCH`` announces nothing: an intent is still installed there, the suppression it causes has not ended, and it belongs to a park this entry knows nothing about. @@ -1428,7 +1428,7 @@ def _park_lock(self, run_id: str) -> asyncio.Lock: """Serializes one Run's park-intent work on the manager's loop. The install/recheck handshake, the resolve, and the reconciliation above - all read-then-write the same `(stream key, wait_id)` objects, and the + all read-then-write the same ``(stream key, wait_id)`` objects, and the reconciliation is scheduled from another thread, so their interleaving is not otherwise constrained. Without this, a reconciliation that overlapped a confirming park could remove the intent that park had just @@ -1493,7 +1493,7 @@ def rearm_ready(self, run_id: str) -> None: Workflow Task whose data had already arrived. Called from the Workflow thread at activation completion, so the work is - hopped onto the manager's loop: `create_task` is not thread-safe, and a + hopped onto the manager's loop: ``create_task`` is not thread-safe, and a task created from the Workflow executor thread is silently never scheduled -- indistinguishable from a stream that never delivers. """ @@ -1803,7 +1803,7 @@ async def _report_ready(self, subscription: Subscription) -> None: leave this method. The watcher calls it in its loop, so an exception escaping ends that watcher for good -- the subscription stays registered, its buffer keeps its records, and nothing ever announces them again. - `_rearm_ready` also launches it with `create_task`, where an exception + `_rearm_ready` also launches it with ``create_task``, where an exception becomes a task result nobody retrieves and the failure is not even logged. """ @@ -1917,7 +1917,7 @@ async def _retry_stale(self, subscription: Subscription) -> str: Returns the result the retries ended on, so the caller can act on it. A Boolean would answer only "was it announced", and the four non-``Accepted`` answers are not interchangeable: they differ in what - happens to the watcher, and `RunNotFound` in particular requires the + happens to the watcher, and ``RunNotFound`` in particular requires the watcher to be torn down. Discarding them left that teardown unreachable from here. @@ -1927,7 +1927,7 @@ async def _retry_stale(self, subscription: Subscription) -> str: Workflow consuming happily, and a wake owed at the end of that costs one empty Workflow Task rather than a silent stall. - `RunNotFound` ends the retries rather than using them up. It is the one + ``RunNotFound`` ends the retries rather than using them up. It is the one answer that cannot change back: the Run is gone from this Worker, so a further report can only be answered the same way, and each attempt costs a delay before the wake this record still needs. @@ -1967,7 +1967,7 @@ async def prepare_park( is waiting on and aborts a legitimate park, which then runs again on the next idle timeout and aborts again; and an intent installed for a wait outside the set is an intent with no park behind it, which is exactly - what `backend-contract.md` forbids leaving in a backend. + what ``backend-contract.md`` forbids leaving in a backend. The order is what closes the append/park race: a producer appends its record *before* it observes the park generation, so an append is either @@ -2328,7 +2328,7 @@ async def cancel(self, run_id: str, wait_id: int) -> None: behind for good. And it is not confined to the closed wait. A stale intent keeps - `parked_wait_ids` non-empty, which suppresses the unparked-wake fallback + ``parked_wait_ids`` non-empty, which suppresses the unparked-wake fallback for the **whole stream**: with no live wait parked, the producer sends only the dead generation, Core discards it as stale, and dedup silences every later publish -- so live waits across the Continue-As-New chain @@ -2684,7 +2684,7 @@ async def _sweep_wake(self, subscription: Subscription) -> None: Counting the failure is deliberately **not** done here for the cancellation case. The grace period expiring cancels this coroutine wherever it is, `_send_owed_wake` re-raises `CancelledError` by design, - and no `except` here could both record the failure and leave the + and no ``except`` here could both record the failure and leave the cancellation intact for the subscriptions after this one -- which are not reached either. The subscription stays in the unaccounted set instead and :meth:`_account_unswept` counts it, which covers being cancelled and diff --git a/temporalio/contrib/external_workflow_streams/_output_backend.py b/temporalio/contrib/external_workflow_streams/_output_backend.py index ec4b1f58e..bcdb154d3 100644 --- a/temporalio/contrib/external_workflow_streams/_output_backend.py +++ b/temporalio/contrib/external_workflow_streams/_output_backend.py @@ -286,7 +286,7 @@ async def append_output( input record's ``(session_id, sequence)`` and byte identity exactly as :meth:`StreamBackend.append`: an identical retry returns the original output record and offset; different bytes under the same key raise - :class:`~temporalio.contrib.external_workflow_streams._backend.AppendConflictError`. + :class:`temporalio.contrib.external_workflow_streams.AppendConflictError`. It may not pass an already-positioned pending stage in client reads. """ diff --git a/temporalio/contrib/external_workflow_streams/_producer.py b/temporalio/contrib/external_workflow_streams/_producer.py index 49ce2876f..18fb0c29b 100644 --- a/temporalio/contrib/external_workflow_streams/_producer.py +++ b/temporalio/contrib/external_workflow_streams/_producer.py @@ -118,17 +118,17 @@ def __init__( whatever ended the attempt (ADR-036). """ self.restart = restart - """Whether the caller must call :meth:`.wake` again instead of retrying. + """Whether the caller must call ``wake()`` again instead of retrying. The wake is three steps -- observe the parked set, claim the generation, Signal -- and only the third produces the requests - :meth:`ProducerTopicHandle.retry_wake` re-sends. A failure in the first + ``ProducerTopicHandle.retry_wake()`` re-sends. A failure in the first two leaves nothing to re-send, so ``pending`` is empty and retrying it would silently do nothing at all: the record would stay durable and unannounced while the caller believed it had recovered. ``True`` therefore says "no wake was composed; compose one". Calling - :meth:`ProducerTopicHandle.wake` again is safe and is the whole recovery + ``ProducerTopicHandle.wake()`` again is safe and is the whole recovery -- it re-observes the parked set, and a parked wake's request ID is derived from the generation rather than from the sender, so a wake some other producer already sent deduplicates against it. @@ -198,7 +198,7 @@ def __init__( """The stream the append was for. Where it must be settled. Carried because a record does not name its own stream and the backend's - idempotency scope does: `(session_id, sequence)` is unused on every + idempotency scope does: ``(session_id, sequence)`` is unused on every *other* stream, so the same record handed to another topic's ``resolve_append`` would append a second copy there rather than deduplicate. The recovery refuses that, and this is what a caller @@ -455,7 +455,7 @@ def __init__( #: Per stream, the appends that never reported an outcome. #: #: Kept because the operation *is* the recovery: only these exact bytes - #: under these exact `(session_id, sequence)` pairs re-append as a no-op + #: under these exact ``(session_id, sequence)`` pairs re-append as a no-op #: if the first attempt landed, and only what the interrupted call owed #: says whether settling it still has a wake to send. A list rather than #: a single slot because concurrent publishes to one stream are supported @@ -872,7 +872,7 @@ def _outstanding(self, record: StreamRecord) -> _UnresolvedAppend: The lookup **is** the safety check, and it is three checks at once. It binds the recovery to the stream, because a record does not name its own - stream and `(session_id, sequence)` is unused on every other one -- so a + stream and ``(session_id, sequence)`` is unused on every other one -- so a record settled against the wrong topic appends a second copy of the value rather than deduplicating, and leaves the real stream still blocked. It binds the recovery to the exact bytes, because idempotency is on identity diff --git a/temporalio/contrib/external_workflow_streams/_redis.py b/temporalio/contrib/external_workflow_streams/_redis.py index dd7cfceb1..c4d69b1cf 100644 --- a/temporalio/contrib/external_workflow_streams/_redis.py +++ b/temporalio/contrib/external_workflow_streams/_redis.py @@ -71,7 +71,7 @@ #: Redis' beginning-of-stream sentinel for `XREAD`. Not an offset: no record #: ever has this id, which is why `BEGINNING` is a distinct cursor form rather -#: than `AFTER(Offset("0-0"))`. +#: than ``AFTER(Offset("0-0"))``. _BEGINNING_SENTINEL: Final = "0-0" _OUTPUT_STAGE_FIELD: Final = "__tes_output_stage" @@ -99,7 +99,7 @@ def _content_hash(record: StreamRecord) -> str: #: Append-if-new-or-identical, atomically. #: -#: Split across two commands without a script, a crash between `XADD` and the +#: Split across two commands without a script, a crash between ``XADD`` and the #: idempotency write would leave a record no retry could recognise as its own, #: so the retry would append a duplicate. _APPEND_LUA: Final = """ @@ -201,7 +201,7 @@ class RedisStreamBackend(StreamBackend, OutputStreamBackend): """Redis Streams as an external workflow stream provider.""" guarantees_immutability: ClassVar[bool | None] = True - """`XADD` entries cannot be rewritten in place -- only deleted or trimmed.""" + """``XADD`` entries cannot be rewritten in place -- only deleted or trimmed.""" provider_id = "redis-streams" provider_format_version = 1 @@ -738,14 +738,14 @@ async def delete_for_test(self, key: StreamKey, offset: Offset) -> None: await self._client.xdel(self.stream_key(key), offset.serialize()) -#: What Redis' glob matcher treats as more than itself. `]` and `^` are special +#: What Redis' glob matcher treats as more than itself. ``]`` and ``^`` are special #: only inside a class, but escaping them too costs nothing and keeps the rule #: one line long. _GLOB_METACHARACTERS: Final = frozenset("*?[]\\") def _as_glob_literal(text: str) -> str: - """A literal string, made safe to embed in a `SCAN MATCH` pattern.""" + """A literal string, made safe to embed in a ``SCAN MATCH`` pattern.""" return "".join( f"\\{character}" if character in _GLOB_METACHARACTERS else character for character in text @@ -756,25 +756,25 @@ def _escaped(key: StreamKey) -> str: r"""One stream identity as a single, unambiguous key component. Percent-encoded per field, then joined -- **not** joined raw. A Workflow ID - and a stream name are user-chosen strings in which `:` is an ordinary + and a stream name are user-chosen strings in which ``:`` is an ordinary character, so joining the raw fields is not injective: ("ns", "wf", r1, f"{r2}:tokens") and ("ns", f"wf:{r1}", r2, "tokens") - both render as `ns:wf:r1:r2:tokens`. Two unrelated Workflows would then share + both render as ``ns:wf:r1:r2:tokens``. Two unrelated Workflows would then share one stream, one idempotency hash, one park intent and one claim -- delivering each other's records, and each concluding the other's claim had already taken - its wake. Same reasoning as `_wake.py`'s length-prefixed request-ID material, + its wake. Same reasoning as ``_wake.py``'s length-prefixed request-ID material, applied to a key rather than to a digest. Percent-encoding rather than length prefixes because a key is read by humans: - an ordinary identity still renders verbatim in `redis-cli`, and only a field + an ordinary identity still renders verbatim in ``redis-cli``, and only a field that actually contains a delimiter pays for it. It buys one property the - length prefix does not -- the encoded form contains no `:`, `*`, `?`, `[` or - `\`, so the derived `:idem`, `:park:` and `:claim:` suffixes stay - unambiguous and `parked_wait_ids`' pattern cannot be widened by a stream name. + length prefix does not -- the encoded form contains no ``:``, ``*``, ``?``, ``[`` or + ``\``, so the derived ``:idem``, ``:park:`` and ``:claim:`` suffixes stay + unambiguous and ``parked_wait_ids``' pattern cannot be widened by a stream name. - Not reversible in practice, and not meant to be: `key_prefix` is + Not reversible in practice, and not meant to be: ``key_prefix`` is operator-supplied and unescaped, so only the identity half round-trips. """ components: tuple[str, ...] = ( diff --git a/temporalio/contrib/external_workflow_streams/_runtime.py b/temporalio/contrib/external_workflow_streams/_runtime.py index 09b38bab0..e57b3aa91 100644 --- a/temporalio/contrib/external_workflow_streams/_runtime.py +++ b/temporalio/contrib/external_workflow_streams/_runtime.py @@ -276,7 +276,7 @@ def __init__( #: the Worker that built this runtime, so `codec_for` hands Workflow #: code a converter carrying the same context every other payload in the #: activation was converted with. Bound out there rather than in here - #: because `with_context` runs user code, and this object lives on the + #: because ``with_context`` runs user code, and this object lives on the #: far side of the sandbox boundary. self._data_converter = data_converter self._default_idle_timeout = default_idle_timeout @@ -332,7 +332,7 @@ def __init__( self._unobserved_segments = 0 self._segment_pending = True self._observed_this_activation = False - #: `wait_id -> Future`, awaited by Workflow code and resolved by the + #: ``wait_id -> Future``, awaited by Workflow code and resolved by the #: readiness activation. It lives here rather than on either side alone #: because the two halves are in different modules and a second map #: would mean the side that resolves is never the side that registered. @@ -343,7 +343,7 @@ def __init__( #: only for the length of one replay job, which is what makes a #: registration made during it checkable against what was recorded. self._replay_bindings: dict[int, StreamBinding] | None = None - #: `wait_id -> the converter a *recorded* wait's records convert with`. + #: ``wait_id -> the converter a *recorded* wait's records convert with``. #: Installed by the Worker before a replay job reaches this thread; see #: :meth:`install_replay_converters` for why it exists and why it is not #: torn down when the replay ends. @@ -411,7 +411,7 @@ def begin_activation(self, history_floor_event_id: int | None = None) -> None: drain a full batch, consume one record and block elsewhere on every activation in turn, so n subscriptions arrive at an activation holding roughly n times the cap between them and hand all of it over in one - `activate()` call. Starting the count at the carry-over makes what an + ``activate()`` call. Starting the count at the carry-over makes what an activation may hand over -- carried-over plus newly delivered -- exactly the cap, whatever the schedule. """ @@ -1312,7 +1312,7 @@ def _verify_binding( Only the **stream name** is compared, not the whole key. The other three components -- namespace, Workflow id, first execution Run id -- are the Run's identity rather than anything the code chose, and a replay harness - legitimately supplies its own: `Replayer` runs under `ReplayNamespace`, + legitimately supplies its own: `Replayer` runs under ``ReplayNamespace``, so comparing the full key would report every replayed history as nondeterministic. The key is still *recorded* whole, because replay has to read the ranges it names. diff --git a/temporalio/worker/_workflow.py b/temporalio/worker/_workflow.py index b22b779f3..42fb07ff8 100644 --- a/temporalio/worker/_workflow.py +++ b/temporalio/worker/_workflow.py @@ -249,7 +249,7 @@ def __init__( #: A sandboxed Workflow's `instance` is a proxy that exposes only the #: `WorkflowInstance` protocol, so reaching through it for the runtime #: silently found nothing -- and the jobs that must never reach - #: `activate()` quietly went to `_apply` instead. + #: `activate()` quietly went to ``_apply`` instead. self._external_stream_runtimes: dict[str, Any] = {} # Stages survive activations within the cached Run because Core may # defer the server completion behind a local activity. A marker can be @@ -1252,7 +1252,7 @@ def _replay_stream_converters( to have re-created the subscription that would otherwise carry it. Bound from the Worker's own converter rather than from the runtime's, - which is already bound to this Run: a second `with_context` over the + which is already bound to this Run: a second ``with_context`` over the first would ask a user's component converter to rebind itself, and nothing in the protocol promises that composes. From a3f50cd551f520e065e2fe220109558a8477f12e Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 16:41:28 -0700 Subject: [PATCH 30/96] Woke outside readers across threads and ignored idle_timeout in memory. A workflow's publish runs on the workflow thread, so waking a consumer parked on another loop needs call_soon_threadsafe. The memory provider also stops reading idle_timeout as its poll period, which gave the parameter a second meaning. --- temporalio/streams/providers/memory.py | 38 ++++++++++++++++++++------ 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/temporalio/streams/providers/memory.py b/temporalio/streams/providers/memory.py index f7e58ef44..7b6e74fdc 100644 --- a/temporalio/streams/providers/memory.py +++ b/temporalio/streams/providers/memory.py @@ -31,6 +31,13 @@ _DEFAULT_POLL = timedelta(milliseconds=100) +logger = logging.getLogger(__name__) + + +def _wake(future: asyncio.Future[None]) -> None: + if not future.done(): + future.set_result(None) + class _MemoryStream: def __init__(self) -> None: @@ -38,9 +45,16 @@ def __init__(self) -> None: # Dedupe identity is (producer#attempt, first sequence of the append), # the same pair the storage providers use. self.seen: dict[tuple[str, int], int] = {} - self._arrival = asyncio.Event() - - def append(self, frames: list[bytes], *, producer_id: str, sequence: int) -> int: + # Each waiter is parked with the loop it belongs to. A workflow's + # publish runs on the workflow thread, and waking a foreign loop's + # future from there needs call_soon_threadsafe or the loop stays + # blocked in select until unrelated I/O happens to wake it. + self._waiters: list[tuple[asyncio.AbstractEventLoop, asyncio.Future[None]]] = [] + + def append( + self, frames: list[bytes], *, producer_id: str, sequence: int + ) -> int | None: + """Store ``frames`` and return the first one's offset, or ``None`` for a repeat.""" key = (producer_id, sequence) already = self.seen.get(key) if already is not None: @@ -48,13 +62,17 @@ def append(self, frames: list[bytes], *, producer_id: str, sequence: int) -> int offset = len(self.frames) self.frames.extend(frames) self.seen[key] = offset - arrival, self._arrival = self._arrival, asyncio.Event() - arrival.set() + waiters, self._waiters = self._waiters, [] + for loop, future in waiters: + loop.call_soon_threadsafe(_wake, future) return offset async def wait_past(self, offset: int) -> None: while len(self.frames) <= offset: - await self._arrival.wait() + loop = asyncio.get_running_loop() + future: asyncio.Future[None] = loop.create_future() + self._waiters.append((loop, future)) + await future _streams: dict[str, _MemoryStream] = {} @@ -290,11 +308,13 @@ def open_read( after: Cursor = BEGINNING, idle_timeout: timedelta | None = None, ) -> ReadSource: + # Ignored: this provider never releases the worker, it polls. Reading + # idle_timeout as the poll period would give the parameter a second + # meaning that a port copying the reference would copy too. + del idle_timeout store = _stream(_inbound_id(workflow.info().workflow_id, stream)) return _MemReadSource( - store, - int(after.token) + 1 if after.token else 0, - idle_timeout or self._poll, + store, int(after.token) + 1 if after.token else 0, self._poll ) def open_write(self, topic: str) -> WriteSink: From 528b811a1ee7419ea3c3ddf160a72aed5fba4124 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 16:41:28 -0700 Subject: [PATCH 31/96] Named the last record in append's cursor and allowed None for it. A caller resuming with read(after=appended) must see only what came later, so the cursor names the last record of the batch. None says nothing was written, or that the transport learns positions at read time. --- temporalio/streams/_provider.py | 11 ++++++++-- temporalio/streams/providers/memory.py | 15 ++++++++------ tests/streams/test_streams_conformance.py | 25 +++++++++++++++++++---- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/temporalio/streams/_provider.py b/temporalio/streams/_provider.py index 18e2bb983..e0011b0c7 100644 --- a/temporalio/streams/_provider.py +++ b/temporalio/streams/_provider.py @@ -48,8 +48,15 @@ def attempt(self) -> int: """The generation this producer is writing.""" ... - async def append(self, *values: Any) -> Cursor: - """Append values and return where the first one landed.""" + async def append(self, *values: Any) -> Cursor | None: + """Append values and return the cursor of the last one written. + + The last rather than the first, so ``read(after=appended)`` yields + only what came later. ``None`` when nothing was written, because the + batch was empty or a repeat the provider dropped, and on a transport + that learns positions only at read time; a caller that needs to + position itself on such a transport asks :meth:`Consumer.latest`. + """ ... async def finish(self) -> None: diff --git a/temporalio/streams/providers/memory.py b/temporalio/streams/providers/memory.py index 7b6e74fdc..2244069b8 100644 --- a/temporalio/streams/providers/memory.py +++ b/temporalio/streams/providers/memory.py @@ -56,9 +56,8 @@ def append( ) -> int | None: """Store ``frames`` and return the first one's offset, or ``None`` for a repeat.""" key = (producer_id, sequence) - already = self.seen.get(key) - if already is not None: - return already + if key in self.seen: + return None offset = len(self.frames) self.frames.extend(frames) self.seen[key] = offset @@ -177,8 +176,10 @@ def _provider_id(self) -> str: else self._producer_id ) - async def append(self, *values: Any) -> Cursor: - """Append ``values`` and return the cursor of the last one.""" + async def append(self, *values: Any) -> Cursor | None: + """Append ``values`` and return the last one's cursor, or ``None`` if nothing landed.""" + if not values: + return None frames = [] for value in values: frames.append( @@ -197,7 +198,9 @@ async def append(self, *values: Any) -> Cursor: producer_id=self._provider_id, sequence=self._sequence - len(frames), ) - return Cursor(str(offset)) + if offset is None: + return None + return Cursor(str(offset + len(frames) - 1)) async def finish(self) -> None: """Mark this producer done, so a reader stops waiting on it.""" diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index 0c7818064..a0cae8fc7 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -98,17 +98,18 @@ async def test_retried_append_is_deduplicated(): ) await first.append({"id": "r1"}) # The retry of the same attempt starts its sequence over and appends the - # same record. The provider must not store it twice. + # same record. The provider must not store it twice, and says so by + # returning no position for what it dropped. retry = await streams.producer( None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 ) - await retry.append({"id": "r1"}) + assert await retry.append({"id": "r1"}) is None consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") records = await take(consumer.read(type=dict), 1) assert records[0].value == {"id": "r1"} - with pytest.raises(asyncio.TimeoutError): - await take(consumer.read(type=dict), 2, timeout=0.2) + # The store holds exactly the one record: the newest position is its cursor. + assert await consumer.latest() == records[0].cursor async def test_new_attempt_supersedes_the_old_one(): @@ -161,6 +162,22 @@ async def test_cursor_resumes_where_it_points(): assert [r.value for r in again] == [{"n": 2}, {"n": 3}] +async def test_append_cursor_names_the_last_record_of_the_batch(): + producer = await streams.producer( + None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 + ) + appended = await producer.append({"n": 1}, {"n": 2}, {"n": 3}) + assert appended is not None + await producer.append({"n": 4}) + + # A producer that resumes a reader after its own append must see only + # what came later, not the tail of the batch it just wrote. + consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") + records = await take(consumer.read(type=dict, after=appended), 1) + assert [r.value for r in records] == [{"n": 4}] + assert await producer.append() is None + + async def test_latest_positions_a_reader_at_the_end(): producer = await streams.producer( None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 From 217497f8f53508a14cbdaa9e240a3edd0b39e3f2 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 16:41:28 -0700 Subject: [PATCH 32/96] Resolved producer identity once in the package. The activity defaulting rule is an interface rule, and every provider carried its own copy. --- temporalio/streams/_provider.py | 14 +++++++++++++- temporalio/streams/providers/memory.py | 5 ----- tests/streams/test_streams_conformance.py | 3 +++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/temporalio/streams/_provider.py b/temporalio/streams/_provider.py index e0011b0c7..2d8e14f2b 100644 --- a/temporalio/streams/_provider.py +++ b/temporalio/streams/_provider.py @@ -14,6 +14,7 @@ from datetime import timedelta from typing import Any, Callable, Protocol +from temporalio import activity from temporalio.streams._handles import ReadSource, WriteSink from temporalio.streams._record import BEGINNING, Cursor, StreamRecord @@ -148,7 +149,9 @@ async def producer( ``stream`` names one of the workflow's inbound streams. With no ``stream``, ``topic`` names a topic on the stream the workflow itself publishes, which is how an activity puts its live output next to the - workflow's own records for the same outside reader. + workflow's own records for the same outside reader. ``producer_id`` + is never empty here: the package resolved it, from the activity + context when the caller left it unset. """ ... @@ -312,6 +315,15 @@ async def producer( "name exactly one of stream (an inbound stream of the workflow) or " "topic (a topic on the stream the workflow publishes)" ) + if not producer_id: + if not activity.in_activity(): + raise ValueError( + "producer_id is required outside an activity; inside one it " + "defaults to the activity's id and attempt" + ) + info = activity.info() + producer_id = info.activity_id + attempt = attempt or info.attempt return await _current().producer( client, workflow_id=workflow_id, diff --git a/temporalio/streams/providers/memory.py b/temporalio/streams/providers/memory.py index 2244069b8..64e8feb8f 100644 --- a/temporalio/streams/providers/memory.py +++ b/temporalio/streams/providers/memory.py @@ -334,11 +334,6 @@ async def producer( producer_id: str = "", attempt: int = 0, ) -> MemoryProducer: - if not producer_id: - from temporalio import activity - - producer_id = activity.info().activity_id - attempt = attempt or activity.info().attempt # With no inbound stream named, the target is the store the workflow's # own writer appends to, and the frame carries the topic instead. return MemoryProducer( diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index a0cae8fc7..4db5b9fe9 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -210,6 +210,9 @@ async def test_producer_appends_onto_the_owners_topic(): with pytest.raises(ValueError): await streams.producer(None, workflow_id="wf", producer_id="model", attempt=1) + # Outside an activity there is no identity to fall back on. + with pytest.raises(ValueError, match="producer_id is required"): + await streams.producer(None, workflow_id="wf", stream="inputs") def test_unknown_provider_is_a_clear_error(): From 8495140aea9ce8bdf1cc4db4d4633512c593669e Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 16:41:28 -0700 Subject: [PATCH 33/96] Required an explicit provider and exported instance. Building a provider is process setup. Workers resolve the default through worker_options(); workflow code that opens a stream before that gets a clear error instead of a racy lazy build. --- temporalio/streams/__init__.py | 8 ++++-- temporalio/streams/_provider.py | 33 ++++++++++++++++------- tests/streams/test_streams_conformance.py | 12 ++++++++- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/temporalio/streams/__init__.py b/temporalio/streams/__init__.py index cd55ee9d4..ea0d86c81 100644 --- a/temporalio/streams/__init__.py +++ b/temporalio/streams/__init__.py @@ -33,7 +33,9 @@ :func:`configure` once before it opens a :func:`producer` or a :func:`consumer`, and passes :func:`worker_options` to its ``Worker`` and ``Replayer``. Those two calls are where a provider is named. Nothing else in -this module mentions one. +this module mentions one. Code that needs a second provider next to the +process default, such as a handler that serves one store while its process +talks to another, holds one from :func:`instance`. Reading somebody else's stream is out of scope for the first release. It is the topology neither prototype has evidence for, and leaving it out is what @@ -68,6 +70,7 @@ configure, consumer, drain, + instance, open_read, open_write, prepare, @@ -99,10 +102,11 @@ "configure", "consumer", "drain", + "instance", "prepare", "producer", - "worker_options", "reader", + "worker_options", "writer", ] diff --git a/temporalio/streams/_provider.py b/temporalio/streams/_provider.py index 2d8e14f2b..b22dbbfd8 100644 --- a/temporalio/streams/_provider.py +++ b/temporalio/streams/_provider.py @@ -4,8 +4,8 @@ around it are shared, so every provider module is small: it implements :class:`StreamProvider` and registers a factory under a short name when it is imported. One :func:`configure` call decides which factory serves this -process; when exactly one provider is registered, it is the default and the -call can be omitted. +process; :func:`worker_options` makes the same choice for a worker when +exactly one provider is registered. """ from __future__ import annotations @@ -26,6 +26,7 @@ "configure", "consumer", "drain", + "instance", "open_read", "open_write", "prepare", @@ -158,7 +159,7 @@ async def producer( async def consumer( self, client: Any, *, workflow_id: str, stream: str = "" ) -> Consumer: - """Open a reader for what ``workflow_id`` publishes.""" + """Open a reader on ``workflow_id``'s own stream, or its inbound ``stream``.""" ... @@ -240,16 +241,25 @@ def instance(provider: str | None = None, **options: Any) -> StreamProvider: def _current() -> StreamProvider: - global _active if _active is None: - chosen = _make(None) - chosen.configure() - _active = chosen + # Building a provider is process setup. Doing it on the first + # workflow's thread would race a second workflow thread for the + # global and hide the choice inside workflow code. + raise RuntimeError( + "no stream provider is configured; call streams.configure() or " + "streams.worker_options() before opening a stream" + ) return _active def worker_options() -> dict[str, Any]: - """What a ``Worker`` or ``Replayer`` needs to serve this provider.""" + """What a ``Worker`` or ``Replayer`` needs to serve this provider. + + Every worker calls this, so it is where the default is resolved when + :func:`configure` was not called and exactly one provider is registered. + """ + if _active is None: + configure() return _current().worker_options() @@ -335,5 +345,10 @@ async def producer( async def consumer(client: Any, *, workflow_id: str, stream: str = "") -> Consumer: - """Open a reader for what ``workflow_id`` publishes.""" + """Open a reader on ``workflow_id``'s own stream, or its inbound ``stream``. + + With no ``stream`` the reader follows what the workflow publishes, topic + by topic. With one, it follows the inbound stream of that name, which is + how a process watches what producers hand the workflow. + """ return await _current().consumer(client, workflow_id=workflow_id, stream=stream) diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index 4db5b9fe9..3a69ea2dd 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -15,7 +15,7 @@ import pytest from temporalio import streams -from temporalio.streams import _frame +from temporalio.streams import _frame, _provider from temporalio.streams._policy import AttemptTracker from temporalio.streams._record import Cursor, RecordKind from temporalio.streams.providers import memory @@ -218,3 +218,13 @@ async def test_producer_appends_onto_the_owners_topic(): def test_unknown_provider_is_a_clear_error(): with pytest.raises(RuntimeError, match="no stream provider 'nope'"): streams.configure(provider="nope") + + +async def test_opening_a_stream_needs_a_configured_provider(): + # Building a provider is process setup, so the first workflow's thread + # is not allowed to do it as a side effect of opening a stream. + _provider._active = None + with pytest.raises(RuntimeError, match="streams.configure"): + await streams.consumer(None, workflow_id="wf") + streams.configure(provider="memory") + assert await streams.consumer(None, workflow_id="wf") is not None From ad177b2570f9c2b3f2dd840648a61f95086c016f Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 16:41:29 -0700 Subject: [PATCH 34/96] Typed the handles and records honestly. reader() and Consumer.read bind T through overloads, StreamRecord.value admits the control bodies it actually carries, writer() drops the type it ignored, and next() leaves the public surface. --- streams_demo/agent_loop.py | 3 ++- temporalio/streams/__init__.py | 33 ++++++++++++++++------- temporalio/streams/_handles.py | 13 +++------ temporalio/streams/_provider.py | 32 +++++++++++++++++++--- temporalio/streams/_record.py | 9 +++++-- temporalio/streams/providers/memory.py | 5 ++-- tests/streams/test_streams_conformance.py | 2 ++ 7 files changed, 70 insertions(+), 27 deletions(-) diff --git a/streams_demo/agent_loop.py b/streams_demo/agent_loop.py index 79d92c367..0ab6831b5 100644 --- a/streams_demo/agent_loop.py +++ b/streams_demo/agent_loop.py @@ -59,7 +59,7 @@ async def run(self, limit: int) -> list[dict[str, Any]]: accepted = 0 try: async for record in inputs: - if record.kind is streams.RecordKind.SUPERSEDED: + if isinstance(record.value, streams.Supersession): # A newer attempt of the same producer started writing. The # decisions already published stand, so the workflow says so # rather than pretending they can be withdrawn. @@ -82,6 +82,7 @@ async def run(self, limit: int) -> list[dict[str, Any]]: # still writing. trace.append({"kind": "finish", "producer": record.producer}) break + assert isinstance(record.value, dict) decision = decide(record.value) await decisions.publish(decision) receipt = await workflow.execute_activity( diff --git a/temporalio/streams/__init__.py b/temporalio/streams/__init__.py index ea0d86c81..1f1f6acba 100644 --- a/temporalio/streams/__init__.py +++ b/temporalio/streams/__init__.py @@ -54,7 +54,7 @@ from __future__ import annotations from datetime import timedelta -from typing import Any, TypeVar +from typing import Any, TypeVar, overload from temporalio.streams._handles import ( ReadSource, @@ -113,6 +113,26 @@ T = TypeVar("T") +@overload +def reader( + stream: str, + *, + type: type[T], + after: Cursor = ..., + idle_timeout: timedelta | None = ..., +) -> StreamReader[T]: ... + + +@overload +def reader( + stream: str, + *, + type: None = None, + after: Cursor = ..., + idle_timeout: timedelta | None = ..., +) -> StreamReader[Any]: ... + + def reader( stream: str, *, @@ -145,21 +165,16 @@ def reader( ) -def writer(topic: str, *, type: type | None = None) -> StreamWriter[Any]: +def writer(topic: str) -> StreamWriter[Any]: """Publish to ``topic`` on the stream this workflow owns. Args: - topic: The topic name. - type: Declared for symmetry with :func:`reader` and for documentation. - Encoding follows the value. + topic: The topic name. Encoding follows each published value. """ - del type return StreamWriter(open_write(topic), topic) # Importing the package registers every provider whose dependencies are # present in this tree. Import order matters: the registry above must exist # before a provider module can register with it. -from temporalio.streams import ( - providers as _providers, # noqa: E402,F401 # pyright: ignore[reportUnusedImport] -) +from temporalio.streams import providers as providers # noqa: E402 diff --git a/temporalio/streams/_handles.py b/temporalio/streams/_handles.py index a6dc9a073..24c6defa2 100644 --- a/temporalio/streams/_handles.py +++ b/temporalio/streams/_handles.py @@ -8,7 +8,7 @@ from __future__ import annotations from collections.abc import AsyncIterator -from typing import Any, Generic, Protocol, TypeVar +from typing import Any, Generic, Protocol, TypeVar, cast from temporalio import workflow from temporalio.api.common.v1 import Payload @@ -161,7 +161,7 @@ def __aiter__(self) -> AsyncIterator[StreamRecord[T]]: async def _iterate(self) -> AsyncIterator[StreamRecord[T]]: while not self._closed: try: - record = await self.next() + record = await self._next() except StopAsyncIteration: # The provider ended the subscription. Iteration stops rather # than raising, so a workflow that reads to the end of a @@ -173,14 +173,9 @@ async def values(self) -> AsyncIterator[T]: """Iterate the data values, dropping control records.""" async for record in self: if record.kind is RecordKind.DATA: - yield record.value + yield cast("T", record.value) - async def next(self) -> StreamRecord[T]: - """The next record, waiting for one if there is none buffered. - - Raises: - StopAsyncIteration: The provider ended the subscription. - """ + async def _next(self) -> StreamRecord[T]: while not self._pending: await self._fill() return self._pending.pop(0) diff --git a/temporalio/streams/_provider.py b/temporalio/streams/_provider.py index b22dbbfd8..1f9dc727d 100644 --- a/temporalio/streams/_provider.py +++ b/temporalio/streams/_provider.py @@ -10,9 +10,9 @@ from __future__ import annotations -from collections.abc import AsyncIterator +from collections.abc import AsyncGenerator from datetime import timedelta -from typing import Any, Callable, Protocol +from typing import Any, Callable, Protocol, TypeVar, overload from temporalio import activity from temporalio.streams._handles import ReadSource, WriteSink @@ -36,6 +36,8 @@ "worker_options", ] +T = TypeVar("T") + class Producer(Protocol): """Appends to a stream from outside workflow code. @@ -69,19 +71,41 @@ async def finish(self) -> None: class Consumer(Protocol): """Reads a stream from outside workflow code, resumably.""" + @overload + def read( + self, + *, + after: Cursor = ..., + topic: str | None = ..., + type: type[T], + ) -> AsyncGenerator[StreamRecord[T], None]: ... + + @overload + def read( + self, + *, + after: Cursor = ..., + topic: str | None = ..., + type: None = None, + ) -> AsyncGenerator[StreamRecord[Any], None]: ... + def read( self, *, after: Cursor = BEGINNING, topic: str | None = None, type: type | None = None, - ) -> AsyncIterator[StreamRecord[Any]]: + ) -> AsyncGenerator[StreamRecord[Any], None]: """Yield the records after ``after`` as they arrive. ``BEGINNING`` yields everything the stream retains. Any other cursor came from a record this or another reader saw, and reading resumes just past it, so a reader that stores the last cursor it handled and - hands it back sees every record exactly once. + hands it back sees every record exactly once. ``topic`` filters the + stream the workflow publishes; an inbound stream has no topics, and + naming one there raises ``ValueError``. The result is a generator so + a caller that stops early can ``aclose()`` it and release whatever + the provider parked against the store. """ ... diff --git a/temporalio/streams/_record.py b/temporalio/streams/_record.py index 4fd322189..5eb3e21e4 100644 --- a/temporalio/streams/_record.py +++ b/temporalio/streams/_record.py @@ -76,9 +76,14 @@ class Supersession: @dataclass(frozen=True) class StreamRecord(Generic[T]): - """One record as workflow code sees it.""" + """One record as workflow code sees it. - value: T + ``value`` follows ``kind``: a data record carries a ``T``, a supersession + carries the :class:`Supersession` it reports, and a finish marker carries + ``None``. Check ``kind`` before reading it. + """ + + value: T | Supersession | None cursor: Cursor kind: RecordKind = RecordKind.DATA topic: str = "" diff --git a/temporalio/streams/providers/memory.py b/temporalio/streams/providers/memory.py index 64e8feb8f..fcf873184 100644 --- a/temporalio/streams/providers/memory.py +++ b/temporalio/streams/providers/memory.py @@ -17,7 +17,8 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterator +import logging +from collections.abc import AsyncGenerator from datetime import timedelta from typing import Any @@ -240,7 +241,7 @@ async def read( after: Cursor = BEGINNING, topic: str | None = None, type: type | None = None, - ) -> AsyncIterator[StreamRecord[Any]]: + ) -> AsyncGenerator[StreamRecord[Any], None]: """Yield records after ``after``, waiting for ones not written yet.""" attempts = AttemptTracker() offset = int(after.token) + 1 if after.token else 0 diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index 3a69ea2dd..47b6e29d5 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -68,6 +68,7 @@ def test_supersession_is_synthesized_from_observations(): superseded = attempts.note("model", 2, Cursor("1")) assert superseded is not None assert superseded.kind is RecordKind.SUPERSEDED + assert isinstance(superseded.value, streams.Supersession) assert superseded.value.previous_attempt == 1 # The same attempt again is not a new generation. assert attempts.note("model", 2, Cursor("2")) is None @@ -126,6 +127,7 @@ async def test_new_attempt_supersedes_the_old_one(): records = await take(consumer.read(type=dict), 3) assert records[0].kind is RecordKind.DATA and records[0].attempt == 1 assert records[1].kind is RecordKind.SUPERSEDED + assert isinstance(records[1].value, streams.Supersession) assert records[1].value.previous_attempt == 1 assert records[2].kind is RecordKind.DATA and records[2].attempt == 2 From 952adfb69d042927ecdc08fc5ee05d72a09089e9 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 16:41:29 -0700 Subject: [PATCH 35/96] Kept topics off inbound frames and skipped unreadable frames with a warning. An inbound stream's name is its whole address, so its records carry no topic and a topic filter there is a ValueError on every provider. Both readers now skip a frame the interface did not write and log it, instead of one failing the task while the other drops it silently. --- temporalio/streams/__init__.py | 10 +++---- temporalio/streams/_handles.py | 27 ++++++++++--------- temporalio/streams/_provider.py | 16 ++++++++++++ temporalio/streams/_record.py | 1 + temporalio/streams/providers/memory.py | 15 +++++++---- tests/streams/test_streams_conformance.py | 32 ++++++++++++++++------- 6 files changed, 69 insertions(+), 32 deletions(-) diff --git a/temporalio/streams/__init__.py b/temporalio/streams/__init__.py index 1f1f6acba..ff2a75c61 100644 --- a/temporalio/streams/__init__.py +++ b/temporalio/streams/__init__.py @@ -137,7 +137,6 @@ def reader( stream: str, *, type: type | None = None, - topic: str | None = None, after: Cursor = BEGINNING, idle_timeout: timedelta | None = None, ) -> StreamReader[Any]: @@ -147,10 +146,13 @@ def reader( removing or reordering one renumbers the waits after it. Gate a change behind :func:`temporalio.workflow.patched` as you would for a timer. + An inbound stream has no topics: its name is the whole address, and + every record on it arrives with an empty ``topic``. Topics belong to the + stream the workflow publishes, which :func:`consumer` reads from outside. + Args: stream: The inbound stream's name, relative to this workflow. type: The value type, used as the decode hint. - topic: Only records on this topic, or every topic when omitted. after: Resume after this record. Only honoured on the first subscription of a run, because after that the recorded cursor decides. @@ -159,9 +161,7 @@ def reader( provider's default. """ return StreamReader( - open_read(stream, after=after, idle_timeout=idle_timeout), - topic=topic, - type=type, + open_read(stream, after=after, idle_timeout=idle_timeout), type=type ) diff --git a/temporalio/streams/_handles.py b/temporalio/streams/_handles.py index 24c6defa2..9571d6e0c 100644 --- a/temporalio/streams/_handles.py +++ b/temporalio/streams/_handles.py @@ -1,8 +1,8 @@ """The handles workflow code holds. Everything provider-specific sits behind the two protocols at the top. A -handle converts values, frames records, filters topics and synthesizes -supersession; a binding only moves bytes. +handle converts values, frames records and synthesizes supersession; a +provider only moves bytes. """ from __future__ import annotations @@ -131,7 +131,7 @@ async def finish(self) -> None: class StreamReader(Generic[T]): - """Reads a stream from inside workflow code. + """Reads an inbound stream from inside workflow code. Iterating yields every kind of record, including the supersession the reader synthesizes when a producer's newer attempt appears. Check @@ -139,16 +139,9 @@ class StreamReader(Generic[T]): wants data. """ - def __init__( - self, - source: ReadSource, - *, - topic: str | None = None, - type: type | None = None, - ) -> None: + def __init__(self, source: ReadSource, *, type: type | None = None) -> None: """Prefer :func:`temporalio.streams.reader`.""" self._source = source - self._topic = topic self._type = type self._pending: list[StreamRecord[Any]] = [] self._attempts = AttemptTracker() @@ -182,8 +175,16 @@ async def _next(self) -> StreamRecord[T]: async def _fill(self) -> None: for cursor, frame in await self._source.next_batch(): - kind, topic, producer, attempt, sequence, body = _frame.decode(frame) - if self._topic is not None and topic != self._topic: + try: + kind, topic, producer, attempt, sequence, body = _frame.decode(frame) + except ValueError as error: + # Skipped rather than raised: a poisoned record would + # otherwise fail this task on every retry and pin the + # workflow, while an outside reader of the same stream + # skips it. The two readers agree on the answer. + workflow.logger.warning( + "skipping stream record at %s: %s", cursor, error + ) continue superseded = self._attempts.note(producer, attempt, cursor) if superseded is not None: diff --git a/temporalio/streams/_provider.py b/temporalio/streams/_provider.py index 1f9dc727d..a2506649f 100644 --- a/temporalio/streams/_provider.py +++ b/temporalio/streams/_provider.py @@ -23,6 +23,7 @@ "Producer", "StreamProvider", "StreamProviderLifecycle", + "check_topic", "configure", "consumer", "drain", @@ -328,6 +329,21 @@ def drain() -> None: release() +def check_topic(stream: str, topic: str | None) -> None: + """Reject a topic filter on an inbound stream. + + An inbound record carries no topic; the stream's name is its whole + address, so a filter there could only ever match nothing. Every provider + calls this at the top of its ``read`` so the answer is the same on all + of them. + """ + if stream and topic is not None: + raise ValueError( + f"inbound stream {stream!r} has no topics; topic= applies to the " + "stream the workflow publishes" + ) + + async def producer( client: Any, *, diff --git a/temporalio/streams/_record.py b/temporalio/streams/_record.py index 5eb3e21e4..63f140aea 100644 --- a/temporalio/streams/_record.py +++ b/temporalio/streams/_record.py @@ -87,6 +87,7 @@ class StreamRecord(Generic[T]): cursor: Cursor kind: RecordKind = RecordKind.DATA topic: str = "" + """The topic on the owner's stream, or empty for an inbound record.""" producer: str = "" """Who wrote it, or empty when the owning workflow wrote it itself.""" attempt: int = 0 diff --git a/temporalio/streams/providers/memory.py b/temporalio/streams/providers/memory.py index fcf873184..ec1f78fc4 100644 --- a/temporalio/streams/providers/memory.py +++ b/temporalio/streams/providers/memory.py @@ -230,10 +230,11 @@ def _encode(self, value: Any) -> bytes: class MemoryConsumer: """The outside reader, with the shared supersession rule.""" - def __init__(self, store: _MemoryStream, converter: Any) -> None: + def __init__(self, store: _MemoryStream, converter: Any, stream: str) -> None: """Read whatever ``store`` holds, now and as it grows.""" self._store = store self._converter = converter + self._stream = stream async def read( self, @@ -243,6 +244,7 @@ async def read( type: type | None = None, ) -> AsyncGenerator[StreamRecord[Any], None]: """Yield records after ``after``, waiting for ones not written yet.""" + _provider.check_topic(self._stream, topic) attempts = AttemptTracker() offset = int(after.token) + 1 if after.token else 0 while True: @@ -256,7 +258,9 @@ async def read( kind, frame_topic, source, attempt, sequence, body = _frame.decode( frame ) - except ValueError: + except ValueError as error: + # Same answer as the workflow-side reader: skip and say so. + logger.warning("skipping stream record at %s: %s", cursor, error) continue if topic is not None and frame_topic != topic: continue @@ -336,11 +340,12 @@ async def producer( attempt: int = 0, ) -> MemoryProducer: # With no inbound stream named, the target is the store the workflow's - # own writer appends to, and the frame carries the topic instead. + # own writer appends to, and the frame carries the topic. An inbound + # record carries none: the stream's name is its whole address. return MemoryProducer( _stream(_inbound_id(workflow_id, stream)), _converter(client), - stream or topic, + topic, producer_id, attempt, ) @@ -349,7 +354,7 @@ async def consumer( self, client: Any, *, workflow_id: str, stream: str = "" ) -> MemoryConsumer: return MemoryConsumer( - _stream(_inbound_id(workflow_id, stream)), _converter(client) + _stream(_inbound_id(workflow_id, stream)), _converter(client), stream ) diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index 47b6e29d5..61eea6b70 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -91,6 +91,8 @@ async def test_append_read_roundtrip(): assert [r.value for r in records[:2]] == [{"id": "r1"}, {"id": "r2"}] assert all(r.producer == "model" and r.attempt == 1 for r in records) assert [r.sequence for r in records] == [0, 1, 2] + # An inbound stream has no topics; its name is the whole address. + assert all(r.topic == "" for r in records) async def test_retried_append_is_deduplicated(): @@ -132,19 +134,31 @@ async def test_new_attempt_supersedes_the_old_one(): assert records[2].kind is RecordKind.DATA and records[2].attempt == 2 -async def test_topic_filter(): - producer = await streams.producer( - None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 +async def test_topic_filter_on_the_owners_stream(): + # Two producers on two topics of the stream the workflow publishes. The + # filter is only meaningful on a store that mixes topics, which is this + # one; an inbound stream carries none. + on_a = await streams.producer( + None, workflow_id="wf", topic="a", producer_id="tool-a", attempt=1 ) - await producer.append({"n": 1}) - other = await streams.producer( - None, workflow_id="wf", stream="other", producer_id="model", attempt=1 + await on_a.append({"n": 1}) + on_b = await streams.producer( + None, workflow_id="wf", topic="b", producer_id="tool-b", attempt=1 ) - await other.append({"n": 2}) + await on_b.append({"n": 2}) + + consumer = await streams.consumer(None, workflow_id="wf") + only_a = await take(consumer.read(type=dict, topic="a"), 1) + assert [r.value for r in only_a] == [{"n": 1}] + + both = await take(consumer.read(type=dict), 2) + assert [(r.topic, r.value) for r in both] == [("a", {"n": 1}), ("b", {"n": 2})] + +async def test_inbound_streams_have_no_topics(): consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") - records = await take(consumer.read(type=dict, topic="inputs"), 1) - assert records[0].value == {"n": 1} + with pytest.raises(ValueError, match="inbound stream 'inputs' has no topics"): + await take(consumer.read(type=dict, topic="inputs"), 1) async def test_cursor_resumes_where_it_points(): From 276cb15b3c54248d251340ef3cb64a8933d63509 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 16:41:29 -0700 Subject: [PATCH 36/96] Let a broken provider import surface instead of vanishing from the registry. --- temporalio/streams/providers/__init__.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/temporalio/streams/providers/__init__.py b/temporalio/streams/providers/__init__.py index dff94b250..c254db0fb 100644 --- a/temporalio/streams/providers/__init__.py +++ b/temporalio/streams/providers/__init__.py @@ -13,7 +13,13 @@ for _name in _KNOWN: try: __import__(f"{__name__}.{_name}") - except ImportError: - # This tree does not carry that provider. The registry error message - # lists what is actually available. - pass + except ModuleNotFoundError as error: + # Two things may be missing: the provider module itself, or a + # dependency from outside this package. A name missing inside + # temporalio is a broken provider, and hiding that would turn its + # traceback into "no such provider". + missing = error.name or "" + if missing != f"{__name__}.{_name}" and ( + not missing or missing.startswith("temporalio") + ): + raise From 785402dee2eb706157ed481f812aa5222fb647ee Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 16:41:29 -0700 Subject: [PATCH 37/96] Added the workflow-side conformance tests. The reader and writer handles run in a real workflow on the memory provider. Rule 1 and rule 2 are strict expected failures there, so a storage provider running this module turns them into passes. --- tests/streams/test_streams_conformance.py | 5 +- tests/streams/test_streams_workflow.py | 208 ++++++++++++++++++++++ 2 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 tests/streams/test_streams_workflow.py diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index 61eea6b70..b66756af9 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -1,10 +1,11 @@ -"""Conformance tests for the stream contract. +"""Conformance tests for the stream contract's outside surface. Written against the public surface plus the in-memory reference provider, so they run with no server and no store. A storage provider reuses the same expectations through its own fixtures; what this file pins down is the contract: framing, producer identity, retry deduplication, supersession, -topic filtering, and cursor resumption. +topic filtering, and cursor resumption. The workflow-side handles and the +two rules about workflow tasks live in ``test_streams_workflow``. """ from __future__ import annotations diff --git a/tests/streams/test_streams_workflow.py b/tests/streams/test_streams_workflow.py new file mode 100644 index 000000000..40487686d --- /dev/null +++ b/tests/streams/test_streams_workflow.py @@ -0,0 +1,208 @@ +"""Workflow-side conformance for the stream contract. + +Runs the reader and writer handles inside a real workflow on the memory +provider with a warm cache, and states the two rules about workflow tasks as +tests: a publish commits with its task (rule 1), and reads are recorded +observations that replay re-supplies (rule 2). The memory provider keeps +neither and says so in its docstring, so those two are strict expected +failures here. A storage provider that runs this module turns them into +passes; that is the measurement they exist for. +""" + +from __future__ import annotations + +import uuid +from typing import Any + +import pytest + +from temporalio import streams, workflow +from temporalio.client import Client +from temporalio.streams import RecordKind +from temporalio.streams.providers import memory +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Replayer +from tests.helpers import new_worker +from tests.streams.test_streams_conformance import take + +INPUTS = "inputs" +DECISIONS = "decisions" + + +@pytest.fixture(autouse=True) +def _fresh_memory_provider(env: WorkflowEnvironment): # pyright: ignore[reportUnusedFunction] + if env.supports_time_skipping: + pytest.skip( + "the memory provider polls on a timer, which time skipping turns into a spin" + ) + memory.reset() + streams.configure(provider="memory") + yield + memory.reset() + + +@workflow.defn +class ContractLoop: + """Reads ``inputs``, publishes a decision per value, reports control records.""" + + def __init__(self) -> None: + streams.prepare() + + @workflow.run + async def run(self) -> list[dict[str, Any]]: + inputs = streams.reader(INPUTS, type=dict) + decisions = streams.writer(DECISIONS) + trace: list[dict[str, Any]] = [] + try: + async for record in inputs: + if isinstance(record.value, streams.Supersession): + trace.append( + { + "kind": "superseded", + "replaced": record.value.previous_attempt, + "attempt": record.value.attempt, + } + ) + await decisions.publish( + {"retracting_attempt": record.value.previous_attempt} + ) + continue + if record.kind is RecordKind.FINISH: + trace.append({"kind": "finish", "producer": record.producer}) + break + assert isinstance(record.value, dict) + await decisions.publish({"decided": record.value["n"]}) + trace.append( + { + "kind": "decision", + "n": record.value["n"], + "attempt": record.attempt, + } + ) + finally: + inputs.close() + await decisions.finish() + # Twice on purpose: a finished topic stays finished, with one marker. + await decisions.finish() + streams.drain() + return trace + + +async def _run_the_loop(client: Client) -> tuple[Any, list[dict[str, Any]]]: + workflow_id = f"streams-wf-{uuid.uuid4().hex}" + async with new_worker(client, ContractLoop, **streams.worker_options()) as worker: + handle = await client.start_workflow( + ContractLoop.run, id=workflow_id, task_queue=worker.task_queue + ) + first = await streams.producer( + client, + workflow_id=workflow_id, + stream=INPUTS, + producer_id="model", + attempt=1, + ) + await first.append({"n": 1}, {"n": 2}) + second = await streams.producer( + client, + workflow_id=workflow_id, + stream=INPUTS, + producer_id="model", + attempt=2, + ) + await second.append({"n": 3}) + await second.finish() + trace = await handle.result() + return handle, trace + + +async def test_workflow_reads_decides_and_publishes(client: Client): + handle, trace = await _run_the_loop(client) + assert trace == [ + {"kind": "decision", "n": 1, "attempt": 1}, + {"kind": "decision", "n": 2, "attempt": 1}, + {"kind": "superseded", "replaced": 1, "attempt": 2}, + {"kind": "decision", "n": 3, "attempt": 2}, + {"kind": "finish", "producer": "model"}, + ] + + # The outside view of what the workflow published, on its own topic. + consumer = await streams.consumer(client, workflow_id=handle.id) + records = await take(consumer.read(type=dict, topic=DECISIONS), 5) + assert [(r.kind, r.value) for r in records] == [ + (RecordKind.DATA, {"decided": 1}), + (RecordKind.DATA, {"decided": 2}), + (RecordKind.DATA, {"retracting_attempt": 1}), + (RecordKind.DATA, {"decided": 3}), + (RecordKind.FINISH, None), + ] + assert all(r.producer == "" and r.topic == DECISIONS for r in records) + # The second finish() wrote nothing: the marker is the newest record. + assert await consumer.latest() == records[-1].cursor + + +@pytest.mark.xfail( + strict=True, + reason=( + "rule 2: reading is a recorded observation, so replaying the history " + "with the store gone must re-supply the same records; the memory " + "provider reads live process memory instead" + ), +) +async def test_replay_without_the_store_resupplies_the_records(client: Client): + handle, _ = await _run_the_loop(client) + history = await handle.fetch_history() + + memory.reset() + replayer = Replayer(workflows=[ContractLoop], **streams.worker_options()) + await replayer.replay_workflow(history) + + +# Run ids whose first workflow task already failed, shared with the workflow +# thread so the retry can tell it is the retry. Outside the sandbox on +# purpose: the sandbox re-imports this module per run and would hide the set. +_failed_once: set[str] = set() + + +@workflow.defn(sandboxed=False) +class PublishThenFail: + """Publishes, then fails its first workflow task; the retry publishes again.""" + + def __init__(self) -> None: + streams.prepare() + + @workflow.run + async def run(self) -> None: + decisions = streams.writer(DECISIONS) + run_id = workflow.info().run_id + committed = run_id in _failed_once + await decisions.publish({"committed": committed}) + if not committed: + _failed_once.add(run_id) + raise RuntimeError("the first task fails after publishing") + await decisions.finish() + streams.drain() + + +@pytest.mark.xfail( + strict=True, + reason=( + "rule 1: a publish commits with its workflow task, so no reader sees " + "a record from a task that failed; the memory provider makes it " + "visible at publish time" + ), +) +async def test_a_failed_task_publishes_nothing(client: Client): + workflow_id = f"streams-wf-{uuid.uuid4().hex}" + async with new_worker( + client, PublishThenFail, **streams.worker_options() + ) as worker: + handle = await client.start_workflow( + PublishThenFail.run, id=workflow_id, task_queue=worker.task_queue + ) + consumer = await streams.consumer(client, workflow_id=workflow_id) + records = await take(consumer.read(type=dict, topic=DECISIONS), 2, timeout=30) + await handle.result() + assert [(r.kind, r.value) for r in records] == [ + (RecordKind.DATA, {"committed": True}), + (RecordKind.FINISH, None), + ] From 73256437fd666a641d077d2f5c78aec9e969f61e Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 16:41:29 -0700 Subject: [PATCH 38/96] Replaced stale wording and renamed the demo types. --- streams_demo/agent_loop.py | 6 +++--- streams_demo/run_demo.py | 4 ++-- temporalio/streams/__init__.py | 10 +++++----- temporalio/streams/_frame.py | 6 +++--- temporalio/streams/_handles.py | 6 +++--- temporalio/streams/_record.py | 7 ++++--- 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/streams_demo/agent_loop.py b/streams_demo/agent_loop.py index 0ab6831b5..f6f14b873 100644 --- a/streams_demo/agent_loop.py +++ b/streams_demo/agent_loop.py @@ -1,4 +1,4 @@ -"""One agent loop, written once, run on both stream providers. +"""One agent loop, written once, run on every stream provider. Reads input, decides on it, publishes the decision, and runs an ordinary Activity in the same workflow task. Also handles the two control records the @@ -20,7 +20,7 @@ INPUTS = "inputs" -@activity.defn(name="AI198RecordDecision") +@activity.defn(name="RecordDecision") async def record_decision(decision: dict[str, Any]) -> str: """An ordinary command in the same task as the publish.""" return f"recorded:{decision['source']}:{decision['branch']}" @@ -37,7 +37,7 @@ def decide(token: dict[str, Any]) -> dict[str, Any]: return {"source": token["id"], "branch": "odd", "computed": token["value"] + 100} -@workflow.defn(name="AI198StreamContractDemo", sandboxed=False) +@workflow.defn(name="StreamContractDemo", sandboxed=False) class AgentLoop: """Read, decide, write, until the producer says it has finished.""" diff --git a/streams_demo/run_demo.py b/streams_demo/run_demo.py index 9d6a62cb5..c4fc8e042 100644 --- a/streams_demo/run_demo.py +++ b/streams_demo/run_demo.py @@ -1,6 +1,6 @@ """Run the shared agent loop against whichever provider is configured. -Three cases, the same on both providers: +Three cases, the same on every provider: - read, decide, publish and an ordinary Activity in the same workflow task, with the smallest workflow cache the provider supports, so that as much of @@ -9,7 +9,7 @@ to report and the workflow has to act on; - an outside consumer reading what the workflow published. -Byte-identical in both trees. ``provider_setup`` is what differs, and it is +Byte-identical in every tree. ``provider_setup`` is what differs, and it is the only import here that names a provider. """ diff --git a/temporalio/streams/__init__.py b/temporalio/streams/__init__.py index ff2a75c61..c46a9faf0 100644 --- a/temporalio/streams/__init__.py +++ b/temporalio/streams/__init__.py @@ -38,17 +38,17 @@ talks to another, holds one from :func:`instance`. Reading somebody else's stream is out of scope for the first release. It is -the topology neither prototype has evidence for, and leaving it out is what -lets both of them implement the rest. +the topology no provider has evidence for, and leaving it out is what lets +every provider implement the rest. What the contract does not promise: that a :attr:`RecordKind.FINISH` record means the writing activity succeeded, that a superseded attempt's records can be withdrawn, or that a stream outlives the retention its provider is configured for. -Prototype support for AI-198. The public names are the proposal; providers -live under :mod:`temporalio.streams.providers`, one module each, registered -by name and chosen by :func:`configure`. Everything else is shared. +The public names are the proposal; providers live under +:mod:`temporalio.streams.providers`, one module each, registered by name and +chosen by :func:`configure`. Everything else is shared. """ from __future__ import annotations diff --git a/temporalio/streams/_frame.py b/temporalio/streams/_frame.py index c32811d32..df4b4dbd6 100644 --- a/temporalio/streams/_frame.py +++ b/temporalio/streams/_frame.py @@ -1,9 +1,9 @@ -"""The record envelope both bindings put on the wire. +"""The record envelope every provider puts on the wire. A provider stores an opaque body. The contract needs more than a body: which topic a record belongs to, whether it is data or a terminal marker, and which -producer attempt wrote it. Neither prototype carries all of that in a channel -of its own, so the interface carries it here and each binding stores the result +producer attempt wrote it. No provider carries all of that in a channel of +its own, so the interface carries it here and each provider stores the result as an ordinary body. Encoding is synchronous and deterministic because a workflow publishes on the diff --git a/temporalio/streams/_handles.py b/temporalio/streams/_handles.py index 9571d6e0c..6d821f32c 100644 --- a/temporalio/streams/_handles.py +++ b/temporalio/streams/_handles.py @@ -72,9 +72,9 @@ def _decode_value(body: bytes, as_type: type | None) -> Any: class StreamWriter(Generic[T]): """Publishes to a topic on the stream this workflow owns. - A workflow can only publish transactionally to a stream it owns, on both - providers. Writing to somebody else's stream is an activity's job, and it - gets the weaker guarantee that goes with doing I/O. + A workflow can only publish transactionally to a stream it owns, on + every provider. Writing to somebody else's stream is an activity's job, + and it gets the weaker guarantee that goes with doing I/O. """ def __init__(self, sink: WriteSink, topic: str) -> None: diff --git a/temporalio/streams/_record.py b/temporalio/streams/_record.py index 63f140aea..1f08302a8 100644 --- a/temporalio/streams/_record.py +++ b/temporalio/streams/_record.py @@ -1,6 +1,6 @@ """The value types the stream contract is expressed in. -Nothing here touches Temporal or a provider, so both bindings share it +Nothing here touches Temporal or a provider, so every provider shares it unchanged. """ @@ -39,8 +39,9 @@ class RecordKind(enum.IntEnum): SUPERSEDED = 3 """A later attempt of the same producer started writing. - Synthesized by the reader from what it observed, so both providers deliver - it identically and replay reproduces it without the provider's help. + Synthesized by the reader from what it observed, so every provider + delivers it identically and replay reproduces it without the provider's + help. """ From 0c7616c8df66f410be16ac9206124a51476a66db Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 17:09:22 -0700 Subject: [PATCH 39/96] Keyed stores by one helper that survives a colon in the workflow id. Joining the workflow id and the stream name with a bare colon let two addresses share a store. Every provider that keys by the pair now goes through stream_key, which percent-encodes both components first. --- temporalio/streams/_ids.py | 28 ++++++++++++++++++++++++++ temporalio/streams/providers/memory.py | 13 +++++------- 2 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 temporalio/streams/_ids.py diff --git a/temporalio/streams/_ids.py b/temporalio/streams/_ids.py new file mode 100644 index 000000000..625522f75 --- /dev/null +++ b/temporalio/streams/_ids.py @@ -0,0 +1,28 @@ +"""The store key a provider derives from a workflow id and a stream name. + +A workflow id may contain any character, ``:`` included, so joining the pair +with a bare ``:`` is ambiguous: ``("a:b", "c")`` and ``("a", "b:c")`` would +land in one store. Every provider that keys a store by the pair goes through +:func:`stream_key`, so they all agree and none of them collides. +""" + +from __future__ import annotations + +__all__ = ["stream_key"] + + +def _escape(component: str) -> str: + # Percent first, so an escaped component cannot be mistaken for one that + # already contained the escape. + return component.replace("%", "%25").replace(":", "%3A") + + +def stream_key(workflow_id: str, stream: str) -> str: + """The key for ``stream`` of ``workflow_id``, or for the owner's stream when empty. + + Both components are percent-encoded before joining, so the only bare ``:`` + in the result is the separator, and an owner-stream key has none. + """ + if not stream: + return _escape(workflow_id) + return f"{_escape(workflow_id)}:{_escape(stream)}" diff --git a/temporalio/streams/providers/memory.py b/temporalio/streams/providers/memory.py index ec1f78fc4..390c10b9c 100644 --- a/temporalio/streams/providers/memory.py +++ b/temporalio/streams/providers/memory.py @@ -27,6 +27,7 @@ from temporalio.api.common.v1 import Payload from temporalio.streams import _frame, _provider from temporalio.streams._handles import ReadSource, WriteSink +from temporalio.streams._ids import stream_key from temporalio.streams._policy import AttemptTracker from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord @@ -90,10 +91,6 @@ def reset() -> None: _streams.clear() -def _inbound_id(workflow_id: str, stream: str) -> str: - return f"{workflow_id}:{stream}" if stream else workflow_id - - def _converter(client: Any) -> Any: if client is None: return temporalio.converter.DataConverter.default.payload_converter @@ -320,13 +317,13 @@ def open_read( # idle_timeout as the poll period would give the parameter a second # meaning that a port copying the reference would copy too. del idle_timeout - store = _stream(_inbound_id(workflow.info().workflow_id, stream)) + store = _stream(stream_key(workflow.info().workflow_id, stream)) return _MemReadSource( store, int(after.token) + 1 if after.token else 0, self._poll ) def open_write(self, topic: str) -> WriteSink: - store = _stream(_inbound_id(workflow.info().workflow_id, "")) + store = _stream(stream_key(workflow.info().workflow_id, "")) return _MemWriteSink(store, topic) async def producer( @@ -343,7 +340,7 @@ async def producer( # own writer appends to, and the frame carries the topic. An inbound # record carries none: the stream's name is its whole address. return MemoryProducer( - _stream(_inbound_id(workflow_id, stream)), + _stream(stream_key(workflow_id, stream)), _converter(client), topic, producer_id, @@ -354,7 +351,7 @@ async def consumer( self, client: Any, *, workflow_id: str, stream: str = "" ) -> MemoryConsumer: return MemoryConsumer( - _stream(_inbound_id(workflow_id, stream)), _converter(client), stream + _stream(stream_key(workflow_id, stream)), _converter(client), stream ) From 836873534186e32594937e4401b15654f440caa8 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 17:09:22 -0700 Subject: [PATCH 40/96] Added an async close hook next to prepare and drain. A provider that holds a connection pool or an HTTP session needs a moment where the process says it is done. It is a no-op when nothing is configured, so a shutdown path can call it unconditionally. --- temporalio/streams/__init__.py | 10 ++++++++++ temporalio/streams/_provider.py | 30 +++++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/temporalio/streams/__init__.py b/temporalio/streams/__init__.py index c46a9faf0..f564135fd 100644 --- a/temporalio/streams/__init__.py +++ b/temporalio/streams/__init__.py @@ -37,6 +37,14 @@ process default, such as a handler that serves one store while its process talks to another, holds one from :func:`instance`. +Three lifecycle hooks let a provider borrow the caller's lifetime, and each +is a no-op on a provider that needs nothing, so portable code calls all +three unconditionally: :func:`prepare` from the workflow's constructor, for a +transport that registers handlers on the workflow; :func:`drain` before the +workflow returns, for one that parks readers against the run; and +:func:`close`, awaited when the process is done with streams, for one that +holds connections. + Reading somebody else's stream is out of scope for the first release. It is the topology no provider has evidence for, and leaving it out is what lets every provider implement the rest. @@ -67,6 +75,7 @@ Producer, StreamProvider, StreamProviderLifecycle, + close, configure, consumer, drain, @@ -99,6 +108,7 @@ "StreamWriter", "Supersession", "WriteSink", + "close", "configure", "consumer", "drain", diff --git a/temporalio/streams/_provider.py b/temporalio/streams/_provider.py index a2506649f..89e32a2c7 100644 --- a/temporalio/streams/_provider.py +++ b/temporalio/streams/_provider.py @@ -24,6 +24,7 @@ "StreamProvider", "StreamProviderLifecycle", "check_topic", + "close", "configure", "consumer", "drain", @@ -191,9 +192,10 @@ async def consumer( class StreamProviderLifecycle(Protocol): """The hooks a provider adds when it needs the workflow's own lifetime. - Separate from :class:`StreamProvider` because most transports need - neither, and a provider is not asked to carry a pair of empty methods to - say so. :func:`prepare` and :func:`drain` call whichever half is present. + Separate from :class:`StreamProvider` because most transports need none + of them, and a provider is not asked to carry empty methods to say so. + :func:`prepare`, :func:`drain` and :func:`close` each call the hook when + the provider defines it. """ def prepare(self) -> None: @@ -214,6 +216,14 @@ def drain(self) -> None: """ ... + async def close(self) -> None: + """Release what this provider holds for the process. + + A provider that keeps a connection pool or an HTTP session open needs + a moment where the process says it is done; this is it. + """ + ... + _factories: dict[str, Callable[[], StreamProvider]] = {} _active: StreamProvider | None = None @@ -329,6 +339,20 @@ def drain() -> None: release() +async def close() -> None: + """Release what the provider holds for this process. + + Await it when the process is done with streams. It is a no-op on + providers that hold nothing, and on a process that never configured one, + so a shutdown path can call it unconditionally. + """ + if _active is None: + return + release = getattr(_active, "close", None) + if release is not None: + await release() + + def check_topic(stream: str, topic: str | None) -> None: """Reject a topic filter on an inbound stream. From e3522aee6434c1556613703ea7256b2a54c81e4a Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 17:09:22 -0700 Subject: [PATCH 41/96] Parametrised the conformance suite over providers. The memory provider always runs; a store branch registers its own setup behind its STREAMS_LIVE gate and declares the capabilities it lacks, which the fixture turns into skips with a reason. Cases read with a topic wherever they can, and two new cases prove colon-bearing addresses never share a store. --- tests/streams/conftest.py | 12 + tests/streams/test_streams_conformance.py | 309 +++++++++++++++++----- 2 files changed, 255 insertions(+), 66 deletions(-) create mode 100644 tests/streams/conftest.py diff --git a/tests/streams/conftest.py b/tests/streams/conftest.py new file mode 100644 index 000000000..1758f2598 --- /dev/null +++ b/tests/streams/conftest.py @@ -0,0 +1,12 @@ +import pytest + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line( + "markers", + "inbound_stream: the case writes a workflow's inbound stream from outside", + ) + config.addinivalue_line( + "markers", + "unfiltered_read: the case reads the owner's stream without naming a topic", + ) diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index b66756af9..0a50ce2cb 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -1,35 +1,79 @@ """Conformance tests for the stream contract's outside surface. -Written against the public surface plus the in-memory reference provider, so -they run with no server and no store. A storage provider reuses the same -expectations through its own fixtures; what this file pins down is the -contract: framing, producer identity, retry deduplication, supersession, -topic filtering, and cursor resumption. The workflow-side handles and the -two rules about workflow tasks live in ``test_streams_workflow``. +Written against the public surface, parametrised over the providers this +tree can stand up. The memory provider always runs, with no server and no +store. A storage provider adds itself to ``SETUPS`` behind its own +``STREAMS_LIVE`` gate: its setup configures the provider, hands back the +client the cases should use, and says which capabilities it lacks, so the +cases marked ``inbound_stream`` or ``unfiltered_read`` are skipped with a +reason on a provider that cannot do them. Every other case reads with a +topic, which is what a store that keys by topic needs. + +What this file pins down is the contract: framing, producer identity, retry +deduplication, supersession, topic filtering, cursor resumption, and store +keys that cannot collide. The workflow-side handles and the two rules about +workflow tasks live in ``test_streams_workflow``. """ from __future__ import annotations import asyncio +import uuid +from collections.abc import AsyncIterator, Callable +from dataclasses import dataclass from typing import Any import pytest from temporalio import streams -from temporalio.streams import _frame, _provider +from temporalio.streams import _frame, _ids, _provider from temporalio.streams._policy import AttemptTracker from temporalio.streams._record import Cursor, RecordKind from temporalio.streams.providers import memory -@pytest.fixture(autouse=True) -def _fresh_memory_provider(): # pyright: ignore[reportUnusedFunction] +@dataclass +class ProviderCase: + """One provider under test, and what the cases may ask of it.""" + + name: str + client: Any = None + inbound_streams: bool = True + """Outside producers may write a workflow's inbound stream.""" + unfiltered_reads: bool = True + """A consumer may read the owner's stream without naming a topic.""" + + +async def _memory_case() -> AsyncIterator[ProviderCase]: memory.reset() streams.configure(provider="memory") - yield + yield ProviderCase("memory") memory.reset() +SETUPS: dict[str, Callable[[], AsyncIterator[ProviderCase]]] = {"memory": _memory_case} + +_CAPABILITIES = { + "inbound_stream": lambda case: case.inbound_streams, + "unfiltered_read": lambda case: case.unfiltered_reads, +} + + +@pytest.fixture(params=sorted(SETUPS)) +async def provider(request: pytest.FixtureRequest) -> AsyncIterator[ProviderCase]: + async for case in SETUPS[request.param](): + for marker, supported in _CAPABILITIES.items(): + if request.node.get_closest_marker(marker) and not supported(case): + pytest.skip(f"the {case.name} provider does not support {marker}") + yield case + + +def new_workflow_id() -> str: + # Unique per case, because a storage provider keeps what earlier cases + # wrote and the memory provider only happens to forget. + return f"wf-{uuid.uuid4().hex}" + + async def take(records: Any, count: int, timeout: float = 5.0) -> list: out: list = [] @@ -75,15 +119,28 @@ def test_supersession_is_synthesized_from_observations(): assert attempts.note("model", 2, Cursor("2")) is None -async def test_append_read_roundtrip(): +def test_stream_keys_cannot_collide(): + # A colon in a workflow id must not make two addresses one key. + assert _ids.stream_key("a:b", "c") != _ids.stream_key("a", "b:c") + assert _ids.stream_key("a:b", "") != _ids.stream_key("a", "b") + assert _ids.stream_key("a%3Ab", "c") != _ids.stream_key("a:b", "c") + assert _ids.stream_key("wf", "inputs") == "wf:inputs" + + +async def test_append_read_roundtrip(provider: ProviderCase): + workflow_id = new_workflow_id() producer = await streams.producer( - None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 + provider.client, + workflow_id=workflow_id, + topic="out", + producer_id="model", + attempt=1, ) await producer.append({"id": "r1"}, {"id": "r2"}) await producer.finish() - consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") - records = await take(consumer.read(type=dict), 3) + consumer = await streams.consumer(provider.client, workflow_id=workflow_id) + records = await take(consumer.read(type=dict, topic="out"), 3) assert [r.kind for r in records] == [ RecordKind.DATA, RecordKind.DATA, @@ -92,42 +149,84 @@ async def test_append_read_roundtrip(): assert [r.value for r in records[:2]] == [{"id": "r1"}, {"id": "r2"}] assert all(r.producer == "model" and r.attempt == 1 for r in records) assert [r.sequence for r in records] == [0, 1, 2] + assert all(r.topic == "out" for r in records) + + +@pytest.mark.inbound_stream +async def test_inbound_records_carry_no_topic(provider: ProviderCase): + workflow_id = new_workflow_id() + producer = await streams.producer( + provider.client, + workflow_id=workflow_id, + stream="inputs", + producer_id="model", + attempt=1, + ) + await producer.append({"id": "r1"}) + await producer.finish() + + consumer = await streams.consumer( + provider.client, workflow_id=workflow_id, stream="inputs" + ) + records = await take(consumer.read(type=dict), 2) + assert [r.kind for r in records] == [RecordKind.DATA, RecordKind.FINISH] + assert records[0].value == {"id": "r1"} # An inbound stream has no topics; its name is the whole address. assert all(r.topic == "" for r in records) + with pytest.raises(ValueError, match="inbound stream 'inputs' has no topics"): + await take(consumer.read(type=dict, topic="inputs"), 1) -async def test_retried_append_is_deduplicated(): +async def test_retried_append_is_deduplicated(provider: ProviderCase): + workflow_id = new_workflow_id() first = await streams.producer( - None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 + provider.client, + workflow_id=workflow_id, + topic="out", + producer_id="model", + attempt=1, ) await first.append({"id": "r1"}) # The retry of the same attempt starts its sequence over and appends the # same record. The provider must not store it twice, and says so by # returning no position for what it dropped. retry = await streams.producer( - None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 + provider.client, + workflow_id=workflow_id, + topic="out", + producer_id="model", + attempt=1, ) assert await retry.append({"id": "r1"}) is None - consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") - records = await take(consumer.read(type=dict), 1) + consumer = await streams.consumer(provider.client, workflow_id=workflow_id) + records = await take(consumer.read(type=dict, topic="out"), 1) assert records[0].value == {"id": "r1"} # The store holds exactly the one record: the newest position is its cursor. - assert await consumer.latest() == records[0].cursor + assert await consumer.latest(topic="out") == records[0].cursor -async def test_new_attempt_supersedes_the_old_one(): +async def test_new_attempt_supersedes_the_old_one(provider: ProviderCase): + workflow_id = new_workflow_id() first = await streams.producer( - None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 + provider.client, + workflow_id=workflow_id, + topic="out", + producer_id="model", + attempt=1, ) await first.append({"text": "The capital of"}) second = await streams.producer( - None, workflow_id="wf", stream="inputs", producer_id="model", attempt=2 + provider.client, + workflow_id=workflow_id, + topic="out", + producer_id="model", + attempt=2, ) await second.append({"text": "Paris is the capital"}) - consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") - records = await take(consumer.read(type=dict), 3) + consumer = await streams.consumer(provider.client, workflow_id=workflow_id) + records = await take(consumer.read(type=dict, topic="out"), 3) assert records[0].kind is RecordKind.DATA and records[0].attempt == 1 assert records[1].kind is RecordKind.SUPERSEDED assert isinstance(records[1].value, streams.Supersession) @@ -135,101 +234,175 @@ async def test_new_attempt_supersedes_the_old_one(): assert records[2].kind is RecordKind.DATA and records[2].attempt == 2 -async def test_topic_filter_on_the_owners_stream(): +async def test_topic_filter_on_the_owners_stream(provider: ProviderCase): # Two producers on two topics of the stream the workflow publishes. The # filter is only meaningful on a store that mixes topics, which is this # one; an inbound stream carries none. + workflow_id = new_workflow_id() on_a = await streams.producer( - None, workflow_id="wf", topic="a", producer_id="tool-a", attempt=1 + provider.client, + workflow_id=workflow_id, + topic="a", + producer_id="tool-a", + attempt=1, ) await on_a.append({"n": 1}) on_b = await streams.producer( - None, workflow_id="wf", topic="b", producer_id="tool-b", attempt=1 + provider.client, + workflow_id=workflow_id, + topic="b", + producer_id="tool-b", + attempt=1, ) await on_b.append({"n": 2}) - consumer = await streams.consumer(None, workflow_id="wf") + consumer = await streams.consumer(provider.client, workflow_id=workflow_id) only_a = await take(consumer.read(type=dict, topic="a"), 1) - assert [r.value for r in only_a] == [{"n": 1}] + assert [(r.topic, r.value) for r in only_a] == [("a", {"n": 1})] + only_b = await take(consumer.read(type=dict, topic="b"), 1) + assert [(r.topic, r.value) for r in only_b] == [("b", {"n": 2})] - both = await take(consumer.read(type=dict), 2) - assert [(r.topic, r.value) for r in both] == [("a", {"n": 1}), ("b", {"n": 2})] +@pytest.mark.unfiltered_read +async def test_a_read_without_a_topic_sees_every_topic(provider: ProviderCase): + workflow_id = new_workflow_id() + on_a = await streams.producer( + provider.client, + workflow_id=workflow_id, + topic="a", + producer_id="tool-a", + attempt=1, + ) + await on_a.append({"n": 1}) + on_b = await streams.producer( + provider.client, + workflow_id=workflow_id, + topic="b", + producer_id="tool-b", + attempt=1, + ) + await on_b.append({"n": 2}) -async def test_inbound_streams_have_no_topics(): - consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") - with pytest.raises(ValueError, match="inbound stream 'inputs' has no topics"): - await take(consumer.read(type=dict, topic="inputs"), 1) + consumer = await streams.consumer(provider.client, workflow_id=workflow_id) + both = await take(consumer.read(type=dict), 2) + assert [(r.topic, r.value) for r in both] == [("a", {"n": 1}), ("b", {"n": 2})] -async def test_cursor_resumes_where_it_points(): +async def test_cursor_resumes_where_it_points(provider: ProviderCase): + workflow_id = new_workflow_id() producer = await streams.producer( - None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 + provider.client, + workflow_id=workflow_id, + topic="out", + producer_id="model", + attempt=1, ) await producer.append({"n": 1}, {"n": 2}, {"n": 3}) - consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") - records = await take(consumer.read(type=dict), 3) + consumer = await streams.consumer(provider.client, workflow_id=workflow_id) + records = await take(consumer.read(type=dict, topic="out"), 3) checkpoint = records[0].cursor # Resuming after a record hands back everything past it and nothing # twice, without the reader ever advancing a cursor itself. - resumed = await streams.consumer(None, workflow_id="wf", stream="inputs") - again = await take(resumed.read(type=dict, after=checkpoint), 2) + resumed = await streams.consumer(provider.client, workflow_id=workflow_id) + again = await take(resumed.read(type=dict, topic="out", after=checkpoint), 2) assert [r.value for r in again] == [{"n": 2}, {"n": 3}] -async def test_append_cursor_names_the_last_record_of_the_batch(): +async def test_append_cursor_names_the_last_record_of_the_batch( + provider: ProviderCase, +): + workflow_id = new_workflow_id() producer = await streams.producer( - None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 + provider.client, + workflow_id=workflow_id, + topic="out", + producer_id="model", + attempt=1, ) appended = await producer.append({"n": 1}, {"n": 2}, {"n": 3}) - assert appended is not None + if appended is None: + pytest.skip(f"the {provider.name} provider learns positions at read time") await producer.append({"n": 4}) # A producer that resumes a reader after its own append must see only # what came later, not the tail of the batch it just wrote. - consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") - records = await take(consumer.read(type=dict, after=appended), 1) + consumer = await streams.consumer(provider.client, workflow_id=workflow_id) + records = await take(consumer.read(type=dict, topic="out", after=appended), 1) assert [r.value for r in records] == [{"n": 4}] assert await producer.append() is None -async def test_latest_positions_a_reader_at_the_end(): +async def test_latest_positions_a_reader_at_the_end(provider: ProviderCase): + workflow_id = new_workflow_id() producer = await streams.producer( - None, workflow_id="wf", stream="inputs", producer_id="model", attempt=1 + provider.client, + workflow_id=workflow_id, + topic="out", + producer_id="model", + attempt=1, ) - consumer = await streams.consumer(None, workflow_id="wf", stream="inputs") - assert await consumer.latest() == streams.BEGINNING + consumer = await streams.consumer(provider.client, workflow_id=workflow_id) + assert await consumer.latest(topic="out") == streams.BEGINNING await producer.append({"n": 1}, {"n": 2}) - since = await consumer.latest() + since = await consumer.latest(topic="out") await producer.append({"n": 3}) # A reader that positioned itself before the last append sees only what # came after, which is how a client follows a turn it is about to start. - records = await take(consumer.read(type=dict, after=since), 1) + records = await take(consumer.read(type=dict, topic="out", after=since), 1) assert [r.value for r in records] == [{"n": 3}] -async def test_producer_appends_onto_the_owners_topic(): - # An activity's live output lands on the same topic the workflow - # publishes, so one outside reader follows both. - producer = await streams.producer( - None, workflow_id="wf", topic="events", producer_id="model", attempt=1 +@pytest.mark.inbound_stream +async def test_stream_addresses_with_colons_do_not_share_a_store( + provider: ProviderCase, +): + # ("wf:x", "y") and ("wf", "x:y") differ only in where the colon sits. + base = new_workflow_id() + left = await streams.producer( + provider.client, workflow_id=f"{base}:x", stream="y", producer_id="l", attempt=1 ) - await producer.append({"delta": "hel"}, {"delta": "lo"}) - - consumer = await streams.consumer(None, workflow_id="wf") - records = await take(consumer.read(type=dict, topic="events"), 2) - assert [r.value["delta"] for r in records] == ["hel", "lo"] - assert {r.producer for r in records} == {"model"} + right = await streams.producer( + provider.client, workflow_id=base, stream="x:y", producer_id="r", attempt=1 + ) + await left.append({"side": "left"}) + await right.append({"side": "right"}) + seen_left = await streams.consumer( + provider.client, workflow_id=f"{base}:x", stream="y" + ) + only_left = await take(seen_left.read(type=dict), 1) + assert [r.value for r in only_left] == [{"side": "left"}] + assert await seen_left.latest() == only_left[0].cursor + seen_right = await streams.consumer(provider.client, workflow_id=base, stream="x:y") + only_right = await take(seen_right.read(type=dict), 1) + assert [r.value for r in only_right] == [{"side": "right"}] + assert await seen_right.latest() == only_right[0].cursor + + +async def test_producer_needs_exactly_one_address_and_an_identity( + provider: ProviderCase, +): + workflow_id = new_workflow_id() + with pytest.raises(ValueError): + await streams.producer( + provider.client, workflow_id=workflow_id, producer_id="model", attempt=1 + ) with pytest.raises(ValueError): - await streams.producer(None, workflow_id="wf", producer_id="model", attempt=1) + await streams.producer( + provider.client, + workflow_id=workflow_id, + stream="inputs", + topic="out", + producer_id="model", + attempt=1, + ) # Outside an activity there is no identity to fall back on. with pytest.raises(ValueError, match="producer_id is required"): - await streams.producer(None, workflow_id="wf", stream="inputs") + await streams.producer(provider.client, workflow_id=workflow_id, topic="out") def test_unknown_provider_is_a_clear_error(): @@ -243,5 +416,9 @@ async def test_opening_a_stream_needs_a_configured_provider(): _provider._active = None with pytest.raises(RuntimeError, match="streams.configure"): await streams.consumer(None, workflow_id="wf") + # Closing with nothing configured is allowed, so a shutdown path can + # call it unconditionally. + await streams.close() streams.configure(provider="memory") assert await streams.consumer(None, workflow_id="wf") is not None + await streams.close() From 31f6fc0658cdb1932cedb38bf6b7519b043746df Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 17:22:26 -0700 Subject: [PATCH 42/96] Let a provider setup own the workflow the conformance cases address. A store keeps a workflow's stream with the workflow and refuses a producer on an execution that does not exist, so the case has to be handed one that does. --- tests/streams/test_streams_conformance.py | 39 +++++++++++++---------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index 0a50ce2cb..e51fddb78 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -4,10 +4,11 @@ tree can stand up. The memory provider always runs, with no server and no store. A storage provider adds itself to ``SETUPS`` behind its own ``STREAMS_LIVE`` gate: its setup configures the provider, hands back the -client the cases should use, and says which capabilities it lacks, so the -cases marked ``inbound_stream`` or ``unfiltered_read`` are skipped with a -reason on a provider that cannot do them. Every other case reads with a -topic, which is what a store that keys by topic needs. +client the cases should use and a workflow that exists for the case to +address, and says which capabilities it lacks, so the cases marked +``inbound_stream`` or ``unfiltered_read`` are skipped with a reason on a +provider that cannot do them. Every other case reads with a topic, which is +what a store that keys by topic needs. What this file pins down is the contract: framing, producer identity, retry deduplication, supersession, topic filtering, cursor resumption, and store @@ -38,6 +39,12 @@ class ProviderCase: name: str client: Any = None + workflow_id: str = "" + """The workflow whose streams the case addresses. + + A store keeps a workflow's stream with the workflow, so the setup makes + the execution exist before the case opens a producer on its account. + """ inbound_streams: bool = True """Outside producers may write a workflow's inbound stream.""" unfiltered_reads: bool = True @@ -47,7 +54,7 @@ class ProviderCase: async def _memory_case() -> AsyncIterator[ProviderCase]: memory.reset() streams.configure(provider="memory") - yield ProviderCase("memory") + yield ProviderCase("memory", workflow_id=new_workflow_id()) memory.reset() @@ -128,7 +135,7 @@ def test_stream_keys_cannot_collide(): async def test_append_read_roundtrip(provider: ProviderCase): - workflow_id = new_workflow_id() + workflow_id = provider.workflow_id producer = await streams.producer( provider.client, workflow_id=workflow_id, @@ -154,7 +161,7 @@ async def test_append_read_roundtrip(provider: ProviderCase): @pytest.mark.inbound_stream async def test_inbound_records_carry_no_topic(provider: ProviderCase): - workflow_id = new_workflow_id() + workflow_id = provider.workflow_id producer = await streams.producer( provider.client, workflow_id=workflow_id, @@ -178,7 +185,7 @@ async def test_inbound_records_carry_no_topic(provider: ProviderCase): async def test_retried_append_is_deduplicated(provider: ProviderCase): - workflow_id = new_workflow_id() + workflow_id = provider.workflow_id first = await streams.producer( provider.client, workflow_id=workflow_id, @@ -207,7 +214,7 @@ async def test_retried_append_is_deduplicated(provider: ProviderCase): async def test_new_attempt_supersedes_the_old_one(provider: ProviderCase): - workflow_id = new_workflow_id() + workflow_id = provider.workflow_id first = await streams.producer( provider.client, workflow_id=workflow_id, @@ -238,7 +245,7 @@ async def test_topic_filter_on_the_owners_stream(provider: ProviderCase): # Two producers on two topics of the stream the workflow publishes. The # filter is only meaningful on a store that mixes topics, which is this # one; an inbound stream carries none. - workflow_id = new_workflow_id() + workflow_id = provider.workflow_id on_a = await streams.producer( provider.client, workflow_id=workflow_id, @@ -265,7 +272,7 @@ async def test_topic_filter_on_the_owners_stream(provider: ProviderCase): @pytest.mark.unfiltered_read async def test_a_read_without_a_topic_sees_every_topic(provider: ProviderCase): - workflow_id = new_workflow_id() + workflow_id = provider.workflow_id on_a = await streams.producer( provider.client, workflow_id=workflow_id, @@ -289,7 +296,7 @@ async def test_a_read_without_a_topic_sees_every_topic(provider: ProviderCase): async def test_cursor_resumes_where_it_points(provider: ProviderCase): - workflow_id = new_workflow_id() + workflow_id = provider.workflow_id producer = await streams.producer( provider.client, workflow_id=workflow_id, @@ -313,7 +320,7 @@ async def test_cursor_resumes_where_it_points(provider: ProviderCase): async def test_append_cursor_names_the_last_record_of_the_batch( provider: ProviderCase, ): - workflow_id = new_workflow_id() + workflow_id = provider.workflow_id producer = await streams.producer( provider.client, workflow_id=workflow_id, @@ -335,7 +342,7 @@ async def test_append_cursor_names_the_last_record_of_the_batch( async def test_latest_positions_a_reader_at_the_end(provider: ProviderCase): - workflow_id = new_workflow_id() + workflow_id = provider.workflow_id producer = await streams.producer( provider.client, workflow_id=workflow_id, @@ -361,7 +368,7 @@ async def test_stream_addresses_with_colons_do_not_share_a_store( provider: ProviderCase, ): # ("wf:x", "y") and ("wf", "x:y") differ only in where the colon sits. - base = new_workflow_id() + base = provider.workflow_id left = await streams.producer( provider.client, workflow_id=f"{base}:x", stream="y", producer_id="l", attempt=1 ) @@ -386,7 +393,7 @@ async def test_stream_addresses_with_colons_do_not_share_a_store( async def test_producer_needs_exactly_one_address_and_an_identity( provider: ProviderCase, ): - workflow_id = new_workflow_id() + workflow_id = provider.workflow_id with pytest.raises(ValueError): await streams.producer( provider.client, workflow_id=workflow_id, producer_id="model", attempt=1 From f507eefbb78be70fb24c0ddbca5ac0ba76c2cbca Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 17:35:47 -0700 Subject: [PATCH 43/96] Recorded whether a provider reports a dropped repeat. A store whose append answers a repeat with the original position cannot return None for it without a second round trip; the case states what such a provider does instead. --- tests/streams/test_streams_conformance.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index e51fddb78..35f65ad0e 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -49,6 +49,13 @@ class ProviderCase: """Outside producers may write a workflow's inbound stream.""" unfiltered_reads: bool = True """A consumer may read the owner's stream without naming a topic.""" + reports_dropped_repeats: bool = True + """``append`` returns ``None`` for a repeat it dropped. + + A store that answers a repeat with the original position instead still + holds the record once; it just cannot say at append time that this call + wrote nothing. + """ async def _memory_case() -> AsyncIterator[ProviderCase]: @@ -196,7 +203,8 @@ async def test_retried_append_is_deduplicated(provider: ProviderCase): await first.append({"id": "r1"}) # The retry of the same attempt starts its sequence over and appends the # same record. The provider must not store it twice, and says so by - # returning no position for what it dropped. + # returning no position for what it dropped, or the original one when + # its store cannot tell the two apart at append time. retry = await streams.producer( provider.client, workflow_id=workflow_id, @@ -204,11 +212,15 @@ async def test_retried_append_is_deduplicated(provider: ProviderCase): producer_id="model", attempt=1, ) - assert await retry.append({"id": "r1"}) is None + dropped = await retry.append({"id": "r1"}) consumer = await streams.consumer(provider.client, workflow_id=workflow_id) records = await take(consumer.read(type=dict, topic="out"), 1) assert records[0].value == {"id": "r1"} + if provider.reports_dropped_repeats: + assert dropped is None + else: + assert dropped == records[0].cursor # The store holds exactly the one record: the newest position is its cursor. assert await consumer.latest(topic="out") == records[0].cursor From 17bbcfa2aac293afe105c86083c9c8460f3f2f64 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 17:36:46 -0700 Subject: [PATCH 44/96] Dropped the committed demo output and corrected two comments. The run output named a provider this tree does not register, and run output churns on every run; the doc keeps the numbers. --- .gitignore | 3 + streams_demo/provider_setup.py | 7 +- streams_demo/results-external/history.json | 1040 ------------------- streams_demo/results-external/results.json | 107 -- temporalio/contrib/openai_agents/testing.py | 4 +- 5 files changed, 8 insertions(+), 1153 deletions(-) delete mode 100644 streams_demo/results-external/history.json delete mode 100644 streams_demo/results-external/results.json diff --git a/.gitignore b/.gitignore index 8cd439e05..42baf649d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ temporalio/bridge/temporal_sdk_bridge* tags /.claude tmpclaude-* + +# Demo run output, written per provider; the numbers live in the doc. +streams_demo/results-*/ diff --git a/streams_demo/provider_setup.py b/streams_demo/provider_setup.py index 52ebbd266..aab3b6cfc 100644 --- a/streams_demo/provider_setup.py +++ b/streams_demo/provider_setup.py @@ -1,9 +1,8 @@ """Pick the provider for a demo run from the environment. -``STREAMS_PROVIDER`` names any registered provider; this base tree carries -only ``memory``, and each provider branch adds its own name. The demo needs a -Temporal server to run the workflow either way; ``TEMPORAL_ADDRESS`` points -at it. +``STREAMS_PROVIDER`` names any registered provider; this tree carries +``memory`` and ``redis``. The demo needs a Temporal server to run the +workflow either way; ``TEMPORAL_ADDRESS`` points at it. """ from __future__ import annotations diff --git a/streams_demo/results-external/history.json b/streams_demo/results-external/history.json deleted file mode 100644 index 1013bf0e7..000000000 --- a/streams_demo/results-external/history.json +++ /dev/null @@ -1,1040 +0,0 @@ -{ - "events": [ - { - "eventId": "1", - "eventTime": "2026-09-06T18:23:04.307989Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED", - "taskId": "1052794", - "workflowExecutionStartedEventAttributes": { - "workflowType": { - "name": "AI198StreamContractDemo" - }, - "taskQueue": { - "name": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "input": { - "payloads": [ - { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "OA==" - } - ] - }, - "workflowTaskTimeout": "10s", - "originalExecutionRunId": "01a077f5-ac73-7f13-9fa6-ebc5c0821a69", - "identity": "34059@Mac.lan", - "firstExecutionRunId": "01a077f5-ac73-7f13-9fa6-ebc5c0821a69", - "attempt": 1, - "firstWorkflowTaskBackoff": "0s", - "workflowId": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", - "priority": {} - } - }, - { - "eventId": "2", - "eventTime": "2026-09-06T18:23:04.308024Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1052795", - "workflowTaskScheduledEventAttributes": { - "taskQueue": { - "name": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "startToCloseTimeout": "10s", - "attempt": 1 - } - }, - { - "eventId": "3", - "eventTime": "2026-09-06T18:23:04.310387Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1052800", - "workflowTaskStartedEventAttributes": { - "scheduledEventId": "2", - "identity": "34059@Mac.lan", - "requestId": "26ca0dc2-9c92-4899-9736-a16e48687a91", - "historySizeBytes": "406", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - } - } - }, - { - "eventId": "4", - "eventTime": "2026-09-06T18:23:04.324822Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1052805", - "workflowTaskCompletedEventAttributes": { - "scheduledEventId": "2", - "startedEventId": "3", - "identity": "34059@Mac.lan", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - }, - "sdkMetadata": { - "coreUsedFlags": [ - 1, - 2, - 3 - ], - "sdkName": "temporal-python", - "sdkVersion": "1.31.0" - }, - "meteringMetadata": {} - } - }, - { - "eventId": "5", - "eventTime": "2026-09-06T18:23:04.324847Z", - "eventType": "EVENT_TYPE_MARKER_RECORDED", - "taskId": "1052806", - "markerRecordedEventAttributes": { - "markerName": "core_external_stream", - "details": { - "external_stream": { - "payloads": [ - { - "metadata": { - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAEQARoECAEQASK+AQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAA1yZWRpcy1zdHJlYW1zAQIAAQIBAQ8xNzg4NzE4OTg0MzIyLTAPMTc4ODcxODk4NDMyMi0wAQABAwEBAQ8xNzg4NzE4OTg0MzIyLTAoBjKdAQgBEAEaIGV1MFlKZjgxdXJfTDZ0YU9lZUlxRVVJUzNsQnVGNzBaIAEqJDAxYTA3N2Y1LWFjNzMtN2YxMy05ZmE2LWViYzVjMDgyMWE2OTIyCglkZWNpc2lvbnMQARi0ASIgjBxO69u2MphhOmkhkT4NDYD9yThaPpqKHcAWa2oslZg6AwoBADoDCgEBQg1yZWRpcy1zdHJlYW1zSAE=" - } - ] - } - }, - "workflowTaskCompletedEventId": "4" - } - }, - { - "eventId": "6", - "eventTime": "2026-09-06T18:23:04.324856Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", - "taskId": "1052807", - "activityTaskScheduledEventAttributes": { - "activityId": "decision-r1", - "activityType": { - "name": "AI198RecordDecision" - }, - "taskQueue": { - "name": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "header": {}, - "input": { - "payloads": [ - { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "eyJicmFuY2giOiJvZGQiLCJjb21wdXRlZCI6MTAxLCJzb3VyY2UiOiJyMSJ9" - } - ] - }, - "scheduleToCloseTimeout": "0s", - "scheduleToStartTimeout": "0s", - "startToCloseTimeout": "10s", - "heartbeatTimeout": "0s", - "workflowTaskCompletedEventId": "4", - "retryPolicy": { - "initialInterval": "1s", - "backoffCoefficient": 2.0, - "maximumInterval": "100s" - }, - "useWorkflowBuildId": true, - "priority": {} - } - }, - { - "eventId": "7", - "eventTime": "2026-09-06T18:23:04.324868Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", - "taskId": "1052811", - "activityTaskStartedEventAttributes": { - "scheduledEventId": "6", - "identity": "34059@Mac.lan", - "requestId": "47dd481e-e301-4a50-b342-23ff777038be", - "attempt": 1, - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - } - } - }, - { - "eventId": "8", - "eventTime": "2026-09-06T18:23:04.326319Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", - "taskId": "1052812", - "activityTaskCompletedEventAttributes": { - "result": { - "payloads": [ - { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "InJlY29yZGVkOnIxOm9kZCI=" - } - ] - }, - "scheduledEventId": "6", - "startedEventId": "7", - "identity": "34059@Mac.lan" - } - }, - { - "eventId": "9", - "eventTime": "2026-09-06T18:23:04.326325Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1052813", - "workflowTaskScheduledEventAttributes": { - "taskQueue": { - "name": "34059@Mac.lan-37fc3e6910fe473798ebf07f75c25409", - "kind": "TASK_QUEUE_KIND_STICKY", - "normalName": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9" - }, - "startToCloseTimeout": "10s", - "attempt": 1 - } - }, - { - "eventId": "10", - "eventTime": "2026-09-06T18:23:04.326970Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1052817", - "workflowTaskStartedEventAttributes": { - "scheduledEventId": "9", - "identity": "34059@Mac.lan", - "requestId": "bd8c44cb-8931-4f15-be7f-9c0ef23bebf0", - "historySizeBytes": "1711", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - } - } - }, - { - "eventId": "11", - "eventTime": "2026-09-06T18:23:04.334247Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1052823", - "workflowTaskCompletedEventAttributes": { - "scheduledEventId": "9", - "startedEventId": "10", - "identity": "34059@Mac.lan", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - }, - "sdkMetadata": {}, - "meteringMetadata": {} - } - }, - { - "eventId": "12", - "eventTime": "2026-09-06T18:23:04.334266Z", - "eventType": "EVENT_TYPE_MARKER_RECORDED", - "taskId": "1052824", - "markerRecordedEventAttributes": { - "markerName": "core_external_stream", - "details": { - "external_stream": { - "payloads": [ - { - "metadata": { - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAEQAhoECAEQAiLOAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAQ8xNzg4NzE4OTg0MzIyLTANcmVkaXMtc3RyZWFtcwECAAECAQEPMTc4ODcxODk4NDMzMi0wDzE3ODg3MTg5ODQzMzItMAEAAQMBAQEPMTc4ODcxODk4NDMzMi0wKAYynQEIARABGiAxUTNvYU1sREF6aXVQNkNwR3l5U1BRa3J2bVhuNGotUSAIKiQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkyMgoJZGVjaXNpb25zEAEYtAEiIEG2KSpLbvL+t7NXgoIazG7djtT9TCEi2Caa1YjB4xlZOgMKAQA6AwoBAUINcmVkaXMtc3RyZWFtc0gB" - } - ] - } - }, - "workflowTaskCompletedEventId": "11" - } - }, - { - "eventId": "13", - "eventTime": "2026-09-06T18:23:04.334272Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", - "taskId": "1052825", - "activityTaskScheduledEventAttributes": { - "activityId": "decision-r2", - "activityType": { - "name": "AI198RecordDecision" - }, - "taskQueue": { - "name": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "header": {}, - "input": { - "payloads": [ - { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "eyJicmFuY2giOiJldmVuIiwiY29tcHV0ZWQiOjIwLCJzb3VyY2UiOiJyMiJ9" - } - ] - }, - "scheduleToCloseTimeout": "0s", - "scheduleToStartTimeout": "0s", - "startToCloseTimeout": "10s", - "heartbeatTimeout": "0s", - "workflowTaskCompletedEventId": "11", - "retryPolicy": { - "initialInterval": "1s", - "backoffCoefficient": 2.0, - "maximumInterval": "100s" - }, - "useWorkflowBuildId": true, - "priority": {} - } - }, - { - "eventId": "14", - "eventTime": "2026-09-06T18:23:04.331874Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1052826", - "workflowExecutionSignaledEventAttributes": { - "signalName": "__temporal_external_stream_wake", - "input": { - "payloads": [ - { - "metadata": { - "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAESBmlucHV0cyokMDFhMDc3ZjUtYWM3My03ZjEzLTlmYTYtZWJjNWMwODIxYTY5Mgdtb2RlbCMx" - } - ] - }, - "identity": "34059@Mac.lan", - "requestId": "c70d6ed3-a072-5673-8c23-4d7fb2864d3f" - } - }, - { - "eventId": "15", - "eventTime": "2026-09-06T18:23:04.334287Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1052827", - "workflowTaskScheduledEventAttributes": { - "taskQueue": { - "name": "34059@Mac.lan-37fc3e6910fe473798ebf07f75c25409", - "kind": "TASK_QUEUE_KIND_STICKY", - "normalName": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9" - }, - "startToCloseTimeout": "10s", - "attempt": 1 - } - }, - { - "eventId": "16", - "eventTime": "2026-09-06T18:23:04.334301Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1052828", - "workflowTaskStartedEventAttributes": { - "scheduledEventId": "15", - "identity": "34059@Mac.lan", - "requestId": "request-from-RespondWorkflowTaskCompleted", - "historySizeBytes": "1831", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - } - } - }, - { - "eventId": "17", - "eventTime": "2026-09-06T18:23:04.336066Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1052833", - "workflowTaskCompletedEventAttributes": { - "scheduledEventId": "15", - "startedEventId": "16", - "identity": "34059@Mac.lan", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - }, - "sdkMetadata": {}, - "meteringMetadata": {} - } - }, - { - "eventId": "18", - "eventTime": "2026-09-06T18:23:04.336077Z", - "eventType": "EVENT_TYPE_MARKER_RECORDED", - "taskId": "1052834", - "markerRecordedEventAttributes": { - "markerName": "core_external_stream", - "details": { - "external_stream": { - "payloads": [ - { - "metadata": { - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAEQAhoECAEQAiKoAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAQ8xNzg4NzE4OTg0MzMyLTANcmVkaXMtc3RyZWFtcwECAAEDAQEBDzE3ODg3MTg5ODQzMzItMCgI" - } - ] - } - }, - "workflowTaskCompletedEventId": "17" - } - }, - { - "eventId": "19", - "eventTime": "2026-09-06T18:23:04.334282Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", - "taskId": "1052835", - "activityTaskStartedEventAttributes": { - "scheduledEventId": "13", - "identity": "34059@Mac.lan", - "requestId": "9fc0cd1b-60de-4c07-b64d-b2b9e8cd3c8e", - "attempt": 1, - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - } - } - }, - { - "eventId": "20", - "eventTime": "2026-09-06T18:23:04.335841Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", - "taskId": "1052836", - "activityTaskCompletedEventAttributes": { - "result": { - "payloads": [ - { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "InJlY29yZGVkOnIyOmV2ZW4i" - } - ] - }, - "scheduledEventId": "13", - "startedEventId": "19", - "identity": "34059@Mac.lan" - } - }, - { - "eventId": "21", - "eventTime": "2026-09-06T18:23:04.336080Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1052837", - "workflowTaskScheduledEventAttributes": { - "taskQueue": { - "name": "34059@Mac.lan-37fc3e6910fe473798ebf07f75c25409", - "kind": "TASK_QUEUE_KIND_STICKY", - "normalName": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9" - }, - "startToCloseTimeout": "10s", - "attempt": 1 - } - }, - { - "eventId": "22", - "eventTime": "2026-09-06T18:23:04.336092Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1052838", - "workflowTaskStartedEventAttributes": { - "scheduledEventId": "21", - "identity": "34059@Mac.lan", - "requestId": "request-from-RespondWorkflowTaskCompleted", - "historySizeBytes": "3174", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - } - } - }, - { - "eventId": "23", - "eventTime": "2026-09-06T18:23:04.856009Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1052843", - "workflowTaskCompletedEventAttributes": { - "scheduledEventId": "21", - "startedEventId": "22", - "identity": "34059@Mac.lan", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - }, - "sdkMetadata": {}, - "meteringMetadata": {} - } - }, - { - "eventId": "24", - "eventTime": "2026-09-06T18:23:04.856043Z", - "eventType": "EVENT_TYPE_MARKER_RECORDED", - "taskId": "1052844", - "markerRecordedEventAttributes": { - "markerName": "core_external_stream", - "details": { - "external_stream": { - "payloads": [ - { - "metadata": { - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAEQAxoECAEQAyLOAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAQ8xNzg4NzE4OTg0MzMyLTANcmVkaXMtc3RyZWFtcwECAAECAQEPMTc4ODcxODk4NDg1MS0wDzE3ODg3MTg5ODQ4NTEtMAEAAQMBAQEPMTc4ODcxODk4NDg1MS0wKAYynQEIARABGiBhekMwV1J5bktxbVRMUjNMLWFLdDZNRjVuc25jTzRMdCAUKiQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkyMgoJZGVjaXNpb25zEAIY0wIiIMlkAX98qOr68/irYtiKi0b09uWIahOZe6Q558yOXlphOgMKAQA6AwoBAkINcmVkaXMtc3RyZWFtc0gB" - } - ] - } - }, - "workflowTaskCompletedEventId": "23" - } - }, - { - "eventId": "25", - "eventTime": "2026-09-06T18:23:04.856056Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", - "taskId": "1052845", - "activityTaskScheduledEventAttributes": { - "activityId": "decision-r3", - "activityType": { - "name": "AI198RecordDecision" - }, - "taskQueue": { - "name": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "header": {}, - "input": { - "payloads": [ - { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "eyJicmFuY2giOiJvZGQiLCJjb21wdXRlZCI6MTAzLCJzb3VyY2UiOiJyMyJ9" - } - ] - }, - "scheduleToCloseTimeout": "0s", - "scheduleToStartTimeout": "0s", - "startToCloseTimeout": "10s", - "heartbeatTimeout": "0s", - "workflowTaskCompletedEventId": "23", - "retryPolicy": { - "initialInterval": "1s", - "backoffCoefficient": 2.0, - "maximumInterval": "100s" - }, - "useWorkflowBuildId": true, - "priority": {} - } - }, - { - "eventId": "26", - "eventTime": "2026-09-06T18:23:04.341085Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1052846", - "workflowExecutionSignaledEventAttributes": { - "signalName": "__temporal_external_stream_wake", - "input": { - "payloads": [ - { - "metadata": { - "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAESBmlucHV0cyokMDFhMDc3ZjUtYWM3My03ZjEzLTlmYTYtZWJjNWMwODIxYTY5Mgdtb2RlbCMx" - } - ] - }, - "identity": "34059@Mac.lan", - "requestId": "0da6d15a-59e2-5ce9-89c3-fdb107a4d620" - } - }, - { - "eventId": "27", - "eventTime": "2026-09-06T18:23:04.856092Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1052847", - "workflowTaskScheduledEventAttributes": { - "taskQueue": { - "name": "34059@Mac.lan-37fc3e6910fe473798ebf07f75c25409", - "kind": "TASK_QUEUE_KIND_STICKY", - "normalName": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9" - }, - "startToCloseTimeout": "10s", - "attempt": 1 - } - }, - { - "eventId": "28", - "eventTime": "2026-09-06T18:23:04.856126Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1052848", - "workflowTaskStartedEventAttributes": { - "scheduledEventId": "27", - "identity": "34059@Mac.lan", - "requestId": "request-from-RespondWorkflowTaskCompleted", - "historySizeBytes": "4035", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - } - } - }, - { - "eventId": "29", - "eventTime": "2026-09-06T18:23:04.860129Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1052853", - "workflowTaskCompletedEventAttributes": { - "scheduledEventId": "27", - "startedEventId": "28", - "identity": "34059@Mac.lan", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - }, - "sdkMetadata": {}, - "meteringMetadata": {} - } - }, - { - "eventId": "30", - "eventTime": "2026-09-06T18:23:04.860156Z", - "eventType": "EVENT_TYPE_MARKER_RECORDED", - "taskId": "1052854", - "markerRecordedEventAttributes": { - "markerName": "core_external_stream", - "details": { - "external_stream": { - "payloads": [ - { - "metadata": { - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAEQAxoECAEQAyKoAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAQ8xNzg4NzE4OTg0ODUxLTANcmVkaXMtc3RyZWFtcwECAAEDAQEBDzE3ODg3MTg5ODQ4NTEtMCgI" - } - ] - } - }, - "workflowTaskCompletedEventId": "29" - } - }, - { - "eventId": "31", - "eventTime": "2026-09-06T18:23:04.856080Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", - "taskId": "1052855", - "activityTaskStartedEventAttributes": { - "scheduledEventId": "25", - "identity": "34059@Mac.lan", - "requestId": "ae70d169-fcae-4c8b-b56f-24ef10977f72", - "attempt": 1, - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - } - } - }, - { - "eventId": "32", - "eventTime": "2026-09-06T18:23:04.859154Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", - "taskId": "1052856", - "activityTaskCompletedEventAttributes": { - "result": { - "payloads": [ - { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "InJlY29yZGVkOnIzOm9kZCI=" - } - ] - }, - "scheduledEventId": "25", - "startedEventId": "31", - "identity": "34059@Mac.lan" - } - }, - { - "eventId": "33", - "eventTime": "2026-09-06T18:23:04.860164Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1052857", - "workflowTaskScheduledEventAttributes": { - "taskQueue": { - "name": "34059@Mac.lan-37fc3e6910fe473798ebf07f75c25409", - "kind": "TASK_QUEUE_KIND_STICKY", - "normalName": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9" - }, - "startToCloseTimeout": "10s", - "attempt": 1 - } - }, - { - "eventId": "34", - "eventTime": "2026-09-06T18:23:04.860195Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1052858", - "workflowTaskStartedEventAttributes": { - "scheduledEventId": "33", - "identity": "34059@Mac.lan", - "requestId": "request-from-RespondWorkflowTaskCompleted", - "historySizeBytes": "5378", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - } - } - }, - { - "eventId": "35", - "eventTime": "2026-09-06T18:23:04.873800Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1052863", - "workflowTaskCompletedEventAttributes": { - "scheduledEventId": "33", - "startedEventId": "34", - "identity": "34059@Mac.lan", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - }, - "sdkMetadata": {}, - "meteringMetadata": {} - } - }, - { - "eventId": "36", - "eventTime": "2026-09-06T18:23:04.873824Z", - "eventType": "EVENT_TYPE_MARKER_RECORDED", - "taskId": "1052864", - "markerRecordedEventAttributes": { - "markerName": "core_external_stream", - "details": { - "external_stream": { - "payloads": [ - { - "metadata": { - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAEQBBoECAEQBCLOAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAQ8xNzg4NzE4OTg0ODUxLTANcmVkaXMtc3RyZWFtcwECAAECAQEPMTc4ODcxODk4NDg3MC0wDzE3ODg3MTg5ODQ4NzAtMAEAAQMBAQEPMTc4ODcxODk4NDg3MC0wKAYynQEIARABGiBBU25qczFkZkowaTZZdHdBSkt0RW9XTlZUek9jcUxTbCAgKiQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkyMgoJZGVjaXNpb25zEAEYtAEiID5nSwItjUt3xrIwLwmnksCGdUXwRytSYqhf4/HFL9GVOgMKAQA6AwoBAUINcmVkaXMtc3RyZWFtc0gB" - } - ] - } - }, - "workflowTaskCompletedEventId": "35" - } - }, - { - "eventId": "37", - "eventTime": "2026-09-06T18:23:04.873833Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", - "taskId": "1052865", - "activityTaskScheduledEventAttributes": { - "activityId": "decision-r4", - "activityType": { - "name": "AI198RecordDecision" - }, - "taskQueue": { - "name": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "header": {}, - "input": { - "payloads": [ - { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "eyJicmFuY2giOiJldmVuIiwiY29tcHV0ZWQiOjQwLCJzb3VyY2UiOiJyNCJ9" - } - ] - }, - "scheduleToCloseTimeout": "0s", - "scheduleToStartTimeout": "0s", - "startToCloseTimeout": "10s", - "heartbeatTimeout": "0s", - "workflowTaskCompletedEventId": "35", - "retryPolicy": { - "initialInterval": "1s", - "backoffCoefficient": 2.0, - "maximumInterval": "100s" - }, - "useWorkflowBuildId": true, - "priority": {} - } - }, - { - "eventId": "38", - "eventTime": "2026-09-06T18:23:04.869822Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1052866", - "workflowExecutionSignaledEventAttributes": { - "signalName": "__temporal_external_stream_wake", - "input": { - "payloads": [ - { - "metadata": { - "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAESBmlucHV0cyokMDFhMDc3ZjUtYWM3My03ZjEzLTlmYTYtZWJjNWMwODIxYTY5Mgdtb2RlbCMy" - } - ] - }, - "identity": "34059@Mac.lan", - "requestId": "5d10fc28-9e67-5c92-8e99-a7efb1f1d9cb" - } - }, - { - "eventId": "39", - "eventTime": "2026-09-06T18:23:04.873860Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1052867", - "workflowTaskScheduledEventAttributes": { - "taskQueue": { - "name": "34059@Mac.lan-37fc3e6910fe473798ebf07f75c25409", - "kind": "TASK_QUEUE_KIND_STICKY", - "normalName": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9" - }, - "startToCloseTimeout": "10s", - "attempt": 1 - } - }, - { - "eventId": "40", - "eventTime": "2026-09-06T18:23:04.873883Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1052868", - "workflowTaskStartedEventAttributes": { - "scheduledEventId": "39", - "identity": "34059@Mac.lan", - "requestId": "request-from-RespondWorkflowTaskCompleted", - "historySizeBytes": "6238", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - } - } - }, - { - "eventId": "41", - "eventTime": "2026-09-06T18:23:04.876306Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1052873", - "workflowTaskCompletedEventAttributes": { - "scheduledEventId": "39", - "startedEventId": "40", - "identity": "34059@Mac.lan", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - }, - "sdkMetadata": {}, - "meteringMetadata": {} - } - }, - { - "eventId": "42", - "eventTime": "2026-09-06T18:23:04.876327Z", - "eventType": "EVENT_TYPE_MARKER_RECORDED", - "taskId": "1052874", - "markerRecordedEventAttributes": { - "markerName": "core_external_stream", - "details": { - "external_stream": { - "payloads": [ - { - "metadata": { - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAEQBBoECAEQBCKoAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAQ8xNzg4NzE4OTg0ODcwLTANcmVkaXMtc3RyZWFtcwECAAEDAQEBDzE3ODg3MTg5ODQ4NzAtMCgI" - } - ] - } - }, - "workflowTaskCompletedEventId": "41" - } - }, - { - "eventId": "43", - "eventTime": "2026-09-06T18:23:04.873853Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", - "taskId": "1052875", - "activityTaskStartedEventAttributes": { - "scheduledEventId": "37", - "identity": "34059@Mac.lan", - "requestId": "23c51461-54a6-4666-9081-7a1f0ab7dfd2", - "attempt": 1, - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - } - } - }, - { - "eventId": "44", - "eventTime": "2026-09-06T18:23:04.875977Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", - "taskId": "1052876", - "activityTaskCompletedEventAttributes": { - "result": { - "payloads": [ - { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "InJlY29yZGVkOnI0OmV2ZW4i" - } - ] - }, - "scheduledEventId": "37", - "startedEventId": "43", - "identity": "34059@Mac.lan" - } - }, - { - "eventId": "45", - "eventTime": "2026-09-06T18:23:04.876333Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1052877", - "workflowTaskScheduledEventAttributes": { - "taskQueue": { - "name": "34059@Mac.lan-37fc3e6910fe473798ebf07f75c25409", - "kind": "TASK_QUEUE_KIND_STICKY", - "normalName": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9" - }, - "startToCloseTimeout": "10s", - "attempt": 1 - } - }, - { - "eventId": "46", - "eventTime": "2026-09-06T18:23:04.876353Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1052878", - "workflowTaskStartedEventAttributes": { - "scheduledEventId": "45", - "identity": "34059@Mac.lan", - "requestId": "request-from-RespondWorkflowTaskCompleted", - "historySizeBytes": "7581", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - } - } - }, - { - "eventId": "47", - "eventTime": "2026-09-06T18:23:04.886126Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_FAILED", - "taskId": "1052882", - "workflowTaskFailedEventAttributes": { - "scheduledEventId": "45", - "startedEventId": "46", - "cause": "WORKFLOW_TASK_FAILED_CAUSE_UNHANDLED_COMMAND", - "failure": { - "message": "UnhandledCommand", - "serverFailureInfo": {} - }, - "identity": "34059@Mac.lan" - } - }, - { - "eventId": "48", - "eventTime": "2026-09-06T18:23:04.883008Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", - "taskId": "1052883", - "workflowExecutionSignaledEventAttributes": { - "signalName": "__temporal_external_stream_wake", - "input": { - "payloads": [ - { - "metadata": { - "messageType": "Y29yZXNkay5leHRlcm5hbF9zdHJlYW0uV2FrZVNpZ25hbA==", - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAESBmlucHV0cyokMDFhMDc3ZjUtYWM3My03ZjEzLTlmYTYtZWJjNWMwODIxYTY5Mgdtb2RlbCMy" - } - ] - }, - "identity": "34059@Mac.lan", - "requestId": "da1874cc-ecf7-5813-8e2d-807bf5d43193" - } - }, - { - "eventId": "49", - "eventTime": "2026-09-06T18:23:04.886152Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1052884", - "workflowTaskScheduledEventAttributes": { - "taskQueue": { - "name": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "startToCloseTimeout": "10s", - "attempt": 1 - } - }, - { - "eventId": "50", - "eventTime": "2026-09-06T18:23:04.886996Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1052887", - "workflowTaskStartedEventAttributes": { - "scheduledEventId": "49", - "identity": "34059@Mac.lan", - "requestId": "40f8ac65-5593-41c0-8cd7-a618c78e8bea", - "historySizeBytes": "8865", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - } - } - }, - { - "eventId": "51", - "eventTime": "2026-09-06T18:23:05.303033Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1052891", - "workflowTaskCompletedEventAttributes": { - "scheduledEventId": "49", - "startedEventId": "50", - "identity": "34059@Mac.lan", - "workerVersion": { - "buildId": "bea38d1926cc20b3f7972bbc95740895" - }, - "sdkMetadata": {}, - "meteringMetadata": {} - } - }, - { - "eventId": "52", - "eventTime": "2026-09-06T18:23:05.303054Z", - "eventType": "EVENT_TYPE_MARKER_RECORDED", - "taskId": "1052892", - "markerRecordedEventAttributes": { - "markerName": "core_external_stream", - "details": { - "external_stream": { - "payloads": [ - { - "metadata": { - "encoding": "YmluYXJ5L3Byb3RvYnVm" - }, - "data": "CAEQARoECAEQBSLOAQEBAQEHZGVmYXVsdDhhaTE5OC1jb250cmFjdC1leHRlcm5hbC03Y2EzNDk5OTJkYmY0MTgxYWJkZTNhOGVjNTc4NDljOSQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkGaW5wdXRzAQ8xNzg4NzE4OTg0ODcwLTANcmVkaXMtc3RyZWFtcwECAAECAQEPMTc4ODcxODk4NDg4My0wDzE3ODg3MTg5ODQ4ODMtMAEAAQMBAQEPMTc4ODcxODk4NDg4My0wKAcynAEIARABGiBxeUxQdjZoN0NUZUpfd3IxZzUwOE82NEtPeTRXX0FWOCAwKiQwMWEwNzdmNS1hYzczLTdmMTMtOWZhNi1lYmM1YzA4MjFhNjkyMQoJZGVjaXNpb25zEAEYbSIg3ClteUJUVTZweyuvvwlOF9DoWSLrxmpqEsPbZESWN6I6AwoBADoDCgEBQg1yZWRpcy1zdHJlYW1zSAE=" - } - ] - } - }, - "workflowTaskCompletedEventId": "51" - } - }, - { - "eventId": "53", - "eventTime": "2026-09-06T18:23:05.303058Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED", - "taskId": "1052893", - "workflowExecutionCompletedEventAttributes": { - "result": { - "payloads": [ - { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "W3sia2luZCI6ImRlY2lzaW9uIiwicmVjZWlwdCI6InJlY29yZGVkOnIxOm9kZCIsInZhbHVlIjp7ImJyYW5jaCI6Im9kZCIsImNvbXB1dGVkIjoxMDEsInNvdXJjZSI6InIxIn19LHsia2luZCI6ImRlY2lzaW9uIiwicmVjZWlwdCI6InJlY29yZGVkOnIyOmV2ZW4iLCJ2YWx1ZSI6eyJicmFuY2giOiJldmVuIiwiY29tcHV0ZWQiOjIwLCJzb3VyY2UiOiJyMiJ9fSx7ImF0dGVtcHQiOjIsImtpbmQiOiJzdXBlcnNlZGVkIiwicHJvZHVjZXIiOiJtb2RlbCIsInJlcGxhY2VkIjoxfSx7ImtpbmQiOiJkZWNpc2lvbiIsInJlY2VpcHQiOiJyZWNvcmRlZDpyMzpvZGQiLCJ2YWx1ZSI6eyJicmFuY2giOiJvZGQiLCJjb21wdXRlZCI6MTAzLCJzb3VyY2UiOiJyMyJ9fSx7ImtpbmQiOiJkZWNpc2lvbiIsInJlY2VpcHQiOiJyZWNvcmRlZDpyNDpldmVuIiwidmFsdWUiOnsiYnJhbmNoIjoiZXZlbiIsImNvbXB1dGVkIjo0MCwic291cmNlIjoicjQifX0seyJraW5kIjoiZmluaXNoIiwicHJvZHVjZXIiOiJtb2RlbCJ9XQ==" - } - ] - }, - "workflowTaskCompletedEventId": "51" - } - } - ] -} \ No newline at end of file diff --git a/streams_demo/results-external/results.json b/streams_demo/results-external/results.json deleted file mode 100644 index 149ce1ef9..000000000 --- a/streams_demo/results-external/results.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "provider": "external", - "workflow_id": "ai198-contract-external-7ca349992dbf4181abde3a8ec57849c9", - "target": "127.0.0.1:53266", - "max_cached_workflows": 2, - "outcome": "completed", - "trace": [ - { - "kind": "decision", - "receipt": "recorded:r1:odd", - "value": { - "branch": "odd", - "computed": 101, - "source": "r1" - } - }, - { - "kind": "decision", - "receipt": "recorded:r2:even", - "value": { - "branch": "even", - "computed": 20, - "source": "r2" - } - }, - { - "attempt": 2, - "kind": "superseded", - "producer": "model", - "replaced": 1 - }, - { - "kind": "decision", - "receipt": "recorded:r3:odd", - "value": { - "branch": "odd", - "computed": 103, - "source": "r3" - } - }, - { - "kind": "decision", - "receipt": "recorded:r4:even", - "value": { - "branch": "even", - "computed": 40, - "source": "r4" - } - }, - { - "kind": "finish", - "producer": "model" - } - ], - "task_failures": [ - { - "event_id": 47, - "cause": 1, - "message": "UnhandledCommand" - } - ], - "observed_output": [ - { - "kind": "DATA", - "value": { - "branch": "odd", - "computed": 101, - "source": "r1" - } - }, - { - "kind": "DATA", - "value": { - "branch": "even", - "computed": 20, - "source": "r2" - } - }, - { - "kind": "DATA", - "value": { - "retracting_attempt": 1 - } - }, - { - "kind": "DATA", - "value": { - "branch": "odd", - "computed": 103, - "source": "r3" - } - }, - { - "kind": "DATA", - "value": { - "branch": "even", - "computed": 40, - "source": "r4" - } - }, - { - "kind": "FINISH", - "value": null - } - ], - "history_events": 53 -} diff --git a/temporalio/contrib/openai_agents/testing.py b/temporalio/contrib/openai_agents/testing.py index 3367a81e1..3fcd6a573 100644 --- a/temporalio/contrib/openai_agents/testing.py +++ b/temporalio/contrib/openai_agents/testing.py @@ -74,8 +74,8 @@ def tool_call(arguments: str, name: str) -> ModelResponse: return ResponseBuilders.model_response( ResponseFunctionToolCall( arguments=arguments, - # A script that calls two tools hands the same completed id to - # two invocations, which the Agents SDK now rejects. + # The Agents SDK rejects two invocations sharing one completed + # call id, and a script that calls two tools would hand them one. call_id=f"call-{uuid.uuid4()}", name=name, type="function_call", From f71a030fd9abaf526057f286edf8edb3d8eb3914 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 17:39:11 -0700 Subject: [PATCH 45/96] Adapted the Redis provider to the interface changes. The append cursor names the last record and is None for an empty batch; a publish on the owner's stream answers with the whole record, so its offset is taken before the token is. Producer identity is resolved by the package, inbound frames carry no topic, undecodable frames are skipped with a warning, and the Redis client is opened on first use and released by the close hook, with the socket timeout derived from the backend's block period and the URL read from the same variable Max's suite uses. --- temporalio/streams/providers/redis.py | 144 +++++++++++++++++--------- 1 file changed, 93 insertions(+), 51 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index ad5fbcd9c..3cc24e538 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -5,21 +5,23 @@ a marker in History proves the workflow task that produced it was accepted. Consumption is recorded the same way, as ranges and boundaries in History. -``configure`` builds the backend from ``url`` and ``key_prefix`` (defaulting -to ``AI198_REDIS_URL`` and ``AI198_REDIS_PREFIX``), or takes a constructed -``backend`` when the caller wants to own its lifecycle. +``configure`` takes ``url`` and ``key_prefix`` (defaulting to +``TEMPORAL_TEST_REDIS_URL``, then ``AI198_REDIS_URL``, and +``AI198_REDIS_PREFIX``), or a constructed ``backend`` when the caller wants to +own its lifecycle. The Redis client is opened on first use, so it belongs to +the loop that uses it, and released by ``close``. """ from __future__ import annotations import asyncio +import logging import os import time -from collections.abc import AsyncIterator +from collections.abc import AsyncGenerator from datetime import timedelta from typing import Any -from temporalio import activity from temporalio.api.common.v1 import Payload from temporalio.client import Client, WorkflowExecutionStatus from temporalio.contrib.external_workflow_streams import ( @@ -28,6 +30,7 @@ ExternalOutputStreamProducer, ExternalStreamProducer, Offset, + OutputStreamRecord, WakeNotAcknowledgedError, WorkflowChainKey, external_output_stream, @@ -36,11 +39,14 @@ from temporalio.contrib.external_workflow_streams import ( BEGINNING as PROVIDER_BEGINNING, ) +from temporalio.contrib.external_workflow_streams._backend import DEFAULT_WATCH_BLOCK from temporalio.streams import _frame, _provider from temporalio.streams._handles import ReadSource, WriteSink from temporalio.streams._policy import AttemptTracker from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord +logger = logging.getLogger(__name__) + class _ExternalReadSource: def __init__(self, subscription: Any) -> None: @@ -77,6 +83,12 @@ async def _chain_key(client: Client, workflow_id: str) -> WorkflowChainKey: ) +def _placed(result: Any) -> Offset: + # An inbound topic answers a publish with the offset. The owner's output + # stream answers with the whole record, whose text form is not a position. + return result.offset if isinstance(result, OutputStreamRecord) else result + + class RedisProducer: """Appends to a stream from outside workflow code. @@ -90,16 +102,20 @@ def __init__( self, topic: Any, converter: Any, - stream: str, + frame_topic: str, producer_id: str, attempt: int, client: Client | None = None, workflow_id: str = "", ) -> None: - """Bind this producer to ``topic`` on the external store.""" + """Bind this producer to ``topic`` on the external store. + + ``frame_topic`` is what each record carries: the topic name on the + owner's stream, empty on an inbound stream. + """ self._topic = topic self._converter = converter - self._stream = stream + self._frame_topic = frame_topic self._producer_id = producer_id self._attempt = attempt self._client = client @@ -111,12 +127,17 @@ def attempt(self) -> int: """The generation this producer is writing.""" return self._attempt - async def append(self, *values: Any) -> Cursor: - """Append values and return where the first one landed.""" - first: Any = None + async def append(self, *values: Any) -> Cursor | None: + """Append ``values`` and return the last one's cursor, or ``None`` for none. + + A repeat of an earlier append comes back with the original position. + The store drops the duplicate, but this seam does not say so, and a + reader resuming after that position still sees only what came later. + """ + last: Offset | None = None for value in values: frame = _frame.encode( - topic=self._stream, + topic=self._frame_topic, kind=RecordKind.DATA, producer=self._producer_id, attempt=self._attempt, @@ -124,15 +145,13 @@ async def append(self, *values: Any) -> Cursor: body=self._encode(value), ) self._sequence += 1 - offset = await self._topic.publish(frame) - if first is None: - first = offset - return Cursor(str(first)) + last = _placed(await self._topic.publish(frame)) + return None if last is None else Cursor(last.token) async def finish(self) -> None: """Declare this stream complete.""" frame = _frame.encode( - topic=self._stream, + topic=self._frame_topic, kind=RecordKind.FINISH, producer=self._producer_id, attempt=self._attempt, @@ -183,10 +202,11 @@ def _encode(self, value: Any) -> bytes: class RedisConsumer: """Reads a stream from outside workflow code, resumably.""" - def __init__(self, client: Any, converter: Any) -> None: + def __init__(self, client: Any, converter: Any, workflow_id: str) -> None: """Read what ``client`` reaches in the external store.""" self._client = client self._converter = converter + self._workflow_id = workflow_id async def read( self, @@ -194,22 +214,34 @@ async def read( after: Cursor = BEGINNING, topic: str | None = None, type: type | None = None, - ) -> AsyncIterator[StreamRecord[Any]]: + ) -> AsyncGenerator[StreamRecord[Any], None]: """Yield the records after ``after`` as they arrive. Applies the same supersession rule as a workflow reader, so a browser and a workflow watching one activity agree on which attempt is current. """ + # Only the owner's stream is readable from here, so the inbound check + # can never fire; it runs so every provider answers a topic the same way. + _provider.check_topic("", topic) + name = self._require_topic(topic) attempts = AttemptTracker() boundary = AFTER(Offset(after.token)) if after.token else PROVIDER_BEGINNING - handle = self._client.topic(self._require_topic(topic), type=bytes) + handle = self._client.topic(name, type=bytes) async for item in handle.subscribe(after=boundary): cursor = Cursor(str(item.offset)) try: kind, frame_topic, source, attempt, sequence, body = _frame.decode( item.data ) - except ValueError: + except ValueError as error: + # Same answer as the workflow-side reader: skip and say so. + logger.warning( + "skipping record %s of %s topic %s: %s", + cursor, + self._workflow_id, + name, + error, + ) continue superseded = attempts.note(source, attempt, cursor) if superseded is not None: @@ -253,6 +285,9 @@ class _RedisProvider: def __init__(self) -> None: self._backend: Any = None + self._owned_client: Any = None + self._url = "" + self._key_prefix = "" def configure(self, **options: Any) -> None: backend = options.pop("backend", None) @@ -263,36 +298,47 @@ def configure(self, **options: Any) -> None: "the redis provider takes backend, url and key_prefix, got " f"{sorted(options)}" ) - if backend is not None: - self._backend = backend - return - import redis.asyncio - - from temporalio.contrib.external_workflow_streams._redis import ( - RedisStreamBackend, + self._backend = backend + self._url = ( + url + or os.environ.get("TEMPORAL_TEST_REDIS_URL") + or os.environ.get("AI198_REDIS_URL", "redis://127.0.0.1:6379") ) - - # redis-py defaults to a socket timeout equal to this provider's own - # blocking read, so an idle read would abandon a healthy socket. - self._backend = RedisStreamBackend( - client=redis.asyncio.from_url( - url or os.environ.get("AI198_REDIS_URL", "redis://127.0.0.1:6399"), - decode_responses=False, - socket_timeout=30, - ), - key_prefix=key_prefix - or os.environ.get("AI198_REDIS_PREFIX", "ai198-contract"), + self._key_prefix = key_prefix or os.environ.get( + "AI198_REDIS_PREFIX", "ai198-contract" ) def _require_backend(self) -> Any: if self._backend is None: - raise RuntimeError( - "no store configured; call temporalio.streams.configure(" - 'provider="redis", ...) before opening a producer, a consumer ' - "or a worker" + import redis.asyncio + + from temporalio.contrib.external_workflow_streams._redis import ( + RedisStreamBackend, + ) + + # A dead peer would otherwise hold a blocking read open forever. + # Several block periods, so a healthy socket that is merely idle + # inside one XREAD window is never abandoned. + self._owned_client = redis.asyncio.from_url( + self._url, + decode_responses=False, + socket_timeout=DEFAULT_WATCH_BLOCK.total_seconds() * 6, + ) + self._backend = RedisStreamBackend( + client=self._owned_client, key_prefix=self._key_prefix ) return self._backend + async def close(self) -> None: + """Release the Redis connections this provider opened. + + A backend the caller handed in stays the caller's to close. + """ + client, self._owned_client = self._owned_client, None + if client is not None: + self._backend = None + await client.aclose() + def worker_options(self) -> dict[str, Any]: return {"external_stream_backend": self._require_backend()} @@ -335,14 +381,9 @@ async def producer( ``stream`` names an inbound stream; with none, ``topic`` names a topic on the workflow's output stream, appended directly rather than through - the workflow's staged commit. Inside an activity, leave ``producer_id`` - and ``attempt`` unset: the activity's own id and attempt are the right - answer and are what let a reader tell a retry from a new generation. + the workflow's staged commit. An inbound record carries no topic: the + stream's name is its whole address. """ - if not producer_id: - producer_id = activity.info().activity_id - if not attempt: - attempt = activity.info().attempt # The attempt is part of it. Deduplication answers "is this the # same append again", and a second attempt writing different words # at the same sequence is not: this provider rejects that outright. @@ -367,7 +408,7 @@ async def producer( return RedisProducer( session.topic(stream or topic, type=bytes), client.data_converter.payload_converter, - stream or topic, + topic, producer_id, attempt, client, @@ -396,6 +437,7 @@ async def consumer( client=client, ), client.data_converter.payload_converter, + workflow_id, ) From 6fb7abed160e8a08f6739e8386d3c1dc6ea6972d Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 17:39:12 -0700 Subject: [PATCH 46/96] Registered the Redis provider in the conformance suite. Behind STREAMS_LIVE=redis. Each topic is its own store and an inbound stream is not readable from outside, so the case declares both, and a dropped repeat answers with the original position. --- tests/streams/test_streams_conformance.py | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index 35f65ad0e..e407144dd 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -19,6 +19,7 @@ from __future__ import annotations import asyncio +import os import uuid from collections.abc import AsyncIterator, Callable from dataclasses import dataclass @@ -27,6 +28,7 @@ import pytest from temporalio import streams +from temporalio.client import Client from temporalio.streams import _frame, _ids, _provider from temporalio.streams._policy import AttemptTracker from temporalio.streams._record import Cursor, RecordKind @@ -65,7 +67,40 @@ async def _memory_case() -> AsyncIterator[ProviderCase]: memory.reset() +async def _redis_case() -> AsyncIterator[ProviderCase]: + # A prefix per case, because the store keeps what earlier cases wrote. + streams.configure( + provider="redis", key_prefix=f"streams-conformance-{uuid.uuid4().hex}" + ) + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + # The store keys a workflow's streams under its chain, which is read from + # the execution, so one has to exist; no worker ever picks its task up. + owner = await client.start_workflow( + "StreamOwner", + id=new_workflow_id(), + task_queue="streams-conformance-unserved", + ) + try: + yield ProviderCase( + "redis", + client, + workflow_id=owner.id, + # Each topic is its own store, and an inbound stream is not + # readable from outside; a dropped repeat answers with the + # original position because the store's seam does not say. + inbound_streams=False, + unfiltered_reads=False, + reports_dropped_repeats=False, + ) + finally: + await owner.terminate("conformance case finished") + await streams.close() + + SETUPS: dict[str, Callable[[], AsyncIterator[ProviderCase]]] = {"memory": _memory_case} +if os.environ.get("STREAMS_LIVE") == "redis": + # Needs a dev server at TEMPORAL_ADDRESS and a Redis at TEMPORAL_TEST_REDIS_URL. + SETUPS["redis"] = _redis_case _CAPABILITIES = { "inbound_stream": lambda case: case.inbound_streams, From 05ac20da79ab0627fe57bb699cff9ab192dc7f0d Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 17:39:12 -0700 Subject: [PATCH 47/96] Ran the interface loop and the replay query in one Redis live module. The loop reuses the interface's ContractLoop rather than defining a second workflow under a type name another provider's test module already registers. --- tests/streams/test_redis_replay.py | 97 +++++++++++++++++++++--------- 1 file changed, 67 insertions(+), 30 deletions(-) diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index f47e6937b..63fbe5796 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -1,21 +1,27 @@ -"""A query against a completed run replays it on the client-side provider. - -Gated behind ``STREAMS_LIVE=redis``; needs a dev server (``TEMPORAL_ADDRESS``) -and a Redis (``AI198_REDIS_URL``). +"""Live checks for the client-side (Redis) provider inside a workflow. + +The conformance suite covers the outside surface when ``STREAMS_LIVE=redis``. +This module runs the interface loop inside a workflow over the staged commit, +then queries a completed run, which replays it. Both need a dev server +(``TEMPORAL_ADDRESS``) and a Redis (``TEMPORAL_TEST_REDIS_URL`` or +``AI198_REDIS_URL``). The worker keeps a warm cache because the provider holds +the task open between records. """ from __future__ import annotations -import asyncio import os import uuid -from typing import Any +from collections.abc import AsyncIterator import pytest from temporalio import streams, workflow from temporalio.client import Client +from temporalio.streams import RecordKind from temporalio.worker import Worker +from tests.streams.test_streams_conformance import take +from tests.streams.test_streams_workflow import DECISIONS, INPUTS, ContractLoop pytestmark = pytest.mark.skipif( os.environ.get("STREAMS_LIVE") != "redis", @@ -23,17 +29,54 @@ ) -async def take(records: Any, count: int, timeout: float = 5.0) -> list: - out: list = [] +@pytest.fixture +async def live_client() -> AsyncIterator[Client]: + # A prefix per case, because the store keeps what earlier cases wrote. + streams.configure(provider="redis", key_prefix=f"streams-redis-{uuid.uuid4().hex}") + try: + yield await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + finally: + await streams.close() - async def _collect() -> None: - async for record in records: - out.append(record) - if len(out) >= count: - return - await asyncio.wait_for(_collect(), timeout) - return out +async def test_interface_loop_over_redis(live_client: Client): + workflow_id = f"streams-redis-live-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[ContractLoop], + max_cached_workflows=100, + **streams.worker_options(), + ): + handle = await live_client.start_workflow( + ContractLoop.run, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + producer = await streams.producer( + live_client, + workflow_id=workflow_id, + stream=INPUTS, + producer_id="model", + attempt=1, + ) + await producer.append({"n": 1}, {"n": 2}) + await producer.append({"n": 3}) + await producer.finish() + + consumer = await streams.consumer(live_client, workflow_id=workflow_id) + records = await take(consumer.read(type=dict, topic=DECISIONS), 4, timeout=60) + assert [r.kind for r in records] == [ + RecordKind.DATA, + RecordKind.DATA, + RecordKind.DATA, + RecordKind.FINISH, + ] + assert [r.value["decided"] for r in records[:3]] == [1, 2, 3] + assert await handle.result() == [ + {"kind": "decision", "n": 1, "attempt": 1}, + {"kind": "decision", "n": 2, "attempt": 1}, + {"kind": "decision", "n": 3, "attempt": 1}, + {"kind": "finish", "producer": "model"}, + ] @workflow.defn @@ -61,29 +104,23 @@ def probe(self) -> int: return 1 -async def test_query_after_completion_replays_the_final_task(): - # The final task's publishes were woken by a signal, which the activation - # applies before the replay marker, so the marker's expectations have to be - # installed before that task's code runs or the replay records nothing - # against a manifest of three. - streams.configure( - provider="redis", - url=os.environ.get("AI198_REDIS_URL", "redis://127.0.0.1:6399"), - key_prefix=f"streams-redis-replay-{uuid.uuid4().hex}", - ) - client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) +async def test_query_after_completion_replays_the_final_task(live_client: Client): + # A query against a completed run replays it. The final task's publishes + # were woken by a signal, which the activation applies before the replay + # marker, so the marker's expectations have to be installed before that + # task's code runs or the replay records nothing against a manifest of + # three. workflow_id = f"streams-redis-replay-{uuid.uuid4().hex}" - async with Worker( - client, + live_client, task_queue=f"tq-{workflow_id}", workflows=[SignalWokenPublish], **streams.worker_options(), ): - handle = await client.start_workflow( + handle = await live_client.start_workflow( SignalWokenPublish.run, id=workflow_id, task_queue=f"tq-{workflow_id}" ) - consumer = await streams.consumer(client, workflow_id=workflow_id) + consumer = await streams.consumer(live_client, workflow_id=workflow_id) await take(consumer.read(type=dict, topic="out"), 1, timeout=60) await handle.signal(SignalWokenPublish.close) assert await handle.result() == 4 From e1d1b22fe83a90136128c53580d2f3dd6ca2acf9 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 17:12:44 -0700 Subject: [PATCH 48/96] Renamed the store key helper to inbound_stream_id for the provider branches. The native and Redis providers import it under that name, so the interface exports the name they were told to use. --- temporalio/streams/_ids.py | 11 +++++++---- temporalio/streams/providers/memory.py | 10 +++++----- tests/streams/test_streams_conformance.py | 10 +++++----- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/temporalio/streams/_ids.py b/temporalio/streams/_ids.py index 625522f75..5f4161053 100644 --- a/temporalio/streams/_ids.py +++ b/temporalio/streams/_ids.py @@ -3,12 +3,12 @@ A workflow id may contain any character, ``:`` included, so joining the pair with a bare ``:`` is ambiguous: ``("a:b", "c")`` and ``("a", "b:c")`` would land in one store. Every provider that keys a store by the pair goes through -:func:`stream_key`, so they all agree and none of them collides. +:func:`inbound_stream_id`, so they all agree and none of them collides. """ from __future__ import annotations -__all__ = ["stream_key"] +__all__ = ["inbound_stream_id"] def _escape(component: str) -> str: @@ -17,8 +17,11 @@ def _escape(component: str) -> str: return component.replace("%", "%25").replace(":", "%3A") -def stream_key(workflow_id: str, stream: str) -> str: - """The key for ``stream`` of ``workflow_id``, or for the owner's stream when empty. +def inbound_stream_id(workflow_id: str, stream: str) -> str: + """The store id for inbound ``stream`` of ``workflow_id``. + + With an empty ``stream`` it is the id of the stream the workflow itself + publishes, so one function keys both. Both components are percent-encoded before joining, so the only bare ``:`` in the result is the separator, and an owner-stream key has none. diff --git a/temporalio/streams/providers/memory.py b/temporalio/streams/providers/memory.py index 390c10b9c..4a3cf3298 100644 --- a/temporalio/streams/providers/memory.py +++ b/temporalio/streams/providers/memory.py @@ -27,7 +27,7 @@ from temporalio.api.common.v1 import Payload from temporalio.streams import _frame, _provider from temporalio.streams._handles import ReadSource, WriteSink -from temporalio.streams._ids import stream_key +from temporalio.streams._ids import inbound_stream_id from temporalio.streams._policy import AttemptTracker from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord @@ -317,13 +317,13 @@ def open_read( # idle_timeout as the poll period would give the parameter a second # meaning that a port copying the reference would copy too. del idle_timeout - store = _stream(stream_key(workflow.info().workflow_id, stream)) + store = _stream(inbound_stream_id(workflow.info().workflow_id, stream)) return _MemReadSource( store, int(after.token) + 1 if after.token else 0, self._poll ) def open_write(self, topic: str) -> WriteSink: - store = _stream(stream_key(workflow.info().workflow_id, "")) + store = _stream(inbound_stream_id(workflow.info().workflow_id, "")) return _MemWriteSink(store, topic) async def producer( @@ -340,7 +340,7 @@ async def producer( # own writer appends to, and the frame carries the topic. An inbound # record carries none: the stream's name is its whole address. return MemoryProducer( - _stream(stream_key(workflow_id, stream)), + _stream(inbound_stream_id(workflow_id, stream)), _converter(client), topic, producer_id, @@ -351,7 +351,7 @@ async def consumer( self, client: Any, *, workflow_id: str, stream: str = "" ) -> MemoryConsumer: return MemoryConsumer( - _stream(stream_key(workflow_id, stream)), _converter(client), stream + _stream(inbound_stream_id(workflow_id, stream)), _converter(client), stream ) diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index e407144dd..1cffded48 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -168,12 +168,12 @@ def test_supersession_is_synthesized_from_observations(): assert attempts.note("model", 2, Cursor("2")) is None -def test_stream_keys_cannot_collide(): +def test_inbound_stream_ids_cannot_collide(): # A colon in a workflow id must not make two addresses one key. - assert _ids.stream_key("a:b", "c") != _ids.stream_key("a", "b:c") - assert _ids.stream_key("a:b", "") != _ids.stream_key("a", "b") - assert _ids.stream_key("a%3Ab", "c") != _ids.stream_key("a:b", "c") - assert _ids.stream_key("wf", "inputs") == "wf:inputs" + assert _ids.inbound_stream_id("a:b", "c") != _ids.inbound_stream_id("a", "b:c") + assert _ids.inbound_stream_id("a:b", "") != _ids.inbound_stream_id("a", "b") + assert _ids.inbound_stream_id("a%3Ab", "c") != _ids.inbound_stream_id("a:b", "c") + assert _ids.inbound_stream_id("wf", "inputs") == "wf:inputs" async def test_append_read_roundtrip(provider: ProviderCase): From 8b9f1104d64baaeb724dac94bc0af2e92b8f9026 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 18 Sep 2026 17:44:40 -0700 Subject: [PATCH 49/96] Repinned Core to the reviewed moe/AI-198-external-core-repairs head. --- temporalio/bridge/sdk-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 31af05354..7df8a5b7a 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 31af05354330bb88a8b1e1f598bde1ddddae1d85 +Subproject commit 7df8a5b7a47b8112eb611c9777e04e44bf2d6fc6 From 362b643c8732cf94e74d1dacb277131d03fce3cb Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 01:32:40 -0700 Subject: [PATCH 50/96] Added the StreamError family for stream conditions. --- temporalio/streams/__init__.py | 12 ++++++++++ temporalio/streams/_errors.py | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 temporalio/streams/_errors.py diff --git a/temporalio/streams/__init__.py b/temporalio/streams/__init__.py index f564135fd..0b43830cb 100644 --- a/temporalio/streams/__init__.py +++ b/temporalio/streams/__init__.py @@ -64,6 +64,13 @@ from datetime import timedelta from typing import Any, TypeVar, overload +from temporalio.streams._errors import ( + StreamCursorError, + StreamError, + StreamNotFoundError, + StreamProducerError, + StreamUnsupportedError, +) from temporalio.streams._handles import ( ReadSource, StreamReader, @@ -101,10 +108,15 @@ "Producer", "ReadSource", "RecordKind", + "StreamCursorError", + "StreamError", + "StreamNotFoundError", + "StreamProducerError", "StreamProvider", "StreamProviderLifecycle", "StreamReader", "StreamRecord", + "StreamUnsupportedError", "StreamWriter", "Supersession", "WriteSink", diff --git a/temporalio/streams/_errors.py b/temporalio/streams/_errors.py new file mode 100644 index 000000000..f00bf1c3a --- /dev/null +++ b/temporalio/streams/_errors.py @@ -0,0 +1,40 @@ +"""The errors a stream call raises. + +Every stream condition is a :class:`StreamError`, so a caller can catch by +meaning the way it catches other :class:`temporalio.exceptions.TemporalError` +subclasses. Argument mistakes stay ``ValueError``. A provider's transport +failure surfaces as :class:`temporalio.service.RPCError`, never as the +transport's own exception type. +""" + +from __future__ import annotations + +import temporalio.exceptions + +__all__ = [ + "StreamCursorError", + "StreamError", + "StreamNotFoundError", + "StreamProducerError", + "StreamUnsupportedError", +] + + +class StreamError(temporalio.exceptions.TemporalError): + """Base for stream conditions.""" + + +class StreamNotFoundError(StreamError): + """The workflow, chain or topic does not exist or is past retention.""" + + +class StreamCursorError(StreamError): + """The cursor was minted by another provider or names a record no longer retained.""" + + +class StreamProducerError(StreamError): + """The producer attempt or sequence conflicts with what the store holds.""" + + +class StreamUnsupportedError(StreamError): + """This provider does not offer the requested capability.""" From 600f6d597189d809d6e9af51ca453c4fee202d77 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 01:37:55 -0700 Subject: [PATCH 51/96] Vendored the stream additions to the public API protos. The Core submodule does not pin the api branch that carries them yet, so the generator stages the pinned API tree, applies the branch's diff and regenerates only the files it touches. --- scripts/gen_stream_api_protos.py | 101 ++ temporalio/api/command/v1/__init__.py | 4 + temporalio/api/command/v1/message_pb2.py | 117 +- temporalio/api/command/v1/message_pb2.pyi | 105 ++ temporalio/api/enums/v1/command_type_pb2.py | 6 +- temporalio/api/enums/v1/command_type_pb2.pyi | 4 + temporalio/api/enums/v1/event_type_pb2.py | 6 +- temporalio/api/enums/v1/event_type_pb2.pyi | 20 + temporalio/api/enums/v1/failed_cause_pb2.py | 27 +- temporalio/api/enums/v1/failed_cause_pb2.pyi | 30 + temporalio/api/history/v1/__init__.py | 4 + temporalio/api/history/v1/message_pb2.py | 301 +++-- temporalio/api/history/v1/message_pb2.pyi | 131 ++ temporalio/api/stream/__init__.py | 0 temporalio/api/stream/v1/__init__.py | 8 + temporalio/api/stream/v1/message_pb2.py | 95 ++ temporalio/api/stream/v1/message_pb2.pyi | 267 ++++ .../v1/request_response_pb2.py | 1125 +++++++++-------- .../v1/request_response_pb2.pyi | 17 + 19 files changed, 1617 insertions(+), 751 deletions(-) create mode 100644 scripts/gen_stream_api_protos.py create mode 100644 temporalio/api/stream/__init__.py create mode 100644 temporalio/api/stream/v1/__init__.py create mode 100644 temporalio/api/stream/v1/message_pb2.py create mode 100644 temporalio/api/stream/v1/message_pb2.pyi diff --git a/scripts/gen_stream_api_protos.py b/scripts/gen_stream_api_protos.py new file mode 100644 index 000000000..eeb7bb350 --- /dev/null +++ b/scripts/gen_stream_api_protos.py @@ -0,0 +1,101 @@ +"""Regenerate the stream additions to the vendored public API protos. + +The stream protos live on a branch of the api repo that the Core submodule +does not pin yet. Regenerating everything from that branch would also pull in +whatever else moved on the api main line since the pin, so this stages the +API protos the submodule pins, applies the branch's own diff on top, and +regenerates only the files that diff touches. Every other module under +``temporalio/api`` stays byte-identical. + + uv run --python 3.10 --no-project --with "grpcio-tools==1.48.2" \\ + --with "mypy-protobuf==3.3.0" --with "protobuf<4" \\ + scripts/gen_stream_api_protos.py /path/to/api origin/main..origin/ + +The toolchain pins match ``scripts/_proto/Dockerfile``. Generated code refuses +to load on a protobuf runtime older than the one it was built against, and +these modules end up in applications that pin protobuf themselves. Run +``uv run poe format`` afterwards, as the ``gen-protos`` task does. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +BASE = Path(__file__).parent.parent +sys.path.insert(0, str(BASE / "scripts")) + +import gen_protos # noqa: E402 + +API_OUT = BASE / "temporalio" / "api" + + +def main() -> None: + if len(sys.argv) != 3: + sys.exit("usage: gen_stream_api_protos.py /path/to/api-checkout ..") + api = Path(sys.argv[1]).resolve() + revisions = sys.argv[2] + diff = subprocess.run( + ["git", "-C", str(api), "diff", revisions, "--", "temporal/"], + check=True, + capture_output=True, + text=True, + ).stdout + touched = subprocess.run( + ["git", "-C", str(api), "diff", "--name-only", revisions, "--", "temporal/"], + check=True, + capture_output=True, + text=True, + ).stdout.split() + if not touched: + sys.exit(f"{revisions} touches no proto under temporal/") + + with tempfile.TemporaryDirectory() as tmp: + stage = Path(tmp) / "stage" + shutil.copytree(gen_protos.api_proto_dir / "temporal", stage / "temporal") + subprocess.run( + ["patch", "-p1", "--silent"], + check=True, + cwd=stage, + input=diff, + text=True, + ) + out = Path(tmp) / "out" + out.mkdir() + subprocess.check_call( + [ + sys.executable, + "-mgrpc_tools.protoc", + f"--proto_path={stage}", + f"--python_out={out}", + f"--mypy_out={out}", + *touched, + ] + ) + packages: set[Path] = set() + for proto in touched: + relative = Path(proto).relative_to("temporal/api").with_suffix("") + for suffix in ("_pb2.py", "_pb2.pyi"): + generated = ( + out + / "temporal" + / "api" + / relative.with_name(relative.name + suffix) + ) + target = API_OUT / relative.with_name(relative.name + suffix) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(generated, target) + print(f"wrote {target.relative_to(BASE)}") + packages.add(target.parent) + + for package in sorted(packages): + (package.parent / "__init__.py").touch() + gen_protos.fix_generated_output(package) + print(f"rewrote {(package / '__init__.py').relative_to(BASE)}") + + +if __name__ == "__main__": + main() diff --git a/temporalio/api/command/v1/__init__.py b/temporalio/api/command/v1/__init__.py index e0ce8f712..d1b241274 100644 --- a/temporalio/api/command/v1/__init__.py +++ b/temporalio/api/command/v1/__init__.py @@ -1,4 +1,5 @@ from .message_pb2 import ( + AppendStreamRecordsCommandAttributes, CancelTimerCommandAttributes, CancelWorkflowExecutionCommandAttributes, Command, @@ -16,10 +17,12 @@ SignalExternalWorkflowExecutionCommandAttributes, StartChildWorkflowExecutionCommandAttributes, StartTimerCommandAttributes, + SubscribeStreamCommandAttributes, UpsertWorkflowSearchAttributesCommandAttributes, ) __all__ = [ + "AppendStreamRecordsCommandAttributes", "CancelTimerCommandAttributes", "CancelWorkflowExecutionCommandAttributes", "Command", @@ -37,5 +40,6 @@ "SignalExternalWorkflowExecutionCommandAttributes", "StartChildWorkflowExecutionCommandAttributes", "StartTimerCommandAttributes", + "SubscribeStreamCommandAttributes", "UpsertWorkflowSearchAttributesCommandAttributes", ] diff --git a/temporalio/api/command/v1/message_pb2.py b/temporalio/api/command/v1/message_pb2.py index ad004ae03..8c36c1f7b 100644 --- a/temporalio/api/command/v1/message_pb2.py +++ b/temporalio/api/command/v1/message_pb2.py @@ -34,6 +34,9 @@ from temporalio.api.sdk.v1 import ( user_metadata_pb2 as temporal_dot_api_dot_sdk_dot_v1_dot_user__metadata__pb2, ) +from temporalio.api.stream.v1 import ( + message_pb2 as temporal_dot_api_dot_stream_dot_v1_dot_message__pb2, +) from temporalio.api.taskqueue.v1 import ( message_pb2 as temporal_dot_api_dot_taskqueue_dot_v1_dot_message__pb2, ) @@ -42,7 +45,7 @@ ) DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n%temporal/api/command/v1/message.proto\x12\x17temporal.api.command.v1\x1a\x1egoogle/protobuf/duration.proto\x1a$temporal/api/enums/v1/workflow.proto\x1a(temporal/api/enums/v1/command_type.proto\x1a$temporal/api/common/v1/message.proto\x1a%temporal/api/failure/v1/message.proto\x1a\'temporal/api/taskqueue/v1/message.proto\x1a&temporal/api/workflow/v1/message.proto\x1a\'temporal/api/sdk/v1/user_metadata.proto\x1a,temporal/api/sdk/v1/event_group_marker.proto"\xb6\x05\n%ScheduleActivityTaskCommandAttributes\x12\x13\n\x0b\x61\x63tivity_id\x18\x01 \x01(\t\x12;\n\ractivity_type\x18\x02 \x01(\x0b\x32$.temporal.api.common.v1.ActivityType\x12\x38\n\ntask_queue\x18\x04 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12.\n\x06header\x18\x05 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12/\n\x05input\x18\x06 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12<\n\x19schedule_to_close_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12<\n\x19schedule_to_start_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x34\n\x11heartbeat_timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x0cretry_policy\x18\x0b \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12\x1f\n\x17request_eager_execution\x18\x0c \x01(\x08\x12\x1d\n\x15use_workflow_build_id\x18\r \x01(\x08\x12\x32\n\x08priority\x18\x0e \x01(\x0b\x32 .temporal.api.common.v1.PriorityJ\x04\x08\x03\x10\x04"H\n*RequestCancelActivityTaskCommandAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03"i\n\x1bStartTimerCommandAttributes\x12\x10\n\x08timer_id\x18\x01 \x01(\t\x12\x38\n\x15start_to_fire_timeout\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration"^\n*CompleteWorkflowExecutionCommandAttributes\x12\x30\n\x06result\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads"[\n&FailWorkflowExecutionCommandAttributes\x12\x31\n\x07\x66\x61ilure\x18\x01 \x01(\x0b\x32 .temporal.api.failure.v1.Failure"0\n\x1c\x43\x61ncelTimerCommandAttributes\x12\x10\n\x08timer_id\x18\x01 \x01(\t"]\n(CancelWorkflowExecutionCommandAttributes\x12\x31\n\x07\x64\x65tails\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads"\xb7\x01\n7RequestCancelExternalWorkflowExecutionCommandAttributes\x12\x15\n\tnamespace\x18\x01 \x01(\tB\x02\x18\x01\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x13\n\x07\x63ontrol\x18\x04 \x01(\tB\x02\x18\x01\x12\x1b\n\x13\x63hild_workflow_only\x18\x05 \x01(\x08\x12\x0e\n\x06reason\x18\x06 \x01(\t"\xaf\x02\n0SignalExternalWorkflowExecutionCommandAttributes\x12\x15\n\tnamespace\x18\x01 \x01(\tB\x02\x18\x01\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x13\n\x0bsignal_name\x18\x03 \x01(\t\x12/\n\x05input\x18\x04 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x13\n\x07\x63ontrol\x18\x05 \x01(\tB\x02\x18\x01\x12\x1b\n\x13\x63hild_workflow_only\x18\x06 \x01(\x08\x12.\n\x06header\x18\x07 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header"v\n/UpsertWorkflowSearchAttributesCommandAttributes\x12\x43\n\x11search_attributes\x18\x01 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes"`\n)ModifyWorkflowPropertiesCommandAttributes\x12\x33\n\rupserted_memo\x18\x01 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo"\xbf\x02\n\x1dRecordMarkerCommandAttributes\x12\x13\n\x0bmarker_name\x18\x01 \x01(\t\x12T\n\x07\x64\x65tails\x18\x02 \x03(\x0b\x32\x43.temporal.api.command.v1.RecordMarkerCommandAttributes.DetailsEntry\x12.\n\x06header\x18\x03 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12\x31\n\x07\x66\x61ilure\x18\x04 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x1aP\n\x0c\x44\x65tailsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12/\n\x05value\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads:\x02\x38\x01"\xac\x07\n/ContinueAsNewWorkflowExecutionCommandAttributes\x12;\n\rworkflow_type\x18\x01 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x38\n\ntask_queue\x18\x02 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12/\n\x05input\x18\x03 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x37\n\x14workflow_run_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\x15workflow_task_timeout\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16\x62\x61\x63koff_start_interval\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x0cretry_policy\x18\x07 \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12@\n\tinitiator\x18\x08 \x01(\x0e\x32-.temporal.api.enums.v1.ContinueAsNewInitiator\x12\x31\n\x07\x66\x61ilure\x18\t \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12@\n\x16last_completion_result\x18\n \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x15\n\rcron_schedule\x18\x0b \x01(\t\x12.\n\x06header\x18\x0c \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12*\n\x04memo\x18\r \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x0e \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12\x1c\n\x10inherit_build_id\x18\x0f \x01(\x08\x42\x02\x18\x01\x12[\n\x1binitial_versioning_behavior\x18\x10 \x01(\x0e\x32\x36.temporal.api.enums.v1.ContinueAsNewVersioningBehavior"\xec\x07\n,StartChildWorkflowExecutionCommandAttributes\x12\x15\n\tnamespace\x18\x01 \x01(\tB\x02\x18\x01\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x38\n\ntask_queue\x18\x04 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12/\n\x05input\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12=\n\x1aworkflow_execution_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x37\n\x14workflow_run_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\x15workflow_task_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x45\n\x13parent_close_policy\x18\t \x01(\x0e\x32(.temporal.api.enums.v1.ParentClosePolicy\x12\x0f\n\x07\x63ontrol\x18\n \x01(\t\x12N\n\x18workflow_id_reuse_policy\x18\x0b \x01(\x0e\x32,.temporal.api.enums.v1.WorkflowIdReusePolicy\x12\x39\n\x0cretry_policy\x18\x0c \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12\x15\n\rcron_schedule\x18\r \x01(\t\x12.\n\x06header\x18\x0e \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12*\n\x04memo\x18\x0f \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x10 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12\x1c\n\x10inherit_build_id\x18\x11 \x01(\x08\x42\x02\x18\x01\x12\x32\n\x08priority\x18\x12 \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12I\n\x13versioning_override\x18\x13 \x01(\x0b\x32,.temporal.api.workflow.v1.VersioningOverride"6\n ProtocolMessageCommandAttributes\x12\x12\n\nmessage_id\x18\x01 \x01(\t"\xe3\x03\n\'ScheduleNexusOperationCommandAttributes\x12\x10\n\x08\x65ndpoint\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\x12\x11\n\toperation\x18\x03 \x01(\t\x12.\n\x05input\x18\x04 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12<\n\x19schedule_to_close_timeout\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration\x12g\n\x0cnexus_header\x18\x06 \x03(\x0b\x32Q.temporal.api.command.v1.ScheduleNexusOperationCommandAttributes.NexusHeaderEntry\x12<\n\x19schedule_to_start_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x1a\x32\n\x10NexusHeaderEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01"J\n,RequestCancelNexusOperationCommandAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03"\x87\x12\n\x07\x43ommand\x12\x38\n\x0c\x63ommand_type\x18\x01 \x01(\x0e\x32".temporal.api.enums.v1.CommandType\x12\x39\n\ruser_metadata\x18\xad\x02 \x01(\x0b\x32!.temporal.api.sdk.v1.UserMetadata\x12\x43\n\x13\x65vent_group_markers\x18\xae\x02 \x03(\x0b\x32%.temporal.api.sdk.v1.EventGroupMarker\x12s\n)schedule_activity_task_command_attributes\x18\x02 \x01(\x0b\x32>.temporal.api.command.v1.ScheduleActivityTaskCommandAttributesH\x00\x12^\n\x1estart_timer_command_attributes\x18\x03 \x01(\x0b\x32\x34.temporal.api.command.v1.StartTimerCommandAttributesH\x00\x12}\n.complete_workflow_execution_command_attributes\x18\x04 \x01(\x0b\x32\x43.temporal.api.command.v1.CompleteWorkflowExecutionCommandAttributesH\x00\x12u\n*fail_workflow_execution_command_attributes\x18\x05 \x01(\x0b\x32?.temporal.api.command.v1.FailWorkflowExecutionCommandAttributesH\x00\x12~\n/request_cancel_activity_task_command_attributes\x18\x06 \x01(\x0b\x32\x43.temporal.api.command.v1.RequestCancelActivityTaskCommandAttributesH\x00\x12`\n\x1f\x63\x61ncel_timer_command_attributes\x18\x07 \x01(\x0b\x32\x35.temporal.api.command.v1.CancelTimerCommandAttributesH\x00\x12y\n,cancel_workflow_execution_command_attributes\x18\x08 \x01(\x0b\x32\x41.temporal.api.command.v1.CancelWorkflowExecutionCommandAttributesH\x00\x12\x99\x01\n=request_cancel_external_workflow_execution_command_attributes\x18\t \x01(\x0b\x32P.temporal.api.command.v1.RequestCancelExternalWorkflowExecutionCommandAttributesH\x00\x12\x62\n record_marker_command_attributes\x18\n \x01(\x0b\x32\x36.temporal.api.command.v1.RecordMarkerCommandAttributesH\x00\x12\x89\x01\n5continue_as_new_workflow_execution_command_attributes\x18\x0b \x01(\x0b\x32H.temporal.api.command.v1.ContinueAsNewWorkflowExecutionCommandAttributesH\x00\x12\x82\x01\n1start_child_workflow_execution_command_attributes\x18\x0c \x01(\x0b\x32\x45.temporal.api.command.v1.StartChildWorkflowExecutionCommandAttributesH\x00\x12\x8a\x01\n5signal_external_workflow_execution_command_attributes\x18\r \x01(\x0b\x32I.temporal.api.command.v1.SignalExternalWorkflowExecutionCommandAttributesH\x00\x12\x88\x01\n4upsert_workflow_search_attributes_command_attributes\x18\x0e \x01(\x0b\x32H.temporal.api.command.v1.UpsertWorkflowSearchAttributesCommandAttributesH\x00\x12h\n#protocol_message_command_attributes\x18\x0f \x01(\x0b\x32\x39.temporal.api.command.v1.ProtocolMessageCommandAttributesH\x00\x12{\n-modify_workflow_properties_command_attributes\x18\x11 \x01(\x0b\x32\x42.temporal.api.command.v1.ModifyWorkflowPropertiesCommandAttributesH\x00\x12w\n+schedule_nexus_operation_command_attributes\x18\x12 \x01(\x0b\x32@.temporal.api.command.v1.ScheduleNexusOperationCommandAttributesH\x00\x12\x82\x01\n1request_cancel_nexus_operation_command_attributes\x18\x13 \x01(\x0b\x32\x45.temporal.api.command.v1.RequestCancelNexusOperationCommandAttributesH\x00\x42\x0c\n\nattributesB\x8e\x01\n\x1aio.temporal.api.command.v1B\x0cMessageProtoP\x01Z%go.temporal.io/api/command/v1;command\xaa\x02\x19Temporalio.Api.Command.V1\xea\x02\x1cTemporalio::Api::Command::V1b\x06proto3' + b'\n%temporal/api/command/v1/message.proto\x12\x17temporal.api.command.v1\x1a\x1egoogle/protobuf/duration.proto\x1a$temporal/api/enums/v1/workflow.proto\x1a(temporal/api/enums/v1/command_type.proto\x1a$temporal/api/common/v1/message.proto\x1a$temporal/api/stream/v1/message.proto\x1a%temporal/api/failure/v1/message.proto\x1a\'temporal/api/taskqueue/v1/message.proto\x1a&temporal/api/workflow/v1/message.proto\x1a\'temporal/api/sdk/v1/user_metadata.proto\x1a,temporal/api/sdk/v1/event_group_marker.proto"\xb6\x05\n%ScheduleActivityTaskCommandAttributes\x12\x13\n\x0b\x61\x63tivity_id\x18\x01 \x01(\t\x12;\n\ractivity_type\x18\x02 \x01(\x0b\x32$.temporal.api.common.v1.ActivityType\x12\x38\n\ntask_queue\x18\x04 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12.\n\x06header\x18\x05 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12/\n\x05input\x18\x06 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12<\n\x19schedule_to_close_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12<\n\x19schedule_to_start_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x34\n\x11heartbeat_timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x0cretry_policy\x18\x0b \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12\x1f\n\x17request_eager_execution\x18\x0c \x01(\x08\x12\x1d\n\x15use_workflow_build_id\x18\r \x01(\x08\x12\x32\n\x08priority\x18\x0e \x01(\x0b\x32 .temporal.api.common.v1.PriorityJ\x04\x08\x03\x10\x04"H\n*RequestCancelActivityTaskCommandAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03"i\n\x1bStartTimerCommandAttributes\x12\x10\n\x08timer_id\x18\x01 \x01(\t\x12\x38\n\x15start_to_fire_timeout\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration"^\n*CompleteWorkflowExecutionCommandAttributes\x12\x30\n\x06result\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads"[\n&FailWorkflowExecutionCommandAttributes\x12\x31\n\x07\x66\x61ilure\x18\x01 \x01(\x0b\x32 .temporal.api.failure.v1.Failure"0\n\x1c\x43\x61ncelTimerCommandAttributes\x12\x10\n\x08timer_id\x18\x01 \x01(\t"]\n(CancelWorkflowExecutionCommandAttributes\x12\x31\n\x07\x64\x65tails\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads"\xb7\x01\n7RequestCancelExternalWorkflowExecutionCommandAttributes\x12\x15\n\tnamespace\x18\x01 \x01(\tB\x02\x18\x01\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x13\n\x07\x63ontrol\x18\x04 \x01(\tB\x02\x18\x01\x12\x1b\n\x13\x63hild_workflow_only\x18\x05 \x01(\x08\x12\x0e\n\x06reason\x18\x06 \x01(\t"\xaf\x02\n0SignalExternalWorkflowExecutionCommandAttributes\x12\x15\n\tnamespace\x18\x01 \x01(\tB\x02\x18\x01\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x13\n\x0bsignal_name\x18\x03 \x01(\t\x12/\n\x05input\x18\x04 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x13\n\x07\x63ontrol\x18\x05 \x01(\tB\x02\x18\x01\x12\x1b\n\x13\x63hild_workflow_only\x18\x06 \x01(\x08\x12.\n\x06header\x18\x07 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header"v\n/UpsertWorkflowSearchAttributesCommandAttributes\x12\x43\n\x11search_attributes\x18\x01 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes"`\n)ModifyWorkflowPropertiesCommandAttributes\x12\x33\n\rupserted_memo\x18\x01 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo"\xbf\x02\n\x1dRecordMarkerCommandAttributes\x12\x13\n\x0bmarker_name\x18\x01 \x01(\t\x12T\n\x07\x64\x65tails\x18\x02 \x03(\x0b\x32\x43.temporal.api.command.v1.RecordMarkerCommandAttributes.DetailsEntry\x12.\n\x06header\x18\x03 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12\x31\n\x07\x66\x61ilure\x18\x04 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x1aP\n\x0c\x44\x65tailsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12/\n\x05value\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads:\x02\x38\x01"\xac\x07\n/ContinueAsNewWorkflowExecutionCommandAttributes\x12;\n\rworkflow_type\x18\x01 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x38\n\ntask_queue\x18\x02 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12/\n\x05input\x18\x03 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x37\n\x14workflow_run_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\x15workflow_task_timeout\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16\x62\x61\x63koff_start_interval\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x0cretry_policy\x18\x07 \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12@\n\tinitiator\x18\x08 \x01(\x0e\x32-.temporal.api.enums.v1.ContinueAsNewInitiator\x12\x31\n\x07\x66\x61ilure\x18\t \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12@\n\x16last_completion_result\x18\n \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x15\n\rcron_schedule\x18\x0b \x01(\t\x12.\n\x06header\x18\x0c \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12*\n\x04memo\x18\r \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x0e \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12\x1c\n\x10inherit_build_id\x18\x0f \x01(\x08\x42\x02\x18\x01\x12[\n\x1binitial_versioning_behavior\x18\x10 \x01(\x0e\x32\x36.temporal.api.enums.v1.ContinueAsNewVersioningBehavior"\xec\x07\n,StartChildWorkflowExecutionCommandAttributes\x12\x15\n\tnamespace\x18\x01 \x01(\tB\x02\x18\x01\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x38\n\ntask_queue\x18\x04 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12/\n\x05input\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12=\n\x1aworkflow_execution_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x37\n\x14workflow_run_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\x15workflow_task_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x45\n\x13parent_close_policy\x18\t \x01(\x0e\x32(.temporal.api.enums.v1.ParentClosePolicy\x12\x0f\n\x07\x63ontrol\x18\n \x01(\t\x12N\n\x18workflow_id_reuse_policy\x18\x0b \x01(\x0e\x32,.temporal.api.enums.v1.WorkflowIdReusePolicy\x12\x39\n\x0cretry_policy\x18\x0c \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12\x15\n\rcron_schedule\x18\r \x01(\t\x12.\n\x06header\x18\x0e \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12*\n\x04memo\x18\x0f \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x10 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12\x1c\n\x10inherit_build_id\x18\x11 \x01(\x08\x42\x02\x18\x01\x12\x32\n\x08priority\x18\x12 \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12I\n\x13versioning_override\x18\x13 \x01(\x0b\x32,.temporal.api.workflow.v1.VersioningOverride"6\n ProtocolMessageCommandAttributes\x12\x12\n\nmessage_id\x18\x01 \x01(\t"\xe3\x03\n\'ScheduleNexusOperationCommandAttributes\x12\x10\n\x08\x65ndpoint\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\x12\x11\n\toperation\x18\x03 \x01(\t\x12.\n\x05input\x18\x04 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12<\n\x19schedule_to_close_timeout\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration\x12g\n\x0cnexus_header\x18\x06 \x03(\x0b\x32Q.temporal.api.command.v1.ScheduleNexusOperationCommandAttributes.NexusHeaderEntry\x12<\n\x19schedule_to_start_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x1a\x32\n\x10NexusHeaderEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01"J\n,RequestCancelNexusOperationCommandAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03"\xe4\x13\n\x07\x43ommand\x12\x38\n\x0c\x63ommand_type\x18\x01 \x01(\x0e\x32".temporal.api.enums.v1.CommandType\x12\x39\n\ruser_metadata\x18\xad\x02 \x01(\x0b\x32!.temporal.api.sdk.v1.UserMetadata\x12\x43\n\x13\x65vent_group_markers\x18\xae\x02 \x03(\x0b\x32%.temporal.api.sdk.v1.EventGroupMarker\x12s\n)schedule_activity_task_command_attributes\x18\x02 \x01(\x0b\x32>.temporal.api.command.v1.ScheduleActivityTaskCommandAttributesH\x00\x12^\n\x1estart_timer_command_attributes\x18\x03 \x01(\x0b\x32\x34.temporal.api.command.v1.StartTimerCommandAttributesH\x00\x12}\n.complete_workflow_execution_command_attributes\x18\x04 \x01(\x0b\x32\x43.temporal.api.command.v1.CompleteWorkflowExecutionCommandAttributesH\x00\x12u\n*fail_workflow_execution_command_attributes\x18\x05 \x01(\x0b\x32?.temporal.api.command.v1.FailWorkflowExecutionCommandAttributesH\x00\x12~\n/request_cancel_activity_task_command_attributes\x18\x06 \x01(\x0b\x32\x43.temporal.api.command.v1.RequestCancelActivityTaskCommandAttributesH\x00\x12`\n\x1f\x63\x61ncel_timer_command_attributes\x18\x07 \x01(\x0b\x32\x35.temporal.api.command.v1.CancelTimerCommandAttributesH\x00\x12y\n,cancel_workflow_execution_command_attributes\x18\x08 \x01(\x0b\x32\x41.temporal.api.command.v1.CancelWorkflowExecutionCommandAttributesH\x00\x12\x99\x01\n=request_cancel_external_workflow_execution_command_attributes\x18\t \x01(\x0b\x32P.temporal.api.command.v1.RequestCancelExternalWorkflowExecutionCommandAttributesH\x00\x12\x62\n record_marker_command_attributes\x18\n \x01(\x0b\x32\x36.temporal.api.command.v1.RecordMarkerCommandAttributesH\x00\x12\x89\x01\n5continue_as_new_workflow_execution_command_attributes\x18\x0b \x01(\x0b\x32H.temporal.api.command.v1.ContinueAsNewWorkflowExecutionCommandAttributesH\x00\x12\x82\x01\n1start_child_workflow_execution_command_attributes\x18\x0c \x01(\x0b\x32\x45.temporal.api.command.v1.StartChildWorkflowExecutionCommandAttributesH\x00\x12\x8a\x01\n5signal_external_workflow_execution_command_attributes\x18\r \x01(\x0b\x32I.temporal.api.command.v1.SignalExternalWorkflowExecutionCommandAttributesH\x00\x12\x88\x01\n4upsert_workflow_search_attributes_command_attributes\x18\x0e \x01(\x0b\x32H.temporal.api.command.v1.UpsertWorkflowSearchAttributesCommandAttributesH\x00\x12h\n#protocol_message_command_attributes\x18\x0f \x01(\x0b\x32\x39.temporal.api.command.v1.ProtocolMessageCommandAttributesH\x00\x12{\n-modify_workflow_properties_command_attributes\x18\x11 \x01(\x0b\x32\x42.temporal.api.command.v1.ModifyWorkflowPropertiesCommandAttributesH\x00\x12w\n+schedule_nexus_operation_command_attributes\x18\x12 \x01(\x0b\x32@.temporal.api.command.v1.ScheduleNexusOperationCommandAttributesH\x00\x12\x82\x01\n1request_cancel_nexus_operation_command_attributes\x18\x13 \x01(\x0b\x32\x45.temporal.api.command.v1.RequestCancelNexusOperationCommandAttributesH\x00\x12q\n(append_stream_records_command_attributes\x18\x14 \x01(\x0b\x32=.temporal.api.command.v1.AppendStreamRecordsCommandAttributesH\x00\x12h\n#subscribe_stream_command_attributes\x18\x15 \x01(\x0b\x32\x39.temporal.api.command.v1.SubscribeStreamCommandAttributesH\x00\x42\x0c\n\nattributes"p\n$AppendStreamRecordsCommandAttributes\x12\x11\n\tstream_id\x18\x01 \x01(\t\x12\x35\n\x07records\x18\x02 \x03(\x0b\x32$.temporal.api.stream.v1.StreamRecord"K\n SubscribeStreamCommandAttributes\x12\x11\n\tstream_id\x18\x01 \x01(\t\x12\x14\n\x0cstart_offset\x18\x02 \x01(\x03\x42\x8e\x01\n\x1aio.temporal.api.command.v1B\x0cMessageProtoP\x01Z%go.temporal.io/api/command/v1;command\xaa\x02\x19Temporalio.Api.Command.V1\xea\x02\x1cTemporalio::Api::Command::V1b\x06proto3' ) @@ -106,6 +109,12 @@ "RequestCancelNexusOperationCommandAttributes" ] _COMMAND = DESCRIPTOR.message_types_by_name["Command"] +_APPENDSTREAMRECORDSCOMMANDATTRIBUTES = DESCRIPTOR.message_types_by_name[ + "AppendStreamRecordsCommandAttributes" +] +_SUBSCRIBESTREAMCOMMANDATTRIBUTES = DESCRIPTOR.message_types_by_name[ + "SubscribeStreamCommandAttributes" +] ScheduleActivityTaskCommandAttributes = _reflection.GeneratedProtocolMessageType( "ScheduleActivityTaskCommandAttributes", (_message.Message,), @@ -332,6 +341,28 @@ ) _sym_db.RegisterMessage(Command) +AppendStreamRecordsCommandAttributes = _reflection.GeneratedProtocolMessageType( + "AppendStreamRecordsCommandAttributes", + (_message.Message,), + { + "DESCRIPTOR": _APPENDSTREAMRECORDSCOMMANDATTRIBUTES, + "__module__": "temporalio.api.command.v1.message_pb2", + # @@protoc_insertion_point(class_scope:temporal.api.command.v1.AppendStreamRecordsCommandAttributes) + }, +) +_sym_db.RegisterMessage(AppendStreamRecordsCommandAttributes) + +SubscribeStreamCommandAttributes = _reflection.GeneratedProtocolMessageType( + "SubscribeStreamCommandAttributes", + (_message.Message,), + { + "DESCRIPTOR": _SUBSCRIBESTREAMCOMMANDATTRIBUTES, + "__module__": "temporalio.api.command.v1.message_pb2", + # @@protoc_insertion_point(class_scope:temporal.api.command.v1.SubscribeStreamCommandAttributes) + }, +) +_sym_db.RegisterMessage(SubscribeStreamCommandAttributes) + if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b"\n\032io.temporal.api.command.v1B\014MessageProtoP\001Z%go.temporal.io/api/command/v1;command\252\002\031Temporalio.Api.Command.V1\352\002\034Temporalio::Api::Command::V1" @@ -383,44 +414,48 @@ _SCHEDULENEXUSOPERATIONCOMMANDATTRIBUTES_NEXUSHEADERENTRY._serialized_options = ( b"8\001" ) - _SCHEDULEACTIVITYTASKCOMMANDATTRIBUTES._serialized_start = 424 - _SCHEDULEACTIVITYTASKCOMMANDATTRIBUTES._serialized_end = 1118 - _REQUESTCANCELACTIVITYTASKCOMMANDATTRIBUTES._serialized_start = 1120 - _REQUESTCANCELACTIVITYTASKCOMMANDATTRIBUTES._serialized_end = 1192 - _STARTTIMERCOMMANDATTRIBUTES._serialized_start = 1194 - _STARTTIMERCOMMANDATTRIBUTES._serialized_end = 1299 - _COMPLETEWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_start = 1301 - _COMPLETEWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_end = 1395 - _FAILWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_start = 1397 - _FAILWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_end = 1488 - _CANCELTIMERCOMMANDATTRIBUTES._serialized_start = 1490 - _CANCELTIMERCOMMANDATTRIBUTES._serialized_end = 1538 - _CANCELWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_start = 1540 - _CANCELWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_end = 1633 - _REQUESTCANCELEXTERNALWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_start = 1636 - _REQUESTCANCELEXTERNALWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_end = 1819 - _SIGNALEXTERNALWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_start = 1822 - _SIGNALEXTERNALWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_end = 2125 - _UPSERTWORKFLOWSEARCHATTRIBUTESCOMMANDATTRIBUTES._serialized_start = 2127 - _UPSERTWORKFLOWSEARCHATTRIBUTESCOMMANDATTRIBUTES._serialized_end = 2245 - _MODIFYWORKFLOWPROPERTIESCOMMANDATTRIBUTES._serialized_start = 2247 - _MODIFYWORKFLOWPROPERTIESCOMMANDATTRIBUTES._serialized_end = 2343 - _RECORDMARKERCOMMANDATTRIBUTES._serialized_start = 2346 - _RECORDMARKERCOMMANDATTRIBUTES._serialized_end = 2665 - _RECORDMARKERCOMMANDATTRIBUTES_DETAILSENTRY._serialized_start = 2585 - _RECORDMARKERCOMMANDATTRIBUTES_DETAILSENTRY._serialized_end = 2665 - _CONTINUEASNEWWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_start = 2668 - _CONTINUEASNEWWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_end = 3608 - _STARTCHILDWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_start = 3611 - _STARTCHILDWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_end = 4615 - _PROTOCOLMESSAGECOMMANDATTRIBUTES._serialized_start = 4617 - _PROTOCOLMESSAGECOMMANDATTRIBUTES._serialized_end = 4671 - _SCHEDULENEXUSOPERATIONCOMMANDATTRIBUTES._serialized_start = 4674 - _SCHEDULENEXUSOPERATIONCOMMANDATTRIBUTES._serialized_end = 5157 - _SCHEDULENEXUSOPERATIONCOMMANDATTRIBUTES_NEXUSHEADERENTRY._serialized_start = 5107 - _SCHEDULENEXUSOPERATIONCOMMANDATTRIBUTES_NEXUSHEADERENTRY._serialized_end = 5157 - _REQUESTCANCELNEXUSOPERATIONCOMMANDATTRIBUTES._serialized_start = 5159 - _REQUESTCANCELNEXUSOPERATIONCOMMANDATTRIBUTES._serialized_end = 5233 - _COMMAND._serialized_start = 5236 - _COMMAND._serialized_end = 7547 + _SCHEDULEACTIVITYTASKCOMMANDATTRIBUTES._serialized_start = 462 + _SCHEDULEACTIVITYTASKCOMMANDATTRIBUTES._serialized_end = 1156 + _REQUESTCANCELACTIVITYTASKCOMMANDATTRIBUTES._serialized_start = 1158 + _REQUESTCANCELACTIVITYTASKCOMMANDATTRIBUTES._serialized_end = 1230 + _STARTTIMERCOMMANDATTRIBUTES._serialized_start = 1232 + _STARTTIMERCOMMANDATTRIBUTES._serialized_end = 1337 + _COMPLETEWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_start = 1339 + _COMPLETEWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_end = 1433 + _FAILWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_start = 1435 + _FAILWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_end = 1526 + _CANCELTIMERCOMMANDATTRIBUTES._serialized_start = 1528 + _CANCELTIMERCOMMANDATTRIBUTES._serialized_end = 1576 + _CANCELWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_start = 1578 + _CANCELWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_end = 1671 + _REQUESTCANCELEXTERNALWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_start = 1674 + _REQUESTCANCELEXTERNALWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_end = 1857 + _SIGNALEXTERNALWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_start = 1860 + _SIGNALEXTERNALWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_end = 2163 + _UPSERTWORKFLOWSEARCHATTRIBUTESCOMMANDATTRIBUTES._serialized_start = 2165 + _UPSERTWORKFLOWSEARCHATTRIBUTESCOMMANDATTRIBUTES._serialized_end = 2283 + _MODIFYWORKFLOWPROPERTIESCOMMANDATTRIBUTES._serialized_start = 2285 + _MODIFYWORKFLOWPROPERTIESCOMMANDATTRIBUTES._serialized_end = 2381 + _RECORDMARKERCOMMANDATTRIBUTES._serialized_start = 2384 + _RECORDMARKERCOMMANDATTRIBUTES._serialized_end = 2703 + _RECORDMARKERCOMMANDATTRIBUTES_DETAILSENTRY._serialized_start = 2623 + _RECORDMARKERCOMMANDATTRIBUTES_DETAILSENTRY._serialized_end = 2703 + _CONTINUEASNEWWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_start = 2706 + _CONTINUEASNEWWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_end = 3646 + _STARTCHILDWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_start = 3649 + _STARTCHILDWORKFLOWEXECUTIONCOMMANDATTRIBUTES._serialized_end = 4653 + _PROTOCOLMESSAGECOMMANDATTRIBUTES._serialized_start = 4655 + _PROTOCOLMESSAGECOMMANDATTRIBUTES._serialized_end = 4709 + _SCHEDULENEXUSOPERATIONCOMMANDATTRIBUTES._serialized_start = 4712 + _SCHEDULENEXUSOPERATIONCOMMANDATTRIBUTES._serialized_end = 5195 + _SCHEDULENEXUSOPERATIONCOMMANDATTRIBUTES_NEXUSHEADERENTRY._serialized_start = 5145 + _SCHEDULENEXUSOPERATIONCOMMANDATTRIBUTES_NEXUSHEADERENTRY._serialized_end = 5195 + _REQUESTCANCELNEXUSOPERATIONCOMMANDATTRIBUTES._serialized_start = 5197 + _REQUESTCANCELNEXUSOPERATIONCOMMANDATTRIBUTES._serialized_end = 5271 + _COMMAND._serialized_start = 5274 + _COMMAND._serialized_end = 7806 + _APPENDSTREAMRECORDSCOMMANDATTRIBUTES._serialized_start = 7808 + _APPENDSTREAMRECORDSCOMMANDATTRIBUTES._serialized_end = 7920 + _SUBSCRIBESTREAMCOMMANDATTRIBUTES._serialized_start = 7922 + _SUBSCRIBESTREAMCOMMANDATTRIBUTES._serialized_end = 7997 # @@protoc_insertion_point(module_scope) diff --git a/temporalio/api/command/v1/message_pb2.pyi b/temporalio/api/command/v1/message_pb2.pyi index 18d0abc8b..f623eecfd 100644 --- a/temporalio/api/command/v1/message_pb2.pyi +++ b/temporalio/api/command/v1/message_pb2.pyi @@ -18,6 +18,7 @@ import temporalio.api.enums.v1.workflow_pb2 import temporalio.api.failure.v1.message_pb2 import temporalio.api.sdk.v1.event_group_marker_pb2 import temporalio.api.sdk.v1.user_metadata_pb2 +import temporalio.api.stream.v1.message_pb2 import temporalio.api.taskqueue.v1.message_pb2 import temporalio.api.workflow.v1.message_pb2 @@ -1119,6 +1120,8 @@ class Command(google.protobuf.message.Message): MODIFY_WORKFLOW_PROPERTIES_COMMAND_ATTRIBUTES_FIELD_NUMBER: builtins.int SCHEDULE_NEXUS_OPERATION_COMMAND_ATTRIBUTES_FIELD_NUMBER: builtins.int REQUEST_CANCEL_NEXUS_OPERATION_COMMAND_ATTRIBUTES_FIELD_NUMBER: builtins.int + APPEND_STREAM_RECORDS_COMMAND_ATTRIBUTES_FIELD_NUMBER: builtins.int + SUBSCRIBE_STREAM_COMMAND_ATTRIBUTES_FIELD_NUMBER: builtins.int command_type: temporalio.api.enums.v1.command_type_pb2.CommandType.ValueType @property def user_metadata(self) -> temporalio.api.sdk.v1.user_metadata_pb2.UserMetadata: @@ -1209,6 +1212,14 @@ class Command(google.protobuf.message.Message): def request_cancel_nexus_operation_command_attributes( self, ) -> global___RequestCancelNexusOperationCommandAttributes: ... + @property + def append_stream_records_command_attributes( + self, + ) -> global___AppendStreamRecordsCommandAttributes: ... + @property + def subscribe_stream_command_attributes( + self, + ) -> global___SubscribeStreamCommandAttributes: ... def __init__( self, *, @@ -1253,10 +1264,16 @@ class Command(google.protobuf.message.Message): | None = ..., request_cancel_nexus_operation_command_attributes: global___RequestCancelNexusOperationCommandAttributes | None = ..., + append_stream_records_command_attributes: global___AppendStreamRecordsCommandAttributes + | None = ..., + subscribe_stream_command_attributes: global___SubscribeStreamCommandAttributes + | None = ..., ) -> None: ... def HasField( self, field_name: typing_extensions.Literal[ + "append_stream_records_command_attributes", + b"append_stream_records_command_attributes", "attributes", b"attributes", "cancel_timer_command_attributes", @@ -1291,6 +1308,8 @@ class Command(google.protobuf.message.Message): b"start_child_workflow_execution_command_attributes", "start_timer_command_attributes", b"start_timer_command_attributes", + "subscribe_stream_command_attributes", + b"subscribe_stream_command_attributes", "upsert_workflow_search_attributes_command_attributes", b"upsert_workflow_search_attributes_command_attributes", "user_metadata", @@ -1300,6 +1319,8 @@ class Command(google.protobuf.message.Message): def ClearField( self, field_name: typing_extensions.Literal[ + "append_stream_records_command_attributes", + b"append_stream_records_command_attributes", "attributes", b"attributes", "cancel_timer_command_attributes", @@ -1338,6 +1359,8 @@ class Command(google.protobuf.message.Message): b"start_child_workflow_execution_command_attributes", "start_timer_command_attributes", b"start_timer_command_attributes", + "subscribe_stream_command_attributes", + b"subscribe_stream_command_attributes", "upsert_workflow_search_attributes_command_attributes", b"upsert_workflow_search_attributes_command_attributes", "user_metadata", @@ -1365,8 +1388,90 @@ class Command(google.protobuf.message.Message): "modify_workflow_properties_command_attributes", "schedule_nexus_operation_command_attributes", "request_cancel_nexus_operation_command_attributes", + "append_stream_records_command_attributes", + "subscribe_stream_command_attributes", ] | None ): ... global___Command = Command + +class AppendStreamRecordsCommandAttributes(google.protobuf.message.Message): + """Appends records to a stream the Workflow owns. Applied inside the Workflow + Task's own commit. Produces one `WorkflowStreamRecordsAppended` event + carrying the offset range and none of the payload; it schedules no further + work. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + STREAM_ID_FIELD_NUMBER: builtins.int + RECORDS_FIELD_NUMBER: builtins.int + stream_id: builtins.str + """Empty means the Workflow's default output stream.""" + @property + def records( + self, + ) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[ + temporalio.api.stream.v1.message_pb2.StreamRecord + ]: + """Stored in order. The server sets `producer_id` to empty on each record, + because the owning Workflow is the producer here. + """ + def __init__( + self, + *, + stream_id: builtins.str = ..., + records: collections.abc.Iterable[ + temporalio.api.stream.v1.message_pb2.StreamRecord + ] + | None = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "records", b"records", "stream_id", b"stream_id" + ], + ) -> None: ... + +global___AppendStreamRecordsCommandAttributes = AppendStreamRecordsCommandAttributes + +class SubscribeStreamCommandAttributes(google.protobuf.message.Message): + """Subscribe this Workflow to a stream, so later Workflow Tasks carry the ranges + it has not consumed yet. + + The stream's addressing is resolved by the server rather than supplied here. + A Workflow cannot look it up without doing I/O, and a value it carried would + be a reading rather than a fact, so it could differ on replay. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + STREAM_ID_FIELD_NUMBER: builtins.int + START_OFFSET_FIELD_NUMBER: builtins.int + stream_id: builtins.str + """Stream to consume. A stream in another execution is addressed by its id; + one this Workflow owns is addressed by the name it was published under. + The server resolves an owned name first and falls back to a standalone + id, so a Workflow that owns a stream under this name cannot reach a + standalone stream with the same id. + """ + start_offset: builtins.int + """Where to start. Negative means from wherever the stream is when the + subscription is registered, which the server resolves and records so + replay does not resolve it again. + """ + def __init__( + self, + *, + stream_id: builtins.str = ..., + start_offset: builtins.int = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "start_offset", b"start_offset", "stream_id", b"stream_id" + ], + ) -> None: ... + +global___SubscribeStreamCommandAttributes = SubscribeStreamCommandAttributes diff --git a/temporalio/api/enums/v1/command_type_pb2.py b/temporalio/api/enums/v1/command_type_pb2.py index 6d1c3b6e5..17f70d8a6 100644 --- a/temporalio/api/enums/v1/command_type_pb2.py +++ b/temporalio/api/enums/v1/command_type_pb2.py @@ -16,7 +16,7 @@ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b"\n(temporal/api/enums/v1/command_type.proto\x12\x15temporal.api.enums.v1*\x9c\x06\n\x0b\x43ommandType\x12\x1c\n\x18\x43OMMAND_TYPE_UNSPECIFIED\x10\x00\x12'\n#COMMAND_TYPE_SCHEDULE_ACTIVITY_TASK\x10\x01\x12-\n)COMMAND_TYPE_REQUEST_CANCEL_ACTIVITY_TASK\x10\x02\x12\x1c\n\x18\x43OMMAND_TYPE_START_TIMER\x10\x03\x12,\n(COMMAND_TYPE_COMPLETE_WORKFLOW_EXECUTION\x10\x04\x12(\n$COMMAND_TYPE_FAIL_WORKFLOW_EXECUTION\x10\x05\x12\x1d\n\x19\x43OMMAND_TYPE_CANCEL_TIMER\x10\x06\x12*\n&COMMAND_TYPE_CANCEL_WORKFLOW_EXECUTION\x10\x07\x12;\n7COMMAND_TYPE_REQUEST_CANCEL_EXTERNAL_WORKFLOW_EXECUTION\x10\x08\x12\x1e\n\x1a\x43OMMAND_TYPE_RECORD_MARKER\x10\t\x12\x33\n/COMMAND_TYPE_CONTINUE_AS_NEW_WORKFLOW_EXECUTION\x10\n\x12/\n+COMMAND_TYPE_START_CHILD_WORKFLOW_EXECUTION\x10\x0b\x12\x33\n/COMMAND_TYPE_SIGNAL_EXTERNAL_WORKFLOW_EXECUTION\x10\x0c\x12\x32\n.COMMAND_TYPE_UPSERT_WORKFLOW_SEARCH_ATTRIBUTES\x10\r\x12!\n\x1d\x43OMMAND_TYPE_PROTOCOL_MESSAGE\x10\x0e\x12+\n'COMMAND_TYPE_MODIFY_WORKFLOW_PROPERTIES\x10\x10\x12)\n%COMMAND_TYPE_SCHEDULE_NEXUS_OPERATION\x10\x11\x12/\n+COMMAND_TYPE_REQUEST_CANCEL_NEXUS_OPERATION\x10\x12\x42\x88\x01\n\x18io.temporal.api.enums.v1B\x10\x43ommandTypeProtoP\x01Z!go.temporal.io/api/enums/v1;enums\xaa\x02\x17Temporalio.Api.Enums.V1\xea\x02\x1aTemporalio::Api::Enums::V1b\x06proto3" + b"\n(temporal/api/enums/v1/command_type.proto\x12\x15temporal.api.enums.v1*\xe7\x06\n\x0b\x43ommandType\x12\x1c\n\x18\x43OMMAND_TYPE_UNSPECIFIED\x10\x00\x12'\n#COMMAND_TYPE_SCHEDULE_ACTIVITY_TASK\x10\x01\x12-\n)COMMAND_TYPE_REQUEST_CANCEL_ACTIVITY_TASK\x10\x02\x12\x1c\n\x18\x43OMMAND_TYPE_START_TIMER\x10\x03\x12,\n(COMMAND_TYPE_COMPLETE_WORKFLOW_EXECUTION\x10\x04\x12(\n$COMMAND_TYPE_FAIL_WORKFLOW_EXECUTION\x10\x05\x12\x1d\n\x19\x43OMMAND_TYPE_CANCEL_TIMER\x10\x06\x12*\n&COMMAND_TYPE_CANCEL_WORKFLOW_EXECUTION\x10\x07\x12;\n7COMMAND_TYPE_REQUEST_CANCEL_EXTERNAL_WORKFLOW_EXECUTION\x10\x08\x12\x1e\n\x1a\x43OMMAND_TYPE_RECORD_MARKER\x10\t\x12\x33\n/COMMAND_TYPE_CONTINUE_AS_NEW_WORKFLOW_EXECUTION\x10\n\x12/\n+COMMAND_TYPE_START_CHILD_WORKFLOW_EXECUTION\x10\x0b\x12\x33\n/COMMAND_TYPE_SIGNAL_EXTERNAL_WORKFLOW_EXECUTION\x10\x0c\x12\x32\n.COMMAND_TYPE_UPSERT_WORKFLOW_SEARCH_ATTRIBUTES\x10\r\x12!\n\x1d\x43OMMAND_TYPE_PROTOCOL_MESSAGE\x10\x0e\x12+\n'COMMAND_TYPE_MODIFY_WORKFLOW_PROPERTIES\x10\x10\x12)\n%COMMAND_TYPE_SCHEDULE_NEXUS_OPERATION\x10\x11\x12/\n+COMMAND_TYPE_REQUEST_CANCEL_NEXUS_OPERATION\x10\x12\x12&\n\"COMMAND_TYPE_APPEND_STREAM_RECORDS\x10\x13\x12!\n\x1d\x43OMMAND_TYPE_SUBSCRIBE_STREAM\x10\x14\x42\x88\x01\n\x18io.temporal.api.enums.v1B\x10\x43ommandTypeProtoP\x01Z!go.temporal.io/api/enums/v1;enums\xaa\x02\x17Temporalio.Api.Enums.V1\xea\x02\x1aTemporalio::Api::Enums::V1b\x06proto3" ) _COMMANDTYPE = DESCRIPTOR.enum_types_by_name["CommandType"] @@ -39,11 +39,13 @@ COMMAND_TYPE_MODIFY_WORKFLOW_PROPERTIES = 16 COMMAND_TYPE_SCHEDULE_NEXUS_OPERATION = 17 COMMAND_TYPE_REQUEST_CANCEL_NEXUS_OPERATION = 18 +COMMAND_TYPE_APPEND_STREAM_RECORDS = 19 +COMMAND_TYPE_SUBSCRIBE_STREAM = 20 if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b"\n\030io.temporal.api.enums.v1B\020CommandTypeProtoP\001Z!go.temporal.io/api/enums/v1;enums\252\002\027Temporalio.Api.Enums.V1\352\002\032Temporalio::Api::Enums::V1" _COMMANDTYPE._serialized_start = 68 - _COMMANDTYPE._serialized_end = 864 + _COMMANDTYPE._serialized_end = 939 # @@protoc_insertion_point(module_scope) diff --git a/temporalio/api/enums/v1/command_type_pb2.pyi b/temporalio/api/enums/v1/command_type_pb2.pyi index 9f1bfd240..c6f21ab4b 100644 --- a/temporalio/api/enums/v1/command_type_pb2.pyi +++ b/temporalio/api/enums/v1/command_type_pb2.pyi @@ -44,6 +44,8 @@ class _CommandTypeEnumTypeWrapper( COMMAND_TYPE_MODIFY_WORKFLOW_PROPERTIES: _CommandType.ValueType # 16 COMMAND_TYPE_SCHEDULE_NEXUS_OPERATION: _CommandType.ValueType # 17 COMMAND_TYPE_REQUEST_CANCEL_NEXUS_OPERATION: _CommandType.ValueType # 18 + COMMAND_TYPE_APPEND_STREAM_RECORDS: _CommandType.ValueType # 19 + COMMAND_TYPE_SUBSCRIBE_STREAM: _CommandType.ValueType # 20 class CommandType(_CommandType, metaclass=_CommandTypeEnumTypeWrapper): """Whenever this list of command types is changed do change the function shouldBufferEvent in mutableStateBuilder.go to make sure to do the correct event ordering.""" @@ -66,4 +68,6 @@ COMMAND_TYPE_PROTOCOL_MESSAGE: CommandType.ValueType # 14 COMMAND_TYPE_MODIFY_WORKFLOW_PROPERTIES: CommandType.ValueType # 16 COMMAND_TYPE_SCHEDULE_NEXUS_OPERATION: CommandType.ValueType # 17 COMMAND_TYPE_REQUEST_CANCEL_NEXUS_OPERATION: CommandType.ValueType # 18 +COMMAND_TYPE_APPEND_STREAM_RECORDS: CommandType.ValueType # 19 +COMMAND_TYPE_SUBSCRIBE_STREAM: CommandType.ValueType # 20 global___CommandType = CommandType diff --git a/temporalio/api/enums/v1/event_type_pb2.py b/temporalio/api/enums/v1/event_type_pb2.py index 14ff7984d..a41b486ee 100644 --- a/temporalio/api/enums/v1/event_type_pb2.py +++ b/temporalio/api/enums/v1/event_type_pb2.py @@ -16,7 +16,7 @@ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n&temporal/api/enums/v1/event_type.proto\x12\x15temporal.api.enums.v1*\x9e\x16\n\tEventType\x12\x1a\n\x16\x45VENT_TYPE_UNSPECIFIED\x10\x00\x12)\n%EVENT_TYPE_WORKFLOW_EXECUTION_STARTED\x10\x01\x12+\n\'EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED\x10\x02\x12(\n$EVENT_TYPE_WORKFLOW_EXECUTION_FAILED\x10\x03\x12+\n\'EVENT_TYPE_WORKFLOW_EXECUTION_TIMED_OUT\x10\x04\x12&\n"EVENT_TYPE_WORKFLOW_TASK_SCHEDULED\x10\x05\x12$\n EVENT_TYPE_WORKFLOW_TASK_STARTED\x10\x06\x12&\n"EVENT_TYPE_WORKFLOW_TASK_COMPLETED\x10\x07\x12&\n"EVENT_TYPE_WORKFLOW_TASK_TIMED_OUT\x10\x08\x12#\n\x1f\x45VENT_TYPE_WORKFLOW_TASK_FAILED\x10\t\x12&\n"EVENT_TYPE_ACTIVITY_TASK_SCHEDULED\x10\n\x12$\n EVENT_TYPE_ACTIVITY_TASK_STARTED\x10\x0b\x12&\n"EVENT_TYPE_ACTIVITY_TASK_COMPLETED\x10\x0c\x12#\n\x1f\x45VENT_TYPE_ACTIVITY_TASK_FAILED\x10\r\x12&\n"EVENT_TYPE_ACTIVITY_TASK_TIMED_OUT\x10\x0e\x12-\n)EVENT_TYPE_ACTIVITY_TASK_CANCEL_REQUESTED\x10\x0f\x12%\n!EVENT_TYPE_ACTIVITY_TASK_CANCELED\x10\x10\x12\x1c\n\x18\x45VENT_TYPE_TIMER_STARTED\x10\x11\x12\x1a\n\x16\x45VENT_TYPE_TIMER_FIRED\x10\x12\x12\x1d\n\x19\x45VENT_TYPE_TIMER_CANCELED\x10\x13\x12\x32\n.EVENT_TYPE_WORKFLOW_EXECUTION_CANCEL_REQUESTED\x10\x14\x12*\n&EVENT_TYPE_WORKFLOW_EXECUTION_CANCELED\x10\x15\x12\x43\n?EVENT_TYPE_REQUEST_CANCEL_EXTERNAL_WORKFLOW_EXECUTION_INITIATED\x10\x16\x12@\nB\x86\x01\n\x18io.temporal.api.enums.v1B\x0e\x45ventTypeProtoP\x01Z!go.temporal.io/api/enums/v1;enums\xaa\x02\x17Temporalio.Api.Enums.V1\xea\x02\x1aTemporalio::Api::Enums::V1b\x06proto3' ) _EVENTTYPE = DESCRIPTOR.enum_types_by_name["EventType"] @@ -82,11 +82,13 @@ EVENT_TYPE_WORKFLOW_EXECUTION_PAUSED = 58 EVENT_TYPE_WORKFLOW_EXECUTION_UNPAUSED = 59 EVENT_TYPE_WORKFLOW_EXECUTION_TIME_SKIPPING_TRANSITIONED = 60 +EVENT_TYPE_WORKFLOW_STREAM_SUBSCRIBED = 61 +EVENT_TYPE_WORKFLOW_STREAM_RECORDS_APPENDED = 62 if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b"\n\030io.temporal.api.enums.v1B\016EventTypeProtoP\001Z!go.temporal.io/api/enums/v1;enums\252\002\027Temporalio.Api.Enums.V1\352\002\032Temporalio::Api::Enums::V1" _EVENTTYPE._serialized_start = 66 - _EVENTTYPE._serialized_end = 2912 + _EVENTTYPE._serialized_end = 3004 # @@protoc_insertion_point(module_scope) diff --git a/temporalio/api/enums/v1/event_type_pb2.pyi b/temporalio/api/enums/v1/event_type_pb2.pyi index 16a23b137..84db38b61 100644 --- a/temporalio/api/enums/v1/event_type_pb2.pyi +++ b/temporalio/api/enums/v1/event_type_pb2.pyi @@ -216,6 +216,16 @@ class _EventTypeEnumTypeWrapper( """An event that indicates that the previously paused workflow execution has been unpaused.""" EVENT_TYPE_WORKFLOW_EXECUTION_TIME_SKIPPING_TRANSITIONED: _EventType.ValueType # 60 """An event that indicates time skipping advanced time or was disabled automatically after a bound was reached.""" + EVENT_TYPE_WORKFLOW_STREAM_SUBSCRIBED: _EventType.ValueType # 61 + """A Workflow subscribed to a stream. Recorded once per subscription, not + per record: the offsets a task consumed ride WorkflowTaskCompleted and + the payloads never enter History at all. + """ + EVENT_TYPE_WORKFLOW_STREAM_RECORDS_APPENDED: _EventType.ValueType # 62 + """A Workflow appended a batch of records to a stream. Recorded per + batch, and carrying only the offset range it landed at: the bodies go to + the stream's own log, never into History. + """ class EventType(_EventType, metaclass=_EventTypeEnumTypeWrapper): """Whenever this list of events is changed do change the function shouldBufferEvent in mutableStateBuilder.go to make sure to do the correct event ordering""" @@ -408,4 +418,14 @@ EVENT_TYPE_WORKFLOW_EXECUTION_UNPAUSED: EventType.ValueType # 59 """An event that indicates that the previously paused workflow execution has been unpaused.""" EVENT_TYPE_WORKFLOW_EXECUTION_TIME_SKIPPING_TRANSITIONED: EventType.ValueType # 60 """An event that indicates time skipping advanced time or was disabled automatically after a bound was reached.""" +EVENT_TYPE_WORKFLOW_STREAM_SUBSCRIBED: EventType.ValueType # 61 +"""A Workflow subscribed to a stream. Recorded once per subscription, not +per record: the offsets a task consumed ride WorkflowTaskCompleted and +the payloads never enter History at all. +""" +EVENT_TYPE_WORKFLOW_STREAM_RECORDS_APPENDED: EventType.ValueType # 62 +"""A Workflow appended a batch of records to a stream. Recorded per +batch, and carrying only the offset range it landed at: the bodies go to +the stream's own log, never into History. +""" global___EventType = EventType diff --git a/temporalio/api/enums/v1/failed_cause_pb2.py b/temporalio/api/enums/v1/failed_cause_pb2.py index 03eb094f9..70229ee81 100644 --- a/temporalio/api/enums/v1/failed_cause_pb2.py +++ b/temporalio/api/enums/v1/failed_cause_pb2.py @@ -16,7 +16,7 @@ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n(temporal/api/enums/v1/failed_cause.proto\x12\x15temporal.api.enums.v1*\x90\x14\n\x17WorkflowTaskFailedCause\x12*\n&WORKFLOW_TASK_FAILED_CAUSE_UNSPECIFIED\x10\x00\x12\x30\n,WORKFLOW_TASK_FAILED_CAUSE_UNHANDLED_COMMAND\x10\x01\x12?\n;WORKFLOW_TASK_FAILED_CAUSE_BAD_SCHEDULE_ACTIVITY_ATTRIBUTES\x10\x02\x12\x45\nAWORKFLOW_TASK_FAILED_CAUSE_BAD_REQUEST_CANCEL_ACTIVITY_ATTRIBUTES\x10\x03\x12\x39\n5WORKFLOW_TASK_FAILED_CAUSE_BAD_START_TIMER_ATTRIBUTES\x10\x04\x12:\n6WORKFLOW_TASK_FAILED_CAUSE_BAD_CANCEL_TIMER_ATTRIBUTES\x10\x05\x12;\n7WORKFLOW_TASK_FAILED_CAUSE_BAD_RECORD_MARKER_ATTRIBUTES\x10\x06\x12I\nEWORKFLOW_TASK_FAILED_CAUSE_BAD_COMPLETE_WORKFLOW_EXECUTION_ATTRIBUTES\x10\x07\x12\x45\nAWORKFLOW_TASK_FAILED_CAUSE_BAD_FAIL_WORKFLOW_EXECUTION_ATTRIBUTES\x10\x08\x12G\nCWORKFLOW_TASK_FAILED_CAUSE_BAD_CANCEL_WORKFLOW_EXECUTION_ATTRIBUTES\x10\t\x12X\nTWORKFLOW_TASK_FAILED_CAUSE_BAD_REQUEST_CANCEL_EXTERNAL_WORKFLOW_EXECUTION_ATTRIBUTES\x10\n\x12=\n9WORKFLOW_TASK_FAILED_CAUSE_BAD_CONTINUE_AS_NEW_ATTRIBUTES\x10\x0b\x12\x37\n3WORKFLOW_TASK_FAILED_CAUSE_START_TIMER_DUPLICATE_ID\x10\x0c\x12\x36\n2WORKFLOW_TASK_FAILED_CAUSE_RESET_STICKY_TASK_QUEUE\x10\r\x12@\n\n:WORKFLOW_TASK_FAILED_CAUSE_BAD_SUBSCRIBE_STREAM_ATTRIBUTES\x10*\x12\x37\n3WORKFLOW_TASK_FAILED_CAUSE_STREAM_RANGE_UNAVAILABLE\x10+*\xc0\x02\n&StartChildWorkflowExecutionFailedCause\x12;\n7START_CHILD_WORKFLOW_EXECUTION_FAILED_CAUSE_UNSPECIFIED\x10\x00\x12G\nCSTART_CHILD_WORKFLOW_EXECUTION_FAILED_CAUSE_WORKFLOW_ALREADY_EXISTS\x10\x01\x12\x43\n?START_CHILD_WORKFLOW_EXECUTION_FAILED_CAUSE_NAMESPACE_NOT_FOUND\x10\x02\x12K\nGSTART_CHILD_WORKFLOW_EXECUTION_FAILED_CAUSE_INVALID_VERSIONING_OVERRIDE\x10\x03*\x91\x02\n*CancelExternalWorkflowExecutionFailedCause\x12?\n;CANCEL_EXTERNAL_WORKFLOW_EXECUTION_FAILED_CAUSE_UNSPECIFIED\x10\x00\x12Y\nUCANCEL_EXTERNAL_WORKFLOW_EXECUTION_FAILED_CAUSE_EXTERNAL_WORKFLOW_EXECUTION_NOT_FOUND\x10\x01\x12G\nCCANCEL_EXTERNAL_WORKFLOW_EXECUTION_FAILED_CAUSE_NAMESPACE_NOT_FOUND\x10\x02*\xe2\x02\n*SignalExternalWorkflowExecutionFailedCause\x12?\n;SIGNAL_EXTERNAL_WORKFLOW_EXECUTION_FAILED_CAUSE_UNSPECIFIED\x10\x00\x12Y\nUSIGNAL_EXTERNAL_WORKFLOW_EXECUTION_FAILED_CAUSE_EXTERNAL_WORKFLOW_EXECUTION_NOT_FOUND\x10\x01\x12G\nCSIGNAL_EXTERNAL_WORKFLOW_EXECUTION_FAILED_CAUSE_NAMESPACE_NOT_FOUND\x10\x02\x12O\nKSIGNAL_EXTERNAL_WORKFLOW_EXECUTION_FAILED_CAUSE_SIGNAL_COUNT_LIMIT_EXCEEDED\x10\x03*\x97\x04\n\x16ResourceExhaustedCause\x12(\n$RESOURCE_EXHAUSTED_CAUSE_UNSPECIFIED\x10\x00\x12&\n"RESOURCE_EXHAUSTED_CAUSE_RPS_LIMIT\x10\x01\x12-\n)RESOURCE_EXHAUSTED_CAUSE_CONCURRENT_LIMIT\x10\x02\x12.\n*RESOURCE_EXHAUSTED_CAUSE_SYSTEM_OVERLOADED\x10\x03\x12.\n*RESOURCE_EXHAUSTED_CAUSE_PERSISTENCE_LIMIT\x10\x04\x12*\n&RESOURCE_EXHAUSTED_CAUSE_BUSY_WORKFLOW\x10\x05\x12&\n"RESOURCE_EXHAUSTED_CAUSE_APS_LIMIT\x10\x06\x12\x36\n2RESOURCE_EXHAUSTED_CAUSE_PERSISTENCE_STORAGE_LIMIT\x10\x07\x12\x31\n-RESOURCE_EXHAUSTED_CAUSE_CIRCUIT_BREAKER_OPEN\x10\x08\x12&\n"RESOURCE_EXHAUSTED_CAUSE_OPS_LIMIT\x10\t\x12\x35\n1RESOURCE_EXHAUSTED_CAUSE_WORKER_DEPLOYMENT_LIMITS\x10\n*\x8f\x01\n\x16ResourceExhaustedScope\x12(\n$RESOURCE_EXHAUSTED_SCOPE_UNSPECIFIED\x10\x00\x12&\n"RESOURCE_EXHAUSTED_SCOPE_NAMESPACE\x10\x01\x12#\n\x1fRESOURCE_EXHAUSTED_SCOPE_SYSTEM\x10\x02\x42\x88\x01\n\x18io.temporal.api.enums.v1B\x10\x46\x61iledCauseProtoP\x01Z!go.temporal.io/api/enums/v1;enums\xaa\x02\x17Temporalio.Api.Enums.V1\xea\x02\x1aTemporalio::Api::Enums::V1b\x06proto3' ) _WORKFLOWTASKFAILEDCAUSE = DESCRIPTOR.enum_types_by_name["WorkflowTaskFailedCause"] @@ -86,6 +86,9 @@ WORKFLOW_TASK_FAILED_CAUSE_EXTERNAL_STORAGE_FAILURE = 38 WORKFLOW_TASK_FAILED_CAUSE_WORKFLOW_PAUSE_REQUESTED_BEFORE_TASK_STARTED = 39 WORKFLOW_TASK_FAILED_CAUSE_REQUEST_TOO_LARGE = 40 +WORKFLOW_TASK_FAILED_CAUSE_BAD_APPEND_STREAM_RECORDS_ATTRIBUTES = 41 +WORKFLOW_TASK_FAILED_CAUSE_BAD_SUBSCRIBE_STREAM_ATTRIBUTES = 42 +WORKFLOW_TASK_FAILED_CAUSE_STREAM_RANGE_UNAVAILABLE = 43 START_CHILD_WORKFLOW_EXECUTION_FAILED_CAUSE_UNSPECIFIED = 0 START_CHILD_WORKFLOW_EXECUTION_FAILED_CAUSE_WORKFLOW_ALREADY_EXISTS = 1 START_CHILD_WORKFLOW_EXECUTION_FAILED_CAUSE_NAMESPACE_NOT_FOUND = 2 @@ -117,15 +120,15 @@ DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b"\n\030io.temporal.api.enums.v1B\020FailedCauseProtoP\001Z!go.temporal.io/api/enums/v1;enums\252\002\027Temporalio.Api.Enums.V1\352\002\032Temporalio::Api::Enums::V1" _WORKFLOWTASKFAILEDCAUSE._serialized_start = 68 - _WORKFLOWTASKFAILEDCAUSE._serialized_end = 2644 - _STARTCHILDWORKFLOWEXECUTIONFAILEDCAUSE._serialized_start = 2647 - _STARTCHILDWORKFLOWEXECUTIONFAILEDCAUSE._serialized_end = 2967 - _CANCELEXTERNALWORKFLOWEXECUTIONFAILEDCAUSE._serialized_start = 2970 - _CANCELEXTERNALWORKFLOWEXECUTIONFAILEDCAUSE._serialized_end = 3243 - _SIGNALEXTERNALWORKFLOWEXECUTIONFAILEDCAUSE._serialized_start = 3246 - _SIGNALEXTERNALWORKFLOWEXECUTIONFAILEDCAUSE._serialized_end = 3600 - _RESOURCEEXHAUSTEDCAUSE._serialized_start = 3603 - _RESOURCEEXHAUSTEDCAUSE._serialized_end = 4138 - _RESOURCEEXHAUSTEDSCOPE._serialized_start = 4141 - _RESOURCEEXHAUSTEDSCOPE._serialized_end = 4284 + _WORKFLOWTASKFAILEDCAUSE._serialized_end = 2834 + _STARTCHILDWORKFLOWEXECUTIONFAILEDCAUSE._serialized_start = 2837 + _STARTCHILDWORKFLOWEXECUTIONFAILEDCAUSE._serialized_end = 3157 + _CANCELEXTERNALWORKFLOWEXECUTIONFAILEDCAUSE._serialized_start = 3160 + _CANCELEXTERNALWORKFLOWEXECUTIONFAILEDCAUSE._serialized_end = 3433 + _SIGNALEXTERNALWORKFLOWEXECUTIONFAILEDCAUSE._serialized_start = 3436 + _SIGNALEXTERNALWORKFLOWEXECUTIONFAILEDCAUSE._serialized_end = 3790 + _RESOURCEEXHAUSTEDCAUSE._serialized_start = 3793 + _RESOURCEEXHAUSTEDCAUSE._serialized_end = 4328 + _RESOURCEEXHAUSTEDSCOPE._serialized_start = 4331 + _RESOURCEEXHAUSTEDSCOPE._serialized_end = 4474 # @@protoc_insertion_point(module_scope) diff --git a/temporalio/api/enums/v1/failed_cause_pb2.pyi b/temporalio/api/enums/v1/failed_cause_pb2.pyi index be1c22442..c90da0713 100644 --- a/temporalio/api/enums/v1/failed_cause_pb2.pyi +++ b/temporalio/api/enums/v1/failed_cause_pb2.pyi @@ -188,6 +188,21 @@ class _WorkflowTaskFailedCauseEnumTypeWrapper( _WorkflowTaskFailedCause.ValueType ) # 40 """A workflow task failed because the request exceeded a size limit.""" + WORKFLOW_TASK_FAILED_CAUSE_BAD_APPEND_STREAM_RECORDS_ATTRIBUTES: ( + _WorkflowTaskFailedCause.ValueType + ) # 41 + """A workflow task completed with an invalid AppendStreamRecords command.""" + WORKFLOW_TASK_FAILED_CAUSE_BAD_SUBSCRIBE_STREAM_ATTRIBUTES: ( + _WorkflowTaskFailedCause.ValueType + ) # 42 + """A workflow task completed with an invalid SubscribeStream command.""" + WORKFLOW_TASK_FAILED_CAUSE_STREAM_RANGE_UNAVAILABLE: ( + _WorkflowTaskFailedCause.ValueType + ) # 43 + """A workflow task could not be started because a stream range it consumed and recorded in + History can no longer be served, for example after truncation or because it exceeds the + replay bound. Check the workflow task failure message for more information. + """ class WorkflowTaskFailedCause( _WorkflowTaskFailedCause, metaclass=_WorkflowTaskFailedCauseEnumTypeWrapper @@ -346,6 +361,21 @@ WORKFLOW_TASK_FAILED_CAUSE_WORKFLOW_PAUSE_REQUESTED_BEFORE_TASK_STARTED: ( """A workflow task is failed because the workflow is paused before the task is started.""" WORKFLOW_TASK_FAILED_CAUSE_REQUEST_TOO_LARGE: WorkflowTaskFailedCause.ValueType # 40 """A workflow task failed because the request exceeded a size limit.""" +WORKFLOW_TASK_FAILED_CAUSE_BAD_APPEND_STREAM_RECORDS_ATTRIBUTES: ( + WorkflowTaskFailedCause.ValueType +) # 41 +"""A workflow task completed with an invalid AppendStreamRecords command.""" +WORKFLOW_TASK_FAILED_CAUSE_BAD_SUBSCRIBE_STREAM_ATTRIBUTES: ( + WorkflowTaskFailedCause.ValueType +) # 42 +"""A workflow task completed with an invalid SubscribeStream command.""" +WORKFLOW_TASK_FAILED_CAUSE_STREAM_RANGE_UNAVAILABLE: ( + WorkflowTaskFailedCause.ValueType +) # 43 +"""A workflow task could not be started because a stream range it consumed and recorded in +History can no longer be served, for example after truncation or because it exceeds the +replay bound. Check the workflow task failure message for more information. +""" global___WorkflowTaskFailedCause = WorkflowTaskFailedCause class _StartChildWorkflowExecutionFailedCause: diff --git a/temporalio/api/history/v1/__init__.py b/temporalio/api/history/v1/__init__.py index ec7f65962..13d56f93a 100644 --- a/temporalio/api/history/v1/__init__.py +++ b/temporalio/api/history/v1/__init__.py @@ -57,6 +57,8 @@ WorkflowExecutionUpdateRejectedEventAttributes, WorkflowPropertiesModifiedEventAttributes, WorkflowPropertiesModifiedExternallyEventAttributes, + WorkflowStreamRecordsAppendedEventAttributes, + WorkflowStreamSubscribedEventAttributes, WorkflowTaskCompletedEventAttributes, WorkflowTaskFailedEventAttributes, WorkflowTaskScheduledEventAttributes, @@ -123,6 +125,8 @@ "WorkflowExecutionUpdateRejectedEventAttributes", "WorkflowPropertiesModifiedEventAttributes", "WorkflowPropertiesModifiedExternallyEventAttributes", + "WorkflowStreamRecordsAppendedEventAttributes", + "WorkflowStreamSubscribedEventAttributes", "WorkflowTaskCompletedEventAttributes", "WorkflowTaskFailedEventAttributes", "WorkflowTaskScheduledEventAttributes", diff --git a/temporalio/api/history/v1/message_pb2.py b/temporalio/api/history/v1/message_pb2.py index ac337b708..14b3cbfb9 100644 --- a/temporalio/api/history/v1/message_pb2.py +++ b/temporalio/api/history/v1/message_pb2.py @@ -47,6 +47,9 @@ from temporalio.api.sdk.v1 import ( user_metadata_pb2 as temporal_dot_api_dot_sdk_dot_v1_dot_user__metadata__pb2, ) +from temporalio.api.stream.v1 import ( + message_pb2 as temporal_dot_api_dot_stream_dot_v1_dot_message__pb2, +) from temporalio.api.taskqueue.v1 import ( message_pb2 as temporal_dot_api_dot_taskqueue_dot_v1_dot_message__pb2, ) @@ -58,7 +61,7 @@ ) DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n%temporal/api/history/v1/message.proto\x12\x17temporal.api.history.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a&temporal/api/enums/v1/event_type.proto\x1a(temporal/api/enums/v1/failed_cause.proto\x1a"temporal/api/enums/v1/update.proto\x1a$temporal/api/enums/v1/workflow.proto\x1a$temporal/api/common/v1/message.proto\x1a(temporal/api/deployment/v1/message.proto\x1a%temporal/api/failure/v1/message.proto\x1a\'temporal/api/taskqueue/v1/message.proto\x1a$temporal/api/update/v1/message.proto\x1a&temporal/api/workflow/v1/message.proto\x1a\x30temporal/api/sdk/v1/task_complete_metadata.proto\x1a\'temporal/api/sdk/v1/user_metadata.proto\x1a,temporal/api/sdk/v1/event_group_marker.proto"\xda\x12\n\'WorkflowExecutionStartedEventAttributes\x12;\n\rworkflow_type\x18\x01 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12!\n\x19parent_workflow_namespace\x18\x02 \x01(\t\x12$\n\x1cparent_workflow_namespace_id\x18\x1b \x01(\t\x12L\n\x19parent_workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12!\n\x19parent_initiated_event_id\x18\x04 \x01(\x03\x12\x38\n\ntask_queue\x18\x05 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12/\n\x05input\x18\x06 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12=\n\x1aworkflow_execution_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x37\n\x14workflow_run_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\x15workflow_task_timeout\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12"\n\x1a\x63ontinued_execution_run_id\x18\n \x01(\t\x12@\n\tinitiator\x18\x0b \x01(\x0e\x32-.temporal.api.enums.v1.ContinueAsNewInitiator\x12;\n\x11\x63ontinued_failure\x18\x0c \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12@\n\x16last_completion_result\x18\r \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12!\n\x19original_execution_run_id\x18\x0e \x01(\t\x12\x10\n\x08identity\x18\x0f \x01(\t\x12\x1e\n\x16\x66irst_execution_run_id\x18\x10 \x01(\t\x12\x39\n\x0cretry_policy\x18\x11 \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12\x0f\n\x07\x61ttempt\x18\x12 \x01(\x05\x12\x46\n"workflow_execution_expiration_time\x18\x13 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rcron_schedule\x18\x14 \x01(\t\x12>\n\x1b\x66irst_workflow_task_backoff\x18\x15 \x01(\x0b\x32\x19.google.protobuf.Duration\x12*\n\x04memo\x18\x16 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x17 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12\x45\n\x16prev_auto_reset_points\x18\x18 \x01(\x0b\x32%.temporal.api.workflow.v1.ResetPoints\x12.\n\x06header\x18\x19 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12&\n\x1eparent_initiated_event_version\x18\x1a \x01(\x03\x12\x13\n\x0bworkflow_id\x18\x1c \x01(\t\x12L\n\x14source_version_stamp\x18\x1d \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12>\n\x14\x63ompletion_callbacks\x18\x1e \x03(\x0b\x32 .temporal.api.common.v1.Callback\x12J\n\x17root_workflow_execution\x18\x1f \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x1e\n\x12inherited_build_id\x18 \x01(\tB\x02\x18\x01\x12I\n\x13versioning_override\x18! \x01(\x0b\x32,.temporal.api.workflow.v1.VersioningOverride\x12\x33\n\'parent_pinned_worker_deployment_version\x18" \x01(\tB\x02\x18\x01\x12\x32\n\x08priority\x18# \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12U\n\x18inherited_pinned_version\x18% \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12Y\n\x1binherited_auto_upgrade_info\x18\' \x01(\x0b\x32\x34.temporal.api.deployment.v1.InheritedAutoUpgradeInfo\x12 \n\x18\x65\x61ger_execution_accepted\x18& \x01(\x08\x12^\n\x1f\x64\x65\x63lined_target_version_upgrade\x18( \x01(\x0b\x32\x35.temporal.api.history.v1.DeclinedTargetVersionUpgrade\x12H\n\x14time_skipping_config\x18) \x01(\x0b\x32*.temporal.api.common.v1.TimeSkippingConfig\x12]\n\x1ftime_skipping_state_propagation\x18+ \x01(\x0b\x32\x34.temporal.api.common.v1.TimeSkippingStatePropagationJ\x04\x08$\x10%J\x04\x08*\x10+R parent_pinned_deployment_versionR\x18initial_skipped_duration"\x88\x01\n\x1c\x44\x65\x63linedTargetVersionUpgrade\x12O\n\x12\x64\x65ployment_version\x18\x01 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12\x17\n\x0frevision_number\x18\x02 \x01(\x03"\xa5\x01\n)WorkflowExecutionCompletedEventAttributes\x12\x30\n\x06result\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12(\n workflow_task_completed_event_id\x18\x02 \x01(\x03\x12\x1c\n\x14new_execution_run_id\x18\x03 \x01(\t"\xdb\x01\n&WorkflowExecutionFailedEventAttributes\x12\x31\n\x07\x66\x61ilure\x18\x01 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x36\n\x0bretry_state\x18\x02 \x01(\x0e\x32!.temporal.api.enums.v1.RetryState\x12(\n workflow_task_completed_event_id\x18\x03 \x01(\x03\x12\x1c\n\x14new_execution_run_id\x18\x04 \x01(\t"\x80\x01\n(WorkflowExecutionTimedOutEventAttributes\x12\x36\n\x0bretry_state\x18\x01 \x01(\x0e\x32!.temporal.api.enums.v1.RetryState\x12\x1c\n\x14new_execution_run_id\x18\x02 \x01(\t"\xa5\x07\n.WorkflowExecutionContinuedAsNewEventAttributes\x12\x1c\n\x14new_execution_run_id\x18\x01 \x01(\t\x12;\n\rworkflow_type\x18\x02 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x38\n\ntask_queue\x18\x03 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12/\n\x05input\x18\x04 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x37\n\x14workflow_run_timeout\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\x15workflow_task_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12(\n workflow_task_completed_event_id\x18\x07 \x01(\x03\x12\x39\n\x16\x62\x61\x63koff_start_interval\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12@\n\tinitiator\x18\t \x01(\x0e\x32-.temporal.api.enums.v1.ContinueAsNewInitiator\x12\x35\n\x07\x66\x61ilure\x18\n \x01(\x0b\x32 .temporal.api.failure.v1.FailureB\x02\x18\x01\x12@\n\x16last_completion_result\x18\x0b \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12.\n\x06header\x18\x0c \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12*\n\x04memo\x18\r \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x0e \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12\x1c\n\x10inherit_build_id\x18\x0f \x01(\x08\x42\x02\x18\x01\x12[\n\x1binitial_versioning_behavior\x18\x10 \x01(\x0e\x32\x36.temporal.api.enums.v1.ContinueAsNewVersioningBehavior"\xac\x01\n$WorkflowTaskScheduledEventAttributes\x12\x38\n\ntask_queue\x18\x01 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12\x39\n\x16start_to_close_timeout\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x0f\n\x07\x61ttempt\x18\x03 \x01(\x05"\xa0\x03\n"WorkflowTaskStartedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x12\n\nrequest_id\x18\x03 \x01(\t\x12\x1f\n\x17suggest_continue_as_new\x18\x04 \x01(\x08\x12Z\n\x1fsuggest_continue_as_new_reasons\x18\x08 \x03(\x0e\x32\x31.temporal.api.enums.v1.SuggestContinueAsNewReason\x12\x30\n(target_worker_deployment_version_changed\x18\t \x01(\x08\x12\x1a\n\x12history_size_bytes\x18\x05 \x01(\x03\x12\x46\n\x0eworker_version\x18\x06 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12%\n\x19\x62uild_id_redirect_counter\x18\x07 \x01(\x03\x42\x02\x18\x01"\x82\x05\n$WorkflowTaskCompletedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x18\n\x10started_event_id\x18\x02 \x01(\x03\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x1b\n\x0f\x62inary_checksum\x18\x04 \x01(\tB\x02\x18\x01\x12\x46\n\x0eworker_version\x18\x05 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12H\n\x0csdk_metadata\x18\x06 \x01(\x0b\x32\x32.temporal.api.sdk.v1.WorkflowTaskCompletedMetadata\x12\x43\n\x11metering_metadata\x18\r \x01(\x0b\x32(.temporal.api.common.v1.MeteringMetadata\x12>\n\ndeployment\x18\x07 \x01(\x0b\x32&.temporal.api.deployment.v1.DeploymentB\x02\x18\x01\x12\x46\n\x13versioning_behavior\x18\x08 \x01(\x0e\x32).temporal.api.enums.v1.VersioningBehavior\x12%\n\x19worker_deployment_version\x18\t \x01(\tB\x02\x18\x01\x12\x1e\n\x16worker_deployment_name\x18\n \x01(\t\x12O\n\x12\x64\x65ployment_version\x18\x0b \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion"\x95\x01\n#WorkflowTaskTimedOutEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x18\n\x10started_event_id\x18\x02 \x01(\x03\x12\x38\n\x0ctimeout_type\x18\x03 \x01(\x0e\x32".temporal.api.enums.v1.TimeoutType"\x87\x03\n!WorkflowTaskFailedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x18\n\x10started_event_id\x18\x02 \x01(\x03\x12=\n\x05\x63\x61use\x18\x03 \x01(\x0e\x32..temporal.api.enums.v1.WorkflowTaskFailedCause\x12\x31\n\x07\x66\x61ilure\x18\x04 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x13\n\x0b\x62\x61se_run_id\x18\x06 \x01(\t\x12\x12\n\nnew_run_id\x18\x07 \x01(\t\x12\x1a\n\x12\x66ork_event_version\x18\x08 \x01(\x03\x12\x1b\n\x0f\x62inary_checksum\x18\t \x01(\tB\x02\x18\x01\x12\x46\n\x0eworker_version\x18\n \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01"\xc2\x05\n$ActivityTaskScheduledEventAttributes\x12\x13\n\x0b\x61\x63tivity_id\x18\x01 \x01(\t\x12;\n\ractivity_type\x18\x02 \x01(\x0b\x32$.temporal.api.common.v1.ActivityType\x12\x38\n\ntask_queue\x18\x04 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12.\n\x06header\x18\x05 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12/\n\x05input\x18\x06 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12<\n\x19schedule_to_close_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12<\n\x19schedule_to_start_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x34\n\x11heartbeat_timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.Duration\x12(\n workflow_task_completed_event_id\x18\x0b \x01(\x03\x12\x39\n\x0cretry_policy\x18\x0c \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12!\n\x15use_workflow_build_id\x18\r \x01(\x08\x42\x02\x18\x01\x12\x32\n\x08priority\x18\x0e \x01(\x0b\x32 .temporal.api.common.v1.PriorityJ\x04\x08\x03\x10\x04"\x9e\x02\n"ActivityTaskStartedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x12\n\nrequest_id\x18\x03 \x01(\t\x12\x0f\n\x07\x61ttempt\x18\x04 \x01(\x05\x12\x36\n\x0clast_failure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x46\n\x0eworker_version\x18\x06 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12%\n\x19\x62uild_id_redirect_counter\x18\x07 \x01(\x03\x42\x02\x18\x01"\xe8\x01\n$ActivityTaskCompletedEventAttributes\x12\x30\n\x06result\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x1a\n\x12scheduled_event_id\x18\x02 \x01(\x03\x12\x18\n\x10started_event_id\x18\x03 \x01(\x03\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x46\n\x0eworker_version\x18\x05 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01"\x9e\x02\n!ActivityTaskFailedEventAttributes\x12\x31\n\x07\x66\x61ilure\x18\x01 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x1a\n\x12scheduled_event_id\x18\x02 \x01(\x03\x12\x18\n\x10started_event_id\x18\x03 \x01(\x03\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x36\n\x0bretry_state\x18\x05 \x01(\x0e\x32!.temporal.api.enums.v1.RetryState\x12\x46\n\x0eworker_version\x18\x06 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01"\xc6\x01\n#ActivityTaskTimedOutEventAttributes\x12\x31\n\x07\x66\x61ilure\x18\x01 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x1a\n\x12scheduled_event_id\x18\x02 \x01(\x03\x12\x18\n\x10started_event_id\x18\x03 \x01(\x03\x12\x36\n\x0bretry_state\x18\x04 \x01(\x0e\x32!.temporal.api.enums.v1.RetryState"r\n*ActivityTaskCancelRequestedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12(\n workflow_task_completed_event_id\x18\x02 \x01(\x03"\x92\x02\n#ActivityTaskCanceledEventAttributes\x12\x31\n\x07\x64\x65tails\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12(\n latest_cancel_requested_event_id\x18\x02 \x01(\x03\x12\x1a\n\x12scheduled_event_id\x18\x03 \x01(\x03\x12\x18\n\x10started_event_id\x18\x04 \x01(\x03\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x46\n\x0eworker_version\x18\x06 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01"\x93\x01\n\x1bTimerStartedEventAttributes\x12\x10\n\x08timer_id\x18\x01 \x01(\t\x12\x38\n\x15start_to_fire_timeout\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration\x12(\n workflow_task_completed_event_id\x18\x03 \x01(\x03"G\n\x19TimerFiredEventAttributes\x12\x10\n\x08timer_id\x18\x01 \x01(\t\x12\x18\n\x10started_event_id\x18\x02 \x01(\x03"\x86\x01\n\x1cTimerCanceledEventAttributes\x12\x10\n\x08timer_id\x18\x01 \x01(\t\x12\x18\n\x10started_event_id\x18\x02 \x01(\x03\x12(\n workflow_task_completed_event_id\x18\x03 \x01(\x03\x12\x10\n\x08identity\x18\x04 \x01(\t"\xc7\x01\n/WorkflowExecutionCancelRequestedEventAttributes\x12\r\n\x05\x63\x61use\x18\x01 \x01(\t\x12#\n\x1b\x65xternal_initiated_event_id\x18\x02 \x01(\x03\x12N\n\x1b\x65xternal_workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x10\n\x08identity\x18\x04 \x01(\t"\x87\x01\n(WorkflowExecutionCanceledEventAttributes\x12(\n workflow_task_completed_event_id\x18\x01 \x01(\x03\x12\x31\n\x07\x64\x65tails\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads"\xe9\x02\n\x1dMarkerRecordedEventAttributes\x12\x13\n\x0bmarker_name\x18\x01 \x01(\t\x12T\n\x07\x64\x65tails\x18\x02 \x03(\x0b\x32\x43.temporal.api.history.v1.MarkerRecordedEventAttributes.DetailsEntry\x12(\n workflow_task_completed_event_id\x18\x03 \x01(\x03\x12.\n\x06header\x18\x04 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12\x31\n\x07\x66\x61ilure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x1aP\n\x0c\x44\x65tailsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12/\n\x05value\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads:\x02\x38\x01"\xbf\x02\n(WorkflowExecutionSignaledEventAttributes\x12\x13\n\x0bsignal_name\x18\x01 \x01(\t\x12/\n\x05input\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x03 \x01(\t\x12.\n\x06header\x18\x04 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12\'\n\x1bskip_generate_workflow_task\x18\x05 \x01(\x08\x42\x02\x18\x01\x12N\n\x1b\x65xternal_workflow_execution\x18\x06 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x12\n\nrequest_id\x18\x07 \x01(\t"\x81\x01\n*WorkflowExecutionTerminatedEventAttributes\x12\x0e\n\x06reason\x18\x01 \x01(\t\x12\x31\n\x07\x64\x65tails\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x03 \x01(\t"\x9c\x02\n>RequestCancelExternalWorkflowExecutionInitiatedEventAttributes\x12(\n workflow_task_completed_event_id\x18\x01 \x01(\x03\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x07 \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x13\n\x07\x63ontrol\x18\x04 \x01(\tB\x02\x18\x01\x12\x1b\n\x13\x63hild_workflow_only\x18\x05 \x01(\x08\x12\x0e\n\x06reason\x18\x06 \x01(\t"\xda\x02\n;RequestCancelExternalWorkflowExecutionFailedEventAttributes\x12P\n\x05\x63\x61use\x18\x01 \x01(\x0e\x32\x41.temporal.api.enums.v1.CancelExternalWorkflowExecutionFailedCause\x12(\n workflow_task_completed_event_id\x18\x02 \x01(\x03\x12\x11\n\tnamespace\x18\x03 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x07 \x01(\t\x12\x45\n\x12workflow_execution\x18\x04 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x1a\n\x12initiated_event_id\x18\x05 \x01(\x03\x12\x13\n\x07\x63ontrol\x18\x06 \x01(\tB\x02\x18\x01"\xc5\x01\n7ExternalWorkflowExecutionCancelRequestedEventAttributes\x12\x1a\n\x12initiated_event_id\x18\x01 \x01(\x03\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x04 \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution"\xfb\x02\n7SignalExternalWorkflowExecutionInitiatedEventAttributes\x12(\n workflow_task_completed_event_id\x18\x01 \x01(\x03\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12\x14\n\x0cnamespace_id\x18\t \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x13\n\x0bsignal_name\x18\x04 \x01(\t\x12/\n\x05input\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x13\n\x07\x63ontrol\x18\x06 \x01(\tB\x02\x18\x01\x12\x1b\n\x13\x63hild_workflow_only\x18\x07 \x01(\x08\x12.\n\x06header\x18\x08 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header"\xd3\x02\n4SignalExternalWorkflowExecutionFailedEventAttributes\x12P\n\x05\x63\x61use\x18\x01 \x01(\x0e\x32\x41.temporal.api.enums.v1.SignalExternalWorkflowExecutionFailedCause\x12(\n workflow_task_completed_event_id\x18\x02 \x01(\x03\x12\x11\n\tnamespace\x18\x03 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x07 \x01(\t\x12\x45\n\x12workflow_execution\x18\x04 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x1a\n\x12initiated_event_id\x18\x05 \x01(\x03\x12\x13\n\x07\x63ontrol\x18\x06 \x01(\tB\x02\x18\x01"\xd3\x01\n0ExternalWorkflowExecutionSignaledEventAttributes\x12\x1a\n\x12initiated_event_id\x18\x01 \x01(\x03\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x05 \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x13\n\x07\x63ontrol\x18\x04 \x01(\tB\x02\x18\x01"\x9e\x01\n-UpsertWorkflowSearchAttributesEventAttributes\x12(\n workflow_task_completed_event_id\x18\x01 \x01(\x03\x12\x43\n\x11search_attributes\x18\x02 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes"\x8a\x01\n)WorkflowPropertiesModifiedEventAttributes\x12(\n workflow_task_completed_event_id\x18\x01 \x01(\x03\x12\x33\n\rupserted_memo\x18\x02 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo"\xfc\t\n3StartChildWorkflowExecutionInitiatedEventAttributes\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x12 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x38\n\ntask_queue\x18\x04 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12/\n\x05input\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12=\n\x1aworkflow_execution_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x37\n\x14workflow_run_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\x15workflow_task_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x45\n\x13parent_close_policy\x18\t \x01(\x0e\x32(.temporal.api.enums.v1.ParentClosePolicy\x12\x13\n\x07\x63ontrol\x18\n \x01(\tB\x02\x18\x01\x12(\n workflow_task_completed_event_id\x18\x0b \x01(\x03\x12N\n\x18workflow_id_reuse_policy\x18\x0c \x01(\x0e\x32,.temporal.api.enums.v1.WorkflowIdReusePolicy\x12\x39\n\x0cretry_policy\x18\r \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12\x15\n\rcron_schedule\x18\x0e \x01(\t\x12.\n\x06header\x18\x0f \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12*\n\x04memo\x18\x10 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x11 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12\x1c\n\x10inherit_build_id\x18\x13 \x01(\x08\x42\x02\x18\x01\x12\x32\n\x08priority\x18\x14 \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12H\n\x14time_skipping_config\x18\x15 \x01(\x0b\x32*.temporal.api.common.v1.TimeSkippingConfig\x12]\n\x1ftime_skipping_state_propagation\x18\x17 \x01(\x0b\x32\x34.temporal.api.common.v1.TimeSkippingStatePropagation\x12I\n\x13versioning_override\x18\x18 \x01(\x0b\x32,.temporal.api.workflow.v1.VersioningOverrideJ\x04\x08\x16\x10\x17R\x18initial_skipped_duration"\xd6\x02\n0StartChildWorkflowExecutionFailedEventAttributes\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x08 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12L\n\x05\x63\x61use\x18\x04 \x01(\x0e\x32=.temporal.api.enums.v1.StartChildWorkflowExecutionFailedCause\x12\x13\n\x07\x63ontrol\x18\x05 \x01(\tB\x02\x18\x01\x12\x1a\n\x12initiated_event_id\x18\x06 \x01(\x03\x12(\n workflow_task_completed_event_id\x18\x07 \x01(\x03"\xa7\x02\n,ChildWorkflowExecutionStartedEventAttributes\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x06 \x01(\t\x12\x1a\n\x12initiated_event_id\x18\x02 \x01(\x03\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\rworkflow_type\x18\x04 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12.\n\x06header\x18\x05 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header"\xc5\x02\n.ChildWorkflowExecutionCompletedEventAttributes\x12\x30\n\x06result\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x07 \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\rworkflow_type\x18\x04 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x1a\n\x12initiated_event_id\x18\x05 \x01(\x03\x12\x18\n\x10started_event_id\x18\x06 \x01(\x03"\xfb\x02\n+ChildWorkflowExecutionFailedEventAttributes\x12\x31\n\x07\x66\x61ilure\x18\x01 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x08 \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\rworkflow_type\x18\x04 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x1a\n\x12initiated_event_id\x18\x05 \x01(\x03\x12\x18\n\x10started_event_id\x18\x06 \x01(\x03\x12\x36\n\x0bretry_state\x18\x07 \x01(\x0e\x32!.temporal.api.enums.v1.RetryState"\xc5\x02\n-ChildWorkflowExecutionCanceledEventAttributes\x12\x31\n\x07\x64\x65tails\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x07 \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\rworkflow_type\x18\x04 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x1a\n\x12initiated_event_id\x18\x05 \x01(\x03\x12\x18\n\x10started_event_id\x18\x06 \x01(\x03"\xca\x02\n-ChildWorkflowExecutionTimedOutEventAttributes\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x07 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x1a\n\x12initiated_event_id\x18\x04 \x01(\x03\x12\x18\n\x10started_event_id\x18\x05 \x01(\x03\x12\x36\n\x0bretry_state\x18\x06 \x01(\x0e\x32!.temporal.api.enums.v1.RetryState"\x94\x02\n/ChildWorkflowExecutionTerminatedEventAttributes\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x06 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x1a\n\x12initiated_event_id\x18\x04 \x01(\x03\x12\x18\n\x10started_event_id\x18\x05 \x01(\x03"\xda\x05\n.WorkflowExecutionOptionsUpdatedEventAttributes\x12I\n\x13versioning_override\x18\x01 \x01(\x0b\x32,.temporal.api.workflow.v1.VersioningOverride\x12!\n\x19unset_versioning_override\x18\x02 \x01(\x08\x12\x1b\n\x13\x61ttached_request_id\x18\x03 \x01(\t\x12G\n\x1d\x61ttached_completion_callbacks\x18\x04 \x03(\x0b\x32 .temporal.api.common.v1.Callback\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x32\n\x08priority\x18\x06 \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12H\n\x14time_skipping_config\x18\x07 \x01(\x0b\x32*.temporal.api.common.v1.TimeSkippingConfig\x12$\n\x1ctime_skipping_config_updated\x18\t \x01(\x08\x12\x84\x01\n\x17workflow_update_options\x18\x08 \x03(\x0b\x32\x63.temporal.api.history.v1.WorkflowExecutionOptionsUpdatedEventAttributes.WorkflowUpdateOptionsUpdate\x1a\x96\x01\n\x1bWorkflowUpdateOptionsUpdate\x12\x11\n\tupdate_id\x18\x01 \x01(\t\x12\x1b\n\x13\x61ttached_request_id\x18\x02 \x01(\t\x12G\n\x1d\x61ttached_completion_callbacks\x18\x03 \x03(\x0b\x32 .temporal.api.common.v1.Callback"\xc0\x02\n3WorkflowPropertiesModifiedExternallyEventAttributes\x12\x16\n\x0enew_task_queue\x18\x01 \x01(\t\x12<\n\x19new_workflow_task_timeout\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration\x12;\n\x18new_workflow_run_timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x41\n\x1enew_workflow_execution_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x33\n\rupserted_memo\x18\x05 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo"\x90\x01\n3ActivityPropertiesModifiedExternallyEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12=\n\x10new_retry_policy\x18\x02 \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy"\xdc\x01\n.WorkflowExecutionUpdateAcceptedEventAttributes\x12\x1c\n\x14protocol_instance_id\x18\x01 \x01(\t\x12#\n\x1b\x61\x63\x63\x65pted_request_message_id\x18\x02 \x01(\t\x12,\n$accepted_request_sequencing_event_id\x18\x03 \x01(\x03\x12\x39\n\x10\x61\x63\x63\x65pted_request\x18\x04 \x01(\x0b\x32\x1f.temporal.api.update.v1.Request"\xaa\x01\n/WorkflowExecutionUpdateCompletedEventAttributes\x12*\n\x04meta\x18\x01 \x01(\x0b\x32\x1c.temporal.api.update.v1.Meta\x12\x19\n\x11\x61\x63\x63\x65pted_event_id\x18\x03 \x01(\x03\x12\x30\n\x07outcome\x18\x02 \x01(\x0b\x32\x1f.temporal.api.update.v1.Outcome"\x8f\x02\n.WorkflowExecutionUpdateRejectedEventAttributes\x12\x1c\n\x14protocol_instance_id\x18\x01 \x01(\t\x12#\n\x1brejected_request_message_id\x18\x02 \x01(\t\x12,\n$rejected_request_sequencing_event_id\x18\x03 \x01(\x03\x12\x39\n\x10rejected_request\x18\x04 \x01(\x0b\x32\x1f.temporal.api.update.v1.Request\x12\x31\n\x07\x66\x61ilure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.Failure"\xa4\x01\n.WorkflowExecutionUpdateAdmittedEventAttributes\x12\x30\n\x07request\x18\x01 \x01(\x0b\x32\x1f.temporal.api.update.v1.Request\x12@\n\x06origin\x18\x02 \x01(\x0e\x32\x30.temporal.api.enums.v1.UpdateAdmittedEventOrigin"^\n&WorkflowExecutionPausedEventAttributes\x12\x10\n\x08identity\x18\x01 \x01(\t\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x12\n\nrequest_id\x18\x03 \x01(\t"`\n(WorkflowExecutionUnpausedEventAttributes\x12\x10\n\x08identity\x18\x01 \x01(\t\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x12\n\nrequest_id\x18\x03 \x01(\t"\xc5\x01\n8WorkflowExecutionTimeSkippingTransitionedEventAttributes\x12/\n\x0btarget_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1b\x64isabled_after_fast_forward\x18\x02 \x01(\x08\x12\x33\n\x0fwall_clock_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp"\xb4\x04\n&NexusOperationScheduledEventAttributes\x12\x10\n\x08\x65ndpoint\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\x12\x11\n\toperation\x18\x03 \x01(\t\x12.\n\x05input\x18\x04 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12<\n\x19schedule_to_close_timeout\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x66\n\x0cnexus_header\x18\x06 \x03(\x0b\x32P.temporal.api.history.v1.NexusOperationScheduledEventAttributes.NexusHeaderEntry\x12(\n workflow_task_completed_event_id\x18\x07 \x01(\x03\x12\x12\n\nrequest_id\x18\x08 \x01(\t\x12\x13\n\x0b\x65ndpoint_id\x18\t \x01(\t\x12<\n\x19schedule_to_start_timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\x0b \x01(\x0b\x32\x19.google.protobuf.Duration\x1a\x32\n\x10NexusHeaderEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01"\x89\x01\n$NexusOperationStartedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x18\n\x0coperation_id\x18\x03 \x01(\tB\x02\x18\x01\x12\x12\n\nrequest_id\x18\x04 \x01(\t\x12\x17\n\x0foperation_token\x18\x05 \x01(\t"\x89\x01\n&NexusOperationCompletedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12/\n\x06result\x18\x02 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x12\n\nrequest_id\x18\x03 \x01(\t"\x88\x01\n#NexusOperationFailedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x31\n\x07\x66\x61ilure\x18\x02 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x12\n\nrequest_id\x18\x03 \x01(\t"\x8a\x01\n%NexusOperationTimedOutEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x31\n\x07\x66\x61ilure\x18\x02 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x12\n\nrequest_id\x18\x03 \x01(\t"\x8a\x01\n%NexusOperationCanceledEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x31\n\x07\x66\x61ilure\x18\x02 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x12\n\nrequest_id\x18\x03 \x01(\t"t\n,NexusOperationCancelRequestedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12(\n workflow_task_completed_event_id\x18\x02 \x01(\x03"\x97\x01\n3NexusOperationCancelRequestCompletedEventAttributes\x12\x1a\n\x12requested_event_id\x18\x01 \x01(\x03\x12(\n workflow_task_completed_event_id\x18\x02 \x01(\x03\x12\x1a\n\x12scheduled_event_id\x18\x03 \x01(\x03"\xc7\x01\n0NexusOperationCancelRequestFailedEventAttributes\x12\x1a\n\x12requested_event_id\x18\x01 \x01(\x03\x12(\n workflow_task_completed_event_id\x18\x02 \x01(\x03\x12\x31\n\x07\x66\x61ilure\x18\x03 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x1a\n\x12scheduled_event_id\x18\x04 \x01(\x03"\xca?\n\x0cHistoryEvent\x12\x10\n\x08\x65vent_id\x18\x01 \x01(\x03\x12.\n\nevent_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x34\n\nevent_type\x18\x03 \x01(\x0e\x32 .temporal.api.enums.v1.EventType\x12\x0f\n\x07version\x18\x04 \x01(\x03\x12\x0f\n\x07task_id\x18\x05 \x01(\x03\x12\x1a\n\x11worker_may_ignore\x18\xac\x02 \x01(\x08\x12\x39\n\ruser_metadata\x18\xad\x02 \x01(\x0b\x32!.temporal.api.sdk.v1.UserMetadata\x12,\n\x05links\x18\xae\x02 \x03(\x0b\x32\x1c.temporal.api.common.v1.Link\x12\x35\n\tprincipal\x18\xaf\x02 \x01(\x0b\x32!.temporal.api.common.v1.Principal\x12\x43\n\x13\x65vent_group_markers\x18\xb0\x02 \x03(\x0b\x32%.temporal.api.sdk.v1.EventGroupMarker\x12w\n+workflow_execution_started_event_attributes\x18\x06 \x01(\x0b\x32@.temporal.api.history.v1.WorkflowExecutionStartedEventAttributesH\x00\x12{\n-workflow_execution_completed_event_attributes\x18\x07 \x01(\x0b\x32\x42.temporal.api.history.v1.WorkflowExecutionCompletedEventAttributesH\x00\x12u\n*workflow_execution_failed_event_attributes\x18\x08 \x01(\x0b\x32?.temporal.api.history.v1.WorkflowExecutionFailedEventAttributesH\x00\x12z\n-workflow_execution_timed_out_event_attributes\x18\t \x01(\x0b\x32\x41.temporal.api.history.v1.WorkflowExecutionTimedOutEventAttributesH\x00\x12q\n(workflow_task_scheduled_event_attributes\x18\n \x01(\x0b\x32=.temporal.api.history.v1.WorkflowTaskScheduledEventAttributesH\x00\x12m\n&workflow_task_started_event_attributes\x18\x0b \x01(\x0b\x32;.temporal.api.history.v1.WorkflowTaskStartedEventAttributesH\x00\x12q\n(workflow_task_completed_event_attributes\x18\x0c \x01(\x0b\x32=.temporal.api.history.v1.WorkflowTaskCompletedEventAttributesH\x00\x12p\n(workflow_task_timed_out_event_attributes\x18\r \x01(\x0b\x32<.temporal.api.history.v1.WorkflowTaskTimedOutEventAttributesH\x00\x12k\n%workflow_task_failed_event_attributes\x18\x0e \x01(\x0b\x32:.temporal.api.history.v1.WorkflowTaskFailedEventAttributesH\x00\x12q\n(activity_task_scheduled_event_attributes\x18\x0f \x01(\x0b\x32=.temporal.api.history.v1.ActivityTaskScheduledEventAttributesH\x00\x12m\n&activity_task_started_event_attributes\x18\x10 \x01(\x0b\x32;.temporal.api.history.v1.ActivityTaskStartedEventAttributesH\x00\x12q\n(activity_task_completed_event_attributes\x18\x11 \x01(\x0b\x32=.temporal.api.history.v1.ActivityTaskCompletedEventAttributesH\x00\x12k\n%activity_task_failed_event_attributes\x18\x12 \x01(\x0b\x32:.temporal.api.history.v1.ActivityTaskFailedEventAttributesH\x00\x12p\n(activity_task_timed_out_event_attributes\x18\x13 \x01(\x0b\x32<.temporal.api.history.v1.ActivityTaskTimedOutEventAttributesH\x00\x12^\n\x1etimer_started_event_attributes\x18\x14 \x01(\x0b\x32\x34.temporal.api.history.v1.TimerStartedEventAttributesH\x00\x12Z\n\x1ctimer_fired_event_attributes\x18\x15 \x01(\x0b\x32\x32.temporal.api.history.v1.TimerFiredEventAttributesH\x00\x12~\n/activity_task_cancel_requested_event_attributes\x18\x16 \x01(\x0b\x32\x43.temporal.api.history.v1.ActivityTaskCancelRequestedEventAttributesH\x00\x12o\n\'activity_task_canceled_event_attributes\x18\x17 \x01(\x0b\x32<.temporal.api.history.v1.ActivityTaskCanceledEventAttributesH\x00\x12`\n\x1ftimer_canceled_event_attributes\x18\x18 \x01(\x0b\x32\x35.temporal.api.history.v1.TimerCanceledEventAttributesH\x00\x12\x62\n marker_recorded_event_attributes\x18\x19 \x01(\x0b\x32\x36.temporal.api.history.v1.MarkerRecordedEventAttributesH\x00\x12y\n,workflow_execution_signaled_event_attributes\x18\x1a \x01(\x0b\x32\x41.temporal.api.history.v1.WorkflowExecutionSignaledEventAttributesH\x00\x12}\n.workflow_execution_terminated_event_attributes\x18\x1b \x01(\x0b\x32\x43.temporal.api.history.v1.WorkflowExecutionTerminatedEventAttributesH\x00\x12\x88\x01\n4workflow_execution_cancel_requested_event_attributes\x18\x1c \x01(\x0b\x32H.temporal.api.history.v1.WorkflowExecutionCancelRequestedEventAttributesH\x00\x12y\n,workflow_execution_canceled_event_attributes\x18\x1d \x01(\x0b\x32\x41.temporal.api.history.v1.WorkflowExecutionCanceledEventAttributesH\x00\x12\xa8\x01\nErequest_cancel_external_workflow_execution_initiated_event_attributes\x18\x1e \x01(\x0b\x32W.temporal.api.history.v1.RequestCancelExternalWorkflowExecutionInitiatedEventAttributesH\x00\x12\xa2\x01\nBrequest_cancel_external_workflow_execution_failed_event_attributes\x18\x1f \x01(\x0b\x32T.temporal.api.history.v1.RequestCancelExternalWorkflowExecutionFailedEventAttributesH\x00\x12\x99\x01\n=external_workflow_execution_cancel_requested_event_attributes\x18 \x01(\x0b\x32P.temporal.api.history.v1.ExternalWorkflowExecutionCancelRequestedEventAttributesH\x00\x12\x87\x01\n4workflow_execution_continued_as_new_event_attributes\x18! \x01(\x0b\x32G.temporal.api.history.v1.WorkflowExecutionContinuedAsNewEventAttributesH\x00\x12\x91\x01\n9start_child_workflow_execution_initiated_event_attributes\x18" \x01(\x0b\x32L.temporal.api.history.v1.StartChildWorkflowExecutionInitiatedEventAttributesH\x00\x12\x8b\x01\n6start_child_workflow_execution_failed_event_attributes\x18# \x01(\x0b\x32I.temporal.api.history.v1.StartChildWorkflowExecutionFailedEventAttributesH\x00\x12\x82\x01\n1child_workflow_execution_started_event_attributes\x18$ \x01(\x0b\x32\x45.temporal.api.history.v1.ChildWorkflowExecutionStartedEventAttributesH\x00\x12\x86\x01\n3child_workflow_execution_completed_event_attributes\x18% \x01(\x0b\x32G.temporal.api.history.v1.ChildWorkflowExecutionCompletedEventAttributesH\x00\x12\x80\x01\n0child_workflow_execution_failed_event_attributes\x18& \x01(\x0b\x32\x44.temporal.api.history.v1.ChildWorkflowExecutionFailedEventAttributesH\x00\x12\x84\x01\n2child_workflow_execution_canceled_event_attributes\x18\' \x01(\x0b\x32\x46.temporal.api.history.v1.ChildWorkflowExecutionCanceledEventAttributesH\x00\x12\x85\x01\n3child_workflow_execution_timed_out_event_attributes\x18( \x01(\x0b\x32\x46.temporal.api.history.v1.ChildWorkflowExecutionTimedOutEventAttributesH\x00\x12\x88\x01\n4child_workflow_execution_terminated_event_attributes\x18) \x01(\x0b\x32H.temporal.api.history.v1.ChildWorkflowExecutionTerminatedEventAttributesH\x00\x12\x99\x01\n=signal_external_workflow_execution_initiated_event_attributes\x18* \x01(\x0b\x32P.temporal.api.history.v1.SignalExternalWorkflowExecutionInitiatedEventAttributesH\x00\x12\x93\x01\n:signal_external_workflow_execution_failed_event_attributes\x18+ \x01(\x0b\x32M.temporal.api.history.v1.SignalExternalWorkflowExecutionFailedEventAttributesH\x00\x12\x8a\x01\n5external_workflow_execution_signaled_event_attributes\x18, \x01(\x0b\x32I.temporal.api.history.v1.ExternalWorkflowExecutionSignaledEventAttributesH\x00\x12\x84\x01\n2upsert_workflow_search_attributes_event_attributes\x18- \x01(\x0b\x32\x46.temporal.api.history.v1.UpsertWorkflowSearchAttributesEventAttributesH\x00\x12\x86\x01\n3workflow_execution_update_accepted_event_attributes\x18. \x01(\x0b\x32G.temporal.api.history.v1.WorkflowExecutionUpdateAcceptedEventAttributesH\x00\x12\x86\x01\n3workflow_execution_update_rejected_event_attributes\x18/ \x01(\x0b\x32G.temporal.api.history.v1.WorkflowExecutionUpdateRejectedEventAttributesH\x00\x12\x88\x01\n4workflow_execution_update_completed_event_attributes\x18\x30 \x01(\x0b\x32H.temporal.api.history.v1.WorkflowExecutionUpdateCompletedEventAttributesH\x00\x12\x90\x01\n8workflow_properties_modified_externally_event_attributes\x18\x31 \x01(\x0b\x32L.temporal.api.history.v1.WorkflowPropertiesModifiedExternallyEventAttributesH\x00\x12\x90\x01\n8activity_properties_modified_externally_event_attributes\x18\x32 \x01(\x0b\x32L.temporal.api.history.v1.ActivityPropertiesModifiedExternallyEventAttributesH\x00\x12{\n-workflow_properties_modified_event_attributes\x18\x33 \x01(\x0b\x32\x42.temporal.api.history.v1.WorkflowPropertiesModifiedEventAttributesH\x00\x12\x86\x01\n3workflow_execution_update_admitted_event_attributes\x18\x34 \x01(\x0b\x32G.temporal.api.history.v1.WorkflowExecutionUpdateAdmittedEventAttributesH\x00\x12u\n*nexus_operation_scheduled_event_attributes\x18\x35 \x01(\x0b\x32?.temporal.api.history.v1.NexusOperationScheduledEventAttributesH\x00\x12q\n(nexus_operation_started_event_attributes\x18\x36 \x01(\x0b\x32=.temporal.api.history.v1.NexusOperationStartedEventAttributesH\x00\x12u\n*nexus_operation_completed_event_attributes\x18\x37 \x01(\x0b\x32?.temporal.api.history.v1.NexusOperationCompletedEventAttributesH\x00\x12o\n\'nexus_operation_failed_event_attributes\x18\x38 \x01(\x0b\x32<.temporal.api.history.v1.NexusOperationFailedEventAttributesH\x00\x12s\n)nexus_operation_canceled_event_attributes\x18\x39 \x01(\x0b\x32>.temporal.api.history.v1.NexusOperationCanceledEventAttributesH\x00\x12t\n*nexus_operation_timed_out_event_attributes\x18: \x01(\x0b\x32>.temporal.api.history.v1.NexusOperationTimedOutEventAttributesH\x00\x12\x82\x01\n1nexus_operation_cancel_requested_event_attributes\x18; \x01(\x0b\x32\x45.temporal.api.history.v1.NexusOperationCancelRequestedEventAttributesH\x00\x12\x86\x01\n3workflow_execution_options_updated_event_attributes\x18< \x01(\x0b\x32G.temporal.api.history.v1.WorkflowExecutionOptionsUpdatedEventAttributesH\x00\x12\x91\x01\n9nexus_operation_cancel_request_completed_event_attributes\x18= \x01(\x0b\x32L.temporal.api.history.v1.NexusOperationCancelRequestCompletedEventAttributesH\x00\x12\x8b\x01\n6nexus_operation_cancel_request_failed_event_attributes\x18> \x01(\x0b\x32I.temporal.api.history.v1.NexusOperationCancelRequestFailedEventAttributesH\x00\x12u\n*workflow_execution_paused_event_attributes\x18? \x01(\x0b\x32?.temporal.api.history.v1.WorkflowExecutionPausedEventAttributesH\x00\x12y\n,workflow_execution_unpaused_event_attributes\x18@ \x01(\x0b\x32\x41.temporal.api.history.v1.WorkflowExecutionUnpausedEventAttributesH\x00\x12\x9b\x01\n>workflow_execution_time_skipping_transitioned_event_attributes\x18\x41 \x01(\x0b\x32Q.temporal.api.history.v1.WorkflowExecutionTimeSkippingTransitionedEventAttributesH\x00\x42\x0c\n\nattributes"@\n\x07History\x12\x35\n\x06\x65vents\x18\x01 \x03(\x0b\x32%.temporal.api.history.v1.HistoryEventB\x8e\x01\n\x1aio.temporal.api.history.v1B\x0cMessageProtoP\x01Z%go.temporal.io/api/history/v1;history\xaa\x02\x19Temporalio.Api.History.V1\xea\x02\x1cTemporalio::Api::History::V1b\x06proto3' + b'\n%temporal/api/history/v1/message.proto\x12\x17temporal.api.history.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a&temporal/api/enums/v1/event_type.proto\x1a(temporal/api/enums/v1/failed_cause.proto\x1a"temporal/api/enums/v1/update.proto\x1a$temporal/api/enums/v1/workflow.proto\x1a$temporal/api/common/v1/message.proto\x1a$temporal/api/stream/v1/message.proto\x1a(temporal/api/deployment/v1/message.proto\x1a%temporal/api/failure/v1/message.proto\x1a\'temporal/api/taskqueue/v1/message.proto\x1a$temporal/api/update/v1/message.proto\x1a&temporal/api/workflow/v1/message.proto\x1a\x30temporal/api/sdk/v1/task_complete_metadata.proto\x1a\'temporal/api/sdk/v1/user_metadata.proto\x1a,temporal/api/sdk/v1/event_group_marker.proto"\xda\x12\n\'WorkflowExecutionStartedEventAttributes\x12;\n\rworkflow_type\x18\x01 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12!\n\x19parent_workflow_namespace\x18\x02 \x01(\t\x12$\n\x1cparent_workflow_namespace_id\x18\x1b \x01(\t\x12L\n\x19parent_workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12!\n\x19parent_initiated_event_id\x18\x04 \x01(\x03\x12\x38\n\ntask_queue\x18\x05 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12/\n\x05input\x18\x06 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12=\n\x1aworkflow_execution_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x37\n\x14workflow_run_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\x15workflow_task_timeout\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12"\n\x1a\x63ontinued_execution_run_id\x18\n \x01(\t\x12@\n\tinitiator\x18\x0b \x01(\x0e\x32-.temporal.api.enums.v1.ContinueAsNewInitiator\x12;\n\x11\x63ontinued_failure\x18\x0c \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12@\n\x16last_completion_result\x18\r \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12!\n\x19original_execution_run_id\x18\x0e \x01(\t\x12\x10\n\x08identity\x18\x0f \x01(\t\x12\x1e\n\x16\x66irst_execution_run_id\x18\x10 \x01(\t\x12\x39\n\x0cretry_policy\x18\x11 \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12\x0f\n\x07\x61ttempt\x18\x12 \x01(\x05\x12\x46\n"workflow_execution_expiration_time\x18\x13 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rcron_schedule\x18\x14 \x01(\t\x12>\n\x1b\x66irst_workflow_task_backoff\x18\x15 \x01(\x0b\x32\x19.google.protobuf.Duration\x12*\n\x04memo\x18\x16 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x17 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12\x45\n\x16prev_auto_reset_points\x18\x18 \x01(\x0b\x32%.temporal.api.workflow.v1.ResetPoints\x12.\n\x06header\x18\x19 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12&\n\x1eparent_initiated_event_version\x18\x1a \x01(\x03\x12\x13\n\x0bworkflow_id\x18\x1c \x01(\t\x12L\n\x14source_version_stamp\x18\x1d \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12>\n\x14\x63ompletion_callbacks\x18\x1e \x03(\x0b\x32 .temporal.api.common.v1.Callback\x12J\n\x17root_workflow_execution\x18\x1f \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x1e\n\x12inherited_build_id\x18 \x01(\tB\x02\x18\x01\x12I\n\x13versioning_override\x18! \x01(\x0b\x32,.temporal.api.workflow.v1.VersioningOverride\x12\x33\n\'parent_pinned_worker_deployment_version\x18" \x01(\tB\x02\x18\x01\x12\x32\n\x08priority\x18# \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12U\n\x18inherited_pinned_version\x18% \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12Y\n\x1binherited_auto_upgrade_info\x18\' \x01(\x0b\x32\x34.temporal.api.deployment.v1.InheritedAutoUpgradeInfo\x12 \n\x18\x65\x61ger_execution_accepted\x18& \x01(\x08\x12^\n\x1f\x64\x65\x63lined_target_version_upgrade\x18( \x01(\x0b\x32\x35.temporal.api.history.v1.DeclinedTargetVersionUpgrade\x12H\n\x14time_skipping_config\x18) \x01(\x0b\x32*.temporal.api.common.v1.TimeSkippingConfig\x12]\n\x1ftime_skipping_state_propagation\x18+ \x01(\x0b\x32\x34.temporal.api.common.v1.TimeSkippingStatePropagationJ\x04\x08$\x10%J\x04\x08*\x10+R parent_pinned_deployment_versionR\x18initial_skipped_duration"\x88\x01\n\x1c\x44\x65\x63linedTargetVersionUpgrade\x12O\n\x12\x64\x65ployment_version\x18\x01 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12\x17\n\x0frevision_number\x18\x02 \x01(\x03"\xa5\x01\n)WorkflowExecutionCompletedEventAttributes\x12\x30\n\x06result\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12(\n workflow_task_completed_event_id\x18\x02 \x01(\x03\x12\x1c\n\x14new_execution_run_id\x18\x03 \x01(\t"\xdb\x01\n&WorkflowExecutionFailedEventAttributes\x12\x31\n\x07\x66\x61ilure\x18\x01 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x36\n\x0bretry_state\x18\x02 \x01(\x0e\x32!.temporal.api.enums.v1.RetryState\x12(\n workflow_task_completed_event_id\x18\x03 \x01(\x03\x12\x1c\n\x14new_execution_run_id\x18\x04 \x01(\t"\x80\x01\n(WorkflowExecutionTimedOutEventAttributes\x12\x36\n\x0bretry_state\x18\x01 \x01(\x0e\x32!.temporal.api.enums.v1.RetryState\x12\x1c\n\x14new_execution_run_id\x18\x02 \x01(\t"\xa5\x07\n.WorkflowExecutionContinuedAsNewEventAttributes\x12\x1c\n\x14new_execution_run_id\x18\x01 \x01(\t\x12;\n\rworkflow_type\x18\x02 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x38\n\ntask_queue\x18\x03 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12/\n\x05input\x18\x04 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x37\n\x14workflow_run_timeout\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\x15workflow_task_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12(\n workflow_task_completed_event_id\x18\x07 \x01(\x03\x12\x39\n\x16\x62\x61\x63koff_start_interval\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12@\n\tinitiator\x18\t \x01(\x0e\x32-.temporal.api.enums.v1.ContinueAsNewInitiator\x12\x35\n\x07\x66\x61ilure\x18\n \x01(\x0b\x32 .temporal.api.failure.v1.FailureB\x02\x18\x01\x12@\n\x16last_completion_result\x18\x0b \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12.\n\x06header\x18\x0c \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12*\n\x04memo\x18\r \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x0e \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12\x1c\n\x10inherit_build_id\x18\x0f \x01(\x08\x42\x02\x18\x01\x12[\n\x1binitial_versioning_behavior\x18\x10 \x01(\x0e\x32\x36.temporal.api.enums.v1.ContinueAsNewVersioningBehavior"\xac\x01\n$WorkflowTaskScheduledEventAttributes\x12\x38\n\ntask_queue\x18\x01 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12\x39\n\x16start_to_close_timeout\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x0f\n\x07\x61ttempt\x18\x03 \x01(\x05"\xa0\x03\n"WorkflowTaskStartedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x12\n\nrequest_id\x18\x03 \x01(\t\x12\x1f\n\x17suggest_continue_as_new\x18\x04 \x01(\x08\x12Z\n\x1fsuggest_continue_as_new_reasons\x18\x08 \x03(\x0e\x32\x31.temporal.api.enums.v1.SuggestContinueAsNewReason\x12\x30\n(target_worker_deployment_version_changed\x18\t \x01(\x08\x12\x1a\n\x12history_size_bytes\x18\x05 \x01(\x03\x12\x46\n\x0eworker_version\x18\x06 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12%\n\x19\x62uild_id_redirect_counter\x18\x07 \x01(\x03\x42\x02\x18\x01"\xc7\x05\n$WorkflowTaskCompletedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x18\n\x10started_event_id\x18\x02 \x01(\x03\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x1b\n\x0f\x62inary_checksum\x18\x04 \x01(\tB\x02\x18\x01\x12\x46\n\x0eworker_version\x18\x05 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12H\n\x0csdk_metadata\x18\x06 \x01(\x0b\x32\x32.temporal.api.sdk.v1.WorkflowTaskCompletedMetadata\x12\x43\n\x11metering_metadata\x18\r \x01(\x0b\x32(.temporal.api.common.v1.MeteringMetadata\x12>\n\ndeployment\x18\x07 \x01(\x0b\x32&.temporal.api.deployment.v1.DeploymentB\x02\x18\x01\x12\x46\n\x13versioning_behavior\x18\x08 \x01(\x0e\x32).temporal.api.enums.v1.VersioningBehavior\x12%\n\x19worker_deployment_version\x18\t \x01(\tB\x02\x18\x01\x12\x1e\n\x16worker_deployment_name\x18\n \x01(\t\x12O\n\x12\x64\x65ployment_version\x18\x0b \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12\x43\n\x16\x63onsumed_stream_ranges\x18\x14 \x03(\x0b\x32#.temporal.api.stream.v1.StreamRange"\x95\x01\n#WorkflowTaskTimedOutEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x18\n\x10started_event_id\x18\x02 \x01(\x03\x12\x38\n\x0ctimeout_type\x18\x03 \x01(\x0e\x32".temporal.api.enums.v1.TimeoutType"\x87\x03\n!WorkflowTaskFailedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x18\n\x10started_event_id\x18\x02 \x01(\x03\x12=\n\x05\x63\x61use\x18\x03 \x01(\x0e\x32..temporal.api.enums.v1.WorkflowTaskFailedCause\x12\x31\n\x07\x66\x61ilure\x18\x04 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x13\n\x0b\x62\x61se_run_id\x18\x06 \x01(\t\x12\x12\n\nnew_run_id\x18\x07 \x01(\t\x12\x1a\n\x12\x66ork_event_version\x18\x08 \x01(\x03\x12\x1b\n\x0f\x62inary_checksum\x18\t \x01(\tB\x02\x18\x01\x12\x46\n\x0eworker_version\x18\n \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01"\xc2\x05\n$ActivityTaskScheduledEventAttributes\x12\x13\n\x0b\x61\x63tivity_id\x18\x01 \x01(\t\x12;\n\ractivity_type\x18\x02 \x01(\x0b\x32$.temporal.api.common.v1.ActivityType\x12\x38\n\ntask_queue\x18\x04 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12.\n\x06header\x18\x05 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12/\n\x05input\x18\x06 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12<\n\x19schedule_to_close_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12<\n\x19schedule_to_start_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x34\n\x11heartbeat_timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.Duration\x12(\n workflow_task_completed_event_id\x18\x0b \x01(\x03\x12\x39\n\x0cretry_policy\x18\x0c \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12!\n\x15use_workflow_build_id\x18\r \x01(\x08\x42\x02\x18\x01\x12\x32\n\x08priority\x18\x0e \x01(\x0b\x32 .temporal.api.common.v1.PriorityJ\x04\x08\x03\x10\x04"\x9e\x02\n"ActivityTaskStartedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x12\n\nrequest_id\x18\x03 \x01(\t\x12\x0f\n\x07\x61ttempt\x18\x04 \x01(\x05\x12\x36\n\x0clast_failure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x46\n\x0eworker_version\x18\x06 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12%\n\x19\x62uild_id_redirect_counter\x18\x07 \x01(\x03\x42\x02\x18\x01"\xe8\x01\n$ActivityTaskCompletedEventAttributes\x12\x30\n\x06result\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x1a\n\x12scheduled_event_id\x18\x02 \x01(\x03\x12\x18\n\x10started_event_id\x18\x03 \x01(\x03\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x46\n\x0eworker_version\x18\x05 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01"\x9e\x02\n!ActivityTaskFailedEventAttributes\x12\x31\n\x07\x66\x61ilure\x18\x01 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x1a\n\x12scheduled_event_id\x18\x02 \x01(\x03\x12\x18\n\x10started_event_id\x18\x03 \x01(\x03\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x36\n\x0bretry_state\x18\x05 \x01(\x0e\x32!.temporal.api.enums.v1.RetryState\x12\x46\n\x0eworker_version\x18\x06 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01"\xc6\x01\n#ActivityTaskTimedOutEventAttributes\x12\x31\n\x07\x66\x61ilure\x18\x01 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x1a\n\x12scheduled_event_id\x18\x02 \x01(\x03\x12\x18\n\x10started_event_id\x18\x03 \x01(\x03\x12\x36\n\x0bretry_state\x18\x04 \x01(\x0e\x32!.temporal.api.enums.v1.RetryState"r\n*ActivityTaskCancelRequestedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12(\n workflow_task_completed_event_id\x18\x02 \x01(\x03"\x92\x02\n#ActivityTaskCanceledEventAttributes\x12\x31\n\x07\x64\x65tails\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12(\n latest_cancel_requested_event_id\x18\x02 \x01(\x03\x12\x1a\n\x12scheduled_event_id\x18\x03 \x01(\x03\x12\x18\n\x10started_event_id\x18\x04 \x01(\x03\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x46\n\x0eworker_version\x18\x06 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01"\x93\x01\n\x1bTimerStartedEventAttributes\x12\x10\n\x08timer_id\x18\x01 \x01(\t\x12\x38\n\x15start_to_fire_timeout\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration\x12(\n workflow_task_completed_event_id\x18\x03 \x01(\x03"G\n\x19TimerFiredEventAttributes\x12\x10\n\x08timer_id\x18\x01 \x01(\t\x12\x18\n\x10started_event_id\x18\x02 \x01(\x03"\x86\x01\n\x1cTimerCanceledEventAttributes\x12\x10\n\x08timer_id\x18\x01 \x01(\t\x12\x18\n\x10started_event_id\x18\x02 \x01(\x03\x12(\n workflow_task_completed_event_id\x18\x03 \x01(\x03\x12\x10\n\x08identity\x18\x04 \x01(\t"\xc7\x01\n/WorkflowExecutionCancelRequestedEventAttributes\x12\r\n\x05\x63\x61use\x18\x01 \x01(\t\x12#\n\x1b\x65xternal_initiated_event_id\x18\x02 \x01(\x03\x12N\n\x1b\x65xternal_workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x10\n\x08identity\x18\x04 \x01(\t"\x87\x01\n(WorkflowExecutionCanceledEventAttributes\x12(\n workflow_task_completed_event_id\x18\x01 \x01(\x03\x12\x31\n\x07\x64\x65tails\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads"\xe9\x02\n\x1dMarkerRecordedEventAttributes\x12\x13\n\x0bmarker_name\x18\x01 \x01(\t\x12T\n\x07\x64\x65tails\x18\x02 \x03(\x0b\x32\x43.temporal.api.history.v1.MarkerRecordedEventAttributes.DetailsEntry\x12(\n workflow_task_completed_event_id\x18\x03 \x01(\x03\x12.\n\x06header\x18\x04 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12\x31\n\x07\x66\x61ilure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x1aP\n\x0c\x44\x65tailsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12/\n\x05value\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads:\x02\x38\x01"\xbf\x02\n(WorkflowExecutionSignaledEventAttributes\x12\x13\n\x0bsignal_name\x18\x01 \x01(\t\x12/\n\x05input\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x03 \x01(\t\x12.\n\x06header\x18\x04 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12\'\n\x1bskip_generate_workflow_task\x18\x05 \x01(\x08\x42\x02\x18\x01\x12N\n\x1b\x65xternal_workflow_execution\x18\x06 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x12\n\nrequest_id\x18\x07 \x01(\t"\x81\x01\n*WorkflowExecutionTerminatedEventAttributes\x12\x0e\n\x06reason\x18\x01 \x01(\t\x12\x31\n\x07\x64\x65tails\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x03 \x01(\t"\x9c\x02\n>RequestCancelExternalWorkflowExecutionInitiatedEventAttributes\x12(\n workflow_task_completed_event_id\x18\x01 \x01(\x03\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x07 \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x13\n\x07\x63ontrol\x18\x04 \x01(\tB\x02\x18\x01\x12\x1b\n\x13\x63hild_workflow_only\x18\x05 \x01(\x08\x12\x0e\n\x06reason\x18\x06 \x01(\t"\xda\x02\n;RequestCancelExternalWorkflowExecutionFailedEventAttributes\x12P\n\x05\x63\x61use\x18\x01 \x01(\x0e\x32\x41.temporal.api.enums.v1.CancelExternalWorkflowExecutionFailedCause\x12(\n workflow_task_completed_event_id\x18\x02 \x01(\x03\x12\x11\n\tnamespace\x18\x03 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x07 \x01(\t\x12\x45\n\x12workflow_execution\x18\x04 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x1a\n\x12initiated_event_id\x18\x05 \x01(\x03\x12\x13\n\x07\x63ontrol\x18\x06 \x01(\tB\x02\x18\x01"\xc5\x01\n7ExternalWorkflowExecutionCancelRequestedEventAttributes\x12\x1a\n\x12initiated_event_id\x18\x01 \x01(\x03\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x04 \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution"\xfb\x02\n7SignalExternalWorkflowExecutionInitiatedEventAttributes\x12(\n workflow_task_completed_event_id\x18\x01 \x01(\x03\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12\x14\n\x0cnamespace_id\x18\t \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x13\n\x0bsignal_name\x18\x04 \x01(\t\x12/\n\x05input\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x13\n\x07\x63ontrol\x18\x06 \x01(\tB\x02\x18\x01\x12\x1b\n\x13\x63hild_workflow_only\x18\x07 \x01(\x08\x12.\n\x06header\x18\x08 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header"\xd3\x02\n4SignalExternalWorkflowExecutionFailedEventAttributes\x12P\n\x05\x63\x61use\x18\x01 \x01(\x0e\x32\x41.temporal.api.enums.v1.SignalExternalWorkflowExecutionFailedCause\x12(\n workflow_task_completed_event_id\x18\x02 \x01(\x03\x12\x11\n\tnamespace\x18\x03 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x07 \x01(\t\x12\x45\n\x12workflow_execution\x18\x04 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x1a\n\x12initiated_event_id\x18\x05 \x01(\x03\x12\x13\n\x07\x63ontrol\x18\x06 \x01(\tB\x02\x18\x01"\xd3\x01\n0ExternalWorkflowExecutionSignaledEventAttributes\x12\x1a\n\x12initiated_event_id\x18\x01 \x01(\x03\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x05 \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x13\n\x07\x63ontrol\x18\x04 \x01(\tB\x02\x18\x01"\x9e\x01\n-UpsertWorkflowSearchAttributesEventAttributes\x12(\n workflow_task_completed_event_id\x18\x01 \x01(\x03\x12\x43\n\x11search_attributes\x18\x02 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes"\x8a\x01\n)WorkflowPropertiesModifiedEventAttributes\x12(\n workflow_task_completed_event_id\x18\x01 \x01(\x03\x12\x33\n\rupserted_memo\x18\x02 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo"\xfc\t\n3StartChildWorkflowExecutionInitiatedEventAttributes\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x12 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x38\n\ntask_queue\x18\x04 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12/\n\x05input\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12=\n\x1aworkflow_execution_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x37\n\x14workflow_run_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\x15workflow_task_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x45\n\x13parent_close_policy\x18\t \x01(\x0e\x32(.temporal.api.enums.v1.ParentClosePolicy\x12\x13\n\x07\x63ontrol\x18\n \x01(\tB\x02\x18\x01\x12(\n workflow_task_completed_event_id\x18\x0b \x01(\x03\x12N\n\x18workflow_id_reuse_policy\x18\x0c \x01(\x0e\x32,.temporal.api.enums.v1.WorkflowIdReusePolicy\x12\x39\n\x0cretry_policy\x18\r \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12\x15\n\rcron_schedule\x18\x0e \x01(\t\x12.\n\x06header\x18\x0f \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12*\n\x04memo\x18\x10 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x11 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12\x1c\n\x10inherit_build_id\x18\x13 \x01(\x08\x42\x02\x18\x01\x12\x32\n\x08priority\x18\x14 \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12H\n\x14time_skipping_config\x18\x15 \x01(\x0b\x32*.temporal.api.common.v1.TimeSkippingConfig\x12]\n\x1ftime_skipping_state_propagation\x18\x17 \x01(\x0b\x32\x34.temporal.api.common.v1.TimeSkippingStatePropagation\x12I\n\x13versioning_override\x18\x18 \x01(\x0b\x32,.temporal.api.workflow.v1.VersioningOverrideJ\x04\x08\x16\x10\x17R\x18initial_skipped_duration"\xd6\x02\n0StartChildWorkflowExecutionFailedEventAttributes\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x08 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12L\n\x05\x63\x61use\x18\x04 \x01(\x0e\x32=.temporal.api.enums.v1.StartChildWorkflowExecutionFailedCause\x12\x13\n\x07\x63ontrol\x18\x05 \x01(\tB\x02\x18\x01\x12\x1a\n\x12initiated_event_id\x18\x06 \x01(\x03\x12(\n workflow_task_completed_event_id\x18\x07 \x01(\x03"\xa7\x02\n,ChildWorkflowExecutionStartedEventAttributes\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x06 \x01(\t\x12\x1a\n\x12initiated_event_id\x18\x02 \x01(\x03\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\rworkflow_type\x18\x04 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12.\n\x06header\x18\x05 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header"\xc5\x02\n.ChildWorkflowExecutionCompletedEventAttributes\x12\x30\n\x06result\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x07 \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\rworkflow_type\x18\x04 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x1a\n\x12initiated_event_id\x18\x05 \x01(\x03\x12\x18\n\x10started_event_id\x18\x06 \x01(\x03"\xfb\x02\n+ChildWorkflowExecutionFailedEventAttributes\x12\x31\n\x07\x66\x61ilure\x18\x01 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x08 \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\rworkflow_type\x18\x04 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x1a\n\x12initiated_event_id\x18\x05 \x01(\x03\x12\x18\n\x10started_event_id\x18\x06 \x01(\x03\x12\x36\n\x0bretry_state\x18\x07 \x01(\x0e\x32!.temporal.api.enums.v1.RetryState"\xc5\x02\n-ChildWorkflowExecutionCanceledEventAttributes\x12\x31\n\x07\x64\x65tails\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x07 \x01(\t\x12\x45\n\x12workflow_execution\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\rworkflow_type\x18\x04 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x1a\n\x12initiated_event_id\x18\x05 \x01(\x03\x12\x18\n\x10started_event_id\x18\x06 \x01(\x03"\xca\x02\n-ChildWorkflowExecutionTimedOutEventAttributes\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x07 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x1a\n\x12initiated_event_id\x18\x04 \x01(\x03\x12\x18\n\x10started_event_id\x18\x05 \x01(\x03\x12\x36\n\x0bretry_state\x18\x06 \x01(\x0e\x32!.temporal.api.enums.v1.RetryState"\x94\x02\n/ChildWorkflowExecutionTerminatedEventAttributes\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0cnamespace_id\x18\x06 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x1a\n\x12initiated_event_id\x18\x04 \x01(\x03\x12\x18\n\x10started_event_id\x18\x05 \x01(\x03"\xda\x05\n.WorkflowExecutionOptionsUpdatedEventAttributes\x12I\n\x13versioning_override\x18\x01 \x01(\x0b\x32,.temporal.api.workflow.v1.VersioningOverride\x12!\n\x19unset_versioning_override\x18\x02 \x01(\x08\x12\x1b\n\x13\x61ttached_request_id\x18\x03 \x01(\t\x12G\n\x1d\x61ttached_completion_callbacks\x18\x04 \x03(\x0b\x32 .temporal.api.common.v1.Callback\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x32\n\x08priority\x18\x06 \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12H\n\x14time_skipping_config\x18\x07 \x01(\x0b\x32*.temporal.api.common.v1.TimeSkippingConfig\x12$\n\x1ctime_skipping_config_updated\x18\t \x01(\x08\x12\x84\x01\n\x17workflow_update_options\x18\x08 \x03(\x0b\x32\x63.temporal.api.history.v1.WorkflowExecutionOptionsUpdatedEventAttributes.WorkflowUpdateOptionsUpdate\x1a\x96\x01\n\x1bWorkflowUpdateOptionsUpdate\x12\x11\n\tupdate_id\x18\x01 \x01(\t\x12\x1b\n\x13\x61ttached_request_id\x18\x02 \x01(\t\x12G\n\x1d\x61ttached_completion_callbacks\x18\x03 \x03(\x0b\x32 .temporal.api.common.v1.Callback"\xc0\x02\n3WorkflowPropertiesModifiedExternallyEventAttributes\x12\x16\n\x0enew_task_queue\x18\x01 \x01(\t\x12<\n\x19new_workflow_task_timeout\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration\x12;\n\x18new_workflow_run_timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x41\n\x1enew_workflow_execution_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x33\n\rupserted_memo\x18\x05 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo"\x90\x01\n3ActivityPropertiesModifiedExternallyEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12=\n\x10new_retry_policy\x18\x02 \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy"|\n\'WorkflowStreamSubscribedEventAttributes\x12(\n workflow_task_completed_event_id\x18\x01 \x01(\x03\x12\x11\n\tstream_id\x18\x02 \x01(\t\x12\x14\n\x0cstart_offset\x18\x03 \x01(\x03"\x97\x01\n,WorkflowStreamRecordsAppendedEventAttributes\x12(\n workflow_task_completed_event_id\x18\x01 \x01(\x03\x12\x11\n\tstream_id\x18\x02 \x01(\t\x12\x14\n\x0c\x66irst_offset\x18\x03 \x01(\x03\x12\x14\n\x0crecord_count\x18\x04 \x01(\x03"\xdc\x01\n.WorkflowExecutionUpdateAcceptedEventAttributes\x12\x1c\n\x14protocol_instance_id\x18\x01 \x01(\t\x12#\n\x1b\x61\x63\x63\x65pted_request_message_id\x18\x02 \x01(\t\x12,\n$accepted_request_sequencing_event_id\x18\x03 \x01(\x03\x12\x39\n\x10\x61\x63\x63\x65pted_request\x18\x04 \x01(\x0b\x32\x1f.temporal.api.update.v1.Request"\xaa\x01\n/WorkflowExecutionUpdateCompletedEventAttributes\x12*\n\x04meta\x18\x01 \x01(\x0b\x32\x1c.temporal.api.update.v1.Meta\x12\x19\n\x11\x61\x63\x63\x65pted_event_id\x18\x03 \x01(\x03\x12\x30\n\x07outcome\x18\x02 \x01(\x0b\x32\x1f.temporal.api.update.v1.Outcome"\x8f\x02\n.WorkflowExecutionUpdateRejectedEventAttributes\x12\x1c\n\x14protocol_instance_id\x18\x01 \x01(\t\x12#\n\x1brejected_request_message_id\x18\x02 \x01(\t\x12,\n$rejected_request_sequencing_event_id\x18\x03 \x01(\x03\x12\x39\n\x10rejected_request\x18\x04 \x01(\x0b\x32\x1f.temporal.api.update.v1.Request\x12\x31\n\x07\x66\x61ilure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.Failure"\xa4\x01\n.WorkflowExecutionUpdateAdmittedEventAttributes\x12\x30\n\x07request\x18\x01 \x01(\x0b\x32\x1f.temporal.api.update.v1.Request\x12@\n\x06origin\x18\x02 \x01(\x0e\x32\x30.temporal.api.enums.v1.UpdateAdmittedEventOrigin"^\n&WorkflowExecutionPausedEventAttributes\x12\x10\n\x08identity\x18\x01 \x01(\t\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x12\n\nrequest_id\x18\x03 \x01(\t"`\n(WorkflowExecutionUnpausedEventAttributes\x12\x10\n\x08identity\x18\x01 \x01(\t\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x12\n\nrequest_id\x18\x03 \x01(\t"\xc5\x01\n8WorkflowExecutionTimeSkippingTransitionedEventAttributes\x12/\n\x0btarget_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1b\x64isabled_after_fast_forward\x18\x02 \x01(\x08\x12\x33\n\x0fwall_clock_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp"\xb4\x04\n&NexusOperationScheduledEventAttributes\x12\x10\n\x08\x65ndpoint\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\x12\x11\n\toperation\x18\x03 \x01(\t\x12.\n\x05input\x18\x04 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12<\n\x19schedule_to_close_timeout\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x66\n\x0cnexus_header\x18\x06 \x03(\x0b\x32P.temporal.api.history.v1.NexusOperationScheduledEventAttributes.NexusHeaderEntry\x12(\n workflow_task_completed_event_id\x18\x07 \x01(\x03\x12\x12\n\nrequest_id\x18\x08 \x01(\t\x12\x13\n\x0b\x65ndpoint_id\x18\t \x01(\t\x12<\n\x19schedule_to_start_timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\x0b \x01(\x0b\x32\x19.google.protobuf.Duration\x1a\x32\n\x10NexusHeaderEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01"\x89\x01\n$NexusOperationStartedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x18\n\x0coperation_id\x18\x03 \x01(\tB\x02\x18\x01\x12\x12\n\nrequest_id\x18\x04 \x01(\t\x12\x17\n\x0foperation_token\x18\x05 \x01(\t"\x89\x01\n&NexusOperationCompletedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12/\n\x06result\x18\x02 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x12\n\nrequest_id\x18\x03 \x01(\t"\x88\x01\n#NexusOperationFailedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x31\n\x07\x66\x61ilure\x18\x02 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x12\n\nrequest_id\x18\x03 \x01(\t"\x8a\x01\n%NexusOperationTimedOutEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x31\n\x07\x66\x61ilure\x18\x02 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x12\n\nrequest_id\x18\x03 \x01(\t"\x8a\x01\n%NexusOperationCanceledEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12\x31\n\x07\x66\x61ilure\x18\x02 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x12\n\nrequest_id\x18\x03 \x01(\t"t\n,NexusOperationCancelRequestedEventAttributes\x12\x1a\n\x12scheduled_event_id\x18\x01 \x01(\x03\x12(\n workflow_task_completed_event_id\x18\x02 \x01(\x03"\x97\x01\n3NexusOperationCancelRequestCompletedEventAttributes\x12\x1a\n\x12requested_event_id\x18\x01 \x01(\x03\x12(\n workflow_task_completed_event_id\x18\x02 \x01(\x03\x12\x1a\n\x12scheduled_event_id\x18\x03 \x01(\x03"\xc7\x01\n0NexusOperationCancelRequestFailedEventAttributes\x12\x1a\n\x12requested_event_id\x18\x01 \x01(\x03\x12(\n workflow_task_completed_event_id\x18\x02 \x01(\x03\x12\x31\n\x07\x66\x61ilure\x18\x03 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x1a\n\x12scheduled_event_id\x18\x04 \x01(\x03"\xc8\x41\n\x0cHistoryEvent\x12\x10\n\x08\x65vent_id\x18\x01 \x01(\x03\x12.\n\nevent_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x34\n\nevent_type\x18\x03 \x01(\x0e\x32 .temporal.api.enums.v1.EventType\x12\x0f\n\x07version\x18\x04 \x01(\x03\x12\x0f\n\x07task_id\x18\x05 \x01(\x03\x12\x1a\n\x11worker_may_ignore\x18\xac\x02 \x01(\x08\x12\x39\n\ruser_metadata\x18\xad\x02 \x01(\x0b\x32!.temporal.api.sdk.v1.UserMetadata\x12,\n\x05links\x18\xae\x02 \x03(\x0b\x32\x1c.temporal.api.common.v1.Link\x12\x35\n\tprincipal\x18\xaf\x02 \x01(\x0b\x32!.temporal.api.common.v1.Principal\x12\x43\n\x13\x65vent_group_markers\x18\xb0\x02 \x03(\x0b\x32%.temporal.api.sdk.v1.EventGroupMarker\x12w\n+workflow_execution_started_event_attributes\x18\x06 \x01(\x0b\x32@.temporal.api.history.v1.WorkflowExecutionStartedEventAttributesH\x00\x12{\n-workflow_execution_completed_event_attributes\x18\x07 \x01(\x0b\x32\x42.temporal.api.history.v1.WorkflowExecutionCompletedEventAttributesH\x00\x12u\n*workflow_execution_failed_event_attributes\x18\x08 \x01(\x0b\x32?.temporal.api.history.v1.WorkflowExecutionFailedEventAttributesH\x00\x12z\n-workflow_execution_timed_out_event_attributes\x18\t \x01(\x0b\x32\x41.temporal.api.history.v1.WorkflowExecutionTimedOutEventAttributesH\x00\x12q\n(workflow_task_scheduled_event_attributes\x18\n \x01(\x0b\x32=.temporal.api.history.v1.WorkflowTaskScheduledEventAttributesH\x00\x12m\n&workflow_task_started_event_attributes\x18\x0b \x01(\x0b\x32;.temporal.api.history.v1.WorkflowTaskStartedEventAttributesH\x00\x12q\n(workflow_task_completed_event_attributes\x18\x0c \x01(\x0b\x32=.temporal.api.history.v1.WorkflowTaskCompletedEventAttributesH\x00\x12p\n(workflow_task_timed_out_event_attributes\x18\r \x01(\x0b\x32<.temporal.api.history.v1.WorkflowTaskTimedOutEventAttributesH\x00\x12k\n%workflow_task_failed_event_attributes\x18\x0e \x01(\x0b\x32:.temporal.api.history.v1.WorkflowTaskFailedEventAttributesH\x00\x12q\n(activity_task_scheduled_event_attributes\x18\x0f \x01(\x0b\x32=.temporal.api.history.v1.ActivityTaskScheduledEventAttributesH\x00\x12m\n&activity_task_started_event_attributes\x18\x10 \x01(\x0b\x32;.temporal.api.history.v1.ActivityTaskStartedEventAttributesH\x00\x12q\n(activity_task_completed_event_attributes\x18\x11 \x01(\x0b\x32=.temporal.api.history.v1.ActivityTaskCompletedEventAttributesH\x00\x12k\n%activity_task_failed_event_attributes\x18\x12 \x01(\x0b\x32:.temporal.api.history.v1.ActivityTaskFailedEventAttributesH\x00\x12p\n(activity_task_timed_out_event_attributes\x18\x13 \x01(\x0b\x32<.temporal.api.history.v1.ActivityTaskTimedOutEventAttributesH\x00\x12^\n\x1etimer_started_event_attributes\x18\x14 \x01(\x0b\x32\x34.temporal.api.history.v1.TimerStartedEventAttributesH\x00\x12Z\n\x1ctimer_fired_event_attributes\x18\x15 \x01(\x0b\x32\x32.temporal.api.history.v1.TimerFiredEventAttributesH\x00\x12~\n/activity_task_cancel_requested_event_attributes\x18\x16 \x01(\x0b\x32\x43.temporal.api.history.v1.ActivityTaskCancelRequestedEventAttributesH\x00\x12o\n\'activity_task_canceled_event_attributes\x18\x17 \x01(\x0b\x32<.temporal.api.history.v1.ActivityTaskCanceledEventAttributesH\x00\x12`\n\x1ftimer_canceled_event_attributes\x18\x18 \x01(\x0b\x32\x35.temporal.api.history.v1.TimerCanceledEventAttributesH\x00\x12\x62\n marker_recorded_event_attributes\x18\x19 \x01(\x0b\x32\x36.temporal.api.history.v1.MarkerRecordedEventAttributesH\x00\x12y\n,workflow_execution_signaled_event_attributes\x18\x1a \x01(\x0b\x32\x41.temporal.api.history.v1.WorkflowExecutionSignaledEventAttributesH\x00\x12}\n.workflow_execution_terminated_event_attributes\x18\x1b \x01(\x0b\x32\x43.temporal.api.history.v1.WorkflowExecutionTerminatedEventAttributesH\x00\x12\x88\x01\n4workflow_execution_cancel_requested_event_attributes\x18\x1c \x01(\x0b\x32H.temporal.api.history.v1.WorkflowExecutionCancelRequestedEventAttributesH\x00\x12y\n,workflow_execution_canceled_event_attributes\x18\x1d \x01(\x0b\x32\x41.temporal.api.history.v1.WorkflowExecutionCanceledEventAttributesH\x00\x12\xa8\x01\nErequest_cancel_external_workflow_execution_initiated_event_attributes\x18\x1e \x01(\x0b\x32W.temporal.api.history.v1.RequestCancelExternalWorkflowExecutionInitiatedEventAttributesH\x00\x12\xa2\x01\nBrequest_cancel_external_workflow_execution_failed_event_attributes\x18\x1f \x01(\x0b\x32T.temporal.api.history.v1.RequestCancelExternalWorkflowExecutionFailedEventAttributesH\x00\x12\x99\x01\n=external_workflow_execution_cancel_requested_event_attributes\x18 \x01(\x0b\x32P.temporal.api.history.v1.ExternalWorkflowExecutionCancelRequestedEventAttributesH\x00\x12\x87\x01\n4workflow_execution_continued_as_new_event_attributes\x18! \x01(\x0b\x32G.temporal.api.history.v1.WorkflowExecutionContinuedAsNewEventAttributesH\x00\x12\x91\x01\n9start_child_workflow_execution_initiated_event_attributes\x18" \x01(\x0b\x32L.temporal.api.history.v1.StartChildWorkflowExecutionInitiatedEventAttributesH\x00\x12\x8b\x01\n6start_child_workflow_execution_failed_event_attributes\x18# \x01(\x0b\x32I.temporal.api.history.v1.StartChildWorkflowExecutionFailedEventAttributesH\x00\x12\x82\x01\n1child_workflow_execution_started_event_attributes\x18$ \x01(\x0b\x32\x45.temporal.api.history.v1.ChildWorkflowExecutionStartedEventAttributesH\x00\x12\x86\x01\n3child_workflow_execution_completed_event_attributes\x18% \x01(\x0b\x32G.temporal.api.history.v1.ChildWorkflowExecutionCompletedEventAttributesH\x00\x12\x80\x01\n0child_workflow_execution_failed_event_attributes\x18& \x01(\x0b\x32\x44.temporal.api.history.v1.ChildWorkflowExecutionFailedEventAttributesH\x00\x12\x84\x01\n2child_workflow_execution_canceled_event_attributes\x18\' \x01(\x0b\x32\x46.temporal.api.history.v1.ChildWorkflowExecutionCanceledEventAttributesH\x00\x12\x85\x01\n3child_workflow_execution_timed_out_event_attributes\x18( \x01(\x0b\x32\x46.temporal.api.history.v1.ChildWorkflowExecutionTimedOutEventAttributesH\x00\x12\x88\x01\n4child_workflow_execution_terminated_event_attributes\x18) \x01(\x0b\x32H.temporal.api.history.v1.ChildWorkflowExecutionTerminatedEventAttributesH\x00\x12\x99\x01\n=signal_external_workflow_execution_initiated_event_attributes\x18* \x01(\x0b\x32P.temporal.api.history.v1.SignalExternalWorkflowExecutionInitiatedEventAttributesH\x00\x12\x93\x01\n:signal_external_workflow_execution_failed_event_attributes\x18+ \x01(\x0b\x32M.temporal.api.history.v1.SignalExternalWorkflowExecutionFailedEventAttributesH\x00\x12\x8a\x01\n5external_workflow_execution_signaled_event_attributes\x18, \x01(\x0b\x32I.temporal.api.history.v1.ExternalWorkflowExecutionSignaledEventAttributesH\x00\x12\x84\x01\n2upsert_workflow_search_attributes_event_attributes\x18- \x01(\x0b\x32\x46.temporal.api.history.v1.UpsertWorkflowSearchAttributesEventAttributesH\x00\x12\x86\x01\n3workflow_execution_update_accepted_event_attributes\x18. \x01(\x0b\x32G.temporal.api.history.v1.WorkflowExecutionUpdateAcceptedEventAttributesH\x00\x12\x86\x01\n3workflow_execution_update_rejected_event_attributes\x18/ \x01(\x0b\x32G.temporal.api.history.v1.WorkflowExecutionUpdateRejectedEventAttributesH\x00\x12\x88\x01\n4workflow_execution_update_completed_event_attributes\x18\x30 \x01(\x0b\x32H.temporal.api.history.v1.WorkflowExecutionUpdateCompletedEventAttributesH\x00\x12\x90\x01\n8workflow_properties_modified_externally_event_attributes\x18\x31 \x01(\x0b\x32L.temporal.api.history.v1.WorkflowPropertiesModifiedExternallyEventAttributesH\x00\x12\x90\x01\n8activity_properties_modified_externally_event_attributes\x18\x32 \x01(\x0b\x32L.temporal.api.history.v1.ActivityPropertiesModifiedExternallyEventAttributesH\x00\x12{\n-workflow_properties_modified_event_attributes\x18\x33 \x01(\x0b\x32\x42.temporal.api.history.v1.WorkflowPropertiesModifiedEventAttributesH\x00\x12\x86\x01\n3workflow_execution_update_admitted_event_attributes\x18\x34 \x01(\x0b\x32G.temporal.api.history.v1.WorkflowExecutionUpdateAdmittedEventAttributesH\x00\x12u\n*nexus_operation_scheduled_event_attributes\x18\x35 \x01(\x0b\x32?.temporal.api.history.v1.NexusOperationScheduledEventAttributesH\x00\x12q\n(nexus_operation_started_event_attributes\x18\x36 \x01(\x0b\x32=.temporal.api.history.v1.NexusOperationStartedEventAttributesH\x00\x12u\n*nexus_operation_completed_event_attributes\x18\x37 \x01(\x0b\x32?.temporal.api.history.v1.NexusOperationCompletedEventAttributesH\x00\x12o\n\'nexus_operation_failed_event_attributes\x18\x38 \x01(\x0b\x32<.temporal.api.history.v1.NexusOperationFailedEventAttributesH\x00\x12s\n)nexus_operation_canceled_event_attributes\x18\x39 \x01(\x0b\x32>.temporal.api.history.v1.NexusOperationCanceledEventAttributesH\x00\x12t\n*nexus_operation_timed_out_event_attributes\x18: \x01(\x0b\x32>.temporal.api.history.v1.NexusOperationTimedOutEventAttributesH\x00\x12\x82\x01\n1nexus_operation_cancel_requested_event_attributes\x18; \x01(\x0b\x32\x45.temporal.api.history.v1.NexusOperationCancelRequestedEventAttributesH\x00\x12\x86\x01\n3workflow_execution_options_updated_event_attributes\x18< \x01(\x0b\x32G.temporal.api.history.v1.WorkflowExecutionOptionsUpdatedEventAttributesH\x00\x12\x91\x01\n9nexus_operation_cancel_request_completed_event_attributes\x18= \x01(\x0b\x32L.temporal.api.history.v1.NexusOperationCancelRequestCompletedEventAttributesH\x00\x12\x8b\x01\n6nexus_operation_cancel_request_failed_event_attributes\x18> \x01(\x0b\x32I.temporal.api.history.v1.NexusOperationCancelRequestFailedEventAttributesH\x00\x12u\n*workflow_execution_paused_event_attributes\x18? \x01(\x0b\x32?.temporal.api.history.v1.WorkflowExecutionPausedEventAttributesH\x00\x12y\n,workflow_execution_unpaused_event_attributes\x18@ \x01(\x0b\x32\x41.temporal.api.history.v1.WorkflowExecutionUnpausedEventAttributesH\x00\x12\x9b\x01\n>workflow_execution_time_skipping_transitioned_event_attributes\x18\x41 \x01(\x0b\x32Q.temporal.api.history.v1.WorkflowExecutionTimeSkippingTransitionedEventAttributesH\x00\x12w\n+workflow_stream_subscribed_event_attributes\x18\x42 \x01(\x0b\x32@.temporal.api.history.v1.WorkflowStreamSubscribedEventAttributesH\x00\x12\x82\x01\n1workflow_stream_records_appended_event_attributes\x18\x43 \x01(\x0b\x32\x45.temporal.api.history.v1.WorkflowStreamRecordsAppendedEventAttributesH\x00\x42\x0c\n\nattributes"@\n\x07History\x12\x35\n\x06\x65vents\x18\x01 \x03(\x0b\x32%.temporal.api.history.v1.HistoryEventB\x8e\x01\n\x1aio.temporal.api.history.v1B\x0cMessageProtoP\x01Z%go.temporal.io/api/history/v1;history\xaa\x02\x19Temporalio.Api.History.V1\xea\x02\x1cTemporalio::Api::History::V1b\x06proto3' ) @@ -215,6 +218,12 @@ _ACTIVITYPROPERTIESMODIFIEDEXTERNALLYEVENTATTRIBUTES = DESCRIPTOR.message_types_by_name[ "ActivityPropertiesModifiedExternallyEventAttributes" ] +_WORKFLOWSTREAMSUBSCRIBEDEVENTATTRIBUTES = DESCRIPTOR.message_types_by_name[ + "WorkflowStreamSubscribedEventAttributes" +] +_WORKFLOWSTREAMRECORDSAPPENDEDEVENTATTRIBUTES = DESCRIPTOR.message_types_by_name[ + "WorkflowStreamRecordsAppendedEventAttributes" +] _WORKFLOWEXECUTIONUPDATEACCEPTEDEVENTATTRIBUTES = DESCRIPTOR.message_types_by_name[ "WorkflowExecutionUpdateAcceptedEventAttributes" ] @@ -819,6 +828,28 @@ ) _sym_db.RegisterMessage(ActivityPropertiesModifiedExternallyEventAttributes) +WorkflowStreamSubscribedEventAttributes = _reflection.GeneratedProtocolMessageType( + "WorkflowStreamSubscribedEventAttributes", + (_message.Message,), + { + "DESCRIPTOR": _WORKFLOWSTREAMSUBSCRIBEDEVENTATTRIBUTES, + "__module__": "temporalio.api.history.v1.message_pb2", + # @@protoc_insertion_point(class_scope:temporal.api.history.v1.WorkflowStreamSubscribedEventAttributes) + }, +) +_sym_db.RegisterMessage(WorkflowStreamSubscribedEventAttributes) + +WorkflowStreamRecordsAppendedEventAttributes = _reflection.GeneratedProtocolMessageType( + "WorkflowStreamRecordsAppendedEventAttributes", + (_message.Message,), + { + "DESCRIPTOR": _WORKFLOWSTREAMRECORDSAPPENDEDEVENTATTRIBUTES, + "__module__": "temporalio.api.history.v1.message_pb2", + # @@protoc_insertion_point(class_scope:temporal.api.history.v1.WorkflowStreamRecordsAppendedEventAttributes) + }, +) +_sym_db.RegisterMessage(WorkflowStreamRecordsAppendedEventAttributes) + WorkflowExecutionUpdateAcceptedEventAttributes = ( _reflection.GeneratedProtocolMessageType( "WorkflowExecutionUpdateAcceptedEventAttributes", @@ -1210,140 +1241,144 @@ _NEXUSOPERATIONSTARTEDEVENTATTRIBUTES.fields_by_name[ "operation_id" ]._serialized_options = b"\030\001" - _WORKFLOWEXECUTIONSTARTEDEVENTATTRIBUTES._serialized_start = 663 - _WORKFLOWEXECUTIONSTARTEDEVENTATTRIBUTES._serialized_end = 3057 - _DECLINEDTARGETVERSIONUPGRADE._serialized_start = 3060 - _DECLINEDTARGETVERSIONUPGRADE._serialized_end = 3196 - _WORKFLOWEXECUTIONCOMPLETEDEVENTATTRIBUTES._serialized_start = 3199 - _WORKFLOWEXECUTIONCOMPLETEDEVENTATTRIBUTES._serialized_end = 3364 - _WORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_start = 3367 - _WORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_end = 3586 - _WORKFLOWEXECUTIONTIMEDOUTEVENTATTRIBUTES._serialized_start = 3589 - _WORKFLOWEXECUTIONTIMEDOUTEVENTATTRIBUTES._serialized_end = 3717 - _WORKFLOWEXECUTIONCONTINUEDASNEWEVENTATTRIBUTES._serialized_start = 3720 - _WORKFLOWEXECUTIONCONTINUEDASNEWEVENTATTRIBUTES._serialized_end = 4653 - _WORKFLOWTASKSCHEDULEDEVENTATTRIBUTES._serialized_start = 4656 - _WORKFLOWTASKSCHEDULEDEVENTATTRIBUTES._serialized_end = 4828 - _WORKFLOWTASKSTARTEDEVENTATTRIBUTES._serialized_start = 4831 - _WORKFLOWTASKSTARTEDEVENTATTRIBUTES._serialized_end = 5247 - _WORKFLOWTASKCOMPLETEDEVENTATTRIBUTES._serialized_start = 5250 - _WORKFLOWTASKCOMPLETEDEVENTATTRIBUTES._serialized_end = 5892 - _WORKFLOWTASKTIMEDOUTEVENTATTRIBUTES._serialized_start = 5895 - _WORKFLOWTASKTIMEDOUTEVENTATTRIBUTES._serialized_end = 6044 - _WORKFLOWTASKFAILEDEVENTATTRIBUTES._serialized_start = 6047 - _WORKFLOWTASKFAILEDEVENTATTRIBUTES._serialized_end = 6438 - _ACTIVITYTASKSCHEDULEDEVENTATTRIBUTES._serialized_start = 6441 - _ACTIVITYTASKSCHEDULEDEVENTATTRIBUTES._serialized_end = 7147 - _ACTIVITYTASKSTARTEDEVENTATTRIBUTES._serialized_start = 7150 - _ACTIVITYTASKSTARTEDEVENTATTRIBUTES._serialized_end = 7436 - _ACTIVITYTASKCOMPLETEDEVENTATTRIBUTES._serialized_start = 7439 - _ACTIVITYTASKCOMPLETEDEVENTATTRIBUTES._serialized_end = 7671 - _ACTIVITYTASKFAILEDEVENTATTRIBUTES._serialized_start = 7674 - _ACTIVITYTASKFAILEDEVENTATTRIBUTES._serialized_end = 7960 - _ACTIVITYTASKTIMEDOUTEVENTATTRIBUTES._serialized_start = 7963 - _ACTIVITYTASKTIMEDOUTEVENTATTRIBUTES._serialized_end = 8161 - _ACTIVITYTASKCANCELREQUESTEDEVENTATTRIBUTES._serialized_start = 8163 - _ACTIVITYTASKCANCELREQUESTEDEVENTATTRIBUTES._serialized_end = 8277 - _ACTIVITYTASKCANCELEDEVENTATTRIBUTES._serialized_start = 8280 - _ACTIVITYTASKCANCELEDEVENTATTRIBUTES._serialized_end = 8554 - _TIMERSTARTEDEVENTATTRIBUTES._serialized_start = 8557 - _TIMERSTARTEDEVENTATTRIBUTES._serialized_end = 8704 - _TIMERFIREDEVENTATTRIBUTES._serialized_start = 8706 - _TIMERFIREDEVENTATTRIBUTES._serialized_end = 8777 - _TIMERCANCELEDEVENTATTRIBUTES._serialized_start = 8780 - _TIMERCANCELEDEVENTATTRIBUTES._serialized_end = 8914 - _WORKFLOWEXECUTIONCANCELREQUESTEDEVENTATTRIBUTES._serialized_start = 8917 - _WORKFLOWEXECUTIONCANCELREQUESTEDEVENTATTRIBUTES._serialized_end = 9116 - _WORKFLOWEXECUTIONCANCELEDEVENTATTRIBUTES._serialized_start = 9119 - _WORKFLOWEXECUTIONCANCELEDEVENTATTRIBUTES._serialized_end = 9254 - _MARKERRECORDEDEVENTATTRIBUTES._serialized_start = 9257 - _MARKERRECORDEDEVENTATTRIBUTES._serialized_end = 9618 - _MARKERRECORDEDEVENTATTRIBUTES_DETAILSENTRY._serialized_start = 9538 - _MARKERRECORDEDEVENTATTRIBUTES_DETAILSENTRY._serialized_end = 9618 - _WORKFLOWEXECUTIONSIGNALEDEVENTATTRIBUTES._serialized_start = 9621 - _WORKFLOWEXECUTIONSIGNALEDEVENTATTRIBUTES._serialized_end = 9940 - _WORKFLOWEXECUTIONTERMINATEDEVENTATTRIBUTES._serialized_start = 9943 - _WORKFLOWEXECUTIONTERMINATEDEVENTATTRIBUTES._serialized_end = 10072 - _REQUESTCANCELEXTERNALWORKFLOWEXECUTIONINITIATEDEVENTATTRIBUTES._serialized_start = 10075 + _WORKFLOWEXECUTIONSTARTEDEVENTATTRIBUTES._serialized_start = 701 + _WORKFLOWEXECUTIONSTARTEDEVENTATTRIBUTES._serialized_end = 3095 + _DECLINEDTARGETVERSIONUPGRADE._serialized_start = 3098 + _DECLINEDTARGETVERSIONUPGRADE._serialized_end = 3234 + _WORKFLOWEXECUTIONCOMPLETEDEVENTATTRIBUTES._serialized_start = 3237 + _WORKFLOWEXECUTIONCOMPLETEDEVENTATTRIBUTES._serialized_end = 3402 + _WORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_start = 3405 + _WORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_end = 3624 + _WORKFLOWEXECUTIONTIMEDOUTEVENTATTRIBUTES._serialized_start = 3627 + _WORKFLOWEXECUTIONTIMEDOUTEVENTATTRIBUTES._serialized_end = 3755 + _WORKFLOWEXECUTIONCONTINUEDASNEWEVENTATTRIBUTES._serialized_start = 3758 + _WORKFLOWEXECUTIONCONTINUEDASNEWEVENTATTRIBUTES._serialized_end = 4691 + _WORKFLOWTASKSCHEDULEDEVENTATTRIBUTES._serialized_start = 4694 + _WORKFLOWTASKSCHEDULEDEVENTATTRIBUTES._serialized_end = 4866 + _WORKFLOWTASKSTARTEDEVENTATTRIBUTES._serialized_start = 4869 + _WORKFLOWTASKSTARTEDEVENTATTRIBUTES._serialized_end = 5285 + _WORKFLOWTASKCOMPLETEDEVENTATTRIBUTES._serialized_start = 5288 + _WORKFLOWTASKCOMPLETEDEVENTATTRIBUTES._serialized_end = 5999 + _WORKFLOWTASKTIMEDOUTEVENTATTRIBUTES._serialized_start = 6002 + _WORKFLOWTASKTIMEDOUTEVENTATTRIBUTES._serialized_end = 6151 + _WORKFLOWTASKFAILEDEVENTATTRIBUTES._serialized_start = 6154 + _WORKFLOWTASKFAILEDEVENTATTRIBUTES._serialized_end = 6545 + _ACTIVITYTASKSCHEDULEDEVENTATTRIBUTES._serialized_start = 6548 + _ACTIVITYTASKSCHEDULEDEVENTATTRIBUTES._serialized_end = 7254 + _ACTIVITYTASKSTARTEDEVENTATTRIBUTES._serialized_start = 7257 + _ACTIVITYTASKSTARTEDEVENTATTRIBUTES._serialized_end = 7543 + _ACTIVITYTASKCOMPLETEDEVENTATTRIBUTES._serialized_start = 7546 + _ACTIVITYTASKCOMPLETEDEVENTATTRIBUTES._serialized_end = 7778 + _ACTIVITYTASKFAILEDEVENTATTRIBUTES._serialized_start = 7781 + _ACTIVITYTASKFAILEDEVENTATTRIBUTES._serialized_end = 8067 + _ACTIVITYTASKTIMEDOUTEVENTATTRIBUTES._serialized_start = 8070 + _ACTIVITYTASKTIMEDOUTEVENTATTRIBUTES._serialized_end = 8268 + _ACTIVITYTASKCANCELREQUESTEDEVENTATTRIBUTES._serialized_start = 8270 + _ACTIVITYTASKCANCELREQUESTEDEVENTATTRIBUTES._serialized_end = 8384 + _ACTIVITYTASKCANCELEDEVENTATTRIBUTES._serialized_start = 8387 + _ACTIVITYTASKCANCELEDEVENTATTRIBUTES._serialized_end = 8661 + _TIMERSTARTEDEVENTATTRIBUTES._serialized_start = 8664 + _TIMERSTARTEDEVENTATTRIBUTES._serialized_end = 8811 + _TIMERFIREDEVENTATTRIBUTES._serialized_start = 8813 + _TIMERFIREDEVENTATTRIBUTES._serialized_end = 8884 + _TIMERCANCELEDEVENTATTRIBUTES._serialized_start = 8887 + _TIMERCANCELEDEVENTATTRIBUTES._serialized_end = 9021 + _WORKFLOWEXECUTIONCANCELREQUESTEDEVENTATTRIBUTES._serialized_start = 9024 + _WORKFLOWEXECUTIONCANCELREQUESTEDEVENTATTRIBUTES._serialized_end = 9223 + _WORKFLOWEXECUTIONCANCELEDEVENTATTRIBUTES._serialized_start = 9226 + _WORKFLOWEXECUTIONCANCELEDEVENTATTRIBUTES._serialized_end = 9361 + _MARKERRECORDEDEVENTATTRIBUTES._serialized_start = 9364 + _MARKERRECORDEDEVENTATTRIBUTES._serialized_end = 9725 + _MARKERRECORDEDEVENTATTRIBUTES_DETAILSENTRY._serialized_start = 9645 + _MARKERRECORDEDEVENTATTRIBUTES_DETAILSENTRY._serialized_end = 9725 + _WORKFLOWEXECUTIONSIGNALEDEVENTATTRIBUTES._serialized_start = 9728 + _WORKFLOWEXECUTIONSIGNALEDEVENTATTRIBUTES._serialized_end = 10047 + _WORKFLOWEXECUTIONTERMINATEDEVENTATTRIBUTES._serialized_start = 10050 + _WORKFLOWEXECUTIONTERMINATEDEVENTATTRIBUTES._serialized_end = 10179 + _REQUESTCANCELEXTERNALWORKFLOWEXECUTIONINITIATEDEVENTATTRIBUTES._serialized_start = 10182 _REQUESTCANCELEXTERNALWORKFLOWEXECUTIONINITIATEDEVENTATTRIBUTES._serialized_end = ( - 10359 + 10466 ) _REQUESTCANCELEXTERNALWORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_start = ( - 10362 + 10469 ) - _REQUESTCANCELEXTERNALWORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_end = 10708 - _EXTERNALWORKFLOWEXECUTIONCANCELREQUESTEDEVENTATTRIBUTES._serialized_start = 10711 - _EXTERNALWORKFLOWEXECUTIONCANCELREQUESTEDEVENTATTRIBUTES._serialized_end = 10908 - _SIGNALEXTERNALWORKFLOWEXECUTIONINITIATEDEVENTATTRIBUTES._serialized_start = 10911 - _SIGNALEXTERNALWORKFLOWEXECUTIONINITIATEDEVENTATTRIBUTES._serialized_end = 11290 - _SIGNALEXTERNALWORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_start = 11293 - _SIGNALEXTERNALWORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_end = 11632 - _EXTERNALWORKFLOWEXECUTIONSIGNALEDEVENTATTRIBUTES._serialized_start = 11635 - _EXTERNALWORKFLOWEXECUTIONSIGNALEDEVENTATTRIBUTES._serialized_end = 11846 - _UPSERTWORKFLOWSEARCHATTRIBUTESEVENTATTRIBUTES._serialized_start = 11849 - _UPSERTWORKFLOWSEARCHATTRIBUTESEVENTATTRIBUTES._serialized_end = 12007 - _WORKFLOWPROPERTIESMODIFIEDEVENTATTRIBUTES._serialized_start = 12010 - _WORKFLOWPROPERTIESMODIFIEDEVENTATTRIBUTES._serialized_end = 12148 - _STARTCHILDWORKFLOWEXECUTIONINITIATEDEVENTATTRIBUTES._serialized_start = 12151 - _STARTCHILDWORKFLOWEXECUTIONINITIATEDEVENTATTRIBUTES._serialized_end = 13427 - _STARTCHILDWORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_start = 13430 - _STARTCHILDWORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_end = 13772 - _CHILDWORKFLOWEXECUTIONSTARTEDEVENTATTRIBUTES._serialized_start = 13775 - _CHILDWORKFLOWEXECUTIONSTARTEDEVENTATTRIBUTES._serialized_end = 14070 - _CHILDWORKFLOWEXECUTIONCOMPLETEDEVENTATTRIBUTES._serialized_start = 14073 - _CHILDWORKFLOWEXECUTIONCOMPLETEDEVENTATTRIBUTES._serialized_end = 14398 - _CHILDWORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_start = 14401 - _CHILDWORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_end = 14780 - _CHILDWORKFLOWEXECUTIONCANCELEDEVENTATTRIBUTES._serialized_start = 14783 - _CHILDWORKFLOWEXECUTIONCANCELEDEVENTATTRIBUTES._serialized_end = 15108 - _CHILDWORKFLOWEXECUTIONTIMEDOUTEVENTATTRIBUTES._serialized_start = 15111 - _CHILDWORKFLOWEXECUTIONTIMEDOUTEVENTATTRIBUTES._serialized_end = 15441 - _CHILDWORKFLOWEXECUTIONTERMINATEDEVENTATTRIBUTES._serialized_start = 15444 - _CHILDWORKFLOWEXECUTIONTERMINATEDEVENTATTRIBUTES._serialized_end = 15720 - _WORKFLOWEXECUTIONOPTIONSUPDATEDEVENTATTRIBUTES._serialized_start = 15723 - _WORKFLOWEXECUTIONOPTIONSUPDATEDEVENTATTRIBUTES._serialized_end = 16453 - _WORKFLOWEXECUTIONOPTIONSUPDATEDEVENTATTRIBUTES_WORKFLOWUPDATEOPTIONSUPDATE._serialized_start = 16303 - _WORKFLOWEXECUTIONOPTIONSUPDATEDEVENTATTRIBUTES_WORKFLOWUPDATEOPTIONSUPDATE._serialized_end = 16453 - _WORKFLOWPROPERTIESMODIFIEDEXTERNALLYEVENTATTRIBUTES._serialized_start = 16456 - _WORKFLOWPROPERTIESMODIFIEDEXTERNALLYEVENTATTRIBUTES._serialized_end = 16776 - _ACTIVITYPROPERTIESMODIFIEDEXTERNALLYEVENTATTRIBUTES._serialized_start = 16779 - _ACTIVITYPROPERTIESMODIFIEDEXTERNALLYEVENTATTRIBUTES._serialized_end = 16923 - _WORKFLOWEXECUTIONUPDATEACCEPTEDEVENTATTRIBUTES._serialized_start = 16926 - _WORKFLOWEXECUTIONUPDATEACCEPTEDEVENTATTRIBUTES._serialized_end = 17146 - _WORKFLOWEXECUTIONUPDATECOMPLETEDEVENTATTRIBUTES._serialized_start = 17149 - _WORKFLOWEXECUTIONUPDATECOMPLETEDEVENTATTRIBUTES._serialized_end = 17319 - _WORKFLOWEXECUTIONUPDATEREJECTEDEVENTATTRIBUTES._serialized_start = 17322 - _WORKFLOWEXECUTIONUPDATEREJECTEDEVENTATTRIBUTES._serialized_end = 17593 - _WORKFLOWEXECUTIONUPDATEADMITTEDEVENTATTRIBUTES._serialized_start = 17596 - _WORKFLOWEXECUTIONUPDATEADMITTEDEVENTATTRIBUTES._serialized_end = 17760 - _WORKFLOWEXECUTIONPAUSEDEVENTATTRIBUTES._serialized_start = 17762 - _WORKFLOWEXECUTIONPAUSEDEVENTATTRIBUTES._serialized_end = 17856 - _WORKFLOWEXECUTIONUNPAUSEDEVENTATTRIBUTES._serialized_start = 17858 - _WORKFLOWEXECUTIONUNPAUSEDEVENTATTRIBUTES._serialized_end = 17954 - _WORKFLOWEXECUTIONTIMESKIPPINGTRANSITIONEDEVENTATTRIBUTES._serialized_start = 17957 - _WORKFLOWEXECUTIONTIMESKIPPINGTRANSITIONEDEVENTATTRIBUTES._serialized_end = 18154 - _NEXUSOPERATIONSCHEDULEDEVENTATTRIBUTES._serialized_start = 18157 - _NEXUSOPERATIONSCHEDULEDEVENTATTRIBUTES._serialized_end = 18721 - _NEXUSOPERATIONSCHEDULEDEVENTATTRIBUTES_NEXUSHEADERENTRY._serialized_start = 18671 - _NEXUSOPERATIONSCHEDULEDEVENTATTRIBUTES_NEXUSHEADERENTRY._serialized_end = 18721 - _NEXUSOPERATIONSTARTEDEVENTATTRIBUTES._serialized_start = 18724 - _NEXUSOPERATIONSTARTEDEVENTATTRIBUTES._serialized_end = 18861 - _NEXUSOPERATIONCOMPLETEDEVENTATTRIBUTES._serialized_start = 18864 - _NEXUSOPERATIONCOMPLETEDEVENTATTRIBUTES._serialized_end = 19001 - _NEXUSOPERATIONFAILEDEVENTATTRIBUTES._serialized_start = 19004 - _NEXUSOPERATIONFAILEDEVENTATTRIBUTES._serialized_end = 19140 - _NEXUSOPERATIONTIMEDOUTEVENTATTRIBUTES._serialized_start = 19143 - _NEXUSOPERATIONTIMEDOUTEVENTATTRIBUTES._serialized_end = 19281 - _NEXUSOPERATIONCANCELEDEVENTATTRIBUTES._serialized_start = 19284 - _NEXUSOPERATIONCANCELEDEVENTATTRIBUTES._serialized_end = 19422 - _NEXUSOPERATIONCANCELREQUESTEDEVENTATTRIBUTES._serialized_start = 19424 - _NEXUSOPERATIONCANCELREQUESTEDEVENTATTRIBUTES._serialized_end = 19540 - _NEXUSOPERATIONCANCELREQUESTCOMPLETEDEVENTATTRIBUTES._serialized_start = 19543 - _NEXUSOPERATIONCANCELREQUESTCOMPLETEDEVENTATTRIBUTES._serialized_end = 19694 - _NEXUSOPERATIONCANCELREQUESTFAILEDEVENTATTRIBUTES._serialized_start = 19697 - _NEXUSOPERATIONCANCELREQUESTFAILEDEVENTATTRIBUTES._serialized_end = 19896 - _HISTORYEVENT._serialized_start = 19899 - _HISTORYEVENT._serialized_end = 28037 - _HISTORY._serialized_start = 28039 - _HISTORY._serialized_end = 28103 + _REQUESTCANCELEXTERNALWORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_end = 10815 + _EXTERNALWORKFLOWEXECUTIONCANCELREQUESTEDEVENTATTRIBUTES._serialized_start = 10818 + _EXTERNALWORKFLOWEXECUTIONCANCELREQUESTEDEVENTATTRIBUTES._serialized_end = 11015 + _SIGNALEXTERNALWORKFLOWEXECUTIONINITIATEDEVENTATTRIBUTES._serialized_start = 11018 + _SIGNALEXTERNALWORKFLOWEXECUTIONINITIATEDEVENTATTRIBUTES._serialized_end = 11397 + _SIGNALEXTERNALWORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_start = 11400 + _SIGNALEXTERNALWORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_end = 11739 + _EXTERNALWORKFLOWEXECUTIONSIGNALEDEVENTATTRIBUTES._serialized_start = 11742 + _EXTERNALWORKFLOWEXECUTIONSIGNALEDEVENTATTRIBUTES._serialized_end = 11953 + _UPSERTWORKFLOWSEARCHATTRIBUTESEVENTATTRIBUTES._serialized_start = 11956 + _UPSERTWORKFLOWSEARCHATTRIBUTESEVENTATTRIBUTES._serialized_end = 12114 + _WORKFLOWPROPERTIESMODIFIEDEVENTATTRIBUTES._serialized_start = 12117 + _WORKFLOWPROPERTIESMODIFIEDEVENTATTRIBUTES._serialized_end = 12255 + _STARTCHILDWORKFLOWEXECUTIONINITIATEDEVENTATTRIBUTES._serialized_start = 12258 + _STARTCHILDWORKFLOWEXECUTIONINITIATEDEVENTATTRIBUTES._serialized_end = 13534 + _STARTCHILDWORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_start = 13537 + _STARTCHILDWORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_end = 13879 + _CHILDWORKFLOWEXECUTIONSTARTEDEVENTATTRIBUTES._serialized_start = 13882 + _CHILDWORKFLOWEXECUTIONSTARTEDEVENTATTRIBUTES._serialized_end = 14177 + _CHILDWORKFLOWEXECUTIONCOMPLETEDEVENTATTRIBUTES._serialized_start = 14180 + _CHILDWORKFLOWEXECUTIONCOMPLETEDEVENTATTRIBUTES._serialized_end = 14505 + _CHILDWORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_start = 14508 + _CHILDWORKFLOWEXECUTIONFAILEDEVENTATTRIBUTES._serialized_end = 14887 + _CHILDWORKFLOWEXECUTIONCANCELEDEVENTATTRIBUTES._serialized_start = 14890 + _CHILDWORKFLOWEXECUTIONCANCELEDEVENTATTRIBUTES._serialized_end = 15215 + _CHILDWORKFLOWEXECUTIONTIMEDOUTEVENTATTRIBUTES._serialized_start = 15218 + _CHILDWORKFLOWEXECUTIONTIMEDOUTEVENTATTRIBUTES._serialized_end = 15548 + _CHILDWORKFLOWEXECUTIONTERMINATEDEVENTATTRIBUTES._serialized_start = 15551 + _CHILDWORKFLOWEXECUTIONTERMINATEDEVENTATTRIBUTES._serialized_end = 15827 + _WORKFLOWEXECUTIONOPTIONSUPDATEDEVENTATTRIBUTES._serialized_start = 15830 + _WORKFLOWEXECUTIONOPTIONSUPDATEDEVENTATTRIBUTES._serialized_end = 16560 + _WORKFLOWEXECUTIONOPTIONSUPDATEDEVENTATTRIBUTES_WORKFLOWUPDATEOPTIONSUPDATE._serialized_start = 16410 + _WORKFLOWEXECUTIONOPTIONSUPDATEDEVENTATTRIBUTES_WORKFLOWUPDATEOPTIONSUPDATE._serialized_end = 16560 + _WORKFLOWPROPERTIESMODIFIEDEXTERNALLYEVENTATTRIBUTES._serialized_start = 16563 + _WORKFLOWPROPERTIESMODIFIEDEXTERNALLYEVENTATTRIBUTES._serialized_end = 16883 + _ACTIVITYPROPERTIESMODIFIEDEXTERNALLYEVENTATTRIBUTES._serialized_start = 16886 + _ACTIVITYPROPERTIESMODIFIEDEXTERNALLYEVENTATTRIBUTES._serialized_end = 17030 + _WORKFLOWSTREAMSUBSCRIBEDEVENTATTRIBUTES._serialized_start = 17032 + _WORKFLOWSTREAMSUBSCRIBEDEVENTATTRIBUTES._serialized_end = 17156 + _WORKFLOWSTREAMRECORDSAPPENDEDEVENTATTRIBUTES._serialized_start = 17159 + _WORKFLOWSTREAMRECORDSAPPENDEDEVENTATTRIBUTES._serialized_end = 17310 + _WORKFLOWEXECUTIONUPDATEACCEPTEDEVENTATTRIBUTES._serialized_start = 17313 + _WORKFLOWEXECUTIONUPDATEACCEPTEDEVENTATTRIBUTES._serialized_end = 17533 + _WORKFLOWEXECUTIONUPDATECOMPLETEDEVENTATTRIBUTES._serialized_start = 17536 + _WORKFLOWEXECUTIONUPDATECOMPLETEDEVENTATTRIBUTES._serialized_end = 17706 + _WORKFLOWEXECUTIONUPDATEREJECTEDEVENTATTRIBUTES._serialized_start = 17709 + _WORKFLOWEXECUTIONUPDATEREJECTEDEVENTATTRIBUTES._serialized_end = 17980 + _WORKFLOWEXECUTIONUPDATEADMITTEDEVENTATTRIBUTES._serialized_start = 17983 + _WORKFLOWEXECUTIONUPDATEADMITTEDEVENTATTRIBUTES._serialized_end = 18147 + _WORKFLOWEXECUTIONPAUSEDEVENTATTRIBUTES._serialized_start = 18149 + _WORKFLOWEXECUTIONPAUSEDEVENTATTRIBUTES._serialized_end = 18243 + _WORKFLOWEXECUTIONUNPAUSEDEVENTATTRIBUTES._serialized_start = 18245 + _WORKFLOWEXECUTIONUNPAUSEDEVENTATTRIBUTES._serialized_end = 18341 + _WORKFLOWEXECUTIONTIMESKIPPINGTRANSITIONEDEVENTATTRIBUTES._serialized_start = 18344 + _WORKFLOWEXECUTIONTIMESKIPPINGTRANSITIONEDEVENTATTRIBUTES._serialized_end = 18541 + _NEXUSOPERATIONSCHEDULEDEVENTATTRIBUTES._serialized_start = 18544 + _NEXUSOPERATIONSCHEDULEDEVENTATTRIBUTES._serialized_end = 19108 + _NEXUSOPERATIONSCHEDULEDEVENTATTRIBUTES_NEXUSHEADERENTRY._serialized_start = 19058 + _NEXUSOPERATIONSCHEDULEDEVENTATTRIBUTES_NEXUSHEADERENTRY._serialized_end = 19108 + _NEXUSOPERATIONSTARTEDEVENTATTRIBUTES._serialized_start = 19111 + _NEXUSOPERATIONSTARTEDEVENTATTRIBUTES._serialized_end = 19248 + _NEXUSOPERATIONCOMPLETEDEVENTATTRIBUTES._serialized_start = 19251 + _NEXUSOPERATIONCOMPLETEDEVENTATTRIBUTES._serialized_end = 19388 + _NEXUSOPERATIONFAILEDEVENTATTRIBUTES._serialized_start = 19391 + _NEXUSOPERATIONFAILEDEVENTATTRIBUTES._serialized_end = 19527 + _NEXUSOPERATIONTIMEDOUTEVENTATTRIBUTES._serialized_start = 19530 + _NEXUSOPERATIONTIMEDOUTEVENTATTRIBUTES._serialized_end = 19668 + _NEXUSOPERATIONCANCELEDEVENTATTRIBUTES._serialized_start = 19671 + _NEXUSOPERATIONCANCELEDEVENTATTRIBUTES._serialized_end = 19809 + _NEXUSOPERATIONCANCELREQUESTEDEVENTATTRIBUTES._serialized_start = 19811 + _NEXUSOPERATIONCANCELREQUESTEDEVENTATTRIBUTES._serialized_end = 19927 + _NEXUSOPERATIONCANCELREQUESTCOMPLETEDEVENTATTRIBUTES._serialized_start = 19930 + _NEXUSOPERATIONCANCELREQUESTCOMPLETEDEVENTATTRIBUTES._serialized_end = 20081 + _NEXUSOPERATIONCANCELREQUESTFAILEDEVENTATTRIBUTES._serialized_start = 20084 + _NEXUSOPERATIONCANCELREQUESTFAILEDEVENTATTRIBUTES._serialized_end = 20283 + _HISTORYEVENT._serialized_start = 20286 + _HISTORYEVENT._serialized_end = 28678 + _HISTORY._serialized_start = 28680 + _HISTORY._serialized_end = 28744 # @@protoc_insertion_point(module_scope) diff --git a/temporalio/api/history/v1/message_pb2.pyi b/temporalio/api/history/v1/message_pb2.pyi index 783e9d3b6..bc708a6cb 100644 --- a/temporalio/api/history/v1/message_pb2.pyi +++ b/temporalio/api/history/v1/message_pb2.pyi @@ -23,6 +23,7 @@ import temporalio.api.failure.v1.message_pb2 import temporalio.api.sdk.v1.event_group_marker_pb2 import temporalio.api.sdk.v1.task_complete_metadata_pb2 import temporalio.api.sdk.v1.user_metadata_pb2 +import temporalio.api.stream.v1.message_pb2 import temporalio.api.taskqueue.v1.message_pb2 import temporalio.api.update.v1.message_pb2 import temporalio.api.workflow.v1.message_pb2 @@ -1010,6 +1011,7 @@ class WorkflowTaskCompletedEventAttributes(google.protobuf.message.Message): WORKER_DEPLOYMENT_VERSION_FIELD_NUMBER: builtins.int WORKER_DEPLOYMENT_NAME_FIELD_NUMBER: builtins.int DEPLOYMENT_VERSION_FIELD_NUMBER: builtins.int + CONSUMED_STREAM_RANGES_FIELD_NUMBER: builtins.int scheduled_event_id: builtins.int """The id of the `WORKFLOW_TASK_SCHEDULED` event this task corresponds to""" started_event_id: builtins.int @@ -1069,6 +1071,18 @@ class WorkflowTaskCompletedEventAttributes(google.protobuf.message.Message): """The Worker Deployment Version that completed this task. Must be set if `versioning_behavior` is set. This value updates workflow execution's `versioning_info.deployment_version`. """ + @property + def consumed_stream_ranges( + self, + ) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[ + temporalio.api.stream.v1.message_pb2.StreamRange + ]: + """Offset ranges this Workflow Task consumed from streams it subscribes to. + Recorded on every task where a subscription is active, including when it + observed nothing: an empty range is a fact replay must reproduce, and + omitting it would let replay deliver records the Workflow did not have. + Numbered 20 to leave 14 through 19 free for fields added on the main line. + """ def __init__( self, *, @@ -1088,6 +1102,10 @@ class WorkflowTaskCompletedEventAttributes(google.protobuf.message.Message): worker_deployment_name: builtins.str = ..., deployment_version: temporalio.api.deployment.v1.message_pb2.WorkerDeploymentVersion | None = ..., + consumed_stream_ranges: collections.abc.Iterable[ + temporalio.api.stream.v1.message_pb2.StreamRange + ] + | None = ..., ) -> None: ... def HasField( self, @@ -1109,6 +1127,8 @@ class WorkflowTaskCompletedEventAttributes(google.protobuf.message.Message): field_name: typing_extensions.Literal[ "binary_checksum", b"binary_checksum", + "consumed_stream_ranges", + b"consumed_stream_ranges", "deployment", b"deployment", "deployment_version", @@ -3603,6 +3623,93 @@ global___ActivityPropertiesModifiedExternallyEventAttributes = ( ActivityPropertiesModifiedExternallyEventAttributes ) +class WorkflowStreamSubscribedEventAttributes(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + WORKFLOW_TASK_COMPLETED_EVENT_ID_FIELD_NUMBER: builtins.int + STREAM_ID_FIELD_NUMBER: builtins.int + START_OFFSET_FIELD_NUMBER: builtins.int + workflow_task_completed_event_id: builtins.int + """The WorkflowTaskCompleted event of the task whose command created this + subscription. + """ + stream_id: builtins.str + """Stream the Workflow subscribed to.""" + start_offset: builtins.int + """The offset the subscription actually starts from. Resolved by the server + when the subscription is registered and recorded here, so replay reads + the resolved value rather than resolving it again against a stream that + has since moved. + """ + def __init__( + self, + *, + workflow_task_completed_event_id: builtins.int = ..., + stream_id: builtins.str = ..., + start_offset: builtins.int = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "start_offset", + b"start_offset", + "stream_id", + b"stream_id", + "workflow_task_completed_event_id", + b"workflow_task_completed_event_id", + ], + ) -> None: ... + +global___WorkflowStreamSubscribedEventAttributes = ( + WorkflowStreamSubscribedEventAttributes +) + +class WorkflowStreamRecordsAppendedEventAttributes(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + WORKFLOW_TASK_COMPLETED_EVENT_ID_FIELD_NUMBER: builtins.int + STREAM_ID_FIELD_NUMBER: builtins.int + FIRST_OFFSET_FIELD_NUMBER: builtins.int + RECORD_COUNT_FIELD_NUMBER: builtins.int + workflow_task_completed_event_id: builtins.int + """The WorkflowTaskCompleted event of the task whose command appended this + batch. + """ + stream_id: builtins.str + """Stream the Workflow appended to.""" + first_offset: builtins.int + """Offset the first record of the batch landed at.""" + record_count: builtins.int + """How many records the batch held. With first_offset this names the range + without carrying any of it, which is what keeps this event a fixed size + no matter how large the batch or its payloads are. + """ + def __init__( + self, + *, + workflow_task_completed_event_id: builtins.int = ..., + stream_id: builtins.str = ..., + first_offset: builtins.int = ..., + record_count: builtins.int = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "first_offset", + b"first_offset", + "record_count", + b"record_count", + "stream_id", + b"stream_id", + "workflow_task_completed_event_id", + b"workflow_task_completed_event_id", + ], + ) -> None: ... + +global___WorkflowStreamRecordsAppendedEventAttributes = ( + WorkflowStreamRecordsAppendedEventAttributes +) + class WorkflowExecutionUpdateAcceptedEventAttributes(google.protobuf.message.Message): DESCRIPTOR: google.protobuf.descriptor.Descriptor @@ -4460,6 +4567,8 @@ class HistoryEvent(google.protobuf.message.Message): WORKFLOW_EXECUTION_TIME_SKIPPING_TRANSITIONED_EVENT_ATTRIBUTES_FIELD_NUMBER: ( builtins.int ) + WORKFLOW_STREAM_SUBSCRIBED_EVENT_ATTRIBUTES_FIELD_NUMBER: builtins.int + WORKFLOW_STREAM_RECORDS_APPENDED_EVENT_ATTRIBUTES_FIELD_NUMBER: builtins.int event_id: builtins.int """Monotonically increasing event number, starts at 1.""" @property @@ -4745,6 +4854,14 @@ class HistoryEvent(google.protobuf.message.Message): def workflow_execution_time_skipping_transitioned_event_attributes( self, ) -> global___WorkflowExecutionTimeSkippingTransitionedEventAttributes: ... + @property + def workflow_stream_subscribed_event_attributes( + self, + ) -> global___WorkflowStreamSubscribedEventAttributes: ... + @property + def workflow_stream_records_appended_event_attributes( + self, + ) -> global___WorkflowStreamRecordsAppendedEventAttributes: ... def __init__( self, *, @@ -4882,6 +4999,10 @@ class HistoryEvent(google.protobuf.message.Message): | None = ..., workflow_execution_time_skipping_transitioned_event_attributes: global___WorkflowExecutionTimeSkippingTransitionedEventAttributes | None = ..., + workflow_stream_subscribed_event_attributes: global___WorkflowStreamSubscribedEventAttributes + | None = ..., + workflow_stream_records_appended_event_attributes: global___WorkflowStreamRecordsAppendedEventAttributes + | None = ..., ) -> None: ... def HasField( self, @@ -5004,6 +5125,10 @@ class HistoryEvent(google.protobuf.message.Message): b"workflow_properties_modified_event_attributes", "workflow_properties_modified_externally_event_attributes", b"workflow_properties_modified_externally_event_attributes", + "workflow_stream_records_appended_event_attributes", + b"workflow_stream_records_appended_event_attributes", + "workflow_stream_subscribed_event_attributes", + b"workflow_stream_subscribed_event_attributes", "workflow_task_completed_event_attributes", b"workflow_task_completed_event_attributes", "workflow_task_failed_event_attributes", @@ -5151,6 +5276,10 @@ class HistoryEvent(google.protobuf.message.Message): b"workflow_properties_modified_event_attributes", "workflow_properties_modified_externally_event_attributes", b"workflow_properties_modified_externally_event_attributes", + "workflow_stream_records_appended_event_attributes", + b"workflow_stream_records_appended_event_attributes", + "workflow_stream_subscribed_event_attributes", + b"workflow_stream_subscribed_event_attributes", "workflow_task_completed_event_attributes", b"workflow_task_completed_event_attributes", "workflow_task_failed_event_attributes", @@ -5227,6 +5356,8 @@ class HistoryEvent(google.protobuf.message.Message): "workflow_execution_paused_event_attributes", "workflow_execution_unpaused_event_attributes", "workflow_execution_time_skipping_transitioned_event_attributes", + "workflow_stream_subscribed_event_attributes", + "workflow_stream_records_appended_event_attributes", ] | None ): ... diff --git a/temporalio/api/stream/__init__.py b/temporalio/api/stream/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/temporalio/api/stream/v1/__init__.py b/temporalio/api/stream/v1/__init__.py new file mode 100644 index 000000000..9d54188ec --- /dev/null +++ b/temporalio/api/stream/v1/__init__.py @@ -0,0 +1,8 @@ +from .message_pb2 import StreamRange, StreamRecord, StreamRecordKind, StreamSlice + +__all__ = [ + "StreamRange", + "StreamRecord", + "StreamRecordKind", + "StreamSlice", +] diff --git a/temporalio/api/stream/v1/message_pb2.py b/temporalio/api/stream/v1/message_pb2.py new file mode 100644 index 000000000..c28393a50 --- /dev/null +++ b/temporalio/api/stream/v1/message_pb2.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: temporal/api/stream/v1/message.proto +"""Generated protocol buffer code.""" + +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import enum_type_wrapper + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from temporalio.api.common.v1 import ( + message_pb2 as temporal_dot_api_dot_common_dot_v1_dot_message__pb2, +) + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n$temporal/api/stream/v1/message.proto\x12\x16temporal.api.stream.v1\x1a$temporal/api/common/v1/message.proto"\xd4\x02\n\x0cStreamRecord\x12-\n\x04\x62ody\x18\x01 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x44\n\x08metadata\x18\x02 \x03(\x0b\x32\x32.temporal.api.stream.v1.StreamRecord.MetadataEntry\x12\r\n\x05topic\x18\x03 \x01(\t\x12\x36\n\x04kind\x18\x04 \x01(\x0e\x32(.temporal.api.stream.v1.StreamRecordKind\x12\x13\n\x0bproducer_id\x18\x05 \x01(\t\x12\x0f\n\x07\x61ttempt\x18\x06 \x01(\x03\x12\x10\n\x08sequence\x18\x07 \x01(\x03\x1aP\n\rMetadataEntry\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"\xb9\x01\n\x0bStreamSlice\x12\x11\n\tstream_id\x18\x01 \x01(\t\x12\x0e\n\x06run_id\x18\x02 \x01(\t\x12\x13\n\x0b\x66rom_offset\x18\x03 \x01(\x03\x12\x11\n\tto_offset\x18\x04 \x01(\x03\x12\x35\n\x07records\x18\x05 \x03(\x0b\x32$.temporal.api.stream.v1.StreamRecord\x12(\n workflow_task_completed_event_id\x18\x06 \x01(\x03"H\n\x0bStreamRange\x12\x11\n\tstream_id\x18\x01 \x01(\t\x12\x13\n\x0b\x66rom_offset\x18\x02 \x01(\x03\x12\x11\n\tto_offset\x18\x03 \x01(\x03*r\n\x10StreamRecordKind\x12"\n\x1eSTREAM_RECORD_KIND_UNSPECIFIED\x10\x00\x12\x1b\n\x17STREAM_RECORD_KIND_DATA\x10\x01\x12\x1d\n\x19STREAM_RECORD_KIND_FINISH\x10\x02\x42\x89\x01\n\x19io.temporal.api.stream.v1B\x0cMessageProtoP\x01Z#go.temporal.io/api/stream/v1;stream\xaa\x02\x18Temporalio.Api.Stream.V1\xea\x02\x1bTemporalio::Api::Stream::V1b\x06proto3' +) + +_STREAMRECORDKIND = DESCRIPTOR.enum_types_by_name["StreamRecordKind"] +StreamRecordKind = enum_type_wrapper.EnumTypeWrapper(_STREAMRECORDKIND) +STREAM_RECORD_KIND_UNSPECIFIED = 0 +STREAM_RECORD_KIND_DATA = 1 +STREAM_RECORD_KIND_FINISH = 2 + + +_STREAMRECORD = DESCRIPTOR.message_types_by_name["StreamRecord"] +_STREAMRECORD_METADATAENTRY = _STREAMRECORD.nested_types_by_name["MetadataEntry"] +_STREAMSLICE = DESCRIPTOR.message_types_by_name["StreamSlice"] +_STREAMRANGE = DESCRIPTOR.message_types_by_name["StreamRange"] +StreamRecord = _reflection.GeneratedProtocolMessageType( + "StreamRecord", + (_message.Message,), + { + "MetadataEntry": _reflection.GeneratedProtocolMessageType( + "MetadataEntry", + (_message.Message,), + { + "DESCRIPTOR": _STREAMRECORD_METADATAENTRY, + "__module__": "temporalio.api.stream.v1.message_pb2", + # @@protoc_insertion_point(class_scope:temporal.api.stream.v1.StreamRecord.MetadataEntry) + }, + ), + "DESCRIPTOR": _STREAMRECORD, + "__module__": "temporalio.api.stream.v1.message_pb2", + # @@protoc_insertion_point(class_scope:temporal.api.stream.v1.StreamRecord) + }, +) +_sym_db.RegisterMessage(StreamRecord) +_sym_db.RegisterMessage(StreamRecord.MetadataEntry) + +StreamSlice = _reflection.GeneratedProtocolMessageType( + "StreamSlice", + (_message.Message,), + { + "DESCRIPTOR": _STREAMSLICE, + "__module__": "temporalio.api.stream.v1.message_pb2", + # @@protoc_insertion_point(class_scope:temporal.api.stream.v1.StreamSlice) + }, +) +_sym_db.RegisterMessage(StreamSlice) + +StreamRange = _reflection.GeneratedProtocolMessageType( + "StreamRange", + (_message.Message,), + { + "DESCRIPTOR": _STREAMRANGE, + "__module__": "temporalio.api.stream.v1.message_pb2", + # @@protoc_insertion_point(class_scope:temporal.api.stream.v1.StreamRange) + }, +) +_sym_db.RegisterMessage(StreamRange) + +if _descriptor._USE_C_DESCRIPTORS == False: + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b"\n\031io.temporal.api.stream.v1B\014MessageProtoP\001Z#go.temporal.io/api/stream/v1;stream\252\002\030Temporalio.Api.Stream.V1\352\002\033Temporalio::Api::Stream::V1" + _STREAMRECORD_METADATAENTRY._options = None + _STREAMRECORD_METADATAENTRY._serialized_options = b"8\001" + _STREAMRECORDKIND._serialized_start = 707 + _STREAMRECORDKIND._serialized_end = 821 + _STREAMRECORD._serialized_start = 103 + _STREAMRECORD._serialized_end = 443 + _STREAMRECORD_METADATAENTRY._serialized_start = 363 + _STREAMRECORD_METADATAENTRY._serialized_end = 443 + _STREAMSLICE._serialized_start = 446 + _STREAMSLICE._serialized_end = 631 + _STREAMRANGE._serialized_start = 633 + _STREAMRANGE._serialized_end = 705 +# @@protoc_insertion_point(module_scope) diff --git a/temporalio/api/stream/v1/message_pb2.pyi b/temporalio/api/stream/v1/message_pb2.pyi new file mode 100644 index 000000000..74a6d4a75 --- /dev/null +++ b/temporalio/api/stream/v1/message_pb2.pyi @@ -0,0 +1,267 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" + +import builtins +import collections.abc +import sys +import typing + +import google.protobuf.descriptor +import google.protobuf.internal.containers +import google.protobuf.internal.enum_type_wrapper +import google.protobuf.message + +import temporalio.api.common.v1.message_pb2 + +if sys.version_info >= (3, 10): + import typing as typing_extensions +else: + import typing_extensions + +DESCRIPTOR: google.protobuf.descriptor.FileDescriptor + +class _StreamRecordKind: + ValueType = typing.NewType("ValueType", builtins.int) + V: typing_extensions.TypeAlias = ValueType + +class _StreamRecordKindEnumTypeWrapper( + google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[ + _StreamRecordKind.ValueType + ], + builtins.type, +): # noqa: F821 + DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor + STREAM_RECORD_KIND_UNSPECIFIED: _StreamRecordKind.ValueType # 0 + """Read as DATA.""" + STREAM_RECORD_KIND_DATA: _StreamRecordKind.ValueType # 1 + """A value the producer published; `body` carries it.""" + STREAM_RECORD_KIND_FINISH: _StreamRecordKind.ValueType # 2 + """The producer named by `producer_id` writes nothing more on `topic`. + Says nothing about that producer's outcome and does not end the stream. + """ + +class StreamRecordKind(_StreamRecordKind, metaclass=_StreamRecordKindEnumTypeWrapper): + """What a record means to a reader. Kept on the record itself so every store + and every language reads it the same way without a private envelope. + """ + +STREAM_RECORD_KIND_UNSPECIFIED: StreamRecordKind.ValueType # 0 +"""Read as DATA.""" +STREAM_RECORD_KIND_DATA: StreamRecordKind.ValueType # 1 +"""A value the producer published; `body` carries it.""" +STREAM_RECORD_KIND_FINISH: StreamRecordKind.ValueType # 2 +"""The producer named by `producer_id` writes nothing more on `topic`. +Says nothing about that producer's outcome and does not end the stream. +""" +global___StreamRecordKind = StreamRecordKind + +class StreamRecord(google.protobuf.message.Message): + """One entry in a stream. The record is the wire format: stores keep it + serialized as is and readers in every language decode the same bytes. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + class MetadataEntry(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + KEY_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + key: builtins.str + @property + def value(self) -> temporalio.api.common.v1.message_pb2.Payload: ... + def __init__( + self, + *, + key: builtins.str = ..., + value: temporalio.api.common.v1.message_pb2.Payload | None = ..., + ) -> None: ... + def HasField( + self, field_name: typing_extensions.Literal["value", b"value"] + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing_extensions.Literal["key", b"key", "value", b"value"], + ) -> None: ... + + BODY_FIELD_NUMBER: builtins.int + METADATA_FIELD_NUMBER: builtins.int + TOPIC_FIELD_NUMBER: builtins.int + KIND_FIELD_NUMBER: builtins.int + PRODUCER_ID_FIELD_NUMBER: builtins.int + ATTEMPT_FIELD_NUMBER: builtins.int + SEQUENCE_FIELD_NUMBER: builtins.int + @property + def body(self) -> temporalio.api.common.v1.message_pb2.Payload: + """The value the producer published, stored as sent. A payload codec + applies here as it does to any other payload. + """ + @property + def metadata( + self, + ) -> google.protobuf.internal.containers.MessageMap[ + builtins.str, temporalio.api.common.v1.message_pb2.Payload + ]: + """Producer-supplied provenance, stored as sent.""" + topic: builtins.str + """Producer-supplied grouping label, stored as sent.""" + kind: global___StreamRecordKind.ValueType + """How to read this record. Unspecified is read as DATA.""" + producer_id: builtins.str + """Who wrote the record. Empty when the owning Workflow did.""" + attempt: builtins.int + """The producer's attempt. Readers treat a later attempt by the same + producer as superseding what the earlier one wrote. + """ + sequence: builtins.int + """The producer's position within its attempt, or -1 when unnumbered. + Stored as sent; the server does not assign, validate or order by it. + """ + def __init__( + self, + *, + body: temporalio.api.common.v1.message_pb2.Payload | None = ..., + metadata: collections.abc.Mapping[ + builtins.str, temporalio.api.common.v1.message_pb2.Payload + ] + | None = ..., + topic: builtins.str = ..., + kind: global___StreamRecordKind.ValueType = ..., + producer_id: builtins.str = ..., + attempt: builtins.int = ..., + sequence: builtins.int = ..., + ) -> None: ... + def HasField( + self, field_name: typing_extensions.Literal["body", b"body"] + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "attempt", + b"attempt", + "body", + b"body", + "kind", + b"kind", + "metadata", + b"metadata", + "producer_id", + b"producer_id", + "sequence", + b"sequence", + "topic", + b"topic", + ], + ) -> None: ... + +global___StreamRecord = StreamRecord + +class StreamSlice(google.protobuf.message.Message): + """A contiguous range of a stream delivered to a Workflow Task, along with the + offsets it covers. The offsets are what History records; the records + themselves are never written to History. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + STREAM_ID_FIELD_NUMBER: builtins.int + RUN_ID_FIELD_NUMBER: builtins.int + FROM_OFFSET_FIELD_NUMBER: builtins.int + TO_OFFSET_FIELD_NUMBER: builtins.int + RECORDS_FIELD_NUMBER: builtins.int + WORKFLOW_TASK_COMPLETED_EVENT_ID_FIELD_NUMBER: builtins.int + stream_id: builtins.str + run_id: builtins.str + """Run id of the execution that owns the stream. Set on both a slice for the + task being started and a re-supplied one. + """ + from_offset: builtins.int + """Inclusive.""" + to_offset: builtins.int + """Exclusive. Equal to from_offset when the subscription observed nothing, + which is a fact replay has to reproduce rather than an absence of one. + """ + @property + def records( + self, + ) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[ + global___StreamRecord + ]: ... + workflow_task_completed_event_id: builtins.int + """The WorkflowTaskCompleted event whose consumed_stream_ranges recorded + this range. Set only when the server is re-supplying a range for a task + being replayed; a slice for the task now being started leaves it unset, + because the event closing that task does not exist yet. + + Replay needs this because a Workflow Task response carries one slice set + while a cache miss replays every prior task, so the ranges have to be + matched to the events that recorded them rather than to the response. + """ + def __init__( + self, + *, + stream_id: builtins.str = ..., + run_id: builtins.str = ..., + from_offset: builtins.int = ..., + to_offset: builtins.int = ..., + records: collections.abc.Iterable[global___StreamRecord] | None = ..., + workflow_task_completed_event_id: builtins.int = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "from_offset", + b"from_offset", + "records", + b"records", + "run_id", + b"run_id", + "stream_id", + b"stream_id", + "to_offset", + b"to_offset", + "workflow_task_completed_event_id", + b"workflow_task_completed_event_id", + ], + ) -> None: ... + +global___StreamSlice = StreamSlice + +class StreamRange(google.protobuf.message.Message): + """The offsets a Workflow Task consumed, without the payloads. Recorded on + WorkflowTaskCompleted so History grows with Workflow Tasks rather than with + records. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + STREAM_ID_FIELD_NUMBER: builtins.int + FROM_OFFSET_FIELD_NUMBER: builtins.int + TO_OFFSET_FIELD_NUMBER: builtins.int + stream_id: builtins.str + from_offset: builtins.int + """Inclusive.""" + to_offset: builtins.int + """Exclusive.""" + def __init__( + self, + *, + stream_id: builtins.str = ..., + from_offset: builtins.int = ..., + to_offset: builtins.int = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "from_offset", + b"from_offset", + "stream_id", + b"stream_id", + "to_offset", + b"to_offset", + ], + ) -> None: ... + +global___StreamRange = StreamRange diff --git a/temporalio/api/workflowservice/v1/request_response_pb2.py b/temporalio/api/workflowservice/v1/request_response_pb2.py index e1b2e01c4..cdf2becc2 100644 --- a/temporalio/api/workflowservice/v1/request_response_pb2.py +++ b/temporalio/api/workflowservice/v1/request_response_pb2.py @@ -114,6 +114,9 @@ from temporalio.api.sdk.v1 import ( worker_config_pb2 as temporal_dot_api_dot_sdk_dot_v1_dot_worker__config__pb2, ) +from temporalio.api.stream.v1 import ( + message_pb2 as temporal_dot_api_dot_stream_dot_v1_dot_message__pb2, +) from temporalio.api.taskqueue.v1 import ( message_pb2 as temporal_dot_api_dot_taskqueue_dot_v1_dot_message__pb2, ) @@ -131,7 +134,7 @@ ) DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n6temporal/api/workflowservice/v1/request_response.proto\x12\x1ftemporal.api.workflowservice.v1\x1a+temporal/api/enums/v1/batch_operation.proto\x1a"temporal/api/enums/v1/common.proto\x1a$temporal/api/enums/v1/workflow.proto\x1a%temporal/api/enums/v1/namespace.proto\x1a(temporal/api/enums/v1/failed_cause.proto\x1a!temporal/api/enums/v1/query.proto\x1a!temporal/api/enums/v1/reset.proto\x1a&temporal/api/enums/v1/task_queue.proto\x1a&temporal/api/enums/v1/deployment.proto\x1a"temporal/api/enums/v1/update.proto\x1a)temporal/api/enums/v1/time_skipping.proto\x1a$temporal/api/enums/v1/activity.proto\x1a!temporal/api/enums/v1/nexus.proto\x1a&temporal/api/activity/v1/message.proto\x1a$temporal/api/common/v1/message.proto\x1a%temporal/api/history/v1/message.proto\x1a&temporal/api/workflow/v1/message.proto\x1a%temporal/api/command/v1/message.proto\x1a$temporal/api/compute/v1/config.proto\x1a(temporal/api/deployment/v1/message.proto\x1a%temporal/api/failure/v1/message.proto\x1a$temporal/api/filter/v1/message.proto\x1a&temporal/api/protocol/v1/message.proto\x1a\'temporal/api/namespace/v1/message.proto\x1a#temporal/api/query/v1/message.proto\x1a)temporal/api/replication/v1/message.proto\x1a#temporal/api/rules/v1/message.proto\x1a\'temporal/api/sdk/v1/worker_config.proto\x1a&temporal/api/schedule/v1/message.proto\x1a\'temporal/api/taskqueue/v1/message.proto\x1a$temporal/api/update/v1/message.proto\x1a%temporal/api/version/v1/message.proto\x1a#temporal/api/batch/v1/message.proto\x1a\x30temporal/api/sdk/v1/task_complete_metadata.proto\x1a\'temporal/api/sdk/v1/user_metadata.proto\x1a#temporal/api/nexus/v1/message.proto\x1a$temporal/api/worker/v1/message.proto\x1a\x1egoogle/protobuf/duration.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto"\x88\x05\n\x18RegisterNamespaceRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x13\n\x0bowner_email\x18\x03 \x01(\t\x12\x46\n#workflow_execution_retention_period\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\x12G\n\x08\x63lusters\x18\x05 \x03(\x0b\x32\x35.temporal.api.replication.v1.ClusterReplicationConfig\x12\x1b\n\x13\x61\x63tive_cluster_name\x18\x06 \x01(\t\x12Q\n\x04\x64\x61ta\x18\x07 \x03(\x0b\x32\x43.temporal.api.workflowservice.v1.RegisterNamespaceRequest.DataEntry\x12\x16\n\x0esecurity_token\x18\x08 \x01(\t\x12\x1b\n\x13is_global_namespace\x18\t \x01(\x08\x12\x44\n\x16history_archival_state\x18\n \x01(\x0e\x32$.temporal.api.enums.v1.ArchivalState\x12\x1c\n\x14history_archival_uri\x18\x0b \x01(\t\x12G\n\x19visibility_archival_state\x18\x0c \x01(\x0e\x32$.temporal.api.enums.v1.ArchivalState\x12\x1f\n\x17visibility_archival_uri\x18\r \x01(\t\x1a+\n\tDataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01"\x1b\n\x19RegisterNamespaceResponse"\x89\x01\n\x15ListNamespacesRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c\x12\x44\n\x10namespace_filter\x18\x03 \x01(\x0b\x32*.temporal.api.namespace.v1.NamespaceFilter"\x81\x01\n\x16ListNamespacesResponse\x12N\n\nnamespaces\x18\x01 \x03(\x0b\x32:.temporal.api.workflowservice.v1.DescribeNamespaceResponse\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"S\n\x18\x44\x65scribeNamespaceRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\n\n\x02id\x18\x02 \x01(\t\x12\x18\n\x10weak_consistency\x18\x03 \x01(\x08"\x81\x04\n\x19\x44\x65scribeNamespaceResponse\x12@\n\x0enamespace_info\x18\x01 \x01(\x0b\x32(.temporal.api.namespace.v1.NamespaceInfo\x12:\n\x06\x63onfig\x18\x02 \x01(\x0b\x32*.temporal.api.namespace.v1.NamespaceConfig\x12S\n\x12replication_config\x18\x03 \x01(\x0b\x32\x37.temporal.api.replication.v1.NamespaceReplicationConfig\x12\x18\n\x10\x66\x61ilover_version\x18\x04 \x01(\x03\x12\x1b\n\x13is_global_namespace\x18\x05 \x01(\x08\x12\x45\n\x10\x66\x61ilover_history\x18\x06 \x03(\x0b\x32+.temporal.api.replication.v1.FailoverStatus\x12J\n\x12poller_group_infos\x18\x07 \x03(\x0b\x32*.temporal.api.taskqueue.v1.PollerGroupInfoB\x02\x18\x01\x12G\n\x12poller_groups_info\x18\x08 \x01(\x0b\x32+.temporal.api.taskqueue.v1.PollerGroupsInfo"\xcf\x02\n\x16UpdateNamespaceRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x43\n\x0bupdate_info\x18\x02 \x01(\x0b\x32..temporal.api.namespace.v1.UpdateNamespaceInfo\x12:\n\x06\x63onfig\x18\x03 \x01(\x0b\x32*.temporal.api.namespace.v1.NamespaceConfig\x12S\n\x12replication_config\x18\x04 \x01(\x0b\x32\x37.temporal.api.replication.v1.NamespaceReplicationConfig\x12\x16\n\x0esecurity_token\x18\x05 \x01(\t\x12\x19\n\x11\x64\x65lete_bad_binary\x18\x06 \x01(\t\x12\x19\n\x11promote_namespace\x18\x07 \x01(\x08"\xa3\x02\n\x17UpdateNamespaceResponse\x12@\n\x0enamespace_info\x18\x01 \x01(\x0b\x32(.temporal.api.namespace.v1.NamespaceInfo\x12:\n\x06\x63onfig\x18\x02 \x01(\x0b\x32*.temporal.api.namespace.v1.NamespaceConfig\x12S\n\x12replication_config\x18\x03 \x01(\x0b\x32\x37.temporal.api.replication.v1.NamespaceReplicationConfig\x12\x18\n\x10\x66\x61ilover_version\x18\x04 \x01(\x03\x12\x1b\n\x13is_global_namespace\x18\x05 \x01(\x08"F\n\x19\x44\x65precateNamespaceRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x16\n\x0esecurity_token\x18\x02 \x01(\t"\x1c\n\x1a\x44\x65precateNamespaceResponse"\xd1\x0c\n\x1dStartWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x38\n\ntask_queue\x18\x04 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12/\n\x05input\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12=\n\x1aworkflow_execution_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x37\n\x14workflow_run_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\x15workflow_task_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x10\n\x08identity\x18\t \x01(\t\x12\x12\n\nrequest_id\x18\n \x01(\t\x12N\n\x18workflow_id_reuse_policy\x18\x0b \x01(\x0e\x32,.temporal.api.enums.v1.WorkflowIdReusePolicy\x12T\n\x1bworkflow_id_conflict_policy\x18\x16 \x01(\x0e\x32/.temporal.api.enums.v1.WorkflowIdConflictPolicy\x12\x39\n\x0cretry_policy\x18\x0c \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12\x15\n\rcron_schedule\x18\r \x01(\t\x12*\n\x04memo\x18\x0e \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x0f \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12.\n\x06header\x18\x10 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12\x1f\n\x17request_eager_execution\x18\x11 \x01(\x08\x12;\n\x11\x63ontinued_failure\x18\x12 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12@\n\x16last_completion_result\x18\x13 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x37\n\x14workflow_start_delay\x18\x14 \x01(\x0b\x32\x19.google.protobuf.Duration\x12>\n\x14\x63ompletion_callbacks\x18\x15 \x03(\x0b\x32 .temporal.api.common.v1.Callback\x12\x38\n\ruser_metadata\x18\x17 \x01(\x0b\x32!.temporal.api.sdk.v1.UserMetadata\x12+\n\x05links\x18\x18 \x03(\x0b\x32\x1c.temporal.api.common.v1.Link\x12I\n\x13versioning_override\x18\x19 \x01(\x0b\x32,.temporal.api.workflow.v1.VersioningOverride\x12H\n\x13on_conflict_options\x18\x1a \x01(\x0b\x32+.temporal.api.workflow.v1.OnConflictOptions\x12\x32\n\x08priority\x18\x1b \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12\\\n\x1f\x65\x61ger_worker_deployment_options\x18\x1c \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions\x12H\n\x14time_skipping_config\x18\x1d \x01(\x0b\x32*.temporal.api.common.v1.TimeSkippingConfig"\xaa\x02\n\x1eStartWorkflowExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x1e\n\x16\x66irst_execution_run_id\x18\x06 \x01(\t\x12\x0f\n\x07started\x18\x03 \x01(\x08\x12>\n\x06status\x18\x05 \x01(\x0e\x32..temporal.api.enums.v1.WorkflowExecutionStatus\x12[\n\x13\x65\x61ger_workflow_task\x18\x02 \x01(\x0b\x32>.temporal.api.workflowservice.v1.PollWorkflowTaskQueueResponse\x12*\n\x04link\x18\x04 \x01(\x0b\x32\x1c.temporal.api.common.v1.Link"\xaa\x02\n"GetWorkflowExecutionHistoryRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x19\n\x11maximum_page_size\x18\x03 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x04 \x01(\x0c\x12\x16\n\x0ewait_new_event\x18\x05 \x01(\x08\x12P\n\x19history_event_filter_type\x18\x06 \x01(\x0e\x32-.temporal.api.enums.v1.HistoryEventFilterType\x12\x15\n\rskip_archival\x18\x07 \x01(\x08"\xba\x01\n#GetWorkflowExecutionHistoryResponse\x12\x31\n\x07history\x18\x01 \x01(\x0b\x32 .temporal.api.history.v1.History\x12\x35\n\x0braw_history\x18\x02 \x03(\x0b\x32 .temporal.api.common.v1.DataBlob\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\x10\n\x08\x61rchived\x18\x04 \x01(\x08"\xb0\x01\n)GetWorkflowExecutionHistoryReverseRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x19\n\x11maximum_page_size\x18\x03 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x04 \x01(\x0c"x\n*GetWorkflowExecutionHistoryReverseResponse\x12\x31\n\x07history\x18\x01 \x01(\x0b\x32 .temporal.api.history.v1.History\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c"\xb8\x03\n\x1cPollWorkflowTaskQueueRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\ntask_queue\x18\x02 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12\x17\n\x0fpoller_group_id\x18\n \x01(\t\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x1b\n\x13worker_instance_key\x18\x08 \x01(\t\x12!\n\x19worker_control_task_queue\x18\t \x01(\t\x12\x1b\n\x0f\x62inary_checksum\x18\x04 \x01(\tB\x02\x18\x01\x12Z\n\x1bworker_version_capabilities\x18\x05 \x01(\x0b\x32\x31.temporal.api.common.v1.WorkerVersionCapabilitiesB\x02\x18\x01\x12O\n\x12\x64\x65ployment_options\x18\x06 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptionsJ\x04\x08\x07\x10\x08R\x10worker_heartbeat"\xbf\x08\n\x1dPollWorkflowTaskQueueResponse\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12!\n\x19previous_started_event_id\x18\x04 \x01(\x03\x12\x18\n\x10started_event_id\x18\x05 \x01(\x03\x12\x0f\n\x07\x61ttempt\x18\x06 \x01(\x05\x12\x1a\n\x12\x62\x61\x63klog_count_hint\x18\x07 \x01(\x03\x12\x31\n\x07history\x18\x08 \x01(\x0b\x32 .temporal.api.history.v1.History\x12\x17\n\x0fnext_page_token\x18\t \x01(\x0c\x12\x33\n\x05query\x18\n \x01(\x0b\x32$.temporal.api.query.v1.WorkflowQuery\x12K\n\x1dworkflow_execution_task_queue\x18\x0b \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12\x32\n\x0escheduled_time\x18\x0c \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x30\n\x0cstarted_time\x18\r \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\\\n\x07queries\x18\x0e \x03(\x0b\x32K.temporal.api.workflowservice.v1.PollWorkflowTaskQueueResponse.QueriesEntry\x12\x33\n\x08messages\x18\x0f \x03(\x0b\x32!.temporal.api.protocol.v1.Message\x12Q\n\x17poller_scaling_decision\x18\x10 \x01(\x0b\x32\x30.temporal.api.taskqueue.v1.PollerScalingDecision\x12\x17\n\x0fpoller_group_id\x18\x11 \x01(\t\x12J\n\x12poller_group_infos\x18\x12 \x03(\x0b\x32*.temporal.api.taskqueue.v1.PollerGroupInfoB\x02\x18\x01\x12G\n\x12poller_groups_info\x18\x13 \x01(\x0b\x32+.temporal.api.taskqueue.v1.PollerGroupsInfo\x1aT\n\x0cQueriesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x33\n\x05value\x18\x02 \x01(\x0b\x32$.temporal.api.query.v1.WorkflowQuery:\x02\x38\x01"\xba\n\n#RespondWorkflowTaskCompletedRequest\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12\x32\n\x08\x63ommands\x18\x02 \x03(\x0b\x32 .temporal.api.command.v1.Command\x12\x10\n\x08identity\x18\x03 \x01(\t\x12O\n\x11sticky_attributes\x18\x04 \x01(\x0b\x32\x34.temporal.api.taskqueue.v1.StickyExecutionAttributes\x12 \n\x18return_new_workflow_task\x18\x05 \x01(\x08\x12&\n\x1e\x66orce_create_new_workflow_task\x18\x06 \x01(\x08\x12\x1b\n\x0f\x62inary_checksum\x18\x07 \x01(\tB\x02\x18\x01\x12m\n\rquery_results\x18\x08 \x03(\x0b\x32V.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest.QueryResultsEntry\x12\x11\n\tnamespace\x18\t \x01(\t\x12\x13\n\x0bresource_id\x18\x12 \x01(\t\x12L\n\x14worker_version_stamp\x18\n \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12\x33\n\x08messages\x18\x0b \x03(\x0b\x32!.temporal.api.protocol.v1.Message\x12H\n\x0csdk_metadata\x18\x0c \x01(\x0b\x32\x32.temporal.api.sdk.v1.WorkflowTaskCompletedMetadata\x12\x43\n\x11metering_metadata\x18\r \x01(\x0b\x32(.temporal.api.common.v1.MeteringMetadata\x12g\n\x0c\x63\x61pabilities\x18\x0e \x01(\x0b\x32Q.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest.Capabilities\x12>\n\ndeployment\x18\x0f \x01(\x0b\x32&.temporal.api.deployment.v1.DeploymentB\x02\x18\x01\x12\x46\n\x13versioning_behavior\x18\x10 \x01(\x0e\x32).temporal.api.enums.v1.VersioningBehavior\x12O\n\x12\x64\x65ployment_options\x18\x11 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions\x12\x1b\n\x13worker_instance_key\x18\x13 \x01(\t\x12!\n\x19worker_control_task_queue\x18\x14 \x01(\t\x12\x13\n\x0bpage_number\x18\x15 \x01(\x05\x12\x19\n\x11intermediate_page\x18\x16 \x01(\x08\x1a_\n\x11QueryResultsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x39\n\x05value\x18\x02 \x01(\x0b\x32*.temporal.api.query.v1.WorkflowQueryResult:\x02\x38\x01\x1a\x45\n\x0c\x43\x61pabilities\x12\x35\n-discard_speculative_workflow_task_with_events\x18\x01 \x01(\x08"\xf5\x01\n$RespondWorkflowTaskCompletedResponse\x12U\n\rworkflow_task\x18\x01 \x01(\x0b\x32>.temporal.api.workflowservice.v1.PollWorkflowTaskQueueResponse\x12V\n\x0e\x61\x63tivity_tasks\x18\x02 \x03(\x0b\x32>.temporal.api.workflowservice.v1.PollActivityTaskQueueResponse\x12\x1e\n\x16reset_history_event_id\x18\x03 \x01(\x03"\x8d\x04\n RespondWorkflowTaskFailedRequest\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12=\n\x05\x63\x61use\x18\x02 \x01(\x0e\x32..temporal.api.enums.v1.WorkflowTaskFailedCause\x12\x31\n\x07\x66\x61ilure\x18\x03 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x1b\n\x0f\x62inary_checksum\x18\x05 \x01(\tB\x02\x18\x01\x12\x11\n\tnamespace\x18\x06 \x01(\t\x12\x13\n\x0bresource_id\x18\x0b \x01(\t\x12\x33\n\x08messages\x18\x07 \x03(\x0b\x32!.temporal.api.protocol.v1.Message\x12\x46\n\x0eworker_version\x18\x08 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12>\n\ndeployment\x18\t \x01(\x0b\x32&.temporal.api.deployment.v1.DeploymentB\x02\x18\x01\x12O\n\x12\x64\x65ployment_options\x18\n \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions"#\n!RespondWorkflowTaskFailedResponse"\xe6\x03\n\x1cPollActivityTaskQueueRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\ntask_queue\x18\x02 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12\x17\n\x0fpoller_group_id\x18\n \x01(\t\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x1b\n\x13worker_instance_key\x18\x08 \x01(\t\x12!\n\x19worker_control_task_queue\x18\t \x01(\t\x12I\n\x13task_queue_metadata\x18\x04 \x01(\x0b\x32,.temporal.api.taskqueue.v1.TaskQueueMetadata\x12Z\n\x1bworker_version_capabilities\x18\x05 \x01(\x0b\x32\x31.temporal.api.common.v1.WorkerVersionCapabilitiesB\x02\x18\x01\x12O\n\x12\x64\x65ployment_options\x18\x06 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptionsJ\x04\x08\x07\x10\x08R\x10worker_heartbeat"\x9d\t\n\x1dPollActivityTaskQueueResponse\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12\x1a\n\x12workflow_namespace\x18\x02 \x01(\t\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x45\n\x12workflow_execution\x18\x04 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\ractivity_type\x18\x05 \x01(\x0b\x32$.temporal.api.common.v1.ActivityType\x12\x13\n\x0b\x61\x63tivity_id\x18\x06 \x01(\t\x12.\n\x06header\x18\x07 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12/\n\x05input\x18\x08 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12;\n\x11heartbeat_details\x18\t \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x32\n\x0escheduled_time\x18\n \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x42\n\x1e\x63urrent_attempt_scheduled_time\x18\x0b \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x30\n\x0cstarted_time\x18\x0c \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07\x61ttempt\x18\r \x01(\x05\x12<\n\x19schedule_to_close_timeout\x18\x0e \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\x0f \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x34\n\x11heartbeat_timeout\x18\x10 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x0cretry_policy\x18\x11 \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12Q\n\x17poller_scaling_decision\x18\x12 \x01(\x0b\x32\x30.temporal.api.taskqueue.v1.PollerScalingDecision\x12\x32\n\x08priority\x18\x13 \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12\x17\n\x0f\x61\x63tivity_run_id\x18\x14 \x01(\t\x12J\n\x12poller_group_infos\x18\x15 \x03(\x0b\x32*.temporal.api.taskqueue.v1.PollerGroupInfoB\x02\x18\x01\x12G\n\x12poller_groups_info\x18\x16 \x01(\x0b\x32+.temporal.api.taskqueue.v1.PollerGroupsInfo"\xa5\x01\n"RecordActivityTaskHeartbeatRequest\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12\x31\n\x07\x64\x65tails\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x11\n\tnamespace\x18\x04 \x01(\t\x12\x13\n\x0bresource_id\x18\x05 \x01(\t"p\n#RecordActivityTaskHeartbeatResponse\x12\x18\n\x10\x63\x61ncel_requested\x18\x01 \x01(\x08\x12\x17\n\x0f\x61\x63tivity_paused\x18\x02 \x01(\x08\x12\x16\n\x0e\x61\x63tivity_reset\x18\x03 \x01(\x08"\xcf\x01\n&RecordActivityTaskHeartbeatByIdRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x04 \x01(\t\x12\x31\n\x07\x64\x65tails\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x06 \x01(\t\x12\x13\n\x0bresource_id\x18\x07 \x01(\t"t\n\'RecordActivityTaskHeartbeatByIdResponse\x12\x18\n\x10\x63\x61ncel_requested\x18\x01 \x01(\x08\x12\x17\n\x0f\x61\x63tivity_paused\x18\x02 \x01(\x08\x12\x16\n\x0e\x61\x63tivity_reset\x18\x03 \x01(\x08"\xfe\x02\n#RespondActivityTaskCompletedRequest\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12\x30\n\x06result\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x11\n\tnamespace\x18\x04 \x01(\t\x12\x13\n\x0bresource_id\x18\x08 \x01(\t\x12\x46\n\x0eworker_version\x18\x05 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12>\n\ndeployment\x18\x06 \x01(\x0b\x32&.temporal.api.deployment.v1.DeploymentB\x02\x18\x01\x12O\n\x12\x64\x65ployment_options\x18\x07 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions"&\n$RespondActivityTaskCompletedResponse"\xcf\x01\n\'RespondActivityTaskCompletedByIdRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x04 \x01(\t\x12\x30\n\x06result\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x06 \x01(\t\x12\x13\n\x0bresource_id\x18\x07 \x01(\t"*\n(RespondActivityTaskCompletedByIdResponse"\xbe\x03\n RespondActivityTaskFailedRequest\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12\x31\n\x07\x66\x61ilure\x18\x02 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x11\n\tnamespace\x18\x04 \x01(\t\x12\x13\n\x0bresource_id\x18\t \x01(\t\x12@\n\x16last_heartbeat_details\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x46\n\x0eworker_version\x18\x06 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12>\n\ndeployment\x18\x07 \x01(\x0b\x32&.temporal.api.deployment.v1.DeploymentB\x02\x18\x01\x12O\n\x12\x64\x65ployment_options\x18\x08 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions"W\n!RespondActivityTaskFailedResponse\x12\x32\n\x08\x66\x61ilures\x18\x01 \x03(\x0b\x32 .temporal.api.failure.v1.Failure"\x8f\x02\n$RespondActivityTaskFailedByIdRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x04 \x01(\t\x12\x31\n\x07\x66\x61ilure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x10\n\x08identity\x18\x06 \x01(\t\x12@\n\x16last_heartbeat_details\x18\x07 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x13\n\x0bresource_id\x18\x08 \x01(\t"[\n%RespondActivityTaskFailedByIdResponse\x12\x32\n\x08\x66\x61ilures\x18\x01 \x03(\x0b\x32 .temporal.api.failure.v1.Failure"\xfe\x02\n"RespondActivityTaskCanceledRequest\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12\x31\n\x07\x64\x65tails\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x11\n\tnamespace\x18\x04 \x01(\t\x12\x13\n\x0bresource_id\x18\x08 \x01(\t\x12\x46\n\x0eworker_version\x18\x05 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12>\n\ndeployment\x18\x06 \x01(\x0b\x32&.temporal.api.deployment.v1.DeploymentB\x02\x18\x01\x12O\n\x12\x64\x65ployment_options\x18\x07 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions"%\n#RespondActivityTaskCanceledResponse"\xa0\x02\n&RespondActivityTaskCanceledByIdRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x04 \x01(\t\x12\x31\n\x07\x64\x65tails\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x06 \x01(\t\x12O\n\x12\x64\x65ployment_options\x18\x07 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions\x12\x13\n\x0bresource_id\x18\x08 \x01(\t")\n\'RespondActivityTaskCanceledByIdResponse"\x84\x02\n%RequestCancelWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x12\n\nrequest_id\x18\x04 \x01(\t\x12\x1e\n\x16\x66irst_execution_run_id\x18\x05 \x01(\t\x12\x0e\n\x06reason\x18\x06 \x01(\t\x12+\n\x05links\x18\x07 \x03(\x0b\x32\x1c.temporal.api.common.v1.Link"(\n&RequestCancelWorkflowExecutionResponse"\xde\x02\n\x1eSignalWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x13\n\x0bsignal_name\x18\x03 \x01(\t\x12/\n\x05input\x18\x04 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x12\n\nrequest_id\x18\x06 \x01(\t\x12\x13\n\x07\x63ontrol\x18\x07 \x01(\tB\x02\x18\x01\x12.\n\x06header\x18\x08 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12+\n\x05links\x18\n \x03(\x0b\x32\x1c.temporal.api.common.v1.LinkJ\x04\x08\t\x10\n"M\n\x1fSignalWorkflowExecutionResponse\x12*\n\x04link\x18\x01 \x01(\x0b\x32\x1c.temporal.api.common.v1.Link"\xbb\n\n\'SignalWithStartWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x38\n\ntask_queue\x18\x04 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12/\n\x05input\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12=\n\x1aworkflow_execution_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x37\n\x14workflow_run_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\x15workflow_task_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x10\n\x08identity\x18\t \x01(\t\x12\x12\n\nrequest_id\x18\n \x01(\t\x12N\n\x18workflow_id_reuse_policy\x18\x0b \x01(\x0e\x32,.temporal.api.enums.v1.WorkflowIdReusePolicy\x12T\n\x1bworkflow_id_conflict_policy\x18\x16 \x01(\x0e\x32/.temporal.api.enums.v1.WorkflowIdConflictPolicy\x12\x13\n\x0bsignal_name\x18\x0c \x01(\t\x12\x36\n\x0csignal_input\x18\r \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x13\n\x07\x63ontrol\x18\x0e \x01(\tB\x02\x18\x01\x12\x39\n\x0cretry_policy\x18\x0f \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12\x15\n\rcron_schedule\x18\x10 \x01(\t\x12*\n\x04memo\x18\x11 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x12 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12.\n\x06header\x18\x13 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12\x37\n\x14workflow_start_delay\x18\x14 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\ruser_metadata\x18\x17 \x01(\x0b\x32!.temporal.api.sdk.v1.UserMetadata\x12+\n\x05links\x18\x18 \x03(\x0b\x32\x1c.temporal.api.common.v1.Link\x12I\n\x13versioning_override\x18\x19 \x01(\x0b\x32,.temporal.api.workflow.v1.VersioningOverride\x12\x32\n\x08priority\x18\x1a \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12H\n\x14time_skipping_config\x18\x1b \x01(\x0b\x32*.temporal.api.common.v1.TimeSkippingConfigJ\x04\x08\x15\x10\x16"\x9e\x01\n(SignalWithStartWorkflowExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x1e\n\x16\x66irst_execution_run_id\x18\x04 \x01(\t\x12\x0f\n\x07started\x18\x02 \x01(\x08\x12\x31\n\x0bsignal_link\x18\x03 \x01(\x0b\x32\x1c.temporal.api.common.v1.Link"\xc1\x03\n\x1dResetWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12%\n\x1dworkflow_task_finish_event_id\x18\x04 \x01(\x03\x12\x12\n\nrequest_id\x18\x05 \x01(\t\x12G\n\x12reset_reapply_type\x18\x06 \x01(\x0e\x32\'.temporal.api.enums.v1.ResetReapplyTypeB\x02\x18\x01\x12S\n\x1breset_reapply_exclude_types\x18\x07 \x03(\x0e\x32..temporal.api.enums.v1.ResetReapplyExcludeType\x12K\n\x15post_reset_operations\x18\x08 \x03(\x0b\x32,.temporal.api.workflow.v1.PostResetOperation\x12\x10\n\x08identity\x18\t \x01(\t"0\n\x1eResetWorkflowExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t"\x9f\x02\n!TerminateWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x31\n\x07\x64\x65tails\x18\x04 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x1e\n\x16\x66irst_execution_run_id\x18\x06 \x01(\t\x12+\n\x05links\x18\x07 \x03(\x0b\x32\x1c.temporal.api.common.v1.Link"$\n"TerminateWorkflowExecutionResponse"z\n\x1e\x44\x65leteWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution"!\n\x1f\x44\x65leteWorkflowExecutionResponse"\xc9\x02\n!ListOpenWorkflowExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x19\n\x11maximum_page_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\x42\n\x11start_time_filter\x18\x04 \x01(\x0b\x32\'.temporal.api.filter.v1.StartTimeFilter\x12K\n\x10\x65xecution_filter\x18\x05 \x01(\x0b\x32/.temporal.api.filter.v1.WorkflowExecutionFilterH\x00\x12\x41\n\x0btype_filter\x18\x06 \x01(\x0b\x32*.temporal.api.filter.v1.WorkflowTypeFilterH\x00\x42\t\n\x07\x66ilters"\x82\x01\n"ListOpenWorkflowExecutionsResponse\x12\x43\n\nexecutions\x18\x01 \x03(\x0b\x32/.temporal.api.workflow.v1.WorkflowExecutionInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"\x8a\x03\n#ListClosedWorkflowExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x19\n\x11maximum_page_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\x42\n\x11start_time_filter\x18\x04 \x01(\x0b\x32\'.temporal.api.filter.v1.StartTimeFilter\x12K\n\x10\x65xecution_filter\x18\x05 \x01(\x0b\x32/.temporal.api.filter.v1.WorkflowExecutionFilterH\x00\x12\x41\n\x0btype_filter\x18\x06 \x01(\x0b\x32*.temporal.api.filter.v1.WorkflowTypeFilterH\x00\x12=\n\rstatus_filter\x18\x07 \x01(\x0b\x32$.temporal.api.filter.v1.StatusFilterH\x00\x42\t\n\x07\x66ilters"\x84\x01\n$ListClosedWorkflowExecutionsResponse\x12\x43\n\nexecutions\x18\x01 \x03(\x0b\x32/.temporal.api.workflow.v1.WorkflowExecutionInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"m\n\x1dListWorkflowExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\r\n\x05query\x18\x04 \x01(\t"~\n\x1eListWorkflowExecutionsResponse\x12\x43\n\nexecutions\x18\x01 \x03(\x0b\x32/.temporal.api.workflow.v1.WorkflowExecutionInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"u\n%ListArchivedWorkflowExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\r\n\x05query\x18\x04 \x01(\t"\x86\x01\n&ListArchivedWorkflowExecutionsResponse\x12\x43\n\nexecutions\x18\x01 \x03(\x0b\x32/.temporal.api.workflow.v1.WorkflowExecutionInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"m\n\x1dScanWorkflowExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\r\n\x05query\x18\x04 \x01(\t"~\n\x1eScanWorkflowExecutionsResponse\x12\x43\n\nexecutions\x18\x01 \x03(\x0b\x32/.temporal.api.workflow.v1.WorkflowExecutionInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"B\n\x1e\x43ountWorkflowExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t"\xed\x01\n\x1f\x43ountWorkflowExecutionsResponse\x12\r\n\x05\x63ount\x18\x01 \x01(\x03\x12\x61\n\x06groups\x18\x02 \x03(\x0b\x32Q.temporal.api.workflowservice.v1.CountWorkflowExecutionsResponse.AggregationGroup\x1aX\n\x10\x41ggregationGroup\x12\x35\n\x0cgroup_values\x18\x01 \x03(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\r\n\x05\x63ount\x18\x02 \x01(\x03"\x1c\n\x1aGetSearchAttributesRequest"\xc9\x01\n\x1bGetSearchAttributesResponse\x12T\n\x04keys\x18\x01 \x03(\x0b\x32\x46.temporal.api.workflowservice.v1.GetSearchAttributesResponse.KeysEntry\x1aT\n\tKeysEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x36\n\x05value\x18\x02 \x01(\x0e\x32\'.temporal.api.enums.v1.IndexedValueType:\x02\x38\x01"\xe9\x02\n RespondQueryTaskCompletedRequest\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12>\n\x0e\x63ompleted_type\x18\x02 \x01(\x0e\x32&.temporal.api.enums.v1.QueryResultType\x12\x36\n\x0cquery_result\x18\x03 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x15\n\rerror_message\x18\x04 \x01(\t\x12\x11\n\tnamespace\x18\x06 \x01(\t\x12\x31\n\x07\x66\x61ilure\x18\x07 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12=\n\x05\x63\x61use\x18\x08 \x01(\x0e\x32..temporal.api.enums.v1.WorkflowTaskFailedCause\x12\x17\n\x0fpoller_group_id\x18\t \x01(\tJ\x04\x08\x05\x10\x06"#\n!RespondQueryTaskCompletedResponse"n\n\x1bResetStickyTaskQueueRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution"\x1e\n\x1cResetStickyTaskQueueResponse"\x9b\x02\n\x15ShutdownWorkerRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x19\n\x11sticky_task_queue\x18\x02 \x01(\t\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x0e\n\x06reason\x18\x04 \x01(\t\x12\x41\n\x10worker_heartbeat\x18\x05 \x01(\x0b\x32\'.temporal.api.worker.v1.WorkerHeartbeat\x12\x1b\n\x13worker_instance_key\x18\x06 \x01(\t\x12\x12\n\ntask_queue\x18\x07 \x01(\t\x12>\n\x10task_queue_types\x18\x08 \x03(\x0e\x32$.temporal.api.enums.v1.TaskQueueType"\x18\n\x16ShutdownWorkerResponse"\xe9\x01\n\x14QueryWorkflowRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x33\n\x05query\x18\x03 \x01(\x0b\x32$.temporal.api.query.v1.WorkflowQuery\x12K\n\x16query_reject_condition\x18\x04 \x01(\x0e\x32+.temporal.api.enums.v1.QueryRejectCondition"\xb9\x01\n\x15QueryWorkflowResponse\x12\x36\n\x0cquery_result\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12<\n\x0equery_rejected\x18\x02 \x01(\x0b\x32$.temporal.api.query.v1.QueryRejected\x12*\n\x04link\x18\x03 \x01(\x0b\x32\x1c.temporal.api.common.v1.Link"s\n DescribeWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution"\x99\x05\n!DescribeWorkflowExecutionResponse\x12K\n\x10\x65xecution_config\x18\x01 \x01(\x0b\x32\x31.temporal.api.workflow.v1.WorkflowExecutionConfig\x12P\n\x17workflow_execution_info\x18\x02 \x01(\x0b\x32/.temporal.api.workflow.v1.WorkflowExecutionInfo\x12I\n\x12pending_activities\x18\x03 \x03(\x0b\x32-.temporal.api.workflow.v1.PendingActivityInfo\x12M\n\x10pending_children\x18\x04 \x03(\x0b\x32\x33.temporal.api.workflow.v1.PendingChildExecutionInfo\x12P\n\x15pending_workflow_task\x18\x05 \x01(\x0b\x32\x31.temporal.api.workflow.v1.PendingWorkflowTaskInfo\x12\x39\n\tcallbacks\x18\x06 \x03(\x0b\x32&.temporal.api.workflow.v1.CallbackInfo\x12U\n\x18pending_nexus_operations\x18\x07 \x03(\x0b\x32\x33.temporal.api.workflow.v1.PendingNexusOperationInfo\x12W\n\x16workflow_extended_info\x18\x08 \x01(\x0b\x32\x37.temporal.api.workflow.v1.WorkflowExecutionExtendedInfo"\x90\x04\n\x18\x44\x65scribeTaskQueueRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\ntask_queue\x18\x02 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12=\n\x0ftask_queue_type\x18\x03 \x01(\x0e\x32$.temporal.api.enums.v1.TaskQueueType\x12\x14\n\x0creport_stats\x18\x08 \x01(\x08\x12\x15\n\rreport_config\x18\x0b \x01(\x08\x12%\n\x19include_task_queue_status\x18\x04 \x01(\x08\x42\x02\x18\x01\x12\x42\n\x08\x61pi_mode\x18\x05 \x01(\x0e\x32,.temporal.api.enums.v1.DescribeTaskQueueModeB\x02\x18\x01\x12J\n\x08versions\x18\x06 \x01(\x0b\x32\x34.temporal.api.taskqueue.v1.TaskQueueVersionSelectionB\x02\x18\x01\x12\x42\n\x10task_queue_types\x18\x07 \x03(\x0e\x32$.temporal.api.enums.v1.TaskQueueTypeB\x02\x18\x01\x12\x1a\n\x0ereport_pollers\x18\t \x01(\x08\x42\x02\x18\x01\x12$\n\x18report_task_reachability\x18\n \x01(\x08\x42\x02\x18\x01"\xec\x07\n\x19\x44\x65scribeTaskQueueResponse\x12\x36\n\x07pollers\x18\x01 \x03(\x0b\x32%.temporal.api.taskqueue.v1.PollerInfo\x12\x38\n\x05stats\x18\x05 \x01(\x0b\x32).temporal.api.taskqueue.v1.TaskQueueStats\x12q\n\x15stats_by_priority_key\x18\x08 \x03(\x0b\x32R.temporal.api.workflowservice.v1.DescribeTaskQueueResponse.StatsByPriorityKeyEntry\x12K\n\x0fversioning_info\x18\x04 \x01(\x0b\x32\x32.temporal.api.taskqueue.v1.TaskQueueVersioningInfo\x12:\n\x06\x63onfig\x18\x06 \x01(\x0b\x32*.temporal.api.taskqueue.v1.TaskQueueConfig\x12k\n\x14\x65\x66\x66\x65\x63tive_rate_limit\x18\x07 \x01(\x0b\x32M.temporal.api.workflowservice.v1.DescribeTaskQueueResponse.EffectiveRateLimit\x12I\n\x11task_queue_status\x18\x02 \x01(\x0b\x32*.temporal.api.taskqueue.v1.TaskQueueStatusB\x02\x18\x01\x12g\n\rversions_info\x18\x03 \x03(\x0b\x32L.temporal.api.workflowservice.v1.DescribeTaskQueueResponse.VersionsInfoEntryB\x02\x18\x01\x1a\x64\n\x17StatsByPriorityKeyEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12\x38\n\x05value\x18\x02 \x01(\x0b\x32).temporal.api.taskqueue.v1.TaskQueueStats:\x02\x38\x01\x1at\n\x12\x45\x66\x66\x65\x63tiveRateLimit\x12\x1b\n\x13requests_per_second\x18\x01 \x01(\x02\x12\x41\n\x11rate_limit_source\x18\x02 \x01(\x0e\x32&.temporal.api.enums.v1.RateLimitSource\x1a\x64\n\x11VersionsInfoEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12>\n\x05value\x18\x02 \x01(\x0b\x32/.temporal.api.taskqueue.v1.TaskQueueVersionInfo:\x02\x38\x01"\x17\n\x15GetClusterInfoRequest"\xd1\x03\n\x16GetClusterInfoResponse\x12h\n\x11supported_clients\x18\x01 \x03(\x0b\x32M.temporal.api.workflowservice.v1.GetClusterInfoResponse.SupportedClientsEntry\x12\x16\n\x0eserver_version\x18\x02 \x01(\t\x12\x12\n\ncluster_id\x18\x03 \x01(\t\x12:\n\x0cversion_info\x18\x04 \x01(\x0b\x32$.temporal.api.version.v1.VersionInfo\x12\x14\n\x0c\x63luster_name\x18\x05 \x01(\t\x12\x1b\n\x13history_shard_count\x18\x06 \x01(\x05\x12\x19\n\x11persistence_store\x18\x07 \x01(\t\x12\x18\n\x10visibility_store\x18\x08 \x01(\t\x12 \n\x18initial_failover_version\x18\t \x01(\x03\x12"\n\x1a\x66\x61ilover_version_increment\x18\n \x01(\x03\x1a\x37\n\x15SupportedClientsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01"\x16\n\x14GetSystemInfoRequest"\xc1\x04\n\x15GetSystemInfoResponse\x12\x16\n\x0eserver_version\x18\x01 \x01(\t\x12Y\n\x0c\x63\x61pabilities\x18\x02 \x01(\x0b\x32\x43.temporal.api.workflowservice.v1.GetSystemInfoResponse.Capabilities\x1a\xb4\x03\n\x0c\x43\x61pabilities\x12\x1f\n\x17signal_and_query_header\x18\x01 \x01(\x08\x12&\n\x1einternal_error_differentiation\x18\x02 \x01(\x08\x12*\n"activity_failure_include_heartbeat\x18\x03 \x01(\x08\x12\x1a\n\x12supports_schedules\x18\x04 \x01(\x08\x12"\n\x1a\x65ncoded_failure_attributes\x18\x05 \x01(\x08\x12!\n\x19\x62uild_id_based_versioning\x18\x06 \x01(\x08\x12\x13\n\x0bupsert_memo\x18\x07 \x01(\x08\x12\x1c\n\x14\x65\x61ger_workflow_start\x18\x08 \x01(\x08\x12\x14\n\x0csdk_metadata\x18\t \x01(\x08\x12\'\n\x1f\x63ount_group_by_execution_status\x18\n \x01(\x08\x12\r\n\x05nexus\x18\x0b \x01(\x08\x12!\n\x19server_scaled_deployments\x18\x0c \x01(\x08\x12(\n server_scaled_provider_cloud_run\x18\r \x01(\x08"m\n\x1eListTaskQueuePartitionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\ntask_queue\x18\x02 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue"\xdf\x01\n\x1fListTaskQueuePartitionsResponse\x12]\n\x1e\x61\x63tivity_task_queue_partitions\x18\x01 \x03(\x0b\x32\x35.temporal.api.taskqueue.v1.TaskQueuePartitionMetadata\x12]\n\x1eworkflow_task_queue_partitions\x18\x02 \x03(\x0b\x32\x35.temporal.api.taskqueue.v1.TaskQueuePartitionMetadata"\xcc\x02\n\x15\x43reateScheduleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bschedule_id\x18\x02 \x01(\t\x12\x34\n\x08schedule\x18\x03 \x01(\x0b\x32".temporal.api.schedule.v1.Schedule\x12>\n\rinitial_patch\x18\x04 \x01(\x0b\x32\'.temporal.api.schedule.v1.SchedulePatch\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x12\n\nrequest_id\x18\x06 \x01(\t\x12*\n\x04memo\x18\x07 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x08 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes"0\n\x16\x43reateScheduleResponse\x12\x16\n\x0e\x63onflict_token\x18\x01 \x01(\x0c"A\n\x17\x44\x65scribeScheduleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bschedule_id\x18\x02 \x01(\t"\x8f\x02\n\x18\x44\x65scribeScheduleResponse\x12\x34\n\x08schedule\x18\x01 \x01(\x0b\x32".temporal.api.schedule.v1.Schedule\x12\x34\n\x04info\x18\x02 \x01(\x0b\x32&.temporal.api.schedule.v1.ScheduleInfo\x12*\n\x04memo\x18\x03 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x04 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12\x16\n\x0e\x63onflict_token\x18\x05 \x01(\x0c"\xa4\x02\n\x15UpdateScheduleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bschedule_id\x18\x02 \x01(\t\x12\x34\n\x08schedule\x18\x03 \x01(\x0b\x32".temporal.api.schedule.v1.Schedule\x12\x16\n\x0e\x63onflict_token\x18\x04 \x01(\x0c\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x12\n\nrequest_id\x18\x06 \x01(\t\x12\x43\n\x11search_attributes\x18\x07 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12*\n\x04memo\x18\x08 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo"\x18\n\x16UpdateScheduleResponse"\x9c\x01\n\x14PatchScheduleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bschedule_id\x18\x02 \x01(\t\x12\x36\n\x05patch\x18\x03 \x01(\x0b\x32\'.temporal.api.schedule.v1.SchedulePatch\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x12\n\nrequest_id\x18\x05 \x01(\t"\x17\n\x15PatchScheduleResponse"\xa8\x01\n ListScheduleMatchingTimesRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bschedule_id\x18\x02 \x01(\t\x12.\n\nstart_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12,\n\x08\x65nd_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp"S\n!ListScheduleMatchingTimesResponse\x12.\n\nstart_time\x18\x01 \x03(\x0b\x32\x1a.google.protobuf.Timestamp"Q\n\x15\x44\x65leteScheduleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bschedule_id\x18\x02 \x01(\t\x12\x10\n\x08identity\x18\x03 \x01(\t"\x18\n\x16\x44\x65leteScheduleResponse"l\n\x14ListSchedulesRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x19\n\x11maximum_page_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\r\n\x05query\x18\x04 \x01(\t"p\n\x15ListSchedulesResponse\x12>\n\tschedules\x18\x01 \x03(\x0b\x32+.temporal.api.schedule.v1.ScheduleListEntry\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"9\n\x15\x43ountSchedulesRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t"\xdb\x01\n\x16\x43ountSchedulesResponse\x12\r\n\x05\x63ount\x18\x01 \x01(\x03\x12X\n\x06groups\x18\x02 \x03(\x0b\x32H.temporal.api.workflowservice.v1.CountSchedulesResponse.AggregationGroup\x1aX\n\x10\x41ggregationGroup\x12\x35\n\x0cgroup_values\x18\x01 \x03(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\r\n\x05\x63ount\x18\x02 \x01(\x03"\x86\x05\n\'UpdateWorkerBuildIdCompatibilityRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x12\n\ntask_queue\x18\x02 \x01(\t\x12-\n#add_new_build_id_in_new_default_set\x18\x03 \x01(\tH\x00\x12\x87\x01\n\x1b\x61\x64\x64_new_compatible_build_id\x18\x04 \x01(\x0b\x32`.temporal.api.workflowservice.v1.UpdateWorkerBuildIdCompatibilityRequest.AddNewCompatibleVersionH\x00\x12!\n\x17promote_set_by_build_id\x18\x05 \x01(\tH\x00\x12%\n\x1bpromote_build_id_within_set\x18\x06 \x01(\tH\x00\x12h\n\nmerge_sets\x18\x07 \x01(\x0b\x32R.temporal.api.workflowservice.v1.UpdateWorkerBuildIdCompatibilityRequest.MergeSetsH\x00\x1ao\n\x17\x41\x64\x64NewCompatibleVersion\x12\x14\n\x0cnew_build_id\x18\x01 \x01(\t\x12$\n\x1c\x65xisting_compatible_build_id\x18\x02 \x01(\t\x12\x18\n\x10make_set_default\x18\x03 \x01(\x08\x1aI\n\tMergeSets\x12\x1c\n\x14primary_set_build_id\x18\x01 \x01(\t\x12\x1e\n\x16secondary_set_build_id\x18\x02 \x01(\tB\x0b\n\toperation"@\n(UpdateWorkerBuildIdCompatibilityResponseJ\x04\x08\x01\x10\x02R\x0eversion_set_id"_\n$GetWorkerBuildIdCompatibilityRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x12\n\ntask_queue\x18\x02 \x01(\t\x12\x10\n\x08max_sets\x18\x03 \x01(\x05"t\n%GetWorkerBuildIdCompatibilityResponse\x12K\n\x12major_version_sets\x18\x01 \x03(\x0b\x32/.temporal.api.taskqueue.v1.CompatibleVersionSet"\xb5\r\n"UpdateWorkerVersioningRulesRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x12\n\ntask_queue\x18\x02 \x01(\t\x12\x16\n\x0e\x63onflict_token\x18\x03 \x01(\x0c\x12\x81\x01\n\x16insert_assignment_rule\x18\x04 \x01(\x0b\x32_.temporal.api.workflowservice.v1.UpdateWorkerVersioningRulesRequest.InsertBuildIdAssignmentRuleH\x00\x12\x83\x01\n\x17replace_assignment_rule\x18\x05 \x01(\x0b\x32`.temporal.api.workflowservice.v1.UpdateWorkerVersioningRulesRequest.ReplaceBuildIdAssignmentRuleH\x00\x12\x81\x01\n\x16\x64\x65lete_assignment_rule\x18\x06 \x01(\x0b\x32_.temporal.api.workflowservice.v1.UpdateWorkerVersioningRulesRequest.DeleteBuildIdAssignmentRuleH\x00\x12\x8c\x01\n\x1c\x61\x64\x64_compatible_redirect_rule\x18\x07 \x01(\x0b\x32\x64.temporal.api.workflowservice.v1.UpdateWorkerVersioningRulesRequest.AddCompatibleBuildIdRedirectRuleH\x00\x12\x94\x01\n replace_compatible_redirect_rule\x18\x08 \x01(\x0b\x32h.temporal.api.workflowservice.v1.UpdateWorkerVersioningRulesRequest.ReplaceCompatibleBuildIdRedirectRuleH\x00\x12\x92\x01\n\x1f\x64\x65lete_compatible_redirect_rule\x18\t \x01(\x0b\x32g.temporal.api.workflowservice.v1.UpdateWorkerVersioningRulesRequest.DeleteCompatibleBuildIdRedirectRuleH\x00\x12l\n\x0f\x63ommit_build_id\x18\n \x01(\x0b\x32Q.temporal.api.workflowservice.v1.UpdateWorkerVersioningRulesRequest.CommitBuildIdH\x00\x1aq\n\x1bInsertBuildIdAssignmentRule\x12\x12\n\nrule_index\x18\x01 \x01(\x05\x12>\n\x04rule\x18\x02 \x01(\x0b\x32\x30.temporal.api.taskqueue.v1.BuildIdAssignmentRule\x1a\x81\x01\n\x1cReplaceBuildIdAssignmentRule\x12\x12\n\nrule_index\x18\x01 \x01(\x05\x12>\n\x04rule\x18\x02 \x01(\x0b\x32\x30.temporal.api.taskqueue.v1.BuildIdAssignmentRule\x12\r\n\x05\x66orce\x18\x03 \x01(\x08\x1a@\n\x1b\x44\x65leteBuildIdAssignmentRule\x12\x12\n\nrule_index\x18\x01 \x01(\x05\x12\r\n\x05\x66orce\x18\x02 \x01(\x08\x1aj\n AddCompatibleBuildIdRedirectRule\x12\x46\n\x04rule\x18\x01 \x01(\x0b\x32\x38.temporal.api.taskqueue.v1.CompatibleBuildIdRedirectRule\x1an\n$ReplaceCompatibleBuildIdRedirectRule\x12\x46\n\x04rule\x18\x01 \x01(\x0b\x32\x38.temporal.api.taskqueue.v1.CompatibleBuildIdRedirectRule\x1a>\n#DeleteCompatibleBuildIdRedirectRule\x12\x17\n\x0fsource_build_id\x18\x01 \x01(\t\x1a\x37\n\rCommitBuildId\x12\x17\n\x0ftarget_build_id\x18\x01 \x01(\t\x12\r\n\x05\x66orce\x18\x02 \x01(\x08\x42\x0b\n\toperation"\xfc\x01\n#UpdateWorkerVersioningRulesResponse\x12U\n\x10\x61ssignment_rules\x18\x01 \x03(\x0b\x32;.temporal.api.taskqueue.v1.TimestampedBuildIdAssignmentRule\x12\x66\n\x19\x63ompatible_redirect_rules\x18\x02 \x03(\x0b\x32\x43.temporal.api.taskqueue.v1.TimestampedCompatibleBuildIdRedirectRule\x12\x16\n\x0e\x63onflict_token\x18\x03 \x01(\x0c"H\n\x1fGetWorkerVersioningRulesRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x12\n\ntask_queue\x18\x02 \x01(\t"\xf9\x01\n GetWorkerVersioningRulesResponse\x12U\n\x10\x61ssignment_rules\x18\x01 \x03(\x0b\x32;.temporal.api.taskqueue.v1.TimestampedBuildIdAssignmentRule\x12\x66\n\x19\x63ompatible_redirect_rules\x18\x02 \x03(\x0b\x32\x43.temporal.api.taskqueue.v1.TimestampedCompatibleBuildIdRedirectRule\x12\x16\n\x0e\x63onflict_token\x18\x03 \x01(\x0c"\x9c\x01\n GetWorkerTaskReachabilityRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tbuild_ids\x18\x02 \x03(\t\x12\x13\n\x0btask_queues\x18\x03 \x03(\t\x12=\n\x0creachability\x18\x04 \x01(\x0e\x32\'.temporal.api.enums.v1.TaskReachability"r\n!GetWorkerTaskReachabilityResponse\x12M\n\x15\x62uild_id_reachability\x18\x01 \x03(\x0b\x32..temporal.api.taskqueue.v1.BuildIdReachability"\x85\x02\n\x1eUpdateWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x1e\n\x16\x66irst_execution_run_id\x18\x03 \x01(\t\x12\x37\n\x0bwait_policy\x18\x04 \x01(\x0b\x32".temporal.api.update.v1.WaitPolicy\x12\x30\n\x07request\x18\x05 \x01(\x0b\x32\x1f.temporal.api.update.v1.Request"\x83\x02\n\x1fUpdateWorkflowExecutionResponse\x12\x35\n\nupdate_ref\x18\x01 \x01(\x0b\x32!.temporal.api.update.v1.UpdateRef\x12\x30\n\x07outcome\x18\x02 \x01(\x0b\x32\x1f.temporal.api.update.v1.Outcome\x12K\n\x05stage\x18\x03 \x01(\x0e\x32<.temporal.api.enums.v1.UpdateWorkflowExecutionLifecycleStage\x12*\n\x04link\x18\x04 \x01(\x0b\x32\x1c.temporal.api.common.v1.Link"\xd6\n\n\x1aStartBatchOperationRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x18\n\x10visibility_query\x18\x02 \x01(\t\x12\x0e\n\x06job_id\x18\x03 \x01(\t\x12\x0e\n\x06reason\x18\x04 \x01(\t\x12\x41\n\nexecutions\x18\x05 \x03(\x0b\x32).temporal.api.common.v1.WorkflowExecutionB\x02\x18\x01\x12<\n\x11target_executions\x18\x16 \x03(\x0b\x32!.temporal.api.common.v1.Execution\x12!\n\x19max_operations_per_second\x18\x06 \x01(\x02\x12Q\n\x15termination_operation\x18\n \x01(\x0b\x32\x30.temporal.api.batch.v1.BatchOperationTerminationH\x00\x12G\n\x10signal_operation\x18\x0b \x01(\x0b\x32+.temporal.api.batch.v1.BatchOperationSignalH\x00\x12S\n\x16\x63\x61ncellation_operation\x18\x0c \x01(\x0b\x32\x31.temporal.api.batch.v1.BatchOperationCancellationH\x00\x12K\n\x12\x64\x65letion_operation\x18\r \x01(\x0b\x32-.temporal.api.batch.v1.BatchOperationDeletionH\x00\x12\x45\n\x0freset_operation\x18\x0e \x01(\x0b\x32*.temporal.api.batch.v1.BatchOperationResetH\x00\x12p\n!update_workflow_options_operation\x18\x0f \x01(\x0b\x32\x43.temporal.api.batch.v1.BatchOperationUpdateWorkflowExecutionOptionsH\x00\x12^\n\x1cunpause_activities_operation\x18\x10 \x01(\x0b\x32\x36.temporal.api.batch.v1.BatchOperationUnpauseActivitiesH\x00\x12Z\n\x1areset_activities_operation\x18\x11 \x01(\x0b\x32\x34.temporal.api.batch.v1.BatchOperationResetActivitiesH\x00\x12g\n!update_activity_options_operation\x18\x12 \x01(\x0b\x32:.temporal.api.batch.v1.BatchOperationUpdateActivityOptionsH\x00\x12\\\n\x1b\x63\x61ncel_activities_operation\x18\x13 \x01(\x0b\x32\x35.temporal.api.batch.v1.BatchOperationCancelActivitiesH\x00\x12\x62\n\x1eterminate_activities_operation\x18\x14 \x01(\x0b\x32\x38.temporal.api.batch.v1.BatchOperationTerminateActivitiesH\x00\x12\\\n\x1b\x64\x65lete_activities_operation\x18\x15 \x01(\x0b\x32\x35.temporal.api.batch.v1.BatchOperationDeleteActivitiesH\x00\x42\x0b\n\toperation"\x1d\n\x1bStartBatchOperationResponse"`\n\x19StopBatchOperationRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x0e\n\x06job_id\x18\x02 \x01(\t\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t"\x1c\n\x1aStopBatchOperationResponse"B\n\x1d\x44\x65scribeBatchOperationRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x0e\n\x06job_id\x18\x02 \x01(\t"\xd8\x03\n\x1e\x44\x65scribeBatchOperationResponse\x12\x41\n\x0eoperation_type\x18\x01 \x01(\x0e\x32).temporal.api.enums.v1.BatchOperationType\x12\x0e\n\x06job_id\x18\x02 \x01(\t\x12\x39\n\x05state\x18\x03 \x01(\x0e\x32*.temporal.api.enums.v1.BatchOperationState\x12.\n\nstart_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12.\n\nclose_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1d\n\x15total_operation_count\x18\x06 \x01(\x03\x12 \n\x18\x63omplete_operation_count\x18\x07 \x01(\x03\x12\x1f\n\x17\x66\x61ilure_operation_count\x18\x08 \x01(\x03\x12\x10\n\x08identity\x18\t \x01(\t\x12\x0e\n\x06reason\x18\n \x01(\t\x12\r\n\x05query\x18\x0b \x01(\t\x12\x35\n\nexecutions\x18\x0c \x03(\x0b\x32!.temporal.api.common.v1.Execution"[\n\x1aListBatchOperationsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c"y\n\x1bListBatchOperationsResponse\x12\x41\n\x0eoperation_info\x18\x01 \x03(\x0b\x32).temporal.api.batch.v1.BatchOperationInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"\xb9\x01\n"PollWorkflowExecutionUpdateRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x35\n\nupdate_ref\x18\x02 \x01(\x0b\x32!.temporal.api.update.v1.UpdateRef\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x37\n\x0bwait_policy\x18\x04 \x01(\x0b\x32".temporal.api.update.v1.WaitPolicy"\xdb\x01\n#PollWorkflowExecutionUpdateResponse\x12\x30\n\x07outcome\x18\x01 \x01(\x0b\x32\x1f.temporal.api.update.v1.Outcome\x12K\n\x05stage\x18\x02 \x01(\x0e\x32<.temporal.api.enums.v1.UpdateWorkflowExecutionLifecycleStage\x12\x35\n\nupdate_ref\x18\x03 \x01(\x0b\x32!.temporal.api.update.v1.UpdateRef"\xa0\x03\n\x19PollNexusTaskQueueRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\ntask_queue\x18\x03 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12\x17\n\x0fpoller_group_id\x18\t \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x1b\n\x13worker_instance_key\x18\x08 \x01(\t\x12Z\n\x1bworker_version_capabilities\x18\x04 \x01(\x0b\x32\x31.temporal.api.common.v1.WorkerVersionCapabilitiesB\x02\x18\x01\x12O\n\x12\x64\x65ployment_options\x18\x06 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions\x12\x41\n\x10worker_heartbeat\x18\x07 \x03(\x0b\x32\'.temporal.api.worker.v1.WorkerHeartbeat"\xe2\x02\n\x1aPollNexusTaskQueueResponse\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12/\n\x07request\x18\x02 \x01(\x0b\x32\x1e.temporal.api.nexus.v1.Request\x12Q\n\x17poller_scaling_decision\x18\x03 \x01(\x0b\x32\x30.temporal.api.taskqueue.v1.PollerScalingDecision\x12\x17\n\x0fpoller_group_id\x18\x04 \x01(\t\x12J\n\x12poller_group_infos\x18\x05 \x03(\x0b\x32*.temporal.api.taskqueue.v1.PollerGroupInfoB\x02\x18\x01\x12G\n\x12poller_groups_info\x18\x06 \x01(\x0b\x32+.temporal.api.taskqueue.v1.PollerGroupsInfo"\xa7\x01\n RespondNexusTaskCompletedRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x12\n\ntask_token\x18\x03 \x01(\x0c\x12\x31\n\x08response\x18\x04 \x01(\x0b\x32\x1f.temporal.api.nexus.v1.Response\x12\x17\n\x0fpoller_group_id\x18\x05 \x01(\t"#\n!RespondNexusTaskCompletedResponse"\xdc\x01\n\x1dRespondNexusTaskFailedRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x12\n\ntask_token\x18\x03 \x01(\x0c\x12\x36\n\x05\x65rror\x18\x04 \x01(\x0b\x32#.temporal.api.nexus.v1.HandlerErrorB\x02\x18\x01\x12\x31\n\x07\x66\x61ilure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x17\n\x0fpoller_group_id\x18\x06 \x01(\t" \n\x1eRespondNexusTaskFailedResponse"\xf4\x02\n\x1c\x45xecuteMultiOperationRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12[\n\noperations\x18\x02 \x03(\x0b\x32G.temporal.api.workflowservice.v1.ExecuteMultiOperationRequest.Operation\x12\x13\n\x0bresource_id\x18\x03 \x01(\t\x1a\xce\x01\n\tOperation\x12X\n\x0estart_workflow\x18\x01 \x01(\x0b\x32>.temporal.api.workflowservice.v1.StartWorkflowExecutionRequestH\x00\x12Z\n\x0fupdate_workflow\x18\x02 \x01(\x0b\x32?.temporal.api.workflowservice.v1.UpdateWorkflowExecutionRequestH\x00\x42\x0b\n\toperation"\xcc\x02\n\x1d\x45xecuteMultiOperationResponse\x12Z\n\tresponses\x18\x01 \x03(\x0b\x32G.temporal.api.workflowservice.v1.ExecuteMultiOperationResponse.Response\x1a\xce\x01\n\x08Response\x12Y\n\x0estart_workflow\x18\x01 \x01(\x0b\x32?.temporal.api.workflowservice.v1.StartWorkflowExecutionResponseH\x00\x12[\n\x0fupdate_workflow\x18\x02 \x01(\x0b\x32@.temporal.api.workflowservice.v1.UpdateWorkflowExecutionResponseH\x00\x42\n\n\x08response"\xd0\x02\n\x1cUpdateActivityOptionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x43\n\x10\x61\x63tivity_options\x18\x04 \x01(\x0b\x32).temporal.api.activity.v1.ActivityOptions\x12/\n\x0bupdate_mask\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.FieldMask\x12\x0c\n\x02id\x18\x06 \x01(\tH\x00\x12\x0e\n\x04type\x18\x07 \x01(\tH\x00\x12\x13\n\tmatch_all\x18\t \x01(\x08H\x00\x12\x18\n\x10restore_original\x18\x08 \x01(\x08\x42\n\n\x08\x61\x63tivity"\xbf\x02\n%UpdateActivityExecutionOptionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x03 \x01(\t\x12\x0e\n\x06run_id\x18\x04 \x01(\t\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x43\n\x10\x61\x63tivity_options\x18\x06 \x01(\x0b\x32).temporal.api.activity.v1.ActivityOptions\x12/\n\x0bupdate_mask\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.FieldMask\x12\x18\n\x10restore_original\x18\x08 \x01(\x08\x12\x13\n\x0bresource_id\x18\t \x01(\t\x12\x12\n\nrequest_id\x18\n \x01(\t"d\n\x1dUpdateActivityOptionsResponse\x12\x43\n\x10\x61\x63tivity_options\x18\x01 \x01(\x0b\x32).temporal.api.activity.v1.ActivityOptions"m\n&UpdateActivityExecutionOptionsResponse\x12\x43\n\x10\x61\x63tivity_options\x18\x01 \x01(\x0b\x32).temporal.api.activity.v1.ActivityOptions"\xc7\x01\n\x14PauseActivityRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x0c\n\x02id\x18\x04 \x01(\tH\x00\x12\x0e\n\x04type\x18\x05 \x01(\tH\x00\x12\x0e\n\x06reason\x18\x06 \x01(\t\x12\x12\n\nrequest_id\x18\x07 \x01(\tB\n\n\x08\x61\x63tivity"\xb7\x01\n\x1dPauseActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x03 \x01(\t\x12\x0e\n\x06run_id\x18\x04 \x01(\t\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x0e\n\x06reason\x18\x06 \x01(\t\x12\x13\n\x0bresource_id\x18\x07 \x01(\t\x12\x12\n\nrequest_id\x18\x08 \x01(\t"\x17\n\x15PauseActivityResponse" \n\x1ePauseActivityExecutionResponse"\x98\x02\n\x16UnpauseActivityRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x0c\n\x02id\x18\x04 \x01(\tH\x00\x12\x0e\n\x04type\x18\x05 \x01(\tH\x00\x12\x15\n\x0bunpause_all\x18\x06 \x01(\x08H\x00\x12\x16\n\x0ereset_attempts\x18\x07 \x01(\x08\x12\x17\n\x0freset_heartbeat\x18\x08 \x01(\x08\x12)\n\x06jitter\x18\t \x01(\x0b\x32\x19.google.protobuf.DurationB\n\n\x08\x61\x63tivity"\x91\x02\n\x1fUnpauseActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x03 \x01(\t\x12\x0e\n\x06run_id\x18\x04 \x01(\t\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x0e\n\x06reason\x18\x08 \x01(\t\x12)\n\x06jitter\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x13\n\x0bresource_id\x18\n \x01(\t\x12\x12\n\nrequest_id\x18\x0b \x01(\tJ\x04\x08\x06\x10\x07J\x04\x08\x07\x10\x08R\x0ereset_attemptsR\x0freset_heartbeat"\x19\n\x17UnpauseActivityResponse""\n UnpauseActivityExecutionResponse"\xb3\x02\n\x14ResetActivityRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x0c\n\x02id\x18\x04 \x01(\tH\x00\x12\x0e\n\x04type\x18\x05 \x01(\tH\x00\x12\x13\n\tmatch_all\x18\n \x01(\x08H\x00\x12\x17\n\x0freset_heartbeat\x18\x06 \x01(\x08\x12\x13\n\x0bkeep_paused\x18\x07 \x01(\x08\x12)\n\x06jitter\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12 \n\x18restore_original_options\x18\t \x01(\x08\x42\n\n\x08\x61\x63tivity"\xa2\x02\n\x1dResetActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x03 \x01(\t\x12\x0e\n\x06run_id\x18\x04 \x01(\t\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x13\n\x0bkeep_paused\x18\x06 \x01(\x08\x12)\n\x06jitter\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12 \n\x18restore_original_options\x18\x08 \x01(\x08\x12\x13\n\x0bresource_id\x18\t \x01(\t\x12\x12\n\nrequest_id\x18\n \x01(\t\x12\x17\n\x0freset_heartbeat\x18\x0b \x01(\x08"\x17\n\x15ResetActivityResponse" \n\x1eResetActivityExecutionResponse"\x9c\x02\n%UpdateWorkflowExecutionOptionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12V\n\x1aworkflow_execution_options\x18\x03 \x01(\x0b\x32\x32.temporal.api.workflow.v1.WorkflowExecutionOptions\x12/\n\x0bupdate_mask\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.FieldMask\x12\x10\n\x08identity\x18\x05 \x01(\t"\xb1\x01\n&UpdateWorkflowExecutionOptionsResponse\x12V\n\x1aworkflow_execution_options\x18\x01 \x01(\x0b\x32\x32.temporal.api.workflow.v1.WorkflowExecutionOptions\x12/\n\x0bupdate_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp"j\n\x19\x44\x65scribeDeploymentRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12:\n\ndeployment\x18\x02 \x01(\x0b\x32&.temporal.api.deployment.v1.Deployment"a\n\x1a\x44\x65scribeDeploymentResponse\x12\x43\n\x0f\x64\x65ployment_info\x18\x01 \x01(\x0b\x32*.temporal.api.deployment.v1.DeploymentInfo"\xc2\x01\n&DescribeWorkerDeploymentVersionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x07version\x18\x02 \x01(\tB\x02\x18\x01\x12O\n\x12\x64\x65ployment_version\x18\x03 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12\x1f\n\x17report_task_queue_stats\x18\x04 \x01(\x08"\x8c\x05\n\'DescribeWorkerDeploymentVersionResponse\x12_\n\x1eworker_deployment_version_info\x18\x01 \x01(\x0b\x32\x37.temporal.api.deployment.v1.WorkerDeploymentVersionInfo\x12v\n\x13version_task_queues\x18\x02 \x03(\x0b\x32Y.temporal.api.workflowservice.v1.DescribeWorkerDeploymentVersionResponse.VersionTaskQueue\x1a\x87\x03\n\x10VersionTaskQueue\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x32\n\x04type\x18\x02 \x01(\x0e\x32$.temporal.api.enums.v1.TaskQueueType\x12\x38\n\x05stats\x18\x03 \x01(\x0b\x32).temporal.api.taskqueue.v1.TaskQueueStats\x12\x90\x01\n\x15stats_by_priority_key\x18\x04 \x03(\x0b\x32q.temporal.api.workflowservice.v1.DescribeWorkerDeploymentVersionResponse.VersionTaskQueue.StatsByPriorityKeyEntry\x1a\x64\n\x17StatsByPriorityKeyEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12\x38\n\x05value\x18\x02 \x01(\x0b\x32).temporal.api.taskqueue.v1.TaskQueueStats:\x02\x38\x01"M\n\x1f\x44\x65scribeWorkerDeploymentRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65ployment_name\x18\x02 \x01(\t"\x8c\x01\n DescribeWorkerDeploymentResponse\x12\x16\n\x0e\x63onflict_token\x18\x01 \x01(\x0c\x12P\n\x16worker_deployment_info\x18\x02 \x01(\x0b\x32\x30.temporal.api.deployment.v1.WorkerDeploymentInfo"l\n\x16ListDeploymentsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\x13\n\x0bseries_name\x18\x04 \x01(\t"w\n\x17ListDeploymentsResponse\x12\x17\n\x0fnext_page_token\x18\x01 \x01(\x0c\x12\x43\n\x0b\x64\x65ployments\x18\x02 \x03(\x0b\x32..temporal.api.deployment.v1.DeploymentListInfo"\xcd\x01\n\x1bSetCurrentDeploymentRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12:\n\ndeployment\x18\x02 \x01(\x0b\x32&.temporal.api.deployment.v1.Deployment\x12\x10\n\x08identity\x18\x03 \x01(\t\x12M\n\x0fupdate_metadata\x18\x04 \x01(\x0b\x32\x34.temporal.api.deployment.v1.UpdateDeploymentMetadata"\xb9\x01\n\x1cSetCurrentDeploymentResponse\x12K\n\x17\x63urrent_deployment_info\x18\x01 \x01(\x0b\x32*.temporal.api.deployment.v1.DeploymentInfo\x12L\n\x18previous_deployment_info\x18\x02 \x01(\x0b\x32*.temporal.api.deployment.v1.DeploymentInfo"\xe5\x01\n(SetWorkerDeploymentCurrentVersionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65ployment_name\x18\x02 \x01(\t\x12\x13\n\x07version\x18\x03 \x01(\tB\x02\x18\x01\x12\x10\n\x08\x62uild_id\x18\x07 \x01(\t\x12\x16\n\x0e\x63onflict_token\x18\x04 \x01(\x0c\x12\x10\n\x08identity\x18\x05 \x01(\t\x12"\n\x1aignore_missing_task_queues\x18\x06 \x01(\x08\x12\x18\n\x10\x61llow_no_pollers\x18\t \x01(\x08"\xbf\x01\n)SetWorkerDeploymentCurrentVersionResponse\x12\x16\n\x0e\x63onflict_token\x18\x01 \x01(\x0c\x12\x1c\n\x10previous_version\x18\x02 \x01(\tB\x02\x18\x01\x12\\\n\x1bprevious_deployment_version\x18\x03 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersionB\x02\x18\x01"\xf9\x01\n(SetWorkerDeploymentRampingVersionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65ployment_name\x18\x02 \x01(\t\x12\x13\n\x07version\x18\x03 \x01(\tB\x02\x18\x01\x12\x10\n\x08\x62uild_id\x18\x08 \x01(\t\x12\x12\n\npercentage\x18\x04 \x01(\x02\x12\x16\n\x0e\x63onflict_token\x18\x05 \x01(\x0c\x12\x10\n\x08identity\x18\x06 \x01(\t\x12"\n\x1aignore_missing_task_queues\x18\x07 \x01(\x08\x12\x18\n\x10\x61llow_no_pollers\x18\n \x01(\x08"\xe0\x01\n)SetWorkerDeploymentRampingVersionResponse\x12\x16\n\x0e\x63onflict_token\x18\x01 \x01(\x0c\x12\x1c\n\x10previous_version\x18\x02 \x01(\tB\x02\x18\x01\x12\\\n\x1bprevious_deployment_version\x18\x04 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersionB\x02\x18\x01\x12\x1f\n\x13previous_percentage\x18\x03 \x01(\x02\x42\x02\x18\x01"q\n\x1d\x43reateWorkerDeploymentRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65ployment_name\x18\x02 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x12\n\nrequest_id\x18\x05 \x01(\t"8\n\x1e\x43reateWorkerDeploymentResponse\x12\x16\n\x0e\x63onflict_token\x18\x01 \x01(\x0c"]\n\x1cListWorkerDeploymentsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c"\x9f\x05\n\x1dListWorkerDeploymentsResponse\x12\x17\n\x0fnext_page_token\x18\x01 \x01(\x0c\x12r\n\x12worker_deployments\x18\x02 \x03(\x0b\x32V.temporal.api.workflowservice.v1.ListWorkerDeploymentsResponse.WorkerDeploymentSummary\x1a\xf0\x03\n\x17WorkerDeploymentSummary\x12\x0c\n\x04name\x18\x01 \x01(\t\x12/\n\x0b\x63reate_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x41\n\x0erouting_config\x18\x03 \x01(\x0b\x32).temporal.api.deployment.v1.RoutingConfig\x12o\n\x16latest_version_summary\x18\x04 \x01(\x0b\x32O.temporal.api.deployment.v1.WorkerDeploymentInfo.WorkerDeploymentVersionSummary\x12p\n\x17\x63urrent_version_summary\x18\x05 \x01(\x0b\x32O.temporal.api.deployment.v1.WorkerDeploymentInfo.WorkerDeploymentVersionSummary\x12p\n\x17ramping_version_summary\x18\x06 \x01(\x0b\x32O.temporal.api.deployment.v1.WorkerDeploymentInfo.WorkerDeploymentVersionSummary"\xf0\x01\n$CreateWorkerDeploymentVersionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12O\n\x12\x64\x65ployment_version\x18\x02 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12>\n\x0e\x63ompute_config\x18\x04 \x01(\x0b\x32&.temporal.api.compute.v1.ComputeConfig\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x12\n\nrequest_id\x18\x05 \x01(\t"\'\n%CreateWorkerDeploymentVersionResponse"\xc8\x01\n$DeleteWorkerDeploymentVersionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x07version\x18\x02 \x01(\tB\x02\x18\x01\x12O\n\x12\x64\x65ployment_version\x18\x05 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12\x15\n\rskip_drainage\x18\x03 \x01(\x08\x12\x10\n\x08identity\x18\x04 \x01(\t"\'\n%DeleteWorkerDeploymentVersionResponse"]\n\x1d\x44\x65leteWorkerDeploymentRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65ployment_name\x18\x02 \x01(\t\x12\x10\n\x08identity\x18\x03 \x01(\t" \n\x1e\x44\x65leteWorkerDeploymentResponse"\x84\x04\n1UpdateWorkerDeploymentVersionComputeConfigRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12O\n\x12\x64\x65ployment_version\x18\x02 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12\x99\x01\n\x1d\x63ompute_config_scaling_groups\x18\x06 \x03(\x0b\x32r.temporal.api.workflowservice.v1.UpdateWorkerDeploymentVersionComputeConfigRequest.ComputeConfigScalingGroupsEntry\x12,\n$remove_compute_config_scaling_groups\x18\x07 \x03(\t\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x12\n\nrequest_id\x18\x04 \x01(\t\x1a{\n\x1f\x43omputeConfigScalingGroupsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12G\n\x05value\x18\x02 \x01(\x0b\x32\x38.temporal.api.compute.v1.ComputeConfigScalingGroupUpdate:\x02\x38\x01"4\n2UpdateWorkerDeploymentVersionComputeConfigResponse"\xf4\x03\n3ValidateWorkerDeploymentVersionComputeConfigRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12O\n\x12\x64\x65ployment_version\x18\x02 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12\x9b\x01\n\x1d\x63ompute_config_scaling_groups\x18\x06 \x03(\x0b\x32t.temporal.api.workflowservice.v1.ValidateWorkerDeploymentVersionComputeConfigRequest.ComputeConfigScalingGroupsEntry\x12,\n$remove_compute_config_scaling_groups\x18\x07 \x03(\t\x12\x10\n\x08identity\x18\x03 \x01(\t\x1a{\n\x1f\x43omputeConfigScalingGroupsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12G\n\x05value\x18\x02 \x01(\x0b\x32\x38.temporal.api.compute.v1.ComputeConfigScalingGroupUpdate:\x02\x38\x01"6\n4ValidateWorkerDeploymentVersionComputeConfigResponse"\xa2\x03\n,UpdateWorkerDeploymentVersionMetadataRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x07version\x18\x02 \x01(\tB\x02\x18\x01\x12O\n\x12\x64\x65ployment_version\x18\x05 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12x\n\x0eupsert_entries\x18\x03 \x03(\x0b\x32`.temporal.api.workflowservice.v1.UpdateWorkerDeploymentVersionMetadataRequest.UpsertEntriesEntry\x12\x16\n\x0eremove_entries\x18\x04 \x03(\t\x12\x10\n\x08identity\x18\x06 \x01(\t\x1aU\n\x12UpsertEntriesEntry\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"n\n-UpdateWorkerDeploymentVersionMetadataResponse\x12=\n\x08metadata\x18\x01 \x01(\x0b\x32+.temporal.api.deployment.v1.VersionMetadata"\xbd\x01\n!SetWorkerDeploymentManagerRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65ployment_name\x18\x02 \x01(\t\x12\x1a\n\x10manager_identity\x18\x03 \x01(\tH\x00\x12\x0e\n\x04self\x18\x04 \x01(\x08H\x00\x12\x16\n\x0e\x63onflict_token\x18\x05 \x01(\x0c\x12\x10\n\x08identity\x18\x06 \x01(\tB\x16\n\x14new_manager_identity"c\n"SetWorkerDeploymentManagerResponse\x12\x16\n\x0e\x63onflict_token\x18\x01 \x01(\x0c\x12%\n\x19previous_manager_identity\x18\x02 \x01(\tB\x02\x18\x01"E\n\x1bGetCurrentDeploymentRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bseries_name\x18\x02 \x01(\t"k\n\x1cGetCurrentDeploymentResponse\x12K\n\x17\x63urrent_deployment_info\x18\x01 \x01(\x0b\x32*.temporal.api.deployment.v1.DeploymentInfo"q\n GetDeploymentReachabilityRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12:\n\ndeployment\x18\x02 \x01(\x0b\x32&.temporal.api.deployment.v1.Deployment"\xe3\x01\n!GetDeploymentReachabilityResponse\x12\x43\n\x0f\x64\x65ployment_info\x18\x01 \x01(\x0b\x32*.temporal.api.deployment.v1.DeploymentInfo\x12\x43\n\x0creachability\x18\x02 \x01(\x0e\x32-.temporal.api.enums.v1.DeploymentReachability\x12\x34\n\x10last_update_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp"\xb4\x01\n\x19\x43reateWorkflowRuleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x35\n\x04spec\x18\x02 \x01(\x0b\x32\'.temporal.api.rules.v1.WorkflowRuleSpec\x12\x12\n\nforce_scan\x18\x03 \x01(\x08\x12\x12\n\nrequest_id\x18\x04 \x01(\t\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x06 \x01(\t"_\n\x1a\x43reateWorkflowRuleResponse\x12\x31\n\x04rule\x18\x01 \x01(\x0b\x32#.temporal.api.rules.v1.WorkflowRule\x12\x0e\n\x06job_id\x18\x02 \x01(\t"A\n\x1b\x44\x65scribeWorkflowRuleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x0f\n\x07rule_id\x18\x02 \x01(\t"Q\n\x1c\x44\x65scribeWorkflowRuleResponse\x12\x31\n\x04rule\x18\x01 \x01(\x0b\x32#.temporal.api.rules.v1.WorkflowRule"?\n\x19\x44\x65leteWorkflowRuleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x0f\n\x07rule_id\x18\x02 \x01(\t"\x1c\n\x1a\x44\x65leteWorkflowRuleResponse"F\n\x18ListWorkflowRulesRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"h\n\x19ListWorkflowRulesResponse\x12\x32\n\x05rules\x18\x01 \x03(\x0b\x32#.temporal.api.rules.v1.WorkflowRule\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"\xce\x01\n\x1aTriggerWorkflowRuleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x0c\n\x02id\x18\x04 \x01(\tH\x00\x12\x37\n\x04spec\x18\x05 \x01(\x0b\x32\'.temporal.api.rules.v1.WorkflowRuleSpecH\x00\x12\x10\n\x08identity\x18\x03 \x01(\tB\x06\n\x04rule".\n\x1bTriggerWorkflowRuleResponse\x12\x0f\n\x07\x61pplied\x18\x01 \x01(\x08"\x9b\x01\n\x1cRecordWorkerHeartbeatRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x41\n\x10worker_heartbeat\x18\x03 \x03(\x0b\x32\'.temporal.api.worker.v1.WorkerHeartbeat\x12\x13\n\x0bresource_id\x18\x04 \x01(\t"\x1f\n\x1dRecordWorkerHeartbeatResponse"\x82\x01\n\x12ListWorkersRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\r\n\x05query\x18\x04 \x01(\t\x12\x1e\n\x16include_system_workers\x18\x05 \x01(\x08"\xa5\x01\n\x13ListWorkersResponse\x12<\n\x0cworkers_info\x18\x01 \x03(\x0b\x32".temporal.api.worker.v1.WorkerInfoB\x02\x18\x01\x12\x37\n\x07workers\x18\x03 \x03(\x0b\x32&.temporal.api.worker.v1.WorkerListInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"\xd5\x05\n\x1cUpdateTaskQueueConfigRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x12\n\ntask_queue\x18\x03 \x01(\t\x12=\n\x0ftask_queue_type\x18\x04 \x01(\x0e\x32$.temporal.api.enums.v1.TaskQueueType\x12n\n\x17update_queue_rate_limit\x18\x05 \x01(\x0b\x32M.temporal.api.workflowservice.v1.UpdateTaskQueueConfigRequest.RateLimitUpdate\x12}\n&update_fairness_key_rate_limit_default\x18\x06 \x01(\x0b\x32M.temporal.api.workflowservice.v1.UpdateTaskQueueConfigRequest.RateLimitUpdate\x12\x84\x01\n\x1dset_fairness_weight_overrides\x18\x07 \x03(\x0b\x32].temporal.api.workflowservice.v1.UpdateTaskQueueConfigRequest.SetFairnessWeightOverridesEntry\x12\'\n\x1funset_fairness_weight_overrides\x18\x08 \x03(\t\x1a[\n\x0fRateLimitUpdate\x12\x38\n\nrate_limit\x18\x01 \x01(\x0b\x32$.temporal.api.taskqueue.v1.RateLimit\x12\x0e\n\x06reason\x18\x02 \x01(\t\x1a\x41\n\x1fSetFairnessWeightOverridesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01"[\n\x1dUpdateTaskQueueConfigResponse\x12:\n\x06\x63onfig\x18\x01 \x01(\x0b\x32*.temporal.api.taskqueue.v1.TaskQueueConfig"\x9e\x01\n\x18\x46\x65tchWorkerConfigRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x38\n\x08selector\x18\x06 \x01(\x0b\x32&.temporal.api.common.v1.WorkerSelector\x12\x13\n\x0bresource_id\x18\x07 \x01(\t"U\n\x19\x46\x65tchWorkerConfigResponse\x12\x38\n\rworker_config\x18\x01 \x01(\x0b\x32!.temporal.api.sdk.v1.WorkerConfig"\x8a\x02\n\x19UpdateWorkerConfigRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x38\n\rworker_config\x18\x04 \x01(\x0b\x32!.temporal.api.sdk.v1.WorkerConfig\x12/\n\x0bupdate_mask\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.FieldMask\x12\x38\n\x08selector\x18\x06 \x01(\x0b\x32&.temporal.api.common.v1.WorkerSelector\x12\x13\n\x0bresource_id\x18\x07 \x01(\t"d\n\x1aUpdateWorkerConfigResponse\x12:\n\rworker_config\x18\x01 \x01(\x0b\x32!.temporal.api.sdk.v1.WorkerConfigH\x00\x42\n\n\x08response"G\n\x15\x44\x65scribeWorkerRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x1b\n\x13worker_instance_key\x18\x02 \x01(\t"Q\n\x16\x44\x65scribeWorkerResponse\x12\x37\n\x0bworker_info\x18\x01 \x01(\x0b\x32".temporal.api.worker.v1.WorkerInfo"W\n\x13\x43ountWorkersRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x1e\n\x16include_system_workers\x18\x03 \x01(\x08"%\n\x14\x43ountWorkersResponse\x12\r\n\x05\x63ount\x18\x01 \x01(\x03"\x8d\x01\n\x1dPauseWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x12\n\nrequest_id\x18\x06 \x01(\t" \n\x1ePauseWorkflowExecutionResponse"\x8f\x01\n\x1fUnpauseWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x12\n\nrequest_id\x18\x06 \x01(\t""\n UnpauseWorkflowExecutionResponse"\x99\t\n\x1dStartActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x12\n\nrequest_id\x18\x03 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x04 \x01(\t\x12;\n\ractivity_type\x18\x05 \x01(\x0b\x32$.temporal.api.common.v1.ActivityType\x12\x38\n\ntask_queue\x18\x06 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12<\n\x19schedule_to_close_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12<\n\x19schedule_to_start_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x34\n\x11heartbeat_timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x0cretry_policy\x18\x0b \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12/\n\x05input\x18\x0c \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x45\n\x0fid_reuse_policy\x18\r \x01(\x0e\x32,.temporal.api.enums.v1.ActivityIdReusePolicy\x12K\n\x12id_conflict_policy\x18\x0e \x01(\x0e\x32/.temporal.api.enums.v1.ActivityIdConflictPolicy\x12\x43\n\x11search_attributes\x18\x0f \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12.\n\x06header\x18\x10 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12\x38\n\ruser_metadata\x18\x11 \x01(\x0b\x32!.temporal.api.sdk.v1.UserMetadata\x12\x32\n\x08priority\x18\x12 \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12>\n\x14\x63ompletion_callbacks\x18\x13 \x03(\x0b\x32 .temporal.api.common.v1.Callback\x12+\n\x05links\x18\x14 \x03(\x0b\x32\x1c.temporal.api.common.v1.Link\x12\x46\n\x13on_conflict_options\x18\x15 \x01(\x0b\x32).temporal.api.common.v1.OnConflictOptions\x12.\n\x0bstart_delay\x18\x16 \x01(\x0b\x32\x19.google.protobuf.Duration"m\n\x1eStartActivityExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x0f\n\x07started\x18\x02 \x01(\x08\x12*\n\x04link\x18\x03 \x01(\x0b\x32\x1c.temporal.api.common.v1.Link"\xe4\x01\n DescribeActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x15\n\rinclude_input\x18\x04 \x01(\x08\x12\x17\n\x0finclude_outcome\x18\x05 \x01(\x08\x12\x17\n\x0flong_poll_token\x18\x06 \x01(\x0c\x12!\n\x19include_heartbeat_details\x18\x07 \x01(\x08\x12\x1c\n\x14include_last_failure\x18\x08 \x01(\x08"\xbc\x02\n!DescribeActivityExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12=\n\x04info\x18\x02 \x01(\x0b\x32/.temporal.api.activity.v1.ActivityExecutionInfo\x12/\n\x05input\x18\x03 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x43\n\x07outcome\x18\x04 \x01(\x0b\x32\x32.temporal.api.activity.v1.ActivityExecutionOutcome\x12\x17\n\x0flong_poll_token\x18\x05 \x01(\x0c\x12\x39\n\tcallbacks\x18\x06 \x03(\x0b\x32&.temporal.api.activity.v1.CallbackInfo"V\n\x1cPollActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t"t\n\x1dPollActivityExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x43\n\x07outcome\x18\x02 \x01(\x0b\x32\x32.temporal.api.activity.v1.ActivityExecutionOutcome"m\n\x1dListActivityExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\r\n\x05query\x18\x04 \x01(\t"\x82\x01\n\x1eListActivityExecutionsResponse\x12G\n\nexecutions\x18\x01 \x03(\x0b\x32\x33.temporal.api.activity.v1.ActivityExecutionListInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"\xd1\x06\n#StartNexusOperationExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x12\n\nrequest_id\x18\x03 \x01(\t\x12\x14\n\x0coperation_id\x18\x04 \x01(\t\x12\x10\n\x08\x65ndpoint\x18\x05 \x01(\t\x12\x0f\n\x07service\x18\x06 \x01(\t\x12\x11\n\toperation\x18\x07 \x01(\t\x12<\n\x19schedule_to_close_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12<\n\x19schedule_to_start_timeout\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.Duration\x12.\n\x05input\x18\x0b \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12K\n\x0fid_reuse_policy\x18\x0c \x01(\x0e\x32\x32.temporal.api.enums.v1.NexusOperationIdReusePolicy\x12Q\n\x12id_conflict_policy\x18\r \x01(\x0e\x32\x35.temporal.api.enums.v1.NexusOperationIdConflictPolicy\x12\x43\n\x11search_attributes\x18\x0e \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12k\n\x0cnexus_header\x18\x0f \x03(\x0b\x32U.temporal.api.workflowservice.v1.StartNexusOperationExecutionRequest.NexusHeaderEntry\x12\x38\n\ruser_metadata\x18\x10 \x01(\x0b\x32!.temporal.api.sdk.v1.UserMetadata\x1a\x32\n\x10NexusHeaderEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01"G\n$StartNexusOperationExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x0f\n\x07started\x18\x02 \x01(\x08"\xaa\x01\n&DescribeNexusOperationExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0coperation_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x15\n\rinclude_input\x18\x04 \x01(\x08\x12\x17\n\x0finclude_outcome\x18\x05 \x01(\x08\x12\x17\n\x0flong_poll_token\x18\x06 \x01(\x0c"\xb7\x02\n\'DescribeNexusOperationExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12@\n\x04info\x18\x02 \x01(\x0b\x32\x32.temporal.api.nexus.v1.NexusOperationExecutionInfo\x12.\n\x05input\x18\x03 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x31\n\x06result\x18\x04 \x01(\x0b\x32\x1f.temporal.api.common.v1.PayloadH\x00\x12\x33\n\x07\x66\x61ilure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.FailureH\x00\x12\x17\n\x0flong_poll_token\x18\x06 \x01(\x0c\x42\t\n\x07outcome"\xa1\x01\n"PollNexusOperationExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0coperation_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x42\n\nwait_stage\x18\x04 \x01(\x0e\x32..temporal.api.enums.v1.NexusOperationWaitStage"\x85\x02\n#PollNexusOperationExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x42\n\nwait_stage\x18\x02 \x01(\x0e\x32..temporal.api.enums.v1.NexusOperationWaitStage\x12\x17\n\x0foperation_token\x18\x03 \x01(\t\x12\x31\n\x06result\x18\x04 \x01(\x0b\x32\x1f.temporal.api.common.v1.PayloadH\x00\x12\x33\n\x07\x66\x61ilure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.FailureH\x00\x42\t\n\x07outcome"s\n#ListNexusOperationExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\r\n\x05query\x18\x04 \x01(\t"\x8b\x01\n$ListNexusOperationExecutionsResponse\x12J\n\noperations\x18\x01 \x03(\x0b\x32\x36.temporal.api.nexus.v1.NexusOperationExecutionListInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"B\n\x1e\x43ountActivityExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t"\xed\x01\n\x1f\x43ountActivityExecutionsResponse\x12\r\n\x05\x63ount\x18\x01 \x01(\x03\x12\x61\n\x06groups\x18\x02 \x03(\x0b\x32Q.temporal.api.workflowservice.v1.CountActivityExecutionsResponse.AggregationGroup\x1aX\n\x10\x41ggregationGroup\x12\x35\n\x0cgroup_values\x18\x01 \x03(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\r\n\x05\x63ount\x18\x02 \x01(\x03"H\n$CountNexusOperationExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t"\xf9\x01\n%CountNexusOperationExecutionsResponse\x12\r\n\x05\x63ount\x18\x01 \x01(\x03\x12g\n\x06groups\x18\x02 \x03(\x0b\x32W.temporal.api.workflowservice.v1.CountNexusOperationExecutionsResponse.AggregationGroup\x1aX\n\x10\x41ggregationGroup\x12\x35\n\x0cgroup_values\x18\x01 \x03(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\r\n\x05\x63ount\x18\x02 \x01(\x03"\x95\x01\n%RequestCancelActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x12\n\nrequest_id\x18\x05 \x01(\t\x12\x0e\n\x06reason\x18\x06 \x01(\t"(\n&RequestCancelActivityExecutionResponse"\x91\x01\n!TerminateActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x12\n\nrequest_id\x18\x05 \x01(\t\x12\x0e\n\x06reason\x18\x06 \x01(\t"$\n"TerminateActivityExecutionResponse"X\n\x1e\x44\x65leteActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t"!\n\x1f\x44\x65leteActivityExecutionResponse"\x9c\x01\n+RequestCancelNexusOperationExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0coperation_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x12\n\nrequest_id\x18\x05 \x01(\t\x12\x0e\n\x06reason\x18\x06 \x01(\t".\n,RequestCancelNexusOperationExecutionResponse"\x98\x01\n\'TerminateNexusOperationExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0coperation_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x12\n\nrequest_id\x18\x05 \x01(\t\x12\x0e\n\x06reason\x18\x06 \x01(\t"*\n(TerminateNexusOperationExecutionResponse"_\n$DeleteNexusOperationExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0coperation_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t"\'\n%DeleteNexusOperationExecutionResponse"\x9d\x01\n(PollWorkflowExecutionTimeSkippingRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x17\n\x0f\x66\x61st_forward_id\x18\x03 \x01(\t"\xe8\x01\n)PollWorkflowExecutionTimeSkippingResponse\x12T\n\x1b\x66\x61st_forward_polling_result\x18\x01 \x01(\x0e\x32/.temporal.api.enums.v1.FastForwardPollingResult\x12\x15\n\rfailed_reason\x18\x02 \x01(\t\x12N\n\x11\x66\x61st_forward_info\x18\x03 \x01(\x0b\x32\x33.temporal.api.common.v1.TimeSkippingFastForwardInfoB\xbe\x01\n"io.temporal.api.workflowservice.v1B\x14RequestResponseProtoP\x01Z5go.temporal.io/api/workflowservice/v1;workflowservice\xaa\x02!Temporalio.Api.WorkflowService.V1\xea\x02$Temporalio::Api::WorkflowService::V1b\x06proto3' + b'\n6temporal/api/workflowservice/v1/request_response.proto\x12\x1ftemporal.api.workflowservice.v1\x1a+temporal/api/enums/v1/batch_operation.proto\x1a"temporal/api/enums/v1/common.proto\x1a$temporal/api/enums/v1/workflow.proto\x1a%temporal/api/enums/v1/namespace.proto\x1a(temporal/api/enums/v1/failed_cause.proto\x1a!temporal/api/enums/v1/query.proto\x1a!temporal/api/enums/v1/reset.proto\x1a&temporal/api/enums/v1/task_queue.proto\x1a&temporal/api/enums/v1/deployment.proto\x1a"temporal/api/enums/v1/update.proto\x1a)temporal/api/enums/v1/time_skipping.proto\x1a$temporal/api/enums/v1/activity.proto\x1a!temporal/api/enums/v1/nexus.proto\x1a&temporal/api/activity/v1/message.proto\x1a$temporal/api/common/v1/message.proto\x1a$temporal/api/stream/v1/message.proto\x1a%temporal/api/history/v1/message.proto\x1a&temporal/api/workflow/v1/message.proto\x1a%temporal/api/command/v1/message.proto\x1a$temporal/api/compute/v1/config.proto\x1a(temporal/api/deployment/v1/message.proto\x1a%temporal/api/failure/v1/message.proto\x1a$temporal/api/filter/v1/message.proto\x1a&temporal/api/protocol/v1/message.proto\x1a\'temporal/api/namespace/v1/message.proto\x1a#temporal/api/query/v1/message.proto\x1a)temporal/api/replication/v1/message.proto\x1a#temporal/api/rules/v1/message.proto\x1a\'temporal/api/sdk/v1/worker_config.proto\x1a&temporal/api/schedule/v1/message.proto\x1a\'temporal/api/taskqueue/v1/message.proto\x1a$temporal/api/update/v1/message.proto\x1a%temporal/api/version/v1/message.proto\x1a#temporal/api/batch/v1/message.proto\x1a\x30temporal/api/sdk/v1/task_complete_metadata.proto\x1a\'temporal/api/sdk/v1/user_metadata.proto\x1a#temporal/api/nexus/v1/message.proto\x1a$temporal/api/worker/v1/message.proto\x1a\x1egoogle/protobuf/duration.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto"\x88\x05\n\x18RegisterNamespaceRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x13\n\x0bowner_email\x18\x03 \x01(\t\x12\x46\n#workflow_execution_retention_period\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\x12G\n\x08\x63lusters\x18\x05 \x03(\x0b\x32\x35.temporal.api.replication.v1.ClusterReplicationConfig\x12\x1b\n\x13\x61\x63tive_cluster_name\x18\x06 \x01(\t\x12Q\n\x04\x64\x61ta\x18\x07 \x03(\x0b\x32\x43.temporal.api.workflowservice.v1.RegisterNamespaceRequest.DataEntry\x12\x16\n\x0esecurity_token\x18\x08 \x01(\t\x12\x1b\n\x13is_global_namespace\x18\t \x01(\x08\x12\x44\n\x16history_archival_state\x18\n \x01(\x0e\x32$.temporal.api.enums.v1.ArchivalState\x12\x1c\n\x14history_archival_uri\x18\x0b \x01(\t\x12G\n\x19visibility_archival_state\x18\x0c \x01(\x0e\x32$.temporal.api.enums.v1.ArchivalState\x12\x1f\n\x17visibility_archival_uri\x18\r \x01(\t\x1a+\n\tDataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01"\x1b\n\x19RegisterNamespaceResponse"\x89\x01\n\x15ListNamespacesRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c\x12\x44\n\x10namespace_filter\x18\x03 \x01(\x0b\x32*.temporal.api.namespace.v1.NamespaceFilter"\x81\x01\n\x16ListNamespacesResponse\x12N\n\nnamespaces\x18\x01 \x03(\x0b\x32:.temporal.api.workflowservice.v1.DescribeNamespaceResponse\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"S\n\x18\x44\x65scribeNamespaceRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\n\n\x02id\x18\x02 \x01(\t\x12\x18\n\x10weak_consistency\x18\x03 \x01(\x08"\x81\x04\n\x19\x44\x65scribeNamespaceResponse\x12@\n\x0enamespace_info\x18\x01 \x01(\x0b\x32(.temporal.api.namespace.v1.NamespaceInfo\x12:\n\x06\x63onfig\x18\x02 \x01(\x0b\x32*.temporal.api.namespace.v1.NamespaceConfig\x12S\n\x12replication_config\x18\x03 \x01(\x0b\x32\x37.temporal.api.replication.v1.NamespaceReplicationConfig\x12\x18\n\x10\x66\x61ilover_version\x18\x04 \x01(\x03\x12\x1b\n\x13is_global_namespace\x18\x05 \x01(\x08\x12\x45\n\x10\x66\x61ilover_history\x18\x06 \x03(\x0b\x32+.temporal.api.replication.v1.FailoverStatus\x12J\n\x12poller_group_infos\x18\x07 \x03(\x0b\x32*.temporal.api.taskqueue.v1.PollerGroupInfoB\x02\x18\x01\x12G\n\x12poller_groups_info\x18\x08 \x01(\x0b\x32+.temporal.api.taskqueue.v1.PollerGroupsInfo"\xcf\x02\n\x16UpdateNamespaceRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x43\n\x0bupdate_info\x18\x02 \x01(\x0b\x32..temporal.api.namespace.v1.UpdateNamespaceInfo\x12:\n\x06\x63onfig\x18\x03 \x01(\x0b\x32*.temporal.api.namespace.v1.NamespaceConfig\x12S\n\x12replication_config\x18\x04 \x01(\x0b\x32\x37.temporal.api.replication.v1.NamespaceReplicationConfig\x12\x16\n\x0esecurity_token\x18\x05 \x01(\t\x12\x19\n\x11\x64\x65lete_bad_binary\x18\x06 \x01(\t\x12\x19\n\x11promote_namespace\x18\x07 \x01(\x08"\xa3\x02\n\x17UpdateNamespaceResponse\x12@\n\x0enamespace_info\x18\x01 \x01(\x0b\x32(.temporal.api.namespace.v1.NamespaceInfo\x12:\n\x06\x63onfig\x18\x02 \x01(\x0b\x32*.temporal.api.namespace.v1.NamespaceConfig\x12S\n\x12replication_config\x18\x03 \x01(\x0b\x32\x37.temporal.api.replication.v1.NamespaceReplicationConfig\x12\x18\n\x10\x66\x61ilover_version\x18\x04 \x01(\x03\x12\x1b\n\x13is_global_namespace\x18\x05 \x01(\x08"F\n\x19\x44\x65precateNamespaceRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x16\n\x0esecurity_token\x18\x02 \x01(\t"\x1c\n\x1a\x44\x65precateNamespaceResponse"\xd1\x0c\n\x1dStartWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x38\n\ntask_queue\x18\x04 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12/\n\x05input\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12=\n\x1aworkflow_execution_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x37\n\x14workflow_run_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\x15workflow_task_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x10\n\x08identity\x18\t \x01(\t\x12\x12\n\nrequest_id\x18\n \x01(\t\x12N\n\x18workflow_id_reuse_policy\x18\x0b \x01(\x0e\x32,.temporal.api.enums.v1.WorkflowIdReusePolicy\x12T\n\x1bworkflow_id_conflict_policy\x18\x16 \x01(\x0e\x32/.temporal.api.enums.v1.WorkflowIdConflictPolicy\x12\x39\n\x0cretry_policy\x18\x0c \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12\x15\n\rcron_schedule\x18\r \x01(\t\x12*\n\x04memo\x18\x0e \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x0f \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12.\n\x06header\x18\x10 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12\x1f\n\x17request_eager_execution\x18\x11 \x01(\x08\x12;\n\x11\x63ontinued_failure\x18\x12 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12@\n\x16last_completion_result\x18\x13 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x37\n\x14workflow_start_delay\x18\x14 \x01(\x0b\x32\x19.google.protobuf.Duration\x12>\n\x14\x63ompletion_callbacks\x18\x15 \x03(\x0b\x32 .temporal.api.common.v1.Callback\x12\x38\n\ruser_metadata\x18\x17 \x01(\x0b\x32!.temporal.api.sdk.v1.UserMetadata\x12+\n\x05links\x18\x18 \x03(\x0b\x32\x1c.temporal.api.common.v1.Link\x12I\n\x13versioning_override\x18\x19 \x01(\x0b\x32,.temporal.api.workflow.v1.VersioningOverride\x12H\n\x13on_conflict_options\x18\x1a \x01(\x0b\x32+.temporal.api.workflow.v1.OnConflictOptions\x12\x32\n\x08priority\x18\x1b \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12\\\n\x1f\x65\x61ger_worker_deployment_options\x18\x1c \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions\x12H\n\x14time_skipping_config\x18\x1d \x01(\x0b\x32*.temporal.api.common.v1.TimeSkippingConfig"\xaa\x02\n\x1eStartWorkflowExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x1e\n\x16\x66irst_execution_run_id\x18\x06 \x01(\t\x12\x0f\n\x07started\x18\x03 \x01(\x08\x12>\n\x06status\x18\x05 \x01(\x0e\x32..temporal.api.enums.v1.WorkflowExecutionStatus\x12[\n\x13\x65\x61ger_workflow_task\x18\x02 \x01(\x0b\x32>.temporal.api.workflowservice.v1.PollWorkflowTaskQueueResponse\x12*\n\x04link\x18\x04 \x01(\x0b\x32\x1c.temporal.api.common.v1.Link"\xaa\x02\n"GetWorkflowExecutionHistoryRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x19\n\x11maximum_page_size\x18\x03 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x04 \x01(\x0c\x12\x16\n\x0ewait_new_event\x18\x05 \x01(\x08\x12P\n\x19history_event_filter_type\x18\x06 \x01(\x0e\x32-.temporal.api.enums.v1.HistoryEventFilterType\x12\x15\n\rskip_archival\x18\x07 \x01(\x08"\xba\x01\n#GetWorkflowExecutionHistoryResponse\x12\x31\n\x07history\x18\x01 \x01(\x0b\x32 .temporal.api.history.v1.History\x12\x35\n\x0braw_history\x18\x02 \x03(\x0b\x32 .temporal.api.common.v1.DataBlob\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\x10\n\x08\x61rchived\x18\x04 \x01(\x08"\xb0\x01\n)GetWorkflowExecutionHistoryReverseRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x19\n\x11maximum_page_size\x18\x03 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x04 \x01(\x0c"x\n*GetWorkflowExecutionHistoryReverseResponse\x12\x31\n\x07history\x18\x01 \x01(\x0b\x32 .temporal.api.history.v1.History\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c"\xb8\x03\n\x1cPollWorkflowTaskQueueRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\ntask_queue\x18\x02 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12\x17\n\x0fpoller_group_id\x18\n \x01(\t\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x1b\n\x13worker_instance_key\x18\x08 \x01(\t\x12!\n\x19worker_control_task_queue\x18\t \x01(\t\x12\x1b\n\x0f\x62inary_checksum\x18\x04 \x01(\tB\x02\x18\x01\x12Z\n\x1bworker_version_capabilities\x18\x05 \x01(\x0b\x32\x31.temporal.api.common.v1.WorkerVersionCapabilitiesB\x02\x18\x01\x12O\n\x12\x64\x65ployment_options\x18\x06 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptionsJ\x04\x08\x07\x10\x08R\x10worker_heartbeat"\xfb\x08\n\x1dPollWorkflowTaskQueueResponse\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12!\n\x19previous_started_event_id\x18\x04 \x01(\x03\x12\x18\n\x10started_event_id\x18\x05 \x01(\x03\x12\x0f\n\x07\x61ttempt\x18\x06 \x01(\x05\x12\x1a\n\x12\x62\x61\x63klog_count_hint\x18\x07 \x01(\x03\x12\x31\n\x07history\x18\x08 \x01(\x0b\x32 .temporal.api.history.v1.History\x12\x17\n\x0fnext_page_token\x18\t \x01(\x0c\x12\x33\n\x05query\x18\n \x01(\x0b\x32$.temporal.api.query.v1.WorkflowQuery\x12K\n\x1dworkflow_execution_task_queue\x18\x0b \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12\x32\n\x0escheduled_time\x18\x0c \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x30\n\x0cstarted_time\x18\r \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\\\n\x07queries\x18\x0e \x03(\x0b\x32K.temporal.api.workflowservice.v1.PollWorkflowTaskQueueResponse.QueriesEntry\x12\x33\n\x08messages\x18\x0f \x03(\x0b\x32!.temporal.api.protocol.v1.Message\x12Q\n\x17poller_scaling_decision\x18\x10 \x01(\x0b\x32\x30.temporal.api.taskqueue.v1.PollerScalingDecision\x12\x17\n\x0fpoller_group_id\x18\x11 \x01(\t\x12J\n\x12poller_group_infos\x18\x12 \x03(\x0b\x32*.temporal.api.taskqueue.v1.PollerGroupInfoB\x02\x18\x01\x12G\n\x12poller_groups_info\x18\x13 \x01(\x0b\x32+.temporal.api.taskqueue.v1.PollerGroupsInfo\x12:\n\rstream_slices\x18\x14 \x03(\x0b\x32#.temporal.api.stream.v1.StreamSlice\x1aT\n\x0cQueriesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x33\n\x05value\x18\x02 \x01(\x0b\x32$.temporal.api.query.v1.WorkflowQuery:\x02\x38\x01"\xba\n\n#RespondWorkflowTaskCompletedRequest\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12\x32\n\x08\x63ommands\x18\x02 \x03(\x0b\x32 .temporal.api.command.v1.Command\x12\x10\n\x08identity\x18\x03 \x01(\t\x12O\n\x11sticky_attributes\x18\x04 \x01(\x0b\x32\x34.temporal.api.taskqueue.v1.StickyExecutionAttributes\x12 \n\x18return_new_workflow_task\x18\x05 \x01(\x08\x12&\n\x1e\x66orce_create_new_workflow_task\x18\x06 \x01(\x08\x12\x1b\n\x0f\x62inary_checksum\x18\x07 \x01(\tB\x02\x18\x01\x12m\n\rquery_results\x18\x08 \x03(\x0b\x32V.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest.QueryResultsEntry\x12\x11\n\tnamespace\x18\t \x01(\t\x12\x13\n\x0bresource_id\x18\x12 \x01(\t\x12L\n\x14worker_version_stamp\x18\n \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12\x33\n\x08messages\x18\x0b \x03(\x0b\x32!.temporal.api.protocol.v1.Message\x12H\n\x0csdk_metadata\x18\x0c \x01(\x0b\x32\x32.temporal.api.sdk.v1.WorkflowTaskCompletedMetadata\x12\x43\n\x11metering_metadata\x18\r \x01(\x0b\x32(.temporal.api.common.v1.MeteringMetadata\x12g\n\x0c\x63\x61pabilities\x18\x0e \x01(\x0b\x32Q.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest.Capabilities\x12>\n\ndeployment\x18\x0f \x01(\x0b\x32&.temporal.api.deployment.v1.DeploymentB\x02\x18\x01\x12\x46\n\x13versioning_behavior\x18\x10 \x01(\x0e\x32).temporal.api.enums.v1.VersioningBehavior\x12O\n\x12\x64\x65ployment_options\x18\x11 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions\x12\x1b\n\x13worker_instance_key\x18\x13 \x01(\t\x12!\n\x19worker_control_task_queue\x18\x14 \x01(\t\x12\x13\n\x0bpage_number\x18\x15 \x01(\x05\x12\x19\n\x11intermediate_page\x18\x16 \x01(\x08\x1a_\n\x11QueryResultsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x39\n\x05value\x18\x02 \x01(\x0b\x32*.temporal.api.query.v1.WorkflowQueryResult:\x02\x38\x01\x1a\x45\n\x0c\x43\x61pabilities\x12\x35\n-discard_speculative_workflow_task_with_events\x18\x01 \x01(\x08"\xf5\x01\n$RespondWorkflowTaskCompletedResponse\x12U\n\rworkflow_task\x18\x01 \x01(\x0b\x32>.temporal.api.workflowservice.v1.PollWorkflowTaskQueueResponse\x12V\n\x0e\x61\x63tivity_tasks\x18\x02 \x03(\x0b\x32>.temporal.api.workflowservice.v1.PollActivityTaskQueueResponse\x12\x1e\n\x16reset_history_event_id\x18\x03 \x01(\x03"\x8d\x04\n RespondWorkflowTaskFailedRequest\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12=\n\x05\x63\x61use\x18\x02 \x01(\x0e\x32..temporal.api.enums.v1.WorkflowTaskFailedCause\x12\x31\n\x07\x66\x61ilure\x18\x03 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x1b\n\x0f\x62inary_checksum\x18\x05 \x01(\tB\x02\x18\x01\x12\x11\n\tnamespace\x18\x06 \x01(\t\x12\x13\n\x0bresource_id\x18\x0b \x01(\t\x12\x33\n\x08messages\x18\x07 \x03(\x0b\x32!.temporal.api.protocol.v1.Message\x12\x46\n\x0eworker_version\x18\x08 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12>\n\ndeployment\x18\t \x01(\x0b\x32&.temporal.api.deployment.v1.DeploymentB\x02\x18\x01\x12O\n\x12\x64\x65ployment_options\x18\n \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions"#\n!RespondWorkflowTaskFailedResponse"\xe6\x03\n\x1cPollActivityTaskQueueRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\ntask_queue\x18\x02 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12\x17\n\x0fpoller_group_id\x18\n \x01(\t\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x1b\n\x13worker_instance_key\x18\x08 \x01(\t\x12!\n\x19worker_control_task_queue\x18\t \x01(\t\x12I\n\x13task_queue_metadata\x18\x04 \x01(\x0b\x32,.temporal.api.taskqueue.v1.TaskQueueMetadata\x12Z\n\x1bworker_version_capabilities\x18\x05 \x01(\x0b\x32\x31.temporal.api.common.v1.WorkerVersionCapabilitiesB\x02\x18\x01\x12O\n\x12\x64\x65ployment_options\x18\x06 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptionsJ\x04\x08\x07\x10\x08R\x10worker_heartbeat"\x9d\t\n\x1dPollActivityTaskQueueResponse\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12\x1a\n\x12workflow_namespace\x18\x02 \x01(\t\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x45\n\x12workflow_execution\x18\x04 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12;\n\ractivity_type\x18\x05 \x01(\x0b\x32$.temporal.api.common.v1.ActivityType\x12\x13\n\x0b\x61\x63tivity_id\x18\x06 \x01(\t\x12.\n\x06header\x18\x07 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12/\n\x05input\x18\x08 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12;\n\x11heartbeat_details\x18\t \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x32\n\x0escheduled_time\x18\n \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x42\n\x1e\x63urrent_attempt_scheduled_time\x18\x0b \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x30\n\x0cstarted_time\x18\x0c \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07\x61ttempt\x18\r \x01(\x05\x12<\n\x19schedule_to_close_timeout\x18\x0e \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\x0f \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x34\n\x11heartbeat_timeout\x18\x10 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x0cretry_policy\x18\x11 \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12Q\n\x17poller_scaling_decision\x18\x12 \x01(\x0b\x32\x30.temporal.api.taskqueue.v1.PollerScalingDecision\x12\x32\n\x08priority\x18\x13 \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12\x17\n\x0f\x61\x63tivity_run_id\x18\x14 \x01(\t\x12J\n\x12poller_group_infos\x18\x15 \x03(\x0b\x32*.temporal.api.taskqueue.v1.PollerGroupInfoB\x02\x18\x01\x12G\n\x12poller_groups_info\x18\x16 \x01(\x0b\x32+.temporal.api.taskqueue.v1.PollerGroupsInfo"\xa5\x01\n"RecordActivityTaskHeartbeatRequest\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12\x31\n\x07\x64\x65tails\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x11\n\tnamespace\x18\x04 \x01(\t\x12\x13\n\x0bresource_id\x18\x05 \x01(\t"p\n#RecordActivityTaskHeartbeatResponse\x12\x18\n\x10\x63\x61ncel_requested\x18\x01 \x01(\x08\x12\x17\n\x0f\x61\x63tivity_paused\x18\x02 \x01(\x08\x12\x16\n\x0e\x61\x63tivity_reset\x18\x03 \x01(\x08"\xcf\x01\n&RecordActivityTaskHeartbeatByIdRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x04 \x01(\t\x12\x31\n\x07\x64\x65tails\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x06 \x01(\t\x12\x13\n\x0bresource_id\x18\x07 \x01(\t"t\n\'RecordActivityTaskHeartbeatByIdResponse\x12\x18\n\x10\x63\x61ncel_requested\x18\x01 \x01(\x08\x12\x17\n\x0f\x61\x63tivity_paused\x18\x02 \x01(\x08\x12\x16\n\x0e\x61\x63tivity_reset\x18\x03 \x01(\x08"\xfe\x02\n#RespondActivityTaskCompletedRequest\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12\x30\n\x06result\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x11\n\tnamespace\x18\x04 \x01(\t\x12\x13\n\x0bresource_id\x18\x08 \x01(\t\x12\x46\n\x0eworker_version\x18\x05 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12>\n\ndeployment\x18\x06 \x01(\x0b\x32&.temporal.api.deployment.v1.DeploymentB\x02\x18\x01\x12O\n\x12\x64\x65ployment_options\x18\x07 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions"&\n$RespondActivityTaskCompletedResponse"\xcf\x01\n\'RespondActivityTaskCompletedByIdRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x04 \x01(\t\x12\x30\n\x06result\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x06 \x01(\t\x12\x13\n\x0bresource_id\x18\x07 \x01(\t"*\n(RespondActivityTaskCompletedByIdResponse"\xbe\x03\n RespondActivityTaskFailedRequest\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12\x31\n\x07\x66\x61ilure\x18\x02 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x11\n\tnamespace\x18\x04 \x01(\t\x12\x13\n\x0bresource_id\x18\t \x01(\t\x12@\n\x16last_heartbeat_details\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x46\n\x0eworker_version\x18\x06 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12>\n\ndeployment\x18\x07 \x01(\x0b\x32&.temporal.api.deployment.v1.DeploymentB\x02\x18\x01\x12O\n\x12\x64\x65ployment_options\x18\x08 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions"W\n!RespondActivityTaskFailedResponse\x12\x32\n\x08\x66\x61ilures\x18\x01 \x03(\x0b\x32 .temporal.api.failure.v1.Failure"\x8f\x02\n$RespondActivityTaskFailedByIdRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x04 \x01(\t\x12\x31\n\x07\x66\x61ilure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x10\n\x08identity\x18\x06 \x01(\t\x12@\n\x16last_heartbeat_details\x18\x07 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x13\n\x0bresource_id\x18\x08 \x01(\t"[\n%RespondActivityTaskFailedByIdResponse\x12\x32\n\x08\x66\x61ilures\x18\x01 \x03(\x0b\x32 .temporal.api.failure.v1.Failure"\xfe\x02\n"RespondActivityTaskCanceledRequest\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12\x31\n\x07\x64\x65tails\x18\x02 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x11\n\tnamespace\x18\x04 \x01(\t\x12\x13\n\x0bresource_id\x18\x08 \x01(\t\x12\x46\n\x0eworker_version\x18\x05 \x01(\x0b\x32*.temporal.api.common.v1.WorkerVersionStampB\x02\x18\x01\x12>\n\ndeployment\x18\x06 \x01(\x0b\x32&.temporal.api.deployment.v1.DeploymentB\x02\x18\x01\x12O\n\x12\x64\x65ployment_options\x18\x07 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions"%\n#RespondActivityTaskCanceledResponse"\xa0\x02\n&RespondActivityTaskCanceledByIdRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x04 \x01(\t\x12\x31\n\x07\x64\x65tails\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x06 \x01(\t\x12O\n\x12\x64\x65ployment_options\x18\x07 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions\x12\x13\n\x0bresource_id\x18\x08 \x01(\t")\n\'RespondActivityTaskCanceledByIdResponse"\x84\x02\n%RequestCancelWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x12\n\nrequest_id\x18\x04 \x01(\t\x12\x1e\n\x16\x66irst_execution_run_id\x18\x05 \x01(\t\x12\x0e\n\x06reason\x18\x06 \x01(\t\x12+\n\x05links\x18\x07 \x03(\x0b\x32\x1c.temporal.api.common.v1.Link"(\n&RequestCancelWorkflowExecutionResponse"\xde\x02\n\x1eSignalWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x13\n\x0bsignal_name\x18\x03 \x01(\t\x12/\n\x05input\x18\x04 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x12\n\nrequest_id\x18\x06 \x01(\t\x12\x13\n\x07\x63ontrol\x18\x07 \x01(\tB\x02\x18\x01\x12.\n\x06header\x18\x08 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12+\n\x05links\x18\n \x03(\x0b\x32\x1c.temporal.api.common.v1.LinkJ\x04\x08\t\x10\n"M\n\x1fSignalWorkflowExecutionResponse\x12*\n\x04link\x18\x01 \x01(\x0b\x32\x1c.temporal.api.common.v1.Link"\xbb\n\n\'SignalWithStartWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12;\n\rworkflow_type\x18\x03 \x01(\x0b\x32$.temporal.api.common.v1.WorkflowType\x12\x38\n\ntask_queue\x18\x04 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12/\n\x05input\x18\x05 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12=\n\x1aworkflow_execution_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x37\n\x14workflow_run_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\x15workflow_task_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x10\n\x08identity\x18\t \x01(\t\x12\x12\n\nrequest_id\x18\n \x01(\t\x12N\n\x18workflow_id_reuse_policy\x18\x0b \x01(\x0e\x32,.temporal.api.enums.v1.WorkflowIdReusePolicy\x12T\n\x1bworkflow_id_conflict_policy\x18\x16 \x01(\x0e\x32/.temporal.api.enums.v1.WorkflowIdConflictPolicy\x12\x13\n\x0bsignal_name\x18\x0c \x01(\t\x12\x36\n\x0csignal_input\x18\r \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x13\n\x07\x63ontrol\x18\x0e \x01(\tB\x02\x18\x01\x12\x39\n\x0cretry_policy\x18\x0f \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12\x15\n\rcron_schedule\x18\x10 \x01(\t\x12*\n\x04memo\x18\x11 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x12 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12.\n\x06header\x18\x13 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12\x37\n\x14workflow_start_delay\x18\x14 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x38\n\ruser_metadata\x18\x17 \x01(\x0b\x32!.temporal.api.sdk.v1.UserMetadata\x12+\n\x05links\x18\x18 \x03(\x0b\x32\x1c.temporal.api.common.v1.Link\x12I\n\x13versioning_override\x18\x19 \x01(\x0b\x32,.temporal.api.workflow.v1.VersioningOverride\x12\x32\n\x08priority\x18\x1a \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12H\n\x14time_skipping_config\x18\x1b \x01(\x0b\x32*.temporal.api.common.v1.TimeSkippingConfigJ\x04\x08\x15\x10\x16"\x9e\x01\n(SignalWithStartWorkflowExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x1e\n\x16\x66irst_execution_run_id\x18\x04 \x01(\t\x12\x0f\n\x07started\x18\x02 \x01(\x08\x12\x31\n\x0bsignal_link\x18\x03 \x01(\x0b\x32\x1c.temporal.api.common.v1.Link"\xc1\x03\n\x1dResetWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12%\n\x1dworkflow_task_finish_event_id\x18\x04 \x01(\x03\x12\x12\n\nrequest_id\x18\x05 \x01(\t\x12G\n\x12reset_reapply_type\x18\x06 \x01(\x0e\x32\'.temporal.api.enums.v1.ResetReapplyTypeB\x02\x18\x01\x12S\n\x1breset_reapply_exclude_types\x18\x07 \x03(\x0e\x32..temporal.api.enums.v1.ResetReapplyExcludeType\x12K\n\x15post_reset_operations\x18\x08 \x03(\x0b\x32,.temporal.api.workflow.v1.PostResetOperation\x12\x10\n\x08identity\x18\t \x01(\t"0\n\x1eResetWorkflowExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t"\x9f\x02\n!TerminateWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x31\n\x07\x64\x65tails\x18\x04 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x1e\n\x16\x66irst_execution_run_id\x18\x06 \x01(\t\x12+\n\x05links\x18\x07 \x03(\x0b\x32\x1c.temporal.api.common.v1.Link"$\n"TerminateWorkflowExecutionResponse"z\n\x1e\x44\x65leteWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution"!\n\x1f\x44\x65leteWorkflowExecutionResponse"\xc9\x02\n!ListOpenWorkflowExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x19\n\x11maximum_page_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\x42\n\x11start_time_filter\x18\x04 \x01(\x0b\x32\'.temporal.api.filter.v1.StartTimeFilter\x12K\n\x10\x65xecution_filter\x18\x05 \x01(\x0b\x32/.temporal.api.filter.v1.WorkflowExecutionFilterH\x00\x12\x41\n\x0btype_filter\x18\x06 \x01(\x0b\x32*.temporal.api.filter.v1.WorkflowTypeFilterH\x00\x42\t\n\x07\x66ilters"\x82\x01\n"ListOpenWorkflowExecutionsResponse\x12\x43\n\nexecutions\x18\x01 \x03(\x0b\x32/.temporal.api.workflow.v1.WorkflowExecutionInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"\x8a\x03\n#ListClosedWorkflowExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x19\n\x11maximum_page_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\x42\n\x11start_time_filter\x18\x04 \x01(\x0b\x32\'.temporal.api.filter.v1.StartTimeFilter\x12K\n\x10\x65xecution_filter\x18\x05 \x01(\x0b\x32/.temporal.api.filter.v1.WorkflowExecutionFilterH\x00\x12\x41\n\x0btype_filter\x18\x06 \x01(\x0b\x32*.temporal.api.filter.v1.WorkflowTypeFilterH\x00\x12=\n\rstatus_filter\x18\x07 \x01(\x0b\x32$.temporal.api.filter.v1.StatusFilterH\x00\x42\t\n\x07\x66ilters"\x84\x01\n$ListClosedWorkflowExecutionsResponse\x12\x43\n\nexecutions\x18\x01 \x03(\x0b\x32/.temporal.api.workflow.v1.WorkflowExecutionInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"m\n\x1dListWorkflowExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\r\n\x05query\x18\x04 \x01(\t"~\n\x1eListWorkflowExecutionsResponse\x12\x43\n\nexecutions\x18\x01 \x03(\x0b\x32/.temporal.api.workflow.v1.WorkflowExecutionInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"u\n%ListArchivedWorkflowExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\r\n\x05query\x18\x04 \x01(\t"\x86\x01\n&ListArchivedWorkflowExecutionsResponse\x12\x43\n\nexecutions\x18\x01 \x03(\x0b\x32/.temporal.api.workflow.v1.WorkflowExecutionInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"m\n\x1dScanWorkflowExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\r\n\x05query\x18\x04 \x01(\t"~\n\x1eScanWorkflowExecutionsResponse\x12\x43\n\nexecutions\x18\x01 \x03(\x0b\x32/.temporal.api.workflow.v1.WorkflowExecutionInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"B\n\x1e\x43ountWorkflowExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t"\xed\x01\n\x1f\x43ountWorkflowExecutionsResponse\x12\r\n\x05\x63ount\x18\x01 \x01(\x03\x12\x61\n\x06groups\x18\x02 \x03(\x0b\x32Q.temporal.api.workflowservice.v1.CountWorkflowExecutionsResponse.AggregationGroup\x1aX\n\x10\x41ggregationGroup\x12\x35\n\x0cgroup_values\x18\x01 \x03(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\r\n\x05\x63ount\x18\x02 \x01(\x03"\x1c\n\x1aGetSearchAttributesRequest"\xc9\x01\n\x1bGetSearchAttributesResponse\x12T\n\x04keys\x18\x01 \x03(\x0b\x32\x46.temporal.api.workflowservice.v1.GetSearchAttributesResponse.KeysEntry\x1aT\n\tKeysEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x36\n\x05value\x18\x02 \x01(\x0e\x32\'.temporal.api.enums.v1.IndexedValueType:\x02\x38\x01"\xe9\x02\n RespondQueryTaskCompletedRequest\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12>\n\x0e\x63ompleted_type\x18\x02 \x01(\x0e\x32&.temporal.api.enums.v1.QueryResultType\x12\x36\n\x0cquery_result\x18\x03 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x15\n\rerror_message\x18\x04 \x01(\t\x12\x11\n\tnamespace\x18\x06 \x01(\t\x12\x31\n\x07\x66\x61ilure\x18\x07 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12=\n\x05\x63\x61use\x18\x08 \x01(\x0e\x32..temporal.api.enums.v1.WorkflowTaskFailedCause\x12\x17\n\x0fpoller_group_id\x18\t \x01(\tJ\x04\x08\x05\x10\x06"#\n!RespondQueryTaskCompletedResponse"n\n\x1bResetStickyTaskQueueRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution"\x1e\n\x1cResetStickyTaskQueueResponse"\x9b\x02\n\x15ShutdownWorkerRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x19\n\x11sticky_task_queue\x18\x02 \x01(\t\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x0e\n\x06reason\x18\x04 \x01(\t\x12\x41\n\x10worker_heartbeat\x18\x05 \x01(\x0b\x32\'.temporal.api.worker.v1.WorkerHeartbeat\x12\x1b\n\x13worker_instance_key\x18\x06 \x01(\t\x12\x12\n\ntask_queue\x18\x07 \x01(\t\x12>\n\x10task_queue_types\x18\x08 \x03(\x0e\x32$.temporal.api.enums.v1.TaskQueueType"\x18\n\x16ShutdownWorkerResponse"\xe9\x01\n\x14QueryWorkflowRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x33\n\x05query\x18\x03 \x01(\x0b\x32$.temporal.api.query.v1.WorkflowQuery\x12K\n\x16query_reject_condition\x18\x04 \x01(\x0e\x32+.temporal.api.enums.v1.QueryRejectCondition"\xb9\x01\n\x15QueryWorkflowResponse\x12\x36\n\x0cquery_result\x18\x01 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12<\n\x0equery_rejected\x18\x02 \x01(\x0b\x32$.temporal.api.query.v1.QueryRejected\x12*\n\x04link\x18\x03 \x01(\x0b\x32\x1c.temporal.api.common.v1.Link"s\n DescribeWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution"\x99\x05\n!DescribeWorkflowExecutionResponse\x12K\n\x10\x65xecution_config\x18\x01 \x01(\x0b\x32\x31.temporal.api.workflow.v1.WorkflowExecutionConfig\x12P\n\x17workflow_execution_info\x18\x02 \x01(\x0b\x32/.temporal.api.workflow.v1.WorkflowExecutionInfo\x12I\n\x12pending_activities\x18\x03 \x03(\x0b\x32-.temporal.api.workflow.v1.PendingActivityInfo\x12M\n\x10pending_children\x18\x04 \x03(\x0b\x32\x33.temporal.api.workflow.v1.PendingChildExecutionInfo\x12P\n\x15pending_workflow_task\x18\x05 \x01(\x0b\x32\x31.temporal.api.workflow.v1.PendingWorkflowTaskInfo\x12\x39\n\tcallbacks\x18\x06 \x03(\x0b\x32&.temporal.api.workflow.v1.CallbackInfo\x12U\n\x18pending_nexus_operations\x18\x07 \x03(\x0b\x32\x33.temporal.api.workflow.v1.PendingNexusOperationInfo\x12W\n\x16workflow_extended_info\x18\x08 \x01(\x0b\x32\x37.temporal.api.workflow.v1.WorkflowExecutionExtendedInfo"\x90\x04\n\x18\x44\x65scribeTaskQueueRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\ntask_queue\x18\x02 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12=\n\x0ftask_queue_type\x18\x03 \x01(\x0e\x32$.temporal.api.enums.v1.TaskQueueType\x12\x14\n\x0creport_stats\x18\x08 \x01(\x08\x12\x15\n\rreport_config\x18\x0b \x01(\x08\x12%\n\x19include_task_queue_status\x18\x04 \x01(\x08\x42\x02\x18\x01\x12\x42\n\x08\x61pi_mode\x18\x05 \x01(\x0e\x32,.temporal.api.enums.v1.DescribeTaskQueueModeB\x02\x18\x01\x12J\n\x08versions\x18\x06 \x01(\x0b\x32\x34.temporal.api.taskqueue.v1.TaskQueueVersionSelectionB\x02\x18\x01\x12\x42\n\x10task_queue_types\x18\x07 \x03(\x0e\x32$.temporal.api.enums.v1.TaskQueueTypeB\x02\x18\x01\x12\x1a\n\x0ereport_pollers\x18\t \x01(\x08\x42\x02\x18\x01\x12$\n\x18report_task_reachability\x18\n \x01(\x08\x42\x02\x18\x01"\xec\x07\n\x19\x44\x65scribeTaskQueueResponse\x12\x36\n\x07pollers\x18\x01 \x03(\x0b\x32%.temporal.api.taskqueue.v1.PollerInfo\x12\x38\n\x05stats\x18\x05 \x01(\x0b\x32).temporal.api.taskqueue.v1.TaskQueueStats\x12q\n\x15stats_by_priority_key\x18\x08 \x03(\x0b\x32R.temporal.api.workflowservice.v1.DescribeTaskQueueResponse.StatsByPriorityKeyEntry\x12K\n\x0fversioning_info\x18\x04 \x01(\x0b\x32\x32.temporal.api.taskqueue.v1.TaskQueueVersioningInfo\x12:\n\x06\x63onfig\x18\x06 \x01(\x0b\x32*.temporal.api.taskqueue.v1.TaskQueueConfig\x12k\n\x14\x65\x66\x66\x65\x63tive_rate_limit\x18\x07 \x01(\x0b\x32M.temporal.api.workflowservice.v1.DescribeTaskQueueResponse.EffectiveRateLimit\x12I\n\x11task_queue_status\x18\x02 \x01(\x0b\x32*.temporal.api.taskqueue.v1.TaskQueueStatusB\x02\x18\x01\x12g\n\rversions_info\x18\x03 \x03(\x0b\x32L.temporal.api.workflowservice.v1.DescribeTaskQueueResponse.VersionsInfoEntryB\x02\x18\x01\x1a\x64\n\x17StatsByPriorityKeyEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12\x38\n\x05value\x18\x02 \x01(\x0b\x32).temporal.api.taskqueue.v1.TaskQueueStats:\x02\x38\x01\x1at\n\x12\x45\x66\x66\x65\x63tiveRateLimit\x12\x1b\n\x13requests_per_second\x18\x01 \x01(\x02\x12\x41\n\x11rate_limit_source\x18\x02 \x01(\x0e\x32&.temporal.api.enums.v1.RateLimitSource\x1a\x64\n\x11VersionsInfoEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12>\n\x05value\x18\x02 \x01(\x0b\x32/.temporal.api.taskqueue.v1.TaskQueueVersionInfo:\x02\x38\x01"\x17\n\x15GetClusterInfoRequest"\xd1\x03\n\x16GetClusterInfoResponse\x12h\n\x11supported_clients\x18\x01 \x03(\x0b\x32M.temporal.api.workflowservice.v1.GetClusterInfoResponse.SupportedClientsEntry\x12\x16\n\x0eserver_version\x18\x02 \x01(\t\x12\x12\n\ncluster_id\x18\x03 \x01(\t\x12:\n\x0cversion_info\x18\x04 \x01(\x0b\x32$.temporal.api.version.v1.VersionInfo\x12\x14\n\x0c\x63luster_name\x18\x05 \x01(\t\x12\x1b\n\x13history_shard_count\x18\x06 \x01(\x05\x12\x19\n\x11persistence_store\x18\x07 \x01(\t\x12\x18\n\x10visibility_store\x18\x08 \x01(\t\x12 \n\x18initial_failover_version\x18\t \x01(\x03\x12"\n\x1a\x66\x61ilover_version_increment\x18\n \x01(\x03\x1a\x37\n\x15SupportedClientsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01"\x16\n\x14GetSystemInfoRequest"\xc1\x04\n\x15GetSystemInfoResponse\x12\x16\n\x0eserver_version\x18\x01 \x01(\t\x12Y\n\x0c\x63\x61pabilities\x18\x02 \x01(\x0b\x32\x43.temporal.api.workflowservice.v1.GetSystemInfoResponse.Capabilities\x1a\xb4\x03\n\x0c\x43\x61pabilities\x12\x1f\n\x17signal_and_query_header\x18\x01 \x01(\x08\x12&\n\x1einternal_error_differentiation\x18\x02 \x01(\x08\x12*\n"activity_failure_include_heartbeat\x18\x03 \x01(\x08\x12\x1a\n\x12supports_schedules\x18\x04 \x01(\x08\x12"\n\x1a\x65ncoded_failure_attributes\x18\x05 \x01(\x08\x12!\n\x19\x62uild_id_based_versioning\x18\x06 \x01(\x08\x12\x13\n\x0bupsert_memo\x18\x07 \x01(\x08\x12\x1c\n\x14\x65\x61ger_workflow_start\x18\x08 \x01(\x08\x12\x14\n\x0csdk_metadata\x18\t \x01(\x08\x12\'\n\x1f\x63ount_group_by_execution_status\x18\n \x01(\x08\x12\r\n\x05nexus\x18\x0b \x01(\x08\x12!\n\x19server_scaled_deployments\x18\x0c \x01(\x08\x12(\n server_scaled_provider_cloud_run\x18\r \x01(\x08"m\n\x1eListTaskQueuePartitionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\ntask_queue\x18\x02 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue"\xdf\x01\n\x1fListTaskQueuePartitionsResponse\x12]\n\x1e\x61\x63tivity_task_queue_partitions\x18\x01 \x03(\x0b\x32\x35.temporal.api.taskqueue.v1.TaskQueuePartitionMetadata\x12]\n\x1eworkflow_task_queue_partitions\x18\x02 \x03(\x0b\x32\x35.temporal.api.taskqueue.v1.TaskQueuePartitionMetadata"\xcc\x02\n\x15\x43reateScheduleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bschedule_id\x18\x02 \x01(\t\x12\x34\n\x08schedule\x18\x03 \x01(\x0b\x32".temporal.api.schedule.v1.Schedule\x12>\n\rinitial_patch\x18\x04 \x01(\x0b\x32\'.temporal.api.schedule.v1.SchedulePatch\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x12\n\nrequest_id\x18\x06 \x01(\t\x12*\n\x04memo\x18\x07 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x08 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes"0\n\x16\x43reateScheduleResponse\x12\x16\n\x0e\x63onflict_token\x18\x01 \x01(\x0c"A\n\x17\x44\x65scribeScheduleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bschedule_id\x18\x02 \x01(\t"\x8f\x02\n\x18\x44\x65scribeScheduleResponse\x12\x34\n\x08schedule\x18\x01 \x01(\x0b\x32".temporal.api.schedule.v1.Schedule\x12\x34\n\x04info\x18\x02 \x01(\x0b\x32&.temporal.api.schedule.v1.ScheduleInfo\x12*\n\x04memo\x18\x03 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo\x12\x43\n\x11search_attributes\x18\x04 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12\x16\n\x0e\x63onflict_token\x18\x05 \x01(\x0c"\xa4\x02\n\x15UpdateScheduleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bschedule_id\x18\x02 \x01(\t\x12\x34\n\x08schedule\x18\x03 \x01(\x0b\x32".temporal.api.schedule.v1.Schedule\x12\x16\n\x0e\x63onflict_token\x18\x04 \x01(\x0c\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x12\n\nrequest_id\x18\x06 \x01(\t\x12\x43\n\x11search_attributes\x18\x07 \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12*\n\x04memo\x18\x08 \x01(\x0b\x32\x1c.temporal.api.common.v1.Memo"\x18\n\x16UpdateScheduleResponse"\x9c\x01\n\x14PatchScheduleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bschedule_id\x18\x02 \x01(\t\x12\x36\n\x05patch\x18\x03 \x01(\x0b\x32\'.temporal.api.schedule.v1.SchedulePatch\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x12\n\nrequest_id\x18\x05 \x01(\t"\x17\n\x15PatchScheduleResponse"\xa8\x01\n ListScheduleMatchingTimesRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bschedule_id\x18\x02 \x01(\t\x12.\n\nstart_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12,\n\x08\x65nd_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp"S\n!ListScheduleMatchingTimesResponse\x12.\n\nstart_time\x18\x01 \x03(\x0b\x32\x1a.google.protobuf.Timestamp"Q\n\x15\x44\x65leteScheduleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bschedule_id\x18\x02 \x01(\t\x12\x10\n\x08identity\x18\x03 \x01(\t"\x18\n\x16\x44\x65leteScheduleResponse"l\n\x14ListSchedulesRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x19\n\x11maximum_page_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\r\n\x05query\x18\x04 \x01(\t"p\n\x15ListSchedulesResponse\x12>\n\tschedules\x18\x01 \x03(\x0b\x32+.temporal.api.schedule.v1.ScheduleListEntry\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"9\n\x15\x43ountSchedulesRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t"\xdb\x01\n\x16\x43ountSchedulesResponse\x12\r\n\x05\x63ount\x18\x01 \x01(\x03\x12X\n\x06groups\x18\x02 \x03(\x0b\x32H.temporal.api.workflowservice.v1.CountSchedulesResponse.AggregationGroup\x1aX\n\x10\x41ggregationGroup\x12\x35\n\x0cgroup_values\x18\x01 \x03(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\r\n\x05\x63ount\x18\x02 \x01(\x03"\x86\x05\n\'UpdateWorkerBuildIdCompatibilityRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x12\n\ntask_queue\x18\x02 \x01(\t\x12-\n#add_new_build_id_in_new_default_set\x18\x03 \x01(\tH\x00\x12\x87\x01\n\x1b\x61\x64\x64_new_compatible_build_id\x18\x04 \x01(\x0b\x32`.temporal.api.workflowservice.v1.UpdateWorkerBuildIdCompatibilityRequest.AddNewCompatibleVersionH\x00\x12!\n\x17promote_set_by_build_id\x18\x05 \x01(\tH\x00\x12%\n\x1bpromote_build_id_within_set\x18\x06 \x01(\tH\x00\x12h\n\nmerge_sets\x18\x07 \x01(\x0b\x32R.temporal.api.workflowservice.v1.UpdateWorkerBuildIdCompatibilityRequest.MergeSetsH\x00\x1ao\n\x17\x41\x64\x64NewCompatibleVersion\x12\x14\n\x0cnew_build_id\x18\x01 \x01(\t\x12$\n\x1c\x65xisting_compatible_build_id\x18\x02 \x01(\t\x12\x18\n\x10make_set_default\x18\x03 \x01(\x08\x1aI\n\tMergeSets\x12\x1c\n\x14primary_set_build_id\x18\x01 \x01(\t\x12\x1e\n\x16secondary_set_build_id\x18\x02 \x01(\tB\x0b\n\toperation"@\n(UpdateWorkerBuildIdCompatibilityResponseJ\x04\x08\x01\x10\x02R\x0eversion_set_id"_\n$GetWorkerBuildIdCompatibilityRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x12\n\ntask_queue\x18\x02 \x01(\t\x12\x10\n\x08max_sets\x18\x03 \x01(\x05"t\n%GetWorkerBuildIdCompatibilityResponse\x12K\n\x12major_version_sets\x18\x01 \x03(\x0b\x32/.temporal.api.taskqueue.v1.CompatibleVersionSet"\xb5\r\n"UpdateWorkerVersioningRulesRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x12\n\ntask_queue\x18\x02 \x01(\t\x12\x16\n\x0e\x63onflict_token\x18\x03 \x01(\x0c\x12\x81\x01\n\x16insert_assignment_rule\x18\x04 \x01(\x0b\x32_.temporal.api.workflowservice.v1.UpdateWorkerVersioningRulesRequest.InsertBuildIdAssignmentRuleH\x00\x12\x83\x01\n\x17replace_assignment_rule\x18\x05 \x01(\x0b\x32`.temporal.api.workflowservice.v1.UpdateWorkerVersioningRulesRequest.ReplaceBuildIdAssignmentRuleH\x00\x12\x81\x01\n\x16\x64\x65lete_assignment_rule\x18\x06 \x01(\x0b\x32_.temporal.api.workflowservice.v1.UpdateWorkerVersioningRulesRequest.DeleteBuildIdAssignmentRuleH\x00\x12\x8c\x01\n\x1c\x61\x64\x64_compatible_redirect_rule\x18\x07 \x01(\x0b\x32\x64.temporal.api.workflowservice.v1.UpdateWorkerVersioningRulesRequest.AddCompatibleBuildIdRedirectRuleH\x00\x12\x94\x01\n replace_compatible_redirect_rule\x18\x08 \x01(\x0b\x32h.temporal.api.workflowservice.v1.UpdateWorkerVersioningRulesRequest.ReplaceCompatibleBuildIdRedirectRuleH\x00\x12\x92\x01\n\x1f\x64\x65lete_compatible_redirect_rule\x18\t \x01(\x0b\x32g.temporal.api.workflowservice.v1.UpdateWorkerVersioningRulesRequest.DeleteCompatibleBuildIdRedirectRuleH\x00\x12l\n\x0f\x63ommit_build_id\x18\n \x01(\x0b\x32Q.temporal.api.workflowservice.v1.UpdateWorkerVersioningRulesRequest.CommitBuildIdH\x00\x1aq\n\x1bInsertBuildIdAssignmentRule\x12\x12\n\nrule_index\x18\x01 \x01(\x05\x12>\n\x04rule\x18\x02 \x01(\x0b\x32\x30.temporal.api.taskqueue.v1.BuildIdAssignmentRule\x1a\x81\x01\n\x1cReplaceBuildIdAssignmentRule\x12\x12\n\nrule_index\x18\x01 \x01(\x05\x12>\n\x04rule\x18\x02 \x01(\x0b\x32\x30.temporal.api.taskqueue.v1.BuildIdAssignmentRule\x12\r\n\x05\x66orce\x18\x03 \x01(\x08\x1a@\n\x1b\x44\x65leteBuildIdAssignmentRule\x12\x12\n\nrule_index\x18\x01 \x01(\x05\x12\r\n\x05\x66orce\x18\x02 \x01(\x08\x1aj\n AddCompatibleBuildIdRedirectRule\x12\x46\n\x04rule\x18\x01 \x01(\x0b\x32\x38.temporal.api.taskqueue.v1.CompatibleBuildIdRedirectRule\x1an\n$ReplaceCompatibleBuildIdRedirectRule\x12\x46\n\x04rule\x18\x01 \x01(\x0b\x32\x38.temporal.api.taskqueue.v1.CompatibleBuildIdRedirectRule\x1a>\n#DeleteCompatibleBuildIdRedirectRule\x12\x17\n\x0fsource_build_id\x18\x01 \x01(\t\x1a\x37\n\rCommitBuildId\x12\x17\n\x0ftarget_build_id\x18\x01 \x01(\t\x12\r\n\x05\x66orce\x18\x02 \x01(\x08\x42\x0b\n\toperation"\xfc\x01\n#UpdateWorkerVersioningRulesResponse\x12U\n\x10\x61ssignment_rules\x18\x01 \x03(\x0b\x32;.temporal.api.taskqueue.v1.TimestampedBuildIdAssignmentRule\x12\x66\n\x19\x63ompatible_redirect_rules\x18\x02 \x03(\x0b\x32\x43.temporal.api.taskqueue.v1.TimestampedCompatibleBuildIdRedirectRule\x12\x16\n\x0e\x63onflict_token\x18\x03 \x01(\x0c"H\n\x1fGetWorkerVersioningRulesRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x12\n\ntask_queue\x18\x02 \x01(\t"\xf9\x01\n GetWorkerVersioningRulesResponse\x12U\n\x10\x61ssignment_rules\x18\x01 \x03(\x0b\x32;.temporal.api.taskqueue.v1.TimestampedBuildIdAssignmentRule\x12\x66\n\x19\x63ompatible_redirect_rules\x18\x02 \x03(\x0b\x32\x43.temporal.api.taskqueue.v1.TimestampedCompatibleBuildIdRedirectRule\x12\x16\n\x0e\x63onflict_token\x18\x03 \x01(\x0c"\x9c\x01\n GetWorkerTaskReachabilityRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tbuild_ids\x18\x02 \x03(\t\x12\x13\n\x0btask_queues\x18\x03 \x03(\t\x12=\n\x0creachability\x18\x04 \x01(\x0e\x32\'.temporal.api.enums.v1.TaskReachability"r\n!GetWorkerTaskReachabilityResponse\x12M\n\x15\x62uild_id_reachability\x18\x01 \x03(\x0b\x32..temporal.api.taskqueue.v1.BuildIdReachability"\x85\x02\n\x1eUpdateWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x1e\n\x16\x66irst_execution_run_id\x18\x03 \x01(\t\x12\x37\n\x0bwait_policy\x18\x04 \x01(\x0b\x32".temporal.api.update.v1.WaitPolicy\x12\x30\n\x07request\x18\x05 \x01(\x0b\x32\x1f.temporal.api.update.v1.Request"\x83\x02\n\x1fUpdateWorkflowExecutionResponse\x12\x35\n\nupdate_ref\x18\x01 \x01(\x0b\x32!.temporal.api.update.v1.UpdateRef\x12\x30\n\x07outcome\x18\x02 \x01(\x0b\x32\x1f.temporal.api.update.v1.Outcome\x12K\n\x05stage\x18\x03 \x01(\x0e\x32<.temporal.api.enums.v1.UpdateWorkflowExecutionLifecycleStage\x12*\n\x04link\x18\x04 \x01(\x0b\x32\x1c.temporal.api.common.v1.Link"\xd6\n\n\x1aStartBatchOperationRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x18\n\x10visibility_query\x18\x02 \x01(\t\x12\x0e\n\x06job_id\x18\x03 \x01(\t\x12\x0e\n\x06reason\x18\x04 \x01(\t\x12\x41\n\nexecutions\x18\x05 \x03(\x0b\x32).temporal.api.common.v1.WorkflowExecutionB\x02\x18\x01\x12<\n\x11target_executions\x18\x16 \x03(\x0b\x32!.temporal.api.common.v1.Execution\x12!\n\x19max_operations_per_second\x18\x06 \x01(\x02\x12Q\n\x15termination_operation\x18\n \x01(\x0b\x32\x30.temporal.api.batch.v1.BatchOperationTerminationH\x00\x12G\n\x10signal_operation\x18\x0b \x01(\x0b\x32+.temporal.api.batch.v1.BatchOperationSignalH\x00\x12S\n\x16\x63\x61ncellation_operation\x18\x0c \x01(\x0b\x32\x31.temporal.api.batch.v1.BatchOperationCancellationH\x00\x12K\n\x12\x64\x65letion_operation\x18\r \x01(\x0b\x32-.temporal.api.batch.v1.BatchOperationDeletionH\x00\x12\x45\n\x0freset_operation\x18\x0e \x01(\x0b\x32*.temporal.api.batch.v1.BatchOperationResetH\x00\x12p\n!update_workflow_options_operation\x18\x0f \x01(\x0b\x32\x43.temporal.api.batch.v1.BatchOperationUpdateWorkflowExecutionOptionsH\x00\x12^\n\x1cunpause_activities_operation\x18\x10 \x01(\x0b\x32\x36.temporal.api.batch.v1.BatchOperationUnpauseActivitiesH\x00\x12Z\n\x1areset_activities_operation\x18\x11 \x01(\x0b\x32\x34.temporal.api.batch.v1.BatchOperationResetActivitiesH\x00\x12g\n!update_activity_options_operation\x18\x12 \x01(\x0b\x32:.temporal.api.batch.v1.BatchOperationUpdateActivityOptionsH\x00\x12\\\n\x1b\x63\x61ncel_activities_operation\x18\x13 \x01(\x0b\x32\x35.temporal.api.batch.v1.BatchOperationCancelActivitiesH\x00\x12\x62\n\x1eterminate_activities_operation\x18\x14 \x01(\x0b\x32\x38.temporal.api.batch.v1.BatchOperationTerminateActivitiesH\x00\x12\\\n\x1b\x64\x65lete_activities_operation\x18\x15 \x01(\x0b\x32\x35.temporal.api.batch.v1.BatchOperationDeleteActivitiesH\x00\x42\x0b\n\toperation"\x1d\n\x1bStartBatchOperationResponse"`\n\x19StopBatchOperationRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x0e\n\x06job_id\x18\x02 \x01(\t\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t"\x1c\n\x1aStopBatchOperationResponse"B\n\x1d\x44\x65scribeBatchOperationRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x0e\n\x06job_id\x18\x02 \x01(\t"\xd8\x03\n\x1e\x44\x65scribeBatchOperationResponse\x12\x41\n\x0eoperation_type\x18\x01 \x01(\x0e\x32).temporal.api.enums.v1.BatchOperationType\x12\x0e\n\x06job_id\x18\x02 \x01(\t\x12\x39\n\x05state\x18\x03 \x01(\x0e\x32*.temporal.api.enums.v1.BatchOperationState\x12.\n\nstart_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12.\n\nclose_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1d\n\x15total_operation_count\x18\x06 \x01(\x03\x12 \n\x18\x63omplete_operation_count\x18\x07 \x01(\x03\x12\x1f\n\x17\x66\x61ilure_operation_count\x18\x08 \x01(\x03\x12\x10\n\x08identity\x18\t \x01(\t\x12\x0e\n\x06reason\x18\n \x01(\t\x12\r\n\x05query\x18\x0b \x01(\t\x12\x35\n\nexecutions\x18\x0c \x03(\x0b\x32!.temporal.api.common.v1.Execution"[\n\x1aListBatchOperationsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c"y\n\x1bListBatchOperationsResponse\x12\x41\n\x0eoperation_info\x18\x01 \x03(\x0b\x32).temporal.api.batch.v1.BatchOperationInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"\xb9\x01\n"PollWorkflowExecutionUpdateRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x35\n\nupdate_ref\x18\x02 \x01(\x0b\x32!.temporal.api.update.v1.UpdateRef\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x37\n\x0bwait_policy\x18\x04 \x01(\x0b\x32".temporal.api.update.v1.WaitPolicy"\xdb\x01\n#PollWorkflowExecutionUpdateResponse\x12\x30\n\x07outcome\x18\x01 \x01(\x0b\x32\x1f.temporal.api.update.v1.Outcome\x12K\n\x05stage\x18\x02 \x01(\x0e\x32<.temporal.api.enums.v1.UpdateWorkflowExecutionLifecycleStage\x12\x35\n\nupdate_ref\x18\x03 \x01(\x0b\x32!.temporal.api.update.v1.UpdateRef"\xa0\x03\n\x19PollNexusTaskQueueRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x38\n\ntask_queue\x18\x03 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12\x17\n\x0fpoller_group_id\x18\t \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x1b\n\x13worker_instance_key\x18\x08 \x01(\t\x12Z\n\x1bworker_version_capabilities\x18\x04 \x01(\x0b\x32\x31.temporal.api.common.v1.WorkerVersionCapabilitiesB\x02\x18\x01\x12O\n\x12\x64\x65ployment_options\x18\x06 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentOptions\x12\x41\n\x10worker_heartbeat\x18\x07 \x03(\x0b\x32\'.temporal.api.worker.v1.WorkerHeartbeat"\xe2\x02\n\x1aPollNexusTaskQueueResponse\x12\x12\n\ntask_token\x18\x01 \x01(\x0c\x12/\n\x07request\x18\x02 \x01(\x0b\x32\x1e.temporal.api.nexus.v1.Request\x12Q\n\x17poller_scaling_decision\x18\x03 \x01(\x0b\x32\x30.temporal.api.taskqueue.v1.PollerScalingDecision\x12\x17\n\x0fpoller_group_id\x18\x04 \x01(\t\x12J\n\x12poller_group_infos\x18\x05 \x03(\x0b\x32*.temporal.api.taskqueue.v1.PollerGroupInfoB\x02\x18\x01\x12G\n\x12poller_groups_info\x18\x06 \x01(\x0b\x32+.temporal.api.taskqueue.v1.PollerGroupsInfo"\xa7\x01\n RespondNexusTaskCompletedRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x12\n\ntask_token\x18\x03 \x01(\x0c\x12\x31\n\x08response\x18\x04 \x01(\x0b\x32\x1f.temporal.api.nexus.v1.Response\x12\x17\n\x0fpoller_group_id\x18\x05 \x01(\t"#\n!RespondNexusTaskCompletedResponse"\xdc\x01\n\x1dRespondNexusTaskFailedRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x12\n\ntask_token\x18\x03 \x01(\x0c\x12\x36\n\x05\x65rror\x18\x04 \x01(\x0b\x32#.temporal.api.nexus.v1.HandlerErrorB\x02\x18\x01\x12\x31\n\x07\x66\x61ilure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.Failure\x12\x17\n\x0fpoller_group_id\x18\x06 \x01(\t" \n\x1eRespondNexusTaskFailedResponse"\xf4\x02\n\x1c\x45xecuteMultiOperationRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12[\n\noperations\x18\x02 \x03(\x0b\x32G.temporal.api.workflowservice.v1.ExecuteMultiOperationRequest.Operation\x12\x13\n\x0bresource_id\x18\x03 \x01(\t\x1a\xce\x01\n\tOperation\x12X\n\x0estart_workflow\x18\x01 \x01(\x0b\x32>.temporal.api.workflowservice.v1.StartWorkflowExecutionRequestH\x00\x12Z\n\x0fupdate_workflow\x18\x02 \x01(\x0b\x32?.temporal.api.workflowservice.v1.UpdateWorkflowExecutionRequestH\x00\x42\x0b\n\toperation"\xcc\x02\n\x1d\x45xecuteMultiOperationResponse\x12Z\n\tresponses\x18\x01 \x03(\x0b\x32G.temporal.api.workflowservice.v1.ExecuteMultiOperationResponse.Response\x1a\xce\x01\n\x08Response\x12Y\n\x0estart_workflow\x18\x01 \x01(\x0b\x32?.temporal.api.workflowservice.v1.StartWorkflowExecutionResponseH\x00\x12[\n\x0fupdate_workflow\x18\x02 \x01(\x0b\x32@.temporal.api.workflowservice.v1.UpdateWorkflowExecutionResponseH\x00\x42\n\n\x08response"\xd0\x02\n\x1cUpdateActivityOptionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x43\n\x10\x61\x63tivity_options\x18\x04 \x01(\x0b\x32).temporal.api.activity.v1.ActivityOptions\x12/\n\x0bupdate_mask\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.FieldMask\x12\x0c\n\x02id\x18\x06 \x01(\tH\x00\x12\x0e\n\x04type\x18\x07 \x01(\tH\x00\x12\x13\n\tmatch_all\x18\t \x01(\x08H\x00\x12\x18\n\x10restore_original\x18\x08 \x01(\x08\x42\n\n\x08\x61\x63tivity"\xbf\x02\n%UpdateActivityExecutionOptionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x03 \x01(\t\x12\x0e\n\x06run_id\x18\x04 \x01(\t\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x43\n\x10\x61\x63tivity_options\x18\x06 \x01(\x0b\x32).temporal.api.activity.v1.ActivityOptions\x12/\n\x0bupdate_mask\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.FieldMask\x12\x18\n\x10restore_original\x18\x08 \x01(\x08\x12\x13\n\x0bresource_id\x18\t \x01(\t\x12\x12\n\nrequest_id\x18\n \x01(\t"d\n\x1dUpdateActivityOptionsResponse\x12\x43\n\x10\x61\x63tivity_options\x18\x01 \x01(\x0b\x32).temporal.api.activity.v1.ActivityOptions"m\n&UpdateActivityExecutionOptionsResponse\x12\x43\n\x10\x61\x63tivity_options\x18\x01 \x01(\x0b\x32).temporal.api.activity.v1.ActivityOptions"\xc7\x01\n\x14PauseActivityRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x0c\n\x02id\x18\x04 \x01(\tH\x00\x12\x0e\n\x04type\x18\x05 \x01(\tH\x00\x12\x0e\n\x06reason\x18\x06 \x01(\t\x12\x12\n\nrequest_id\x18\x07 \x01(\tB\n\n\x08\x61\x63tivity"\xb7\x01\n\x1dPauseActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x03 \x01(\t\x12\x0e\n\x06run_id\x18\x04 \x01(\t\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x0e\n\x06reason\x18\x06 \x01(\t\x12\x13\n\x0bresource_id\x18\x07 \x01(\t\x12\x12\n\nrequest_id\x18\x08 \x01(\t"\x17\n\x15PauseActivityResponse" \n\x1ePauseActivityExecutionResponse"\x98\x02\n\x16UnpauseActivityRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x0c\n\x02id\x18\x04 \x01(\tH\x00\x12\x0e\n\x04type\x18\x05 \x01(\tH\x00\x12\x15\n\x0bunpause_all\x18\x06 \x01(\x08H\x00\x12\x16\n\x0ereset_attempts\x18\x07 \x01(\x08\x12\x17\n\x0freset_heartbeat\x18\x08 \x01(\x08\x12)\n\x06jitter\x18\t \x01(\x0b\x32\x19.google.protobuf.DurationB\n\n\x08\x61\x63tivity"\x91\x02\n\x1fUnpauseActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x03 \x01(\t\x12\x0e\n\x06run_id\x18\x04 \x01(\t\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x0e\n\x06reason\x18\x08 \x01(\t\x12)\n\x06jitter\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x13\n\x0bresource_id\x18\n \x01(\t\x12\x12\n\nrequest_id\x18\x0b \x01(\tJ\x04\x08\x06\x10\x07J\x04\x08\x07\x10\x08R\x0ereset_attemptsR\x0freset_heartbeat"\x19\n\x17UnpauseActivityResponse""\n UnpauseActivityExecutionResponse"\xb3\x02\n\x14ResetActivityRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x0c\n\x02id\x18\x04 \x01(\tH\x00\x12\x0e\n\x04type\x18\x05 \x01(\tH\x00\x12\x13\n\tmatch_all\x18\n \x01(\x08H\x00\x12\x17\n\x0freset_heartbeat\x18\x06 \x01(\x08\x12\x13\n\x0bkeep_paused\x18\x07 \x01(\x08\x12)\n\x06jitter\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12 \n\x18restore_original_options\x18\t \x01(\x08\x42\n\n\x08\x61\x63tivity"\xa2\x02\n\x1dResetActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x03 \x01(\t\x12\x0e\n\x06run_id\x18\x04 \x01(\t\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x13\n\x0bkeep_paused\x18\x06 \x01(\x08\x12)\n\x06jitter\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12 \n\x18restore_original_options\x18\x08 \x01(\x08\x12\x13\n\x0bresource_id\x18\t \x01(\t\x12\x12\n\nrequest_id\x18\n \x01(\t\x12\x17\n\x0freset_heartbeat\x18\x0b \x01(\x08"\x17\n\x15ResetActivityResponse" \n\x1eResetActivityExecutionResponse"\x9c\x02\n%UpdateWorkflowExecutionOptionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12V\n\x1aworkflow_execution_options\x18\x03 \x01(\x0b\x32\x32.temporal.api.workflow.v1.WorkflowExecutionOptions\x12/\n\x0bupdate_mask\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.FieldMask\x12\x10\n\x08identity\x18\x05 \x01(\t"\xb1\x01\n&UpdateWorkflowExecutionOptionsResponse\x12V\n\x1aworkflow_execution_options\x18\x01 \x01(\x0b\x32\x32.temporal.api.workflow.v1.WorkflowExecutionOptions\x12/\n\x0bupdate_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp"j\n\x19\x44\x65scribeDeploymentRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12:\n\ndeployment\x18\x02 \x01(\x0b\x32&.temporal.api.deployment.v1.Deployment"a\n\x1a\x44\x65scribeDeploymentResponse\x12\x43\n\x0f\x64\x65ployment_info\x18\x01 \x01(\x0b\x32*.temporal.api.deployment.v1.DeploymentInfo"\xc2\x01\n&DescribeWorkerDeploymentVersionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x07version\x18\x02 \x01(\tB\x02\x18\x01\x12O\n\x12\x64\x65ployment_version\x18\x03 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12\x1f\n\x17report_task_queue_stats\x18\x04 \x01(\x08"\x8c\x05\n\'DescribeWorkerDeploymentVersionResponse\x12_\n\x1eworker_deployment_version_info\x18\x01 \x01(\x0b\x32\x37.temporal.api.deployment.v1.WorkerDeploymentVersionInfo\x12v\n\x13version_task_queues\x18\x02 \x03(\x0b\x32Y.temporal.api.workflowservice.v1.DescribeWorkerDeploymentVersionResponse.VersionTaskQueue\x1a\x87\x03\n\x10VersionTaskQueue\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x32\n\x04type\x18\x02 \x01(\x0e\x32$.temporal.api.enums.v1.TaskQueueType\x12\x38\n\x05stats\x18\x03 \x01(\x0b\x32).temporal.api.taskqueue.v1.TaskQueueStats\x12\x90\x01\n\x15stats_by_priority_key\x18\x04 \x03(\x0b\x32q.temporal.api.workflowservice.v1.DescribeWorkerDeploymentVersionResponse.VersionTaskQueue.StatsByPriorityKeyEntry\x1a\x64\n\x17StatsByPriorityKeyEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12\x38\n\x05value\x18\x02 \x01(\x0b\x32).temporal.api.taskqueue.v1.TaskQueueStats:\x02\x38\x01"M\n\x1f\x44\x65scribeWorkerDeploymentRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65ployment_name\x18\x02 \x01(\t"\x8c\x01\n DescribeWorkerDeploymentResponse\x12\x16\n\x0e\x63onflict_token\x18\x01 \x01(\x0c\x12P\n\x16worker_deployment_info\x18\x02 \x01(\x0b\x32\x30.temporal.api.deployment.v1.WorkerDeploymentInfo"l\n\x16ListDeploymentsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\x13\n\x0bseries_name\x18\x04 \x01(\t"w\n\x17ListDeploymentsResponse\x12\x17\n\x0fnext_page_token\x18\x01 \x01(\x0c\x12\x43\n\x0b\x64\x65ployments\x18\x02 \x03(\x0b\x32..temporal.api.deployment.v1.DeploymentListInfo"\xcd\x01\n\x1bSetCurrentDeploymentRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12:\n\ndeployment\x18\x02 \x01(\x0b\x32&.temporal.api.deployment.v1.Deployment\x12\x10\n\x08identity\x18\x03 \x01(\t\x12M\n\x0fupdate_metadata\x18\x04 \x01(\x0b\x32\x34.temporal.api.deployment.v1.UpdateDeploymentMetadata"\xb9\x01\n\x1cSetCurrentDeploymentResponse\x12K\n\x17\x63urrent_deployment_info\x18\x01 \x01(\x0b\x32*.temporal.api.deployment.v1.DeploymentInfo\x12L\n\x18previous_deployment_info\x18\x02 \x01(\x0b\x32*.temporal.api.deployment.v1.DeploymentInfo"\xe5\x01\n(SetWorkerDeploymentCurrentVersionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65ployment_name\x18\x02 \x01(\t\x12\x13\n\x07version\x18\x03 \x01(\tB\x02\x18\x01\x12\x10\n\x08\x62uild_id\x18\x07 \x01(\t\x12\x16\n\x0e\x63onflict_token\x18\x04 \x01(\x0c\x12\x10\n\x08identity\x18\x05 \x01(\t\x12"\n\x1aignore_missing_task_queues\x18\x06 \x01(\x08\x12\x18\n\x10\x61llow_no_pollers\x18\t \x01(\x08"\xbf\x01\n)SetWorkerDeploymentCurrentVersionResponse\x12\x16\n\x0e\x63onflict_token\x18\x01 \x01(\x0c\x12\x1c\n\x10previous_version\x18\x02 \x01(\tB\x02\x18\x01\x12\\\n\x1bprevious_deployment_version\x18\x03 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersionB\x02\x18\x01"\xf9\x01\n(SetWorkerDeploymentRampingVersionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65ployment_name\x18\x02 \x01(\t\x12\x13\n\x07version\x18\x03 \x01(\tB\x02\x18\x01\x12\x10\n\x08\x62uild_id\x18\x08 \x01(\t\x12\x12\n\npercentage\x18\x04 \x01(\x02\x12\x16\n\x0e\x63onflict_token\x18\x05 \x01(\x0c\x12\x10\n\x08identity\x18\x06 \x01(\t\x12"\n\x1aignore_missing_task_queues\x18\x07 \x01(\x08\x12\x18\n\x10\x61llow_no_pollers\x18\n \x01(\x08"\xe0\x01\n)SetWorkerDeploymentRampingVersionResponse\x12\x16\n\x0e\x63onflict_token\x18\x01 \x01(\x0c\x12\x1c\n\x10previous_version\x18\x02 \x01(\tB\x02\x18\x01\x12\\\n\x1bprevious_deployment_version\x18\x04 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersionB\x02\x18\x01\x12\x1f\n\x13previous_percentage\x18\x03 \x01(\x02\x42\x02\x18\x01"q\n\x1d\x43reateWorkerDeploymentRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65ployment_name\x18\x02 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x12\n\nrequest_id\x18\x05 \x01(\t"8\n\x1e\x43reateWorkerDeploymentResponse\x12\x16\n\x0e\x63onflict_token\x18\x01 \x01(\x0c"]\n\x1cListWorkerDeploymentsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c"\x9f\x05\n\x1dListWorkerDeploymentsResponse\x12\x17\n\x0fnext_page_token\x18\x01 \x01(\x0c\x12r\n\x12worker_deployments\x18\x02 \x03(\x0b\x32V.temporal.api.workflowservice.v1.ListWorkerDeploymentsResponse.WorkerDeploymentSummary\x1a\xf0\x03\n\x17WorkerDeploymentSummary\x12\x0c\n\x04name\x18\x01 \x01(\t\x12/\n\x0b\x63reate_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x41\n\x0erouting_config\x18\x03 \x01(\x0b\x32).temporal.api.deployment.v1.RoutingConfig\x12o\n\x16latest_version_summary\x18\x04 \x01(\x0b\x32O.temporal.api.deployment.v1.WorkerDeploymentInfo.WorkerDeploymentVersionSummary\x12p\n\x17\x63urrent_version_summary\x18\x05 \x01(\x0b\x32O.temporal.api.deployment.v1.WorkerDeploymentInfo.WorkerDeploymentVersionSummary\x12p\n\x17ramping_version_summary\x18\x06 \x01(\x0b\x32O.temporal.api.deployment.v1.WorkerDeploymentInfo.WorkerDeploymentVersionSummary"\xf0\x01\n$CreateWorkerDeploymentVersionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12O\n\x12\x64\x65ployment_version\x18\x02 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12>\n\x0e\x63ompute_config\x18\x04 \x01(\x0b\x32&.temporal.api.compute.v1.ComputeConfig\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x12\n\nrequest_id\x18\x05 \x01(\t"\'\n%CreateWorkerDeploymentVersionResponse"\xc8\x01\n$DeleteWorkerDeploymentVersionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x07version\x18\x02 \x01(\tB\x02\x18\x01\x12O\n\x12\x64\x65ployment_version\x18\x05 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12\x15\n\rskip_drainage\x18\x03 \x01(\x08\x12\x10\n\x08identity\x18\x04 \x01(\t"\'\n%DeleteWorkerDeploymentVersionResponse"]\n\x1d\x44\x65leteWorkerDeploymentRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65ployment_name\x18\x02 \x01(\t\x12\x10\n\x08identity\x18\x03 \x01(\t" \n\x1e\x44\x65leteWorkerDeploymentResponse"\x84\x04\n1UpdateWorkerDeploymentVersionComputeConfigRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12O\n\x12\x64\x65ployment_version\x18\x02 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12\x99\x01\n\x1d\x63ompute_config_scaling_groups\x18\x06 \x03(\x0b\x32r.temporal.api.workflowservice.v1.UpdateWorkerDeploymentVersionComputeConfigRequest.ComputeConfigScalingGroupsEntry\x12,\n$remove_compute_config_scaling_groups\x18\x07 \x03(\t\x12\x10\n\x08identity\x18\x03 \x01(\t\x12\x12\n\nrequest_id\x18\x04 \x01(\t\x1a{\n\x1f\x43omputeConfigScalingGroupsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12G\n\x05value\x18\x02 \x01(\x0b\x32\x38.temporal.api.compute.v1.ComputeConfigScalingGroupUpdate:\x02\x38\x01"4\n2UpdateWorkerDeploymentVersionComputeConfigResponse"\xf4\x03\n3ValidateWorkerDeploymentVersionComputeConfigRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12O\n\x12\x64\x65ployment_version\x18\x02 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12\x9b\x01\n\x1d\x63ompute_config_scaling_groups\x18\x06 \x03(\x0b\x32t.temporal.api.workflowservice.v1.ValidateWorkerDeploymentVersionComputeConfigRequest.ComputeConfigScalingGroupsEntry\x12,\n$remove_compute_config_scaling_groups\x18\x07 \x03(\t\x12\x10\n\x08identity\x18\x03 \x01(\t\x1a{\n\x1f\x43omputeConfigScalingGroupsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12G\n\x05value\x18\x02 \x01(\x0b\x32\x38.temporal.api.compute.v1.ComputeConfigScalingGroupUpdate:\x02\x38\x01"6\n4ValidateWorkerDeploymentVersionComputeConfigResponse"\xa2\x03\n,UpdateWorkerDeploymentVersionMetadataRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x07version\x18\x02 \x01(\tB\x02\x18\x01\x12O\n\x12\x64\x65ployment_version\x18\x05 \x01(\x0b\x32\x33.temporal.api.deployment.v1.WorkerDeploymentVersion\x12x\n\x0eupsert_entries\x18\x03 \x03(\x0b\x32`.temporal.api.workflowservice.v1.UpdateWorkerDeploymentVersionMetadataRequest.UpsertEntriesEntry\x12\x16\n\x0eremove_entries\x18\x04 \x03(\t\x12\x10\n\x08identity\x18\x06 \x01(\t\x1aU\n\x12UpsertEntriesEntry\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"n\n-UpdateWorkerDeploymentVersionMetadataResponse\x12=\n\x08metadata\x18\x01 \x01(\x0b\x32+.temporal.api.deployment.v1.VersionMetadata"\xbd\x01\n!SetWorkerDeploymentManagerRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65ployment_name\x18\x02 \x01(\t\x12\x1a\n\x10manager_identity\x18\x03 \x01(\tH\x00\x12\x0e\n\x04self\x18\x04 \x01(\x08H\x00\x12\x16\n\x0e\x63onflict_token\x18\x05 \x01(\x0c\x12\x10\n\x08identity\x18\x06 \x01(\tB\x16\n\x14new_manager_identity"c\n"SetWorkerDeploymentManagerResponse\x12\x16\n\x0e\x63onflict_token\x18\x01 \x01(\x0c\x12%\n\x19previous_manager_identity\x18\x02 \x01(\tB\x02\x18\x01"E\n\x1bGetCurrentDeploymentRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bseries_name\x18\x02 \x01(\t"k\n\x1cGetCurrentDeploymentResponse\x12K\n\x17\x63urrent_deployment_info\x18\x01 \x01(\x0b\x32*.temporal.api.deployment.v1.DeploymentInfo"q\n GetDeploymentReachabilityRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12:\n\ndeployment\x18\x02 \x01(\x0b\x32&.temporal.api.deployment.v1.Deployment"\xe3\x01\n!GetDeploymentReachabilityResponse\x12\x43\n\x0f\x64\x65ployment_info\x18\x01 \x01(\x0b\x32*.temporal.api.deployment.v1.DeploymentInfo\x12\x43\n\x0creachability\x18\x02 \x01(\x0e\x32-.temporal.api.enums.v1.DeploymentReachability\x12\x34\n\x10last_update_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp"\xb4\x01\n\x19\x43reateWorkflowRuleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x35\n\x04spec\x18\x02 \x01(\x0b\x32\'.temporal.api.rules.v1.WorkflowRuleSpec\x12\x12\n\nforce_scan\x18\x03 \x01(\x08\x12\x12\n\nrequest_id\x18\x04 \x01(\t\x12\x10\n\x08identity\x18\x05 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x06 \x01(\t"_\n\x1a\x43reateWorkflowRuleResponse\x12\x31\n\x04rule\x18\x01 \x01(\x0b\x32#.temporal.api.rules.v1.WorkflowRule\x12\x0e\n\x06job_id\x18\x02 \x01(\t"A\n\x1b\x44\x65scribeWorkflowRuleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x0f\n\x07rule_id\x18\x02 \x01(\t"Q\n\x1c\x44\x65scribeWorkflowRuleResponse\x12\x31\n\x04rule\x18\x01 \x01(\x0b\x32#.temporal.api.rules.v1.WorkflowRule"?\n\x19\x44\x65leteWorkflowRuleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x0f\n\x07rule_id\x18\x02 \x01(\t"\x1c\n\x1a\x44\x65leteWorkflowRuleResponse"F\n\x18ListWorkflowRulesRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"h\n\x19ListWorkflowRulesResponse\x12\x32\n\x05rules\x18\x01 \x03(\x0b\x32#.temporal.api.rules.v1.WorkflowRule\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"\xce\x01\n\x1aTriggerWorkflowRuleRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12<\n\texecution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x0c\n\x02id\x18\x04 \x01(\tH\x00\x12\x37\n\x04spec\x18\x05 \x01(\x0b\x32\'.temporal.api.rules.v1.WorkflowRuleSpecH\x00\x12\x10\n\x08identity\x18\x03 \x01(\tB\x06\n\x04rule".\n\x1bTriggerWorkflowRuleResponse\x12\x0f\n\x07\x61pplied\x18\x01 \x01(\x08"\x9b\x01\n\x1cRecordWorkerHeartbeatRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x41\n\x10worker_heartbeat\x18\x03 \x03(\x0b\x32\'.temporal.api.worker.v1.WorkerHeartbeat\x12\x13\n\x0bresource_id\x18\x04 \x01(\t"\x1f\n\x1dRecordWorkerHeartbeatResponse"\x82\x01\n\x12ListWorkersRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\r\n\x05query\x18\x04 \x01(\t\x12\x1e\n\x16include_system_workers\x18\x05 \x01(\x08"\xa5\x01\n\x13ListWorkersResponse\x12<\n\x0cworkers_info\x18\x01 \x03(\x0b\x32".temporal.api.worker.v1.WorkerInfoB\x02\x18\x01\x12\x37\n\x07workers\x18\x03 \x03(\x0b\x32&.temporal.api.worker.v1.WorkerListInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"\xd5\x05\n\x1cUpdateTaskQueueConfigRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x12\n\ntask_queue\x18\x03 \x01(\t\x12=\n\x0ftask_queue_type\x18\x04 \x01(\x0e\x32$.temporal.api.enums.v1.TaskQueueType\x12n\n\x17update_queue_rate_limit\x18\x05 \x01(\x0b\x32M.temporal.api.workflowservice.v1.UpdateTaskQueueConfigRequest.RateLimitUpdate\x12}\n&update_fairness_key_rate_limit_default\x18\x06 \x01(\x0b\x32M.temporal.api.workflowservice.v1.UpdateTaskQueueConfigRequest.RateLimitUpdate\x12\x84\x01\n\x1dset_fairness_weight_overrides\x18\x07 \x03(\x0b\x32].temporal.api.workflowservice.v1.UpdateTaskQueueConfigRequest.SetFairnessWeightOverridesEntry\x12\'\n\x1funset_fairness_weight_overrides\x18\x08 \x03(\t\x1a[\n\x0fRateLimitUpdate\x12\x38\n\nrate_limit\x18\x01 \x01(\x0b\x32$.temporal.api.taskqueue.v1.RateLimit\x12\x0e\n\x06reason\x18\x02 \x01(\t\x1a\x41\n\x1fSetFairnessWeightOverridesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01"[\n\x1dUpdateTaskQueueConfigResponse\x12:\n\x06\x63onfig\x18\x01 \x01(\x0b\x32*.temporal.api.taskqueue.v1.TaskQueueConfig"\x9e\x01\n\x18\x46\x65tchWorkerConfigRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x38\n\x08selector\x18\x06 \x01(\x0b\x32&.temporal.api.common.v1.WorkerSelector\x12\x13\n\x0bresource_id\x18\x07 \x01(\t"U\n\x19\x46\x65tchWorkerConfigResponse\x12\x38\n\rworker_config\x18\x01 \x01(\x0b\x32!.temporal.api.sdk.v1.WorkerConfig"\x8a\x02\n\x19UpdateWorkerConfigRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x38\n\rworker_config\x18\x04 \x01(\x0b\x32!.temporal.api.sdk.v1.WorkerConfig\x12/\n\x0bupdate_mask\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.FieldMask\x12\x38\n\x08selector\x18\x06 \x01(\x0b\x32&.temporal.api.common.v1.WorkerSelector\x12\x13\n\x0bresource_id\x18\x07 \x01(\t"d\n\x1aUpdateWorkerConfigResponse\x12:\n\rworker_config\x18\x01 \x01(\x0b\x32!.temporal.api.sdk.v1.WorkerConfigH\x00\x42\n\n\x08response"G\n\x15\x44\x65scribeWorkerRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x1b\n\x13worker_instance_key\x18\x02 \x01(\t"Q\n\x16\x44\x65scribeWorkerResponse\x12\x37\n\x0bworker_info\x18\x01 \x01(\x0b\x32".temporal.api.worker.v1.WorkerInfo"W\n\x13\x43ountWorkersRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x1e\n\x16include_system_workers\x18\x03 \x01(\x08"%\n\x14\x43ountWorkersResponse\x12\r\n\x05\x63ount\x18\x01 \x01(\x03"\x8d\x01\n\x1dPauseWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x12\n\nrequest_id\x18\x06 \x01(\t" \n\x1ePauseWorkflowExecutionResponse"\x8f\x01\n\x1fUnpauseWorkflowExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x12\n\nrequest_id\x18\x06 \x01(\t""\n UnpauseWorkflowExecutionResponse"\x99\t\n\x1dStartActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x12\n\nrequest_id\x18\x03 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x04 \x01(\t\x12;\n\ractivity_type\x18\x05 \x01(\x0b\x32$.temporal.api.common.v1.ActivityType\x12\x38\n\ntask_queue\x18\x06 \x01(\x0b\x32$.temporal.api.taskqueue.v1.TaskQueue\x12<\n\x19schedule_to_close_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.Duration\x12<\n\x19schedule_to_start_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x34\n\x11heartbeat_timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x0cretry_policy\x18\x0b \x01(\x0b\x32#.temporal.api.common.v1.RetryPolicy\x12/\n\x05input\x18\x0c \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x45\n\x0fid_reuse_policy\x18\r \x01(\x0e\x32,.temporal.api.enums.v1.ActivityIdReusePolicy\x12K\n\x12id_conflict_policy\x18\x0e \x01(\x0e\x32/.temporal.api.enums.v1.ActivityIdConflictPolicy\x12\x43\n\x11search_attributes\x18\x0f \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12.\n\x06header\x18\x10 \x01(\x0b\x32\x1e.temporal.api.common.v1.Header\x12\x38\n\ruser_metadata\x18\x11 \x01(\x0b\x32!.temporal.api.sdk.v1.UserMetadata\x12\x32\n\x08priority\x18\x12 \x01(\x0b\x32 .temporal.api.common.v1.Priority\x12>\n\x14\x63ompletion_callbacks\x18\x13 \x03(\x0b\x32 .temporal.api.common.v1.Callback\x12+\n\x05links\x18\x14 \x03(\x0b\x32\x1c.temporal.api.common.v1.Link\x12\x46\n\x13on_conflict_options\x18\x15 \x01(\x0b\x32).temporal.api.common.v1.OnConflictOptions\x12.\n\x0bstart_delay\x18\x16 \x01(\x0b\x32\x19.google.protobuf.Duration"m\n\x1eStartActivityExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x0f\n\x07started\x18\x02 \x01(\x08\x12*\n\x04link\x18\x03 \x01(\x0b\x32\x1c.temporal.api.common.v1.Link"\xe4\x01\n DescribeActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x15\n\rinclude_input\x18\x04 \x01(\x08\x12\x17\n\x0finclude_outcome\x18\x05 \x01(\x08\x12\x17\n\x0flong_poll_token\x18\x06 \x01(\x0c\x12!\n\x19include_heartbeat_details\x18\x07 \x01(\x08\x12\x1c\n\x14include_last_failure\x18\x08 \x01(\x08"\xbc\x02\n!DescribeActivityExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12=\n\x04info\x18\x02 \x01(\x0b\x32/.temporal.api.activity.v1.ActivityExecutionInfo\x12/\n\x05input\x18\x03 \x01(\x0b\x32 .temporal.api.common.v1.Payloads\x12\x43\n\x07outcome\x18\x04 \x01(\x0b\x32\x32.temporal.api.activity.v1.ActivityExecutionOutcome\x12\x17\n\x0flong_poll_token\x18\x05 \x01(\x0c\x12\x39\n\tcallbacks\x18\x06 \x03(\x0b\x32&.temporal.api.activity.v1.CallbackInfo"V\n\x1cPollActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t"t\n\x1dPollActivityExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x43\n\x07outcome\x18\x02 \x01(\x0b\x32\x32.temporal.api.activity.v1.ActivityExecutionOutcome"m\n\x1dListActivityExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\r\n\x05query\x18\x04 \x01(\t"\x82\x01\n\x1eListActivityExecutionsResponse\x12G\n\nexecutions\x18\x01 \x03(\x0b\x32\x33.temporal.api.activity.v1.ActivityExecutionListInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"\xd1\x06\n#StartNexusOperationExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x10\n\x08identity\x18\x02 \x01(\t\x12\x12\n\nrequest_id\x18\x03 \x01(\t\x12\x14\n\x0coperation_id\x18\x04 \x01(\t\x12\x10\n\x08\x65ndpoint\x18\x05 \x01(\t\x12\x0f\n\x07service\x18\x06 \x01(\t\x12\x11\n\toperation\x18\x07 \x01(\t\x12<\n\x19schedule_to_close_timeout\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12<\n\x19schedule_to_start_timeout\x18\t \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x39\n\x16start_to_close_timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.Duration\x12.\n\x05input\x18\x0b \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12K\n\x0fid_reuse_policy\x18\x0c \x01(\x0e\x32\x32.temporal.api.enums.v1.NexusOperationIdReusePolicy\x12Q\n\x12id_conflict_policy\x18\r \x01(\x0e\x32\x35.temporal.api.enums.v1.NexusOperationIdConflictPolicy\x12\x43\n\x11search_attributes\x18\x0e \x01(\x0b\x32(.temporal.api.common.v1.SearchAttributes\x12k\n\x0cnexus_header\x18\x0f \x03(\x0b\x32U.temporal.api.workflowservice.v1.StartNexusOperationExecutionRequest.NexusHeaderEntry\x12\x38\n\ruser_metadata\x18\x10 \x01(\x0b\x32!.temporal.api.sdk.v1.UserMetadata\x1a\x32\n\x10NexusHeaderEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01"G\n$StartNexusOperationExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x0f\n\x07started\x18\x02 \x01(\x08"\xaa\x01\n&DescribeNexusOperationExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0coperation_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x15\n\rinclude_input\x18\x04 \x01(\x08\x12\x17\n\x0finclude_outcome\x18\x05 \x01(\x08\x12\x17\n\x0flong_poll_token\x18\x06 \x01(\x0c"\xb7\x02\n\'DescribeNexusOperationExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12@\n\x04info\x18\x02 \x01(\x0b\x32\x32.temporal.api.nexus.v1.NexusOperationExecutionInfo\x12.\n\x05input\x18\x03 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x31\n\x06result\x18\x04 \x01(\x0b\x32\x1f.temporal.api.common.v1.PayloadH\x00\x12\x33\n\x07\x66\x61ilure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.FailureH\x00\x12\x17\n\x0flong_poll_token\x18\x06 \x01(\x0c\x42\t\n\x07outcome"\xa1\x01\n"PollNexusOperationExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0coperation_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x42\n\nwait_stage\x18\x04 \x01(\x0e\x32..temporal.api.enums.v1.NexusOperationWaitStage"\x85\x02\n#PollNexusOperationExecutionResponse\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x42\n\nwait_stage\x18\x02 \x01(\x0e\x32..temporal.api.enums.v1.NexusOperationWaitStage\x12\x17\n\x0foperation_token\x18\x03 \x01(\t\x12\x31\n\x06result\x18\x04 \x01(\x0b\x32\x1f.temporal.api.common.v1.PayloadH\x00\x12\x33\n\x07\x66\x61ilure\x18\x05 \x01(\x0b\x32 .temporal.api.failure.v1.FailureH\x00\x42\t\n\x07outcome"s\n#ListNexusOperationExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x11\n\tpage_size\x18\x02 \x01(\x05\x12\x17\n\x0fnext_page_token\x18\x03 \x01(\x0c\x12\r\n\x05query\x18\x04 \x01(\t"\x8b\x01\n$ListNexusOperationExecutionsResponse\x12J\n\noperations\x18\x01 \x03(\x0b\x32\x36.temporal.api.nexus.v1.NexusOperationExecutionListInfo\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\x0c"B\n\x1e\x43ountActivityExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t"\xed\x01\n\x1f\x43ountActivityExecutionsResponse\x12\r\n\x05\x63ount\x18\x01 \x01(\x03\x12\x61\n\x06groups\x18\x02 \x03(\x0b\x32Q.temporal.api.workflowservice.v1.CountActivityExecutionsResponse.AggregationGroup\x1aX\n\x10\x41ggregationGroup\x12\x35\n\x0cgroup_values\x18\x01 \x03(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\r\n\x05\x63ount\x18\x02 \x01(\x03"H\n$CountNexusOperationExecutionsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t"\xf9\x01\n%CountNexusOperationExecutionsResponse\x12\r\n\x05\x63ount\x18\x01 \x01(\x03\x12g\n\x06groups\x18\x02 \x03(\x0b\x32W.temporal.api.workflowservice.v1.CountNexusOperationExecutionsResponse.AggregationGroup\x1aX\n\x10\x41ggregationGroup\x12\x35\n\x0cgroup_values\x18\x01 \x03(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\r\n\x05\x63ount\x18\x02 \x01(\x03"\x95\x01\n%RequestCancelActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x12\n\nrequest_id\x18\x05 \x01(\t\x12\x0e\n\x06reason\x18\x06 \x01(\t"(\n&RequestCancelActivityExecutionResponse"\x91\x01\n!TerminateActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x12\n\nrequest_id\x18\x05 \x01(\t\x12\x0e\n\x06reason\x18\x06 \x01(\t"$\n"TerminateActivityExecutionResponse"X\n\x1e\x44\x65leteActivityExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t"!\n\x1f\x44\x65leteActivityExecutionResponse"\x9c\x01\n+RequestCancelNexusOperationExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0coperation_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x12\n\nrequest_id\x18\x05 \x01(\t\x12\x0e\n\x06reason\x18\x06 \x01(\t".\n,RequestCancelNexusOperationExecutionResponse"\x98\x01\n\'TerminateNexusOperationExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0coperation_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x10\n\x08identity\x18\x04 \x01(\t\x12\x12\n\nrequest_id\x18\x05 \x01(\t\x12\x0e\n\x06reason\x18\x06 \x01(\t"*\n(TerminateNexusOperationExecutionResponse"_\n$DeleteNexusOperationExecutionRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0coperation_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t"\'\n%DeleteNexusOperationExecutionResponse"\x9d\x01\n(PollWorkflowExecutionTimeSkippingRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x45\n\x12workflow_execution\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.WorkflowExecution\x12\x17\n\x0f\x66\x61st_forward_id\x18\x03 \x01(\t"\xe8\x01\n)PollWorkflowExecutionTimeSkippingResponse\x12T\n\x1b\x66\x61st_forward_polling_result\x18\x01 \x01(\x0e\x32/.temporal.api.enums.v1.FastForwardPollingResult\x12\x15\n\rfailed_reason\x18\x02 \x01(\t\x12N\n\x11\x66\x61st_forward_info\x18\x03 \x01(\x0b\x32\x33.temporal.api.common.v1.TimeSkippingFastForwardInfoB\xbe\x01\n"io.temporal.api.workflowservice.v1B\x14RequestResponseProtoP\x01Z5go.temporal.io/api/workflowservice/v1;workflowservice\xaa\x02!Temporalio.Api.WorkflowService.V1\xea\x02$Temporalio::Api::WorkflowService::V1b\x06proto3' ) @@ -4214,578 +4217,578 @@ _UPDATETASKQUEUECONFIGREQUEST_SETFAIRNESSWEIGHTOVERRIDESENTRY._serialized_options = b"8\001" _STARTNEXUSOPERATIONEXECUTIONREQUEST_NEXUSHEADERENTRY._options = None _STARTNEXUSOPERATIONEXECUTIONREQUEST_NEXUSHEADERENTRY._serialized_options = b"8\001" - _REGISTERNAMESPACEREQUEST._serialized_start = 1646 - _REGISTERNAMESPACEREQUEST._serialized_end = 2294 - _REGISTERNAMESPACEREQUEST_DATAENTRY._serialized_start = 2251 - _REGISTERNAMESPACEREQUEST_DATAENTRY._serialized_end = 2294 - _REGISTERNAMESPACERESPONSE._serialized_start = 2296 - _REGISTERNAMESPACERESPONSE._serialized_end = 2323 - _LISTNAMESPACESREQUEST._serialized_start = 2326 - _LISTNAMESPACESREQUEST._serialized_end = 2463 - _LISTNAMESPACESRESPONSE._serialized_start = 2466 - _LISTNAMESPACESRESPONSE._serialized_end = 2595 - _DESCRIBENAMESPACEREQUEST._serialized_start = 2597 - _DESCRIBENAMESPACEREQUEST._serialized_end = 2680 - _DESCRIBENAMESPACERESPONSE._serialized_start = 2683 - _DESCRIBENAMESPACERESPONSE._serialized_end = 3196 - _UPDATENAMESPACEREQUEST._serialized_start = 3199 - _UPDATENAMESPACEREQUEST._serialized_end = 3534 - _UPDATENAMESPACERESPONSE._serialized_start = 3537 - _UPDATENAMESPACERESPONSE._serialized_end = 3828 - _DEPRECATENAMESPACEREQUEST._serialized_start = 3830 - _DEPRECATENAMESPACEREQUEST._serialized_end = 3900 - _DEPRECATENAMESPACERESPONSE._serialized_start = 3902 - _DEPRECATENAMESPACERESPONSE._serialized_end = 3930 - _STARTWORKFLOWEXECUTIONREQUEST._serialized_start = 3933 - _STARTWORKFLOWEXECUTIONREQUEST._serialized_end = 5550 - _STARTWORKFLOWEXECUTIONRESPONSE._serialized_start = 5553 - _STARTWORKFLOWEXECUTIONRESPONSE._serialized_end = 5851 - _GETWORKFLOWEXECUTIONHISTORYREQUEST._serialized_start = 5854 - _GETWORKFLOWEXECUTIONHISTORYREQUEST._serialized_end = 6152 - _GETWORKFLOWEXECUTIONHISTORYRESPONSE._serialized_start = 6155 - _GETWORKFLOWEXECUTIONHISTORYRESPONSE._serialized_end = 6341 - _GETWORKFLOWEXECUTIONHISTORYREVERSEREQUEST._serialized_start = 6344 - _GETWORKFLOWEXECUTIONHISTORYREVERSEREQUEST._serialized_end = 6520 - _GETWORKFLOWEXECUTIONHISTORYREVERSERESPONSE._serialized_start = 6522 - _GETWORKFLOWEXECUTIONHISTORYREVERSERESPONSE._serialized_end = 6642 - _POLLWORKFLOWTASKQUEUEREQUEST._serialized_start = 6645 - _POLLWORKFLOWTASKQUEUEREQUEST._serialized_end = 7085 - _POLLWORKFLOWTASKQUEUERESPONSE._serialized_start = 7088 - _POLLWORKFLOWTASKQUEUERESPONSE._serialized_end = 8175 - _POLLWORKFLOWTASKQUEUERESPONSE_QUERIESENTRY._serialized_start = 8091 - _POLLWORKFLOWTASKQUEUERESPONSE_QUERIESENTRY._serialized_end = 8175 - _RESPONDWORKFLOWTASKCOMPLETEDREQUEST._serialized_start = 8178 - _RESPONDWORKFLOWTASKCOMPLETEDREQUEST._serialized_end = 9516 - _RESPONDWORKFLOWTASKCOMPLETEDREQUEST_QUERYRESULTSENTRY._serialized_start = 9350 - _RESPONDWORKFLOWTASKCOMPLETEDREQUEST_QUERYRESULTSENTRY._serialized_end = 9445 - _RESPONDWORKFLOWTASKCOMPLETEDREQUEST_CAPABILITIES._serialized_start = 9447 - _RESPONDWORKFLOWTASKCOMPLETEDREQUEST_CAPABILITIES._serialized_end = 9516 - _RESPONDWORKFLOWTASKCOMPLETEDRESPONSE._serialized_start = 9519 - _RESPONDWORKFLOWTASKCOMPLETEDRESPONSE._serialized_end = 9764 - _RESPONDWORKFLOWTASKFAILEDREQUEST._serialized_start = 9767 - _RESPONDWORKFLOWTASKFAILEDREQUEST._serialized_end = 10292 - _RESPONDWORKFLOWTASKFAILEDRESPONSE._serialized_start = 10294 - _RESPONDWORKFLOWTASKFAILEDRESPONSE._serialized_end = 10329 - _POLLACTIVITYTASKQUEUEREQUEST._serialized_start = 10332 - _POLLACTIVITYTASKQUEUEREQUEST._serialized_end = 10818 - _POLLACTIVITYTASKQUEUERESPONSE._serialized_start = 10821 - _POLLACTIVITYTASKQUEUERESPONSE._serialized_end = 12002 - _RECORDACTIVITYTASKHEARTBEATREQUEST._serialized_start = 12005 - _RECORDACTIVITYTASKHEARTBEATREQUEST._serialized_end = 12170 - _RECORDACTIVITYTASKHEARTBEATRESPONSE._serialized_start = 12172 - _RECORDACTIVITYTASKHEARTBEATRESPONSE._serialized_end = 12284 - _RECORDACTIVITYTASKHEARTBEATBYIDREQUEST._serialized_start = 12287 - _RECORDACTIVITYTASKHEARTBEATBYIDREQUEST._serialized_end = 12494 - _RECORDACTIVITYTASKHEARTBEATBYIDRESPONSE._serialized_start = 12496 - _RECORDACTIVITYTASKHEARTBEATBYIDRESPONSE._serialized_end = 12612 - _RESPONDACTIVITYTASKCOMPLETEDREQUEST._serialized_start = 12615 - _RESPONDACTIVITYTASKCOMPLETEDREQUEST._serialized_end = 12997 - _RESPONDACTIVITYTASKCOMPLETEDRESPONSE._serialized_start = 12999 - _RESPONDACTIVITYTASKCOMPLETEDRESPONSE._serialized_end = 13037 - _RESPONDACTIVITYTASKCOMPLETEDBYIDREQUEST._serialized_start = 13040 - _RESPONDACTIVITYTASKCOMPLETEDBYIDREQUEST._serialized_end = 13247 - _RESPONDACTIVITYTASKCOMPLETEDBYIDRESPONSE._serialized_start = 13249 - _RESPONDACTIVITYTASKCOMPLETEDBYIDRESPONSE._serialized_end = 13291 - _RESPONDACTIVITYTASKFAILEDREQUEST._serialized_start = 13294 - _RESPONDACTIVITYTASKFAILEDREQUEST._serialized_end = 13740 - _RESPONDACTIVITYTASKFAILEDRESPONSE._serialized_start = 13742 - _RESPONDACTIVITYTASKFAILEDRESPONSE._serialized_end = 13829 - _RESPONDACTIVITYTASKFAILEDBYIDREQUEST._serialized_start = 13832 - _RESPONDACTIVITYTASKFAILEDBYIDREQUEST._serialized_end = 14103 - _RESPONDACTIVITYTASKFAILEDBYIDRESPONSE._serialized_start = 14105 - _RESPONDACTIVITYTASKFAILEDBYIDRESPONSE._serialized_end = 14196 - _RESPONDACTIVITYTASKCANCELEDREQUEST._serialized_start = 14199 - _RESPONDACTIVITYTASKCANCELEDREQUEST._serialized_end = 14581 - _RESPONDACTIVITYTASKCANCELEDRESPONSE._serialized_start = 14583 - _RESPONDACTIVITYTASKCANCELEDRESPONSE._serialized_end = 14620 - _RESPONDACTIVITYTASKCANCELEDBYIDREQUEST._serialized_start = 14623 - _RESPONDACTIVITYTASKCANCELEDBYIDREQUEST._serialized_end = 14911 - _RESPONDACTIVITYTASKCANCELEDBYIDRESPONSE._serialized_start = 14913 - _RESPONDACTIVITYTASKCANCELEDBYIDRESPONSE._serialized_end = 14954 - _REQUESTCANCELWORKFLOWEXECUTIONREQUEST._serialized_start = 14957 - _REQUESTCANCELWORKFLOWEXECUTIONREQUEST._serialized_end = 15217 - _REQUESTCANCELWORKFLOWEXECUTIONRESPONSE._serialized_start = 15219 - _REQUESTCANCELWORKFLOWEXECUTIONRESPONSE._serialized_end = 15259 - _SIGNALWORKFLOWEXECUTIONREQUEST._serialized_start = 15262 - _SIGNALWORKFLOWEXECUTIONREQUEST._serialized_end = 15612 - _SIGNALWORKFLOWEXECUTIONRESPONSE._serialized_start = 15614 - _SIGNALWORKFLOWEXECUTIONRESPONSE._serialized_end = 15691 - _SIGNALWITHSTARTWORKFLOWEXECUTIONREQUEST._serialized_start = 15694 - _SIGNALWITHSTARTWORKFLOWEXECUTIONREQUEST._serialized_end = 17033 - _SIGNALWITHSTARTWORKFLOWEXECUTIONRESPONSE._serialized_start = 17036 - _SIGNALWITHSTARTWORKFLOWEXECUTIONRESPONSE._serialized_end = 17194 - _RESETWORKFLOWEXECUTIONREQUEST._serialized_start = 17197 - _RESETWORKFLOWEXECUTIONREQUEST._serialized_end = 17646 - _RESETWORKFLOWEXECUTIONRESPONSE._serialized_start = 17648 - _RESETWORKFLOWEXECUTIONRESPONSE._serialized_end = 17696 - _TERMINATEWORKFLOWEXECUTIONREQUEST._serialized_start = 17699 - _TERMINATEWORKFLOWEXECUTIONREQUEST._serialized_end = 17986 - _TERMINATEWORKFLOWEXECUTIONRESPONSE._serialized_start = 17988 - _TERMINATEWORKFLOWEXECUTIONRESPONSE._serialized_end = 18024 - _DELETEWORKFLOWEXECUTIONREQUEST._serialized_start = 18026 - _DELETEWORKFLOWEXECUTIONREQUEST._serialized_end = 18148 - _DELETEWORKFLOWEXECUTIONRESPONSE._serialized_start = 18150 - _DELETEWORKFLOWEXECUTIONRESPONSE._serialized_end = 18183 - _LISTOPENWORKFLOWEXECUTIONSREQUEST._serialized_start = 18186 - _LISTOPENWORKFLOWEXECUTIONSREQUEST._serialized_end = 18515 - _LISTOPENWORKFLOWEXECUTIONSRESPONSE._serialized_start = 18518 - _LISTOPENWORKFLOWEXECUTIONSRESPONSE._serialized_end = 18648 - _LISTCLOSEDWORKFLOWEXECUTIONSREQUEST._serialized_start = 18651 - _LISTCLOSEDWORKFLOWEXECUTIONSREQUEST._serialized_end = 19045 - _LISTCLOSEDWORKFLOWEXECUTIONSRESPONSE._serialized_start = 19048 - _LISTCLOSEDWORKFLOWEXECUTIONSRESPONSE._serialized_end = 19180 - _LISTWORKFLOWEXECUTIONSREQUEST._serialized_start = 19182 - _LISTWORKFLOWEXECUTIONSREQUEST._serialized_end = 19291 - _LISTWORKFLOWEXECUTIONSRESPONSE._serialized_start = 19293 - _LISTWORKFLOWEXECUTIONSRESPONSE._serialized_end = 19419 - _LISTARCHIVEDWORKFLOWEXECUTIONSREQUEST._serialized_start = 19421 - _LISTARCHIVEDWORKFLOWEXECUTIONSREQUEST._serialized_end = 19538 - _LISTARCHIVEDWORKFLOWEXECUTIONSRESPONSE._serialized_start = 19541 - _LISTARCHIVEDWORKFLOWEXECUTIONSRESPONSE._serialized_end = 19675 - _SCANWORKFLOWEXECUTIONSREQUEST._serialized_start = 19677 - _SCANWORKFLOWEXECUTIONSREQUEST._serialized_end = 19786 - _SCANWORKFLOWEXECUTIONSRESPONSE._serialized_start = 19788 - _SCANWORKFLOWEXECUTIONSRESPONSE._serialized_end = 19914 - _COUNTWORKFLOWEXECUTIONSREQUEST._serialized_start = 19916 - _COUNTWORKFLOWEXECUTIONSREQUEST._serialized_end = 19982 - _COUNTWORKFLOWEXECUTIONSRESPONSE._serialized_start = 19985 - _COUNTWORKFLOWEXECUTIONSRESPONSE._serialized_end = 20222 - _COUNTWORKFLOWEXECUTIONSRESPONSE_AGGREGATIONGROUP._serialized_start = 20134 - _COUNTWORKFLOWEXECUTIONSRESPONSE_AGGREGATIONGROUP._serialized_end = 20222 - _GETSEARCHATTRIBUTESREQUEST._serialized_start = 20224 - _GETSEARCHATTRIBUTESREQUEST._serialized_end = 20252 - _GETSEARCHATTRIBUTESRESPONSE._serialized_start = 20255 - _GETSEARCHATTRIBUTESRESPONSE._serialized_end = 20456 - _GETSEARCHATTRIBUTESRESPONSE_KEYSENTRY._serialized_start = 20372 - _GETSEARCHATTRIBUTESRESPONSE_KEYSENTRY._serialized_end = 20456 - _RESPONDQUERYTASKCOMPLETEDREQUEST._serialized_start = 20459 - _RESPONDQUERYTASKCOMPLETEDREQUEST._serialized_end = 20820 - _RESPONDQUERYTASKCOMPLETEDRESPONSE._serialized_start = 20822 - _RESPONDQUERYTASKCOMPLETEDRESPONSE._serialized_end = 20857 - _RESETSTICKYTASKQUEUEREQUEST._serialized_start = 20859 - _RESETSTICKYTASKQUEUEREQUEST._serialized_end = 20969 - _RESETSTICKYTASKQUEUERESPONSE._serialized_start = 20971 - _RESETSTICKYTASKQUEUERESPONSE._serialized_end = 21001 - _SHUTDOWNWORKERREQUEST._serialized_start = 21004 - _SHUTDOWNWORKERREQUEST._serialized_end = 21287 - _SHUTDOWNWORKERRESPONSE._serialized_start = 21289 - _SHUTDOWNWORKERRESPONSE._serialized_end = 21313 - _QUERYWORKFLOWREQUEST._serialized_start = 21316 - _QUERYWORKFLOWREQUEST._serialized_end = 21549 - _QUERYWORKFLOWRESPONSE._serialized_start = 21552 - _QUERYWORKFLOWRESPONSE._serialized_end = 21737 - _DESCRIBEWORKFLOWEXECUTIONREQUEST._serialized_start = 21739 - _DESCRIBEWORKFLOWEXECUTIONREQUEST._serialized_end = 21854 - _DESCRIBEWORKFLOWEXECUTIONRESPONSE._serialized_start = 21857 - _DESCRIBEWORKFLOWEXECUTIONRESPONSE._serialized_end = 22522 - _DESCRIBETASKQUEUEREQUEST._serialized_start = 22525 - _DESCRIBETASKQUEUEREQUEST._serialized_end = 23053 - _DESCRIBETASKQUEUERESPONSE._serialized_start = 23056 - _DESCRIBETASKQUEUERESPONSE._serialized_end = 24060 - _DESCRIBETASKQUEUERESPONSE_STATSBYPRIORITYKEYENTRY._serialized_start = 23740 - _DESCRIBETASKQUEUERESPONSE_STATSBYPRIORITYKEYENTRY._serialized_end = 23840 - _DESCRIBETASKQUEUERESPONSE_EFFECTIVERATELIMIT._serialized_start = 23842 - _DESCRIBETASKQUEUERESPONSE_EFFECTIVERATELIMIT._serialized_end = 23958 - _DESCRIBETASKQUEUERESPONSE_VERSIONSINFOENTRY._serialized_start = 23960 - _DESCRIBETASKQUEUERESPONSE_VERSIONSINFOENTRY._serialized_end = 24060 - _GETCLUSTERINFOREQUEST._serialized_start = 24062 - _GETCLUSTERINFOREQUEST._serialized_end = 24085 - _GETCLUSTERINFORESPONSE._serialized_start = 24088 - _GETCLUSTERINFORESPONSE._serialized_end = 24553 - _GETCLUSTERINFORESPONSE_SUPPORTEDCLIENTSENTRY._serialized_start = 24498 - _GETCLUSTERINFORESPONSE_SUPPORTEDCLIENTSENTRY._serialized_end = 24553 - _GETSYSTEMINFOREQUEST._serialized_start = 24555 - _GETSYSTEMINFOREQUEST._serialized_end = 24577 - _GETSYSTEMINFORESPONSE._serialized_start = 24580 - _GETSYSTEMINFORESPONSE._serialized_end = 25157 - _GETSYSTEMINFORESPONSE_CAPABILITIES._serialized_start = 24721 - _GETSYSTEMINFORESPONSE_CAPABILITIES._serialized_end = 25157 - _LISTTASKQUEUEPARTITIONSREQUEST._serialized_start = 25159 - _LISTTASKQUEUEPARTITIONSREQUEST._serialized_end = 25268 - _LISTTASKQUEUEPARTITIONSRESPONSE._serialized_start = 25271 - _LISTTASKQUEUEPARTITIONSRESPONSE._serialized_end = 25494 - _CREATESCHEDULEREQUEST._serialized_start = 25497 - _CREATESCHEDULEREQUEST._serialized_end = 25829 - _CREATESCHEDULERESPONSE._serialized_start = 25831 - _CREATESCHEDULERESPONSE._serialized_end = 25879 - _DESCRIBESCHEDULEREQUEST._serialized_start = 25881 - _DESCRIBESCHEDULEREQUEST._serialized_end = 25946 - _DESCRIBESCHEDULERESPONSE._serialized_start = 25949 - _DESCRIBESCHEDULERESPONSE._serialized_end = 26220 - _UPDATESCHEDULEREQUEST._serialized_start = 26223 - _UPDATESCHEDULEREQUEST._serialized_end = 26515 - _UPDATESCHEDULERESPONSE._serialized_start = 26517 - _UPDATESCHEDULERESPONSE._serialized_end = 26541 - _PATCHSCHEDULEREQUEST._serialized_start = 26544 - _PATCHSCHEDULEREQUEST._serialized_end = 26700 - _PATCHSCHEDULERESPONSE._serialized_start = 26702 - _PATCHSCHEDULERESPONSE._serialized_end = 26725 - _LISTSCHEDULEMATCHINGTIMESREQUEST._serialized_start = 26728 - _LISTSCHEDULEMATCHINGTIMESREQUEST._serialized_end = 26896 - _LISTSCHEDULEMATCHINGTIMESRESPONSE._serialized_start = 26898 - _LISTSCHEDULEMATCHINGTIMESRESPONSE._serialized_end = 26981 - _DELETESCHEDULEREQUEST._serialized_start = 26983 - _DELETESCHEDULEREQUEST._serialized_end = 27064 - _DELETESCHEDULERESPONSE._serialized_start = 27066 - _DELETESCHEDULERESPONSE._serialized_end = 27090 - _LISTSCHEDULESREQUEST._serialized_start = 27092 - _LISTSCHEDULESREQUEST._serialized_end = 27200 - _LISTSCHEDULESRESPONSE._serialized_start = 27202 - _LISTSCHEDULESRESPONSE._serialized_end = 27314 - _COUNTSCHEDULESREQUEST._serialized_start = 27316 - _COUNTSCHEDULESREQUEST._serialized_end = 27373 - _COUNTSCHEDULESRESPONSE._serialized_start = 27376 - _COUNTSCHEDULESRESPONSE._serialized_end = 27595 - _COUNTSCHEDULESRESPONSE_AGGREGATIONGROUP._serialized_start = 20134 - _COUNTSCHEDULESRESPONSE_AGGREGATIONGROUP._serialized_end = 20222 - _UPDATEWORKERBUILDIDCOMPATIBILITYREQUEST._serialized_start = 27598 - _UPDATEWORKERBUILDIDCOMPATIBILITYREQUEST._serialized_end = 28244 - _UPDATEWORKERBUILDIDCOMPATIBILITYREQUEST_ADDNEWCOMPATIBLEVERSION._serialized_start = 28045 + _REGISTERNAMESPACEREQUEST._serialized_start = 1684 + _REGISTERNAMESPACEREQUEST._serialized_end = 2332 + _REGISTERNAMESPACEREQUEST_DATAENTRY._serialized_start = 2289 + _REGISTERNAMESPACEREQUEST_DATAENTRY._serialized_end = 2332 + _REGISTERNAMESPACERESPONSE._serialized_start = 2334 + _REGISTERNAMESPACERESPONSE._serialized_end = 2361 + _LISTNAMESPACESREQUEST._serialized_start = 2364 + _LISTNAMESPACESREQUEST._serialized_end = 2501 + _LISTNAMESPACESRESPONSE._serialized_start = 2504 + _LISTNAMESPACESRESPONSE._serialized_end = 2633 + _DESCRIBENAMESPACEREQUEST._serialized_start = 2635 + _DESCRIBENAMESPACEREQUEST._serialized_end = 2718 + _DESCRIBENAMESPACERESPONSE._serialized_start = 2721 + _DESCRIBENAMESPACERESPONSE._serialized_end = 3234 + _UPDATENAMESPACEREQUEST._serialized_start = 3237 + _UPDATENAMESPACEREQUEST._serialized_end = 3572 + _UPDATENAMESPACERESPONSE._serialized_start = 3575 + _UPDATENAMESPACERESPONSE._serialized_end = 3866 + _DEPRECATENAMESPACEREQUEST._serialized_start = 3868 + _DEPRECATENAMESPACEREQUEST._serialized_end = 3938 + _DEPRECATENAMESPACERESPONSE._serialized_start = 3940 + _DEPRECATENAMESPACERESPONSE._serialized_end = 3968 + _STARTWORKFLOWEXECUTIONREQUEST._serialized_start = 3971 + _STARTWORKFLOWEXECUTIONREQUEST._serialized_end = 5588 + _STARTWORKFLOWEXECUTIONRESPONSE._serialized_start = 5591 + _STARTWORKFLOWEXECUTIONRESPONSE._serialized_end = 5889 + _GETWORKFLOWEXECUTIONHISTORYREQUEST._serialized_start = 5892 + _GETWORKFLOWEXECUTIONHISTORYREQUEST._serialized_end = 6190 + _GETWORKFLOWEXECUTIONHISTORYRESPONSE._serialized_start = 6193 + _GETWORKFLOWEXECUTIONHISTORYRESPONSE._serialized_end = 6379 + _GETWORKFLOWEXECUTIONHISTORYREVERSEREQUEST._serialized_start = 6382 + _GETWORKFLOWEXECUTIONHISTORYREVERSEREQUEST._serialized_end = 6558 + _GETWORKFLOWEXECUTIONHISTORYREVERSERESPONSE._serialized_start = 6560 + _GETWORKFLOWEXECUTIONHISTORYREVERSERESPONSE._serialized_end = 6680 + _POLLWORKFLOWTASKQUEUEREQUEST._serialized_start = 6683 + _POLLWORKFLOWTASKQUEUEREQUEST._serialized_end = 7123 + _POLLWORKFLOWTASKQUEUERESPONSE._serialized_start = 7126 + _POLLWORKFLOWTASKQUEUERESPONSE._serialized_end = 8273 + _POLLWORKFLOWTASKQUEUERESPONSE_QUERIESENTRY._serialized_start = 8189 + _POLLWORKFLOWTASKQUEUERESPONSE_QUERIESENTRY._serialized_end = 8273 + _RESPONDWORKFLOWTASKCOMPLETEDREQUEST._serialized_start = 8276 + _RESPONDWORKFLOWTASKCOMPLETEDREQUEST._serialized_end = 9614 + _RESPONDWORKFLOWTASKCOMPLETEDREQUEST_QUERYRESULTSENTRY._serialized_start = 9448 + _RESPONDWORKFLOWTASKCOMPLETEDREQUEST_QUERYRESULTSENTRY._serialized_end = 9543 + _RESPONDWORKFLOWTASKCOMPLETEDREQUEST_CAPABILITIES._serialized_start = 9545 + _RESPONDWORKFLOWTASKCOMPLETEDREQUEST_CAPABILITIES._serialized_end = 9614 + _RESPONDWORKFLOWTASKCOMPLETEDRESPONSE._serialized_start = 9617 + _RESPONDWORKFLOWTASKCOMPLETEDRESPONSE._serialized_end = 9862 + _RESPONDWORKFLOWTASKFAILEDREQUEST._serialized_start = 9865 + _RESPONDWORKFLOWTASKFAILEDREQUEST._serialized_end = 10390 + _RESPONDWORKFLOWTASKFAILEDRESPONSE._serialized_start = 10392 + _RESPONDWORKFLOWTASKFAILEDRESPONSE._serialized_end = 10427 + _POLLACTIVITYTASKQUEUEREQUEST._serialized_start = 10430 + _POLLACTIVITYTASKQUEUEREQUEST._serialized_end = 10916 + _POLLACTIVITYTASKQUEUERESPONSE._serialized_start = 10919 + _POLLACTIVITYTASKQUEUERESPONSE._serialized_end = 12100 + _RECORDACTIVITYTASKHEARTBEATREQUEST._serialized_start = 12103 + _RECORDACTIVITYTASKHEARTBEATREQUEST._serialized_end = 12268 + _RECORDACTIVITYTASKHEARTBEATRESPONSE._serialized_start = 12270 + _RECORDACTIVITYTASKHEARTBEATRESPONSE._serialized_end = 12382 + _RECORDACTIVITYTASKHEARTBEATBYIDREQUEST._serialized_start = 12385 + _RECORDACTIVITYTASKHEARTBEATBYIDREQUEST._serialized_end = 12592 + _RECORDACTIVITYTASKHEARTBEATBYIDRESPONSE._serialized_start = 12594 + _RECORDACTIVITYTASKHEARTBEATBYIDRESPONSE._serialized_end = 12710 + _RESPONDACTIVITYTASKCOMPLETEDREQUEST._serialized_start = 12713 + _RESPONDACTIVITYTASKCOMPLETEDREQUEST._serialized_end = 13095 + _RESPONDACTIVITYTASKCOMPLETEDRESPONSE._serialized_start = 13097 + _RESPONDACTIVITYTASKCOMPLETEDRESPONSE._serialized_end = 13135 + _RESPONDACTIVITYTASKCOMPLETEDBYIDREQUEST._serialized_start = 13138 + _RESPONDACTIVITYTASKCOMPLETEDBYIDREQUEST._serialized_end = 13345 + _RESPONDACTIVITYTASKCOMPLETEDBYIDRESPONSE._serialized_start = 13347 + _RESPONDACTIVITYTASKCOMPLETEDBYIDRESPONSE._serialized_end = 13389 + _RESPONDACTIVITYTASKFAILEDREQUEST._serialized_start = 13392 + _RESPONDACTIVITYTASKFAILEDREQUEST._serialized_end = 13838 + _RESPONDACTIVITYTASKFAILEDRESPONSE._serialized_start = 13840 + _RESPONDACTIVITYTASKFAILEDRESPONSE._serialized_end = 13927 + _RESPONDACTIVITYTASKFAILEDBYIDREQUEST._serialized_start = 13930 + _RESPONDACTIVITYTASKFAILEDBYIDREQUEST._serialized_end = 14201 + _RESPONDACTIVITYTASKFAILEDBYIDRESPONSE._serialized_start = 14203 + _RESPONDACTIVITYTASKFAILEDBYIDRESPONSE._serialized_end = 14294 + _RESPONDACTIVITYTASKCANCELEDREQUEST._serialized_start = 14297 + _RESPONDACTIVITYTASKCANCELEDREQUEST._serialized_end = 14679 + _RESPONDACTIVITYTASKCANCELEDRESPONSE._serialized_start = 14681 + _RESPONDACTIVITYTASKCANCELEDRESPONSE._serialized_end = 14718 + _RESPONDACTIVITYTASKCANCELEDBYIDREQUEST._serialized_start = 14721 + _RESPONDACTIVITYTASKCANCELEDBYIDREQUEST._serialized_end = 15009 + _RESPONDACTIVITYTASKCANCELEDBYIDRESPONSE._serialized_start = 15011 + _RESPONDACTIVITYTASKCANCELEDBYIDRESPONSE._serialized_end = 15052 + _REQUESTCANCELWORKFLOWEXECUTIONREQUEST._serialized_start = 15055 + _REQUESTCANCELWORKFLOWEXECUTIONREQUEST._serialized_end = 15315 + _REQUESTCANCELWORKFLOWEXECUTIONRESPONSE._serialized_start = 15317 + _REQUESTCANCELWORKFLOWEXECUTIONRESPONSE._serialized_end = 15357 + _SIGNALWORKFLOWEXECUTIONREQUEST._serialized_start = 15360 + _SIGNALWORKFLOWEXECUTIONREQUEST._serialized_end = 15710 + _SIGNALWORKFLOWEXECUTIONRESPONSE._serialized_start = 15712 + _SIGNALWORKFLOWEXECUTIONRESPONSE._serialized_end = 15789 + _SIGNALWITHSTARTWORKFLOWEXECUTIONREQUEST._serialized_start = 15792 + _SIGNALWITHSTARTWORKFLOWEXECUTIONREQUEST._serialized_end = 17131 + _SIGNALWITHSTARTWORKFLOWEXECUTIONRESPONSE._serialized_start = 17134 + _SIGNALWITHSTARTWORKFLOWEXECUTIONRESPONSE._serialized_end = 17292 + _RESETWORKFLOWEXECUTIONREQUEST._serialized_start = 17295 + _RESETWORKFLOWEXECUTIONREQUEST._serialized_end = 17744 + _RESETWORKFLOWEXECUTIONRESPONSE._serialized_start = 17746 + _RESETWORKFLOWEXECUTIONRESPONSE._serialized_end = 17794 + _TERMINATEWORKFLOWEXECUTIONREQUEST._serialized_start = 17797 + _TERMINATEWORKFLOWEXECUTIONREQUEST._serialized_end = 18084 + _TERMINATEWORKFLOWEXECUTIONRESPONSE._serialized_start = 18086 + _TERMINATEWORKFLOWEXECUTIONRESPONSE._serialized_end = 18122 + _DELETEWORKFLOWEXECUTIONREQUEST._serialized_start = 18124 + _DELETEWORKFLOWEXECUTIONREQUEST._serialized_end = 18246 + _DELETEWORKFLOWEXECUTIONRESPONSE._serialized_start = 18248 + _DELETEWORKFLOWEXECUTIONRESPONSE._serialized_end = 18281 + _LISTOPENWORKFLOWEXECUTIONSREQUEST._serialized_start = 18284 + _LISTOPENWORKFLOWEXECUTIONSREQUEST._serialized_end = 18613 + _LISTOPENWORKFLOWEXECUTIONSRESPONSE._serialized_start = 18616 + _LISTOPENWORKFLOWEXECUTIONSRESPONSE._serialized_end = 18746 + _LISTCLOSEDWORKFLOWEXECUTIONSREQUEST._serialized_start = 18749 + _LISTCLOSEDWORKFLOWEXECUTIONSREQUEST._serialized_end = 19143 + _LISTCLOSEDWORKFLOWEXECUTIONSRESPONSE._serialized_start = 19146 + _LISTCLOSEDWORKFLOWEXECUTIONSRESPONSE._serialized_end = 19278 + _LISTWORKFLOWEXECUTIONSREQUEST._serialized_start = 19280 + _LISTWORKFLOWEXECUTIONSREQUEST._serialized_end = 19389 + _LISTWORKFLOWEXECUTIONSRESPONSE._serialized_start = 19391 + _LISTWORKFLOWEXECUTIONSRESPONSE._serialized_end = 19517 + _LISTARCHIVEDWORKFLOWEXECUTIONSREQUEST._serialized_start = 19519 + _LISTARCHIVEDWORKFLOWEXECUTIONSREQUEST._serialized_end = 19636 + _LISTARCHIVEDWORKFLOWEXECUTIONSRESPONSE._serialized_start = 19639 + _LISTARCHIVEDWORKFLOWEXECUTIONSRESPONSE._serialized_end = 19773 + _SCANWORKFLOWEXECUTIONSREQUEST._serialized_start = 19775 + _SCANWORKFLOWEXECUTIONSREQUEST._serialized_end = 19884 + _SCANWORKFLOWEXECUTIONSRESPONSE._serialized_start = 19886 + _SCANWORKFLOWEXECUTIONSRESPONSE._serialized_end = 20012 + _COUNTWORKFLOWEXECUTIONSREQUEST._serialized_start = 20014 + _COUNTWORKFLOWEXECUTIONSREQUEST._serialized_end = 20080 + _COUNTWORKFLOWEXECUTIONSRESPONSE._serialized_start = 20083 + _COUNTWORKFLOWEXECUTIONSRESPONSE._serialized_end = 20320 + _COUNTWORKFLOWEXECUTIONSRESPONSE_AGGREGATIONGROUP._serialized_start = 20232 + _COUNTWORKFLOWEXECUTIONSRESPONSE_AGGREGATIONGROUP._serialized_end = 20320 + _GETSEARCHATTRIBUTESREQUEST._serialized_start = 20322 + _GETSEARCHATTRIBUTESREQUEST._serialized_end = 20350 + _GETSEARCHATTRIBUTESRESPONSE._serialized_start = 20353 + _GETSEARCHATTRIBUTESRESPONSE._serialized_end = 20554 + _GETSEARCHATTRIBUTESRESPONSE_KEYSENTRY._serialized_start = 20470 + _GETSEARCHATTRIBUTESRESPONSE_KEYSENTRY._serialized_end = 20554 + _RESPONDQUERYTASKCOMPLETEDREQUEST._serialized_start = 20557 + _RESPONDQUERYTASKCOMPLETEDREQUEST._serialized_end = 20918 + _RESPONDQUERYTASKCOMPLETEDRESPONSE._serialized_start = 20920 + _RESPONDQUERYTASKCOMPLETEDRESPONSE._serialized_end = 20955 + _RESETSTICKYTASKQUEUEREQUEST._serialized_start = 20957 + _RESETSTICKYTASKQUEUEREQUEST._serialized_end = 21067 + _RESETSTICKYTASKQUEUERESPONSE._serialized_start = 21069 + _RESETSTICKYTASKQUEUERESPONSE._serialized_end = 21099 + _SHUTDOWNWORKERREQUEST._serialized_start = 21102 + _SHUTDOWNWORKERREQUEST._serialized_end = 21385 + _SHUTDOWNWORKERRESPONSE._serialized_start = 21387 + _SHUTDOWNWORKERRESPONSE._serialized_end = 21411 + _QUERYWORKFLOWREQUEST._serialized_start = 21414 + _QUERYWORKFLOWREQUEST._serialized_end = 21647 + _QUERYWORKFLOWRESPONSE._serialized_start = 21650 + _QUERYWORKFLOWRESPONSE._serialized_end = 21835 + _DESCRIBEWORKFLOWEXECUTIONREQUEST._serialized_start = 21837 + _DESCRIBEWORKFLOWEXECUTIONREQUEST._serialized_end = 21952 + _DESCRIBEWORKFLOWEXECUTIONRESPONSE._serialized_start = 21955 + _DESCRIBEWORKFLOWEXECUTIONRESPONSE._serialized_end = 22620 + _DESCRIBETASKQUEUEREQUEST._serialized_start = 22623 + _DESCRIBETASKQUEUEREQUEST._serialized_end = 23151 + _DESCRIBETASKQUEUERESPONSE._serialized_start = 23154 + _DESCRIBETASKQUEUERESPONSE._serialized_end = 24158 + _DESCRIBETASKQUEUERESPONSE_STATSBYPRIORITYKEYENTRY._serialized_start = 23838 + _DESCRIBETASKQUEUERESPONSE_STATSBYPRIORITYKEYENTRY._serialized_end = 23938 + _DESCRIBETASKQUEUERESPONSE_EFFECTIVERATELIMIT._serialized_start = 23940 + _DESCRIBETASKQUEUERESPONSE_EFFECTIVERATELIMIT._serialized_end = 24056 + _DESCRIBETASKQUEUERESPONSE_VERSIONSINFOENTRY._serialized_start = 24058 + _DESCRIBETASKQUEUERESPONSE_VERSIONSINFOENTRY._serialized_end = 24158 + _GETCLUSTERINFOREQUEST._serialized_start = 24160 + _GETCLUSTERINFOREQUEST._serialized_end = 24183 + _GETCLUSTERINFORESPONSE._serialized_start = 24186 + _GETCLUSTERINFORESPONSE._serialized_end = 24651 + _GETCLUSTERINFORESPONSE_SUPPORTEDCLIENTSENTRY._serialized_start = 24596 + _GETCLUSTERINFORESPONSE_SUPPORTEDCLIENTSENTRY._serialized_end = 24651 + _GETSYSTEMINFOREQUEST._serialized_start = 24653 + _GETSYSTEMINFOREQUEST._serialized_end = 24675 + _GETSYSTEMINFORESPONSE._serialized_start = 24678 + _GETSYSTEMINFORESPONSE._serialized_end = 25255 + _GETSYSTEMINFORESPONSE_CAPABILITIES._serialized_start = 24819 + _GETSYSTEMINFORESPONSE_CAPABILITIES._serialized_end = 25255 + _LISTTASKQUEUEPARTITIONSREQUEST._serialized_start = 25257 + _LISTTASKQUEUEPARTITIONSREQUEST._serialized_end = 25366 + _LISTTASKQUEUEPARTITIONSRESPONSE._serialized_start = 25369 + _LISTTASKQUEUEPARTITIONSRESPONSE._serialized_end = 25592 + _CREATESCHEDULEREQUEST._serialized_start = 25595 + _CREATESCHEDULEREQUEST._serialized_end = 25927 + _CREATESCHEDULERESPONSE._serialized_start = 25929 + _CREATESCHEDULERESPONSE._serialized_end = 25977 + _DESCRIBESCHEDULEREQUEST._serialized_start = 25979 + _DESCRIBESCHEDULEREQUEST._serialized_end = 26044 + _DESCRIBESCHEDULERESPONSE._serialized_start = 26047 + _DESCRIBESCHEDULERESPONSE._serialized_end = 26318 + _UPDATESCHEDULEREQUEST._serialized_start = 26321 + _UPDATESCHEDULEREQUEST._serialized_end = 26613 + _UPDATESCHEDULERESPONSE._serialized_start = 26615 + _UPDATESCHEDULERESPONSE._serialized_end = 26639 + _PATCHSCHEDULEREQUEST._serialized_start = 26642 + _PATCHSCHEDULEREQUEST._serialized_end = 26798 + _PATCHSCHEDULERESPONSE._serialized_start = 26800 + _PATCHSCHEDULERESPONSE._serialized_end = 26823 + _LISTSCHEDULEMATCHINGTIMESREQUEST._serialized_start = 26826 + _LISTSCHEDULEMATCHINGTIMESREQUEST._serialized_end = 26994 + _LISTSCHEDULEMATCHINGTIMESRESPONSE._serialized_start = 26996 + _LISTSCHEDULEMATCHINGTIMESRESPONSE._serialized_end = 27079 + _DELETESCHEDULEREQUEST._serialized_start = 27081 + _DELETESCHEDULEREQUEST._serialized_end = 27162 + _DELETESCHEDULERESPONSE._serialized_start = 27164 + _DELETESCHEDULERESPONSE._serialized_end = 27188 + _LISTSCHEDULESREQUEST._serialized_start = 27190 + _LISTSCHEDULESREQUEST._serialized_end = 27298 + _LISTSCHEDULESRESPONSE._serialized_start = 27300 + _LISTSCHEDULESRESPONSE._serialized_end = 27412 + _COUNTSCHEDULESREQUEST._serialized_start = 27414 + _COUNTSCHEDULESREQUEST._serialized_end = 27471 + _COUNTSCHEDULESRESPONSE._serialized_start = 27474 + _COUNTSCHEDULESRESPONSE._serialized_end = 27693 + _COUNTSCHEDULESRESPONSE_AGGREGATIONGROUP._serialized_start = 20232 + _COUNTSCHEDULESRESPONSE_AGGREGATIONGROUP._serialized_end = 20320 + _UPDATEWORKERBUILDIDCOMPATIBILITYREQUEST._serialized_start = 27696 + _UPDATEWORKERBUILDIDCOMPATIBILITYREQUEST._serialized_end = 28342 + _UPDATEWORKERBUILDIDCOMPATIBILITYREQUEST_ADDNEWCOMPATIBLEVERSION._serialized_start = 28143 _UPDATEWORKERBUILDIDCOMPATIBILITYREQUEST_ADDNEWCOMPATIBLEVERSION._serialized_end = ( - 28156 + 28254 ) - _UPDATEWORKERBUILDIDCOMPATIBILITYREQUEST_MERGESETS._serialized_start = 28158 - _UPDATEWORKERBUILDIDCOMPATIBILITYREQUEST_MERGESETS._serialized_end = 28231 - _UPDATEWORKERBUILDIDCOMPATIBILITYRESPONSE._serialized_start = 28246 - _UPDATEWORKERBUILDIDCOMPATIBILITYRESPONSE._serialized_end = 28310 - _GETWORKERBUILDIDCOMPATIBILITYREQUEST._serialized_start = 28312 - _GETWORKERBUILDIDCOMPATIBILITYREQUEST._serialized_end = 28407 - _GETWORKERBUILDIDCOMPATIBILITYRESPONSE._serialized_start = 28409 - _GETWORKERBUILDIDCOMPATIBILITYRESPONSE._serialized_end = 28525 - _UPDATEWORKERVERSIONINGRULESREQUEST._serialized_start = 28528 - _UPDATEWORKERVERSIONINGRULESREQUEST._serialized_end = 30245 - _UPDATEWORKERVERSIONINGRULESREQUEST_INSERTBUILDIDASSIGNMENTRULE._serialized_start = 29580 + _UPDATEWORKERBUILDIDCOMPATIBILITYREQUEST_MERGESETS._serialized_start = 28256 + _UPDATEWORKERBUILDIDCOMPATIBILITYREQUEST_MERGESETS._serialized_end = 28329 + _UPDATEWORKERBUILDIDCOMPATIBILITYRESPONSE._serialized_start = 28344 + _UPDATEWORKERBUILDIDCOMPATIBILITYRESPONSE._serialized_end = 28408 + _GETWORKERBUILDIDCOMPATIBILITYREQUEST._serialized_start = 28410 + _GETWORKERBUILDIDCOMPATIBILITYREQUEST._serialized_end = 28505 + _GETWORKERBUILDIDCOMPATIBILITYRESPONSE._serialized_start = 28507 + _GETWORKERBUILDIDCOMPATIBILITYRESPONSE._serialized_end = 28623 + _UPDATEWORKERVERSIONINGRULESREQUEST._serialized_start = 28626 + _UPDATEWORKERVERSIONINGRULESREQUEST._serialized_end = 30343 + _UPDATEWORKERVERSIONINGRULESREQUEST_INSERTBUILDIDASSIGNMENTRULE._serialized_start = 29678 _UPDATEWORKERVERSIONINGRULESREQUEST_INSERTBUILDIDASSIGNMENTRULE._serialized_end = ( - 29693 + 29791 ) - _UPDATEWORKERVERSIONINGRULESREQUEST_REPLACEBUILDIDASSIGNMENTRULE._serialized_start = 29696 + _UPDATEWORKERVERSIONINGRULESREQUEST_REPLACEBUILDIDASSIGNMENTRULE._serialized_start = 29794 _UPDATEWORKERVERSIONINGRULESREQUEST_REPLACEBUILDIDASSIGNMENTRULE._serialized_end = ( - 29825 + 29923 ) - _UPDATEWORKERVERSIONINGRULESREQUEST_DELETEBUILDIDASSIGNMENTRULE._serialized_start = 29827 + _UPDATEWORKERVERSIONINGRULESREQUEST_DELETEBUILDIDASSIGNMENTRULE._serialized_start = 29925 _UPDATEWORKERVERSIONINGRULESREQUEST_DELETEBUILDIDASSIGNMENTRULE._serialized_end = ( - 29891 + 29989 ) - _UPDATEWORKERVERSIONINGRULESREQUEST_ADDCOMPATIBLEBUILDIDREDIRECTRULE._serialized_start = 29893 - _UPDATEWORKERVERSIONINGRULESREQUEST_ADDCOMPATIBLEBUILDIDREDIRECTRULE._serialized_end = 29999 - _UPDATEWORKERVERSIONINGRULESREQUEST_REPLACECOMPATIBLEBUILDIDREDIRECTRULE._serialized_start = 30001 - _UPDATEWORKERVERSIONINGRULESREQUEST_REPLACECOMPATIBLEBUILDIDREDIRECTRULE._serialized_end = 30111 - _UPDATEWORKERVERSIONINGRULESREQUEST_DELETECOMPATIBLEBUILDIDREDIRECTRULE._serialized_start = 30113 - _UPDATEWORKERVERSIONINGRULESREQUEST_DELETECOMPATIBLEBUILDIDREDIRECTRULE._serialized_end = 30175 - _UPDATEWORKERVERSIONINGRULESREQUEST_COMMITBUILDID._serialized_start = 30177 - _UPDATEWORKERVERSIONINGRULESREQUEST_COMMITBUILDID._serialized_end = 30232 - _UPDATEWORKERVERSIONINGRULESRESPONSE._serialized_start = 30248 - _UPDATEWORKERVERSIONINGRULESRESPONSE._serialized_end = 30500 - _GETWORKERVERSIONINGRULESREQUEST._serialized_start = 30502 - _GETWORKERVERSIONINGRULESREQUEST._serialized_end = 30574 - _GETWORKERVERSIONINGRULESRESPONSE._serialized_start = 30577 - _GETWORKERVERSIONINGRULESRESPONSE._serialized_end = 30826 - _GETWORKERTASKREACHABILITYREQUEST._serialized_start = 30829 - _GETWORKERTASKREACHABILITYREQUEST._serialized_end = 30985 - _GETWORKERTASKREACHABILITYRESPONSE._serialized_start = 30987 - _GETWORKERTASKREACHABILITYRESPONSE._serialized_end = 31101 - _UPDATEWORKFLOWEXECUTIONREQUEST._serialized_start = 31104 - _UPDATEWORKFLOWEXECUTIONREQUEST._serialized_end = 31365 - _UPDATEWORKFLOWEXECUTIONRESPONSE._serialized_start = 31368 - _UPDATEWORKFLOWEXECUTIONRESPONSE._serialized_end = 31627 - _STARTBATCHOPERATIONREQUEST._serialized_start = 31630 - _STARTBATCHOPERATIONREQUEST._serialized_end = 32996 - _STARTBATCHOPERATIONRESPONSE._serialized_start = 32998 - _STARTBATCHOPERATIONRESPONSE._serialized_end = 33027 - _STOPBATCHOPERATIONREQUEST._serialized_start = 33029 - _STOPBATCHOPERATIONREQUEST._serialized_end = 33125 - _STOPBATCHOPERATIONRESPONSE._serialized_start = 33127 - _STOPBATCHOPERATIONRESPONSE._serialized_end = 33155 - _DESCRIBEBATCHOPERATIONREQUEST._serialized_start = 33157 - _DESCRIBEBATCHOPERATIONREQUEST._serialized_end = 33223 - _DESCRIBEBATCHOPERATIONRESPONSE._serialized_start = 33226 - _DESCRIBEBATCHOPERATIONRESPONSE._serialized_end = 33698 - _LISTBATCHOPERATIONSREQUEST._serialized_start = 33700 - _LISTBATCHOPERATIONSREQUEST._serialized_end = 33791 - _LISTBATCHOPERATIONSRESPONSE._serialized_start = 33793 - _LISTBATCHOPERATIONSRESPONSE._serialized_end = 33914 - _POLLWORKFLOWEXECUTIONUPDATEREQUEST._serialized_start = 33917 - _POLLWORKFLOWEXECUTIONUPDATEREQUEST._serialized_end = 34102 - _POLLWORKFLOWEXECUTIONUPDATERESPONSE._serialized_start = 34105 - _POLLWORKFLOWEXECUTIONUPDATERESPONSE._serialized_end = 34324 - _POLLNEXUSTASKQUEUEREQUEST._serialized_start = 34327 - _POLLNEXUSTASKQUEUEREQUEST._serialized_end = 34743 - _POLLNEXUSTASKQUEUERESPONSE._serialized_start = 34746 - _POLLNEXUSTASKQUEUERESPONSE._serialized_end = 35100 - _RESPONDNEXUSTASKCOMPLETEDREQUEST._serialized_start = 35103 - _RESPONDNEXUSTASKCOMPLETEDREQUEST._serialized_end = 35270 - _RESPONDNEXUSTASKCOMPLETEDRESPONSE._serialized_start = 35272 - _RESPONDNEXUSTASKCOMPLETEDRESPONSE._serialized_end = 35307 - _RESPONDNEXUSTASKFAILEDREQUEST._serialized_start = 35310 - _RESPONDNEXUSTASKFAILEDREQUEST._serialized_end = 35530 - _RESPONDNEXUSTASKFAILEDRESPONSE._serialized_start = 35532 - _RESPONDNEXUSTASKFAILEDRESPONSE._serialized_end = 35564 - _EXECUTEMULTIOPERATIONREQUEST._serialized_start = 35567 - _EXECUTEMULTIOPERATIONREQUEST._serialized_end = 35939 - _EXECUTEMULTIOPERATIONREQUEST_OPERATION._serialized_start = 35733 - _EXECUTEMULTIOPERATIONREQUEST_OPERATION._serialized_end = 35939 - _EXECUTEMULTIOPERATIONRESPONSE._serialized_start = 35942 - _EXECUTEMULTIOPERATIONRESPONSE._serialized_end = 36274 - _EXECUTEMULTIOPERATIONRESPONSE_RESPONSE._serialized_start = 36068 - _EXECUTEMULTIOPERATIONRESPONSE_RESPONSE._serialized_end = 36274 - _UPDATEACTIVITYOPTIONSREQUEST._serialized_start = 36277 - _UPDATEACTIVITYOPTIONSREQUEST._serialized_end = 36613 - _UPDATEACTIVITYEXECUTIONOPTIONSREQUEST._serialized_start = 36616 - _UPDATEACTIVITYEXECUTIONOPTIONSREQUEST._serialized_end = 36935 - _UPDATEACTIVITYOPTIONSRESPONSE._serialized_start = 36937 - _UPDATEACTIVITYOPTIONSRESPONSE._serialized_end = 37037 - _UPDATEACTIVITYEXECUTIONOPTIONSRESPONSE._serialized_start = 37039 - _UPDATEACTIVITYEXECUTIONOPTIONSRESPONSE._serialized_end = 37148 - _PAUSEACTIVITYREQUEST._serialized_start = 37151 - _PAUSEACTIVITYREQUEST._serialized_end = 37350 - _PAUSEACTIVITYEXECUTIONREQUEST._serialized_start = 37353 - _PAUSEACTIVITYEXECUTIONREQUEST._serialized_end = 37536 - _PAUSEACTIVITYRESPONSE._serialized_start = 37538 - _PAUSEACTIVITYRESPONSE._serialized_end = 37561 - _PAUSEACTIVITYEXECUTIONRESPONSE._serialized_start = 37563 - _PAUSEACTIVITYEXECUTIONRESPONSE._serialized_end = 37595 - _UNPAUSEACTIVITYREQUEST._serialized_start = 37598 - _UNPAUSEACTIVITYREQUEST._serialized_end = 37878 - _UNPAUSEACTIVITYEXECUTIONREQUEST._serialized_start = 37881 - _UNPAUSEACTIVITYEXECUTIONREQUEST._serialized_end = 38154 - _UNPAUSEACTIVITYRESPONSE._serialized_start = 38156 - _UNPAUSEACTIVITYRESPONSE._serialized_end = 38181 - _UNPAUSEACTIVITYEXECUTIONRESPONSE._serialized_start = 38183 - _UNPAUSEACTIVITYEXECUTIONRESPONSE._serialized_end = 38217 - _RESETACTIVITYREQUEST._serialized_start = 38220 - _RESETACTIVITYREQUEST._serialized_end = 38527 - _RESETACTIVITYEXECUTIONREQUEST._serialized_start = 38530 - _RESETACTIVITYEXECUTIONREQUEST._serialized_end = 38820 - _RESETACTIVITYRESPONSE._serialized_start = 38822 - _RESETACTIVITYRESPONSE._serialized_end = 38845 - _RESETACTIVITYEXECUTIONRESPONSE._serialized_start = 38847 - _RESETACTIVITYEXECUTIONRESPONSE._serialized_end = 38879 - _UPDATEWORKFLOWEXECUTIONOPTIONSREQUEST._serialized_start = 38882 - _UPDATEWORKFLOWEXECUTIONOPTIONSREQUEST._serialized_end = 39166 - _UPDATEWORKFLOWEXECUTIONOPTIONSRESPONSE._serialized_start = 39169 - _UPDATEWORKFLOWEXECUTIONOPTIONSRESPONSE._serialized_end = 39346 - _DESCRIBEDEPLOYMENTREQUEST._serialized_start = 39348 - _DESCRIBEDEPLOYMENTREQUEST._serialized_end = 39454 - _DESCRIBEDEPLOYMENTRESPONSE._serialized_start = 39456 - _DESCRIBEDEPLOYMENTRESPONSE._serialized_end = 39553 - _DESCRIBEWORKERDEPLOYMENTVERSIONREQUEST._serialized_start = 39556 - _DESCRIBEWORKERDEPLOYMENTVERSIONREQUEST._serialized_end = 39750 - _DESCRIBEWORKERDEPLOYMENTVERSIONRESPONSE._serialized_start = 39753 - _DESCRIBEWORKERDEPLOYMENTVERSIONRESPONSE._serialized_end = 40405 - _DESCRIBEWORKERDEPLOYMENTVERSIONRESPONSE_VERSIONTASKQUEUE._serialized_start = 40014 - _DESCRIBEWORKERDEPLOYMENTVERSIONRESPONSE_VERSIONTASKQUEUE._serialized_end = 40405 - _DESCRIBEWORKERDEPLOYMENTVERSIONRESPONSE_VERSIONTASKQUEUE_STATSBYPRIORITYKEYENTRY._serialized_start = 23740 - _DESCRIBEWORKERDEPLOYMENTVERSIONRESPONSE_VERSIONTASKQUEUE_STATSBYPRIORITYKEYENTRY._serialized_end = 23840 - _DESCRIBEWORKERDEPLOYMENTREQUEST._serialized_start = 40407 - _DESCRIBEWORKERDEPLOYMENTREQUEST._serialized_end = 40484 - _DESCRIBEWORKERDEPLOYMENTRESPONSE._serialized_start = 40487 - _DESCRIBEWORKERDEPLOYMENTRESPONSE._serialized_end = 40627 - _LISTDEPLOYMENTSREQUEST._serialized_start = 40629 - _LISTDEPLOYMENTSREQUEST._serialized_end = 40737 - _LISTDEPLOYMENTSRESPONSE._serialized_start = 40739 - _LISTDEPLOYMENTSRESPONSE._serialized_end = 40858 - _SETCURRENTDEPLOYMENTREQUEST._serialized_start = 40861 - _SETCURRENTDEPLOYMENTREQUEST._serialized_end = 41066 - _SETCURRENTDEPLOYMENTRESPONSE._serialized_start = 41069 - _SETCURRENTDEPLOYMENTRESPONSE._serialized_end = 41254 - _SETWORKERDEPLOYMENTCURRENTVERSIONREQUEST._serialized_start = 41257 - _SETWORKERDEPLOYMENTCURRENTVERSIONREQUEST._serialized_end = 41486 - _SETWORKERDEPLOYMENTCURRENTVERSIONRESPONSE._serialized_start = 41489 - _SETWORKERDEPLOYMENTCURRENTVERSIONRESPONSE._serialized_end = 41680 - _SETWORKERDEPLOYMENTRAMPINGVERSIONREQUEST._serialized_start = 41683 - _SETWORKERDEPLOYMENTRAMPINGVERSIONREQUEST._serialized_end = 41932 - _SETWORKERDEPLOYMENTRAMPINGVERSIONRESPONSE._serialized_start = 41935 - _SETWORKERDEPLOYMENTRAMPINGVERSIONRESPONSE._serialized_end = 42159 - _CREATEWORKERDEPLOYMENTREQUEST._serialized_start = 42161 - _CREATEWORKERDEPLOYMENTREQUEST._serialized_end = 42274 - _CREATEWORKERDEPLOYMENTRESPONSE._serialized_start = 42276 - _CREATEWORKERDEPLOYMENTRESPONSE._serialized_end = 42332 - _LISTWORKERDEPLOYMENTSREQUEST._serialized_start = 42334 - _LISTWORKERDEPLOYMENTSREQUEST._serialized_end = 42427 - _LISTWORKERDEPLOYMENTSRESPONSE._serialized_start = 42430 - _LISTWORKERDEPLOYMENTSRESPONSE._serialized_end = 43101 - _LISTWORKERDEPLOYMENTSRESPONSE_WORKERDEPLOYMENTSUMMARY._serialized_start = 42605 - _LISTWORKERDEPLOYMENTSRESPONSE_WORKERDEPLOYMENTSUMMARY._serialized_end = 43101 - _CREATEWORKERDEPLOYMENTVERSIONREQUEST._serialized_start = 43104 - _CREATEWORKERDEPLOYMENTVERSIONREQUEST._serialized_end = 43344 - _CREATEWORKERDEPLOYMENTVERSIONRESPONSE._serialized_start = 43346 - _CREATEWORKERDEPLOYMENTVERSIONRESPONSE._serialized_end = 43385 - _DELETEWORKERDEPLOYMENTVERSIONREQUEST._serialized_start = 43388 - _DELETEWORKERDEPLOYMENTVERSIONREQUEST._serialized_end = 43588 - _DELETEWORKERDEPLOYMENTVERSIONRESPONSE._serialized_start = 43590 - _DELETEWORKERDEPLOYMENTVERSIONRESPONSE._serialized_end = 43629 - _DELETEWORKERDEPLOYMENTREQUEST._serialized_start = 43631 - _DELETEWORKERDEPLOYMENTREQUEST._serialized_end = 43724 - _DELETEWORKERDEPLOYMENTRESPONSE._serialized_start = 43726 - _DELETEWORKERDEPLOYMENTRESPONSE._serialized_end = 43758 - _UPDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST._serialized_start = 43761 - _UPDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST._serialized_end = 44277 - _UPDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST_COMPUTECONFIGSCALINGGROUPSENTRY._serialized_start = 44154 - _UPDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST_COMPUTECONFIGSCALINGGROUPSENTRY._serialized_end = 44277 - _UPDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGRESPONSE._serialized_start = 44279 - _UPDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGRESPONSE._serialized_end = 44331 - _VALIDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST._serialized_start = 44334 - _VALIDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST._serialized_end = 44834 - _VALIDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST_COMPUTECONFIGSCALINGGROUPSENTRY._serialized_start = 44154 - _VALIDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST_COMPUTECONFIGSCALINGGROUPSENTRY._serialized_end = 44277 - _VALIDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGRESPONSE._serialized_start = 44836 - _VALIDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGRESPONSE._serialized_end = 44890 - _UPDATEWORKERDEPLOYMENTVERSIONMETADATAREQUEST._serialized_start = 44893 - _UPDATEWORKERDEPLOYMENTVERSIONMETADATAREQUEST._serialized_end = 45311 - _UPDATEWORKERDEPLOYMENTVERSIONMETADATAREQUEST_UPSERTENTRIESENTRY._serialized_start = 45226 + _UPDATEWORKERVERSIONINGRULESREQUEST_ADDCOMPATIBLEBUILDIDREDIRECTRULE._serialized_start = 29991 + _UPDATEWORKERVERSIONINGRULESREQUEST_ADDCOMPATIBLEBUILDIDREDIRECTRULE._serialized_end = 30097 + _UPDATEWORKERVERSIONINGRULESREQUEST_REPLACECOMPATIBLEBUILDIDREDIRECTRULE._serialized_start = 30099 + _UPDATEWORKERVERSIONINGRULESREQUEST_REPLACECOMPATIBLEBUILDIDREDIRECTRULE._serialized_end = 30209 + _UPDATEWORKERVERSIONINGRULESREQUEST_DELETECOMPATIBLEBUILDIDREDIRECTRULE._serialized_start = 30211 + _UPDATEWORKERVERSIONINGRULESREQUEST_DELETECOMPATIBLEBUILDIDREDIRECTRULE._serialized_end = 30273 + _UPDATEWORKERVERSIONINGRULESREQUEST_COMMITBUILDID._serialized_start = 30275 + _UPDATEWORKERVERSIONINGRULESREQUEST_COMMITBUILDID._serialized_end = 30330 + _UPDATEWORKERVERSIONINGRULESRESPONSE._serialized_start = 30346 + _UPDATEWORKERVERSIONINGRULESRESPONSE._serialized_end = 30598 + _GETWORKERVERSIONINGRULESREQUEST._serialized_start = 30600 + _GETWORKERVERSIONINGRULESREQUEST._serialized_end = 30672 + _GETWORKERVERSIONINGRULESRESPONSE._serialized_start = 30675 + _GETWORKERVERSIONINGRULESRESPONSE._serialized_end = 30924 + _GETWORKERTASKREACHABILITYREQUEST._serialized_start = 30927 + _GETWORKERTASKREACHABILITYREQUEST._serialized_end = 31083 + _GETWORKERTASKREACHABILITYRESPONSE._serialized_start = 31085 + _GETWORKERTASKREACHABILITYRESPONSE._serialized_end = 31199 + _UPDATEWORKFLOWEXECUTIONREQUEST._serialized_start = 31202 + _UPDATEWORKFLOWEXECUTIONREQUEST._serialized_end = 31463 + _UPDATEWORKFLOWEXECUTIONRESPONSE._serialized_start = 31466 + _UPDATEWORKFLOWEXECUTIONRESPONSE._serialized_end = 31725 + _STARTBATCHOPERATIONREQUEST._serialized_start = 31728 + _STARTBATCHOPERATIONREQUEST._serialized_end = 33094 + _STARTBATCHOPERATIONRESPONSE._serialized_start = 33096 + _STARTBATCHOPERATIONRESPONSE._serialized_end = 33125 + _STOPBATCHOPERATIONREQUEST._serialized_start = 33127 + _STOPBATCHOPERATIONREQUEST._serialized_end = 33223 + _STOPBATCHOPERATIONRESPONSE._serialized_start = 33225 + _STOPBATCHOPERATIONRESPONSE._serialized_end = 33253 + _DESCRIBEBATCHOPERATIONREQUEST._serialized_start = 33255 + _DESCRIBEBATCHOPERATIONREQUEST._serialized_end = 33321 + _DESCRIBEBATCHOPERATIONRESPONSE._serialized_start = 33324 + _DESCRIBEBATCHOPERATIONRESPONSE._serialized_end = 33796 + _LISTBATCHOPERATIONSREQUEST._serialized_start = 33798 + _LISTBATCHOPERATIONSREQUEST._serialized_end = 33889 + _LISTBATCHOPERATIONSRESPONSE._serialized_start = 33891 + _LISTBATCHOPERATIONSRESPONSE._serialized_end = 34012 + _POLLWORKFLOWEXECUTIONUPDATEREQUEST._serialized_start = 34015 + _POLLWORKFLOWEXECUTIONUPDATEREQUEST._serialized_end = 34200 + _POLLWORKFLOWEXECUTIONUPDATERESPONSE._serialized_start = 34203 + _POLLWORKFLOWEXECUTIONUPDATERESPONSE._serialized_end = 34422 + _POLLNEXUSTASKQUEUEREQUEST._serialized_start = 34425 + _POLLNEXUSTASKQUEUEREQUEST._serialized_end = 34841 + _POLLNEXUSTASKQUEUERESPONSE._serialized_start = 34844 + _POLLNEXUSTASKQUEUERESPONSE._serialized_end = 35198 + _RESPONDNEXUSTASKCOMPLETEDREQUEST._serialized_start = 35201 + _RESPONDNEXUSTASKCOMPLETEDREQUEST._serialized_end = 35368 + _RESPONDNEXUSTASKCOMPLETEDRESPONSE._serialized_start = 35370 + _RESPONDNEXUSTASKCOMPLETEDRESPONSE._serialized_end = 35405 + _RESPONDNEXUSTASKFAILEDREQUEST._serialized_start = 35408 + _RESPONDNEXUSTASKFAILEDREQUEST._serialized_end = 35628 + _RESPONDNEXUSTASKFAILEDRESPONSE._serialized_start = 35630 + _RESPONDNEXUSTASKFAILEDRESPONSE._serialized_end = 35662 + _EXECUTEMULTIOPERATIONREQUEST._serialized_start = 35665 + _EXECUTEMULTIOPERATIONREQUEST._serialized_end = 36037 + _EXECUTEMULTIOPERATIONREQUEST_OPERATION._serialized_start = 35831 + _EXECUTEMULTIOPERATIONREQUEST_OPERATION._serialized_end = 36037 + _EXECUTEMULTIOPERATIONRESPONSE._serialized_start = 36040 + _EXECUTEMULTIOPERATIONRESPONSE._serialized_end = 36372 + _EXECUTEMULTIOPERATIONRESPONSE_RESPONSE._serialized_start = 36166 + _EXECUTEMULTIOPERATIONRESPONSE_RESPONSE._serialized_end = 36372 + _UPDATEACTIVITYOPTIONSREQUEST._serialized_start = 36375 + _UPDATEACTIVITYOPTIONSREQUEST._serialized_end = 36711 + _UPDATEACTIVITYEXECUTIONOPTIONSREQUEST._serialized_start = 36714 + _UPDATEACTIVITYEXECUTIONOPTIONSREQUEST._serialized_end = 37033 + _UPDATEACTIVITYOPTIONSRESPONSE._serialized_start = 37035 + _UPDATEACTIVITYOPTIONSRESPONSE._serialized_end = 37135 + _UPDATEACTIVITYEXECUTIONOPTIONSRESPONSE._serialized_start = 37137 + _UPDATEACTIVITYEXECUTIONOPTIONSRESPONSE._serialized_end = 37246 + _PAUSEACTIVITYREQUEST._serialized_start = 37249 + _PAUSEACTIVITYREQUEST._serialized_end = 37448 + _PAUSEACTIVITYEXECUTIONREQUEST._serialized_start = 37451 + _PAUSEACTIVITYEXECUTIONREQUEST._serialized_end = 37634 + _PAUSEACTIVITYRESPONSE._serialized_start = 37636 + _PAUSEACTIVITYRESPONSE._serialized_end = 37659 + _PAUSEACTIVITYEXECUTIONRESPONSE._serialized_start = 37661 + _PAUSEACTIVITYEXECUTIONRESPONSE._serialized_end = 37693 + _UNPAUSEACTIVITYREQUEST._serialized_start = 37696 + _UNPAUSEACTIVITYREQUEST._serialized_end = 37976 + _UNPAUSEACTIVITYEXECUTIONREQUEST._serialized_start = 37979 + _UNPAUSEACTIVITYEXECUTIONREQUEST._serialized_end = 38252 + _UNPAUSEACTIVITYRESPONSE._serialized_start = 38254 + _UNPAUSEACTIVITYRESPONSE._serialized_end = 38279 + _UNPAUSEACTIVITYEXECUTIONRESPONSE._serialized_start = 38281 + _UNPAUSEACTIVITYEXECUTIONRESPONSE._serialized_end = 38315 + _RESETACTIVITYREQUEST._serialized_start = 38318 + _RESETACTIVITYREQUEST._serialized_end = 38625 + _RESETACTIVITYEXECUTIONREQUEST._serialized_start = 38628 + _RESETACTIVITYEXECUTIONREQUEST._serialized_end = 38918 + _RESETACTIVITYRESPONSE._serialized_start = 38920 + _RESETACTIVITYRESPONSE._serialized_end = 38943 + _RESETACTIVITYEXECUTIONRESPONSE._serialized_start = 38945 + _RESETACTIVITYEXECUTIONRESPONSE._serialized_end = 38977 + _UPDATEWORKFLOWEXECUTIONOPTIONSREQUEST._serialized_start = 38980 + _UPDATEWORKFLOWEXECUTIONOPTIONSREQUEST._serialized_end = 39264 + _UPDATEWORKFLOWEXECUTIONOPTIONSRESPONSE._serialized_start = 39267 + _UPDATEWORKFLOWEXECUTIONOPTIONSRESPONSE._serialized_end = 39444 + _DESCRIBEDEPLOYMENTREQUEST._serialized_start = 39446 + _DESCRIBEDEPLOYMENTREQUEST._serialized_end = 39552 + _DESCRIBEDEPLOYMENTRESPONSE._serialized_start = 39554 + _DESCRIBEDEPLOYMENTRESPONSE._serialized_end = 39651 + _DESCRIBEWORKERDEPLOYMENTVERSIONREQUEST._serialized_start = 39654 + _DESCRIBEWORKERDEPLOYMENTVERSIONREQUEST._serialized_end = 39848 + _DESCRIBEWORKERDEPLOYMENTVERSIONRESPONSE._serialized_start = 39851 + _DESCRIBEWORKERDEPLOYMENTVERSIONRESPONSE._serialized_end = 40503 + _DESCRIBEWORKERDEPLOYMENTVERSIONRESPONSE_VERSIONTASKQUEUE._serialized_start = 40112 + _DESCRIBEWORKERDEPLOYMENTVERSIONRESPONSE_VERSIONTASKQUEUE._serialized_end = 40503 + _DESCRIBEWORKERDEPLOYMENTVERSIONRESPONSE_VERSIONTASKQUEUE_STATSBYPRIORITYKEYENTRY._serialized_start = 23838 + _DESCRIBEWORKERDEPLOYMENTVERSIONRESPONSE_VERSIONTASKQUEUE_STATSBYPRIORITYKEYENTRY._serialized_end = 23938 + _DESCRIBEWORKERDEPLOYMENTREQUEST._serialized_start = 40505 + _DESCRIBEWORKERDEPLOYMENTREQUEST._serialized_end = 40582 + _DESCRIBEWORKERDEPLOYMENTRESPONSE._serialized_start = 40585 + _DESCRIBEWORKERDEPLOYMENTRESPONSE._serialized_end = 40725 + _LISTDEPLOYMENTSREQUEST._serialized_start = 40727 + _LISTDEPLOYMENTSREQUEST._serialized_end = 40835 + _LISTDEPLOYMENTSRESPONSE._serialized_start = 40837 + _LISTDEPLOYMENTSRESPONSE._serialized_end = 40956 + _SETCURRENTDEPLOYMENTREQUEST._serialized_start = 40959 + _SETCURRENTDEPLOYMENTREQUEST._serialized_end = 41164 + _SETCURRENTDEPLOYMENTRESPONSE._serialized_start = 41167 + _SETCURRENTDEPLOYMENTRESPONSE._serialized_end = 41352 + _SETWORKERDEPLOYMENTCURRENTVERSIONREQUEST._serialized_start = 41355 + _SETWORKERDEPLOYMENTCURRENTVERSIONREQUEST._serialized_end = 41584 + _SETWORKERDEPLOYMENTCURRENTVERSIONRESPONSE._serialized_start = 41587 + _SETWORKERDEPLOYMENTCURRENTVERSIONRESPONSE._serialized_end = 41778 + _SETWORKERDEPLOYMENTRAMPINGVERSIONREQUEST._serialized_start = 41781 + _SETWORKERDEPLOYMENTRAMPINGVERSIONREQUEST._serialized_end = 42030 + _SETWORKERDEPLOYMENTRAMPINGVERSIONRESPONSE._serialized_start = 42033 + _SETWORKERDEPLOYMENTRAMPINGVERSIONRESPONSE._serialized_end = 42257 + _CREATEWORKERDEPLOYMENTREQUEST._serialized_start = 42259 + _CREATEWORKERDEPLOYMENTREQUEST._serialized_end = 42372 + _CREATEWORKERDEPLOYMENTRESPONSE._serialized_start = 42374 + _CREATEWORKERDEPLOYMENTRESPONSE._serialized_end = 42430 + _LISTWORKERDEPLOYMENTSREQUEST._serialized_start = 42432 + _LISTWORKERDEPLOYMENTSREQUEST._serialized_end = 42525 + _LISTWORKERDEPLOYMENTSRESPONSE._serialized_start = 42528 + _LISTWORKERDEPLOYMENTSRESPONSE._serialized_end = 43199 + _LISTWORKERDEPLOYMENTSRESPONSE_WORKERDEPLOYMENTSUMMARY._serialized_start = 42703 + _LISTWORKERDEPLOYMENTSRESPONSE_WORKERDEPLOYMENTSUMMARY._serialized_end = 43199 + _CREATEWORKERDEPLOYMENTVERSIONREQUEST._serialized_start = 43202 + _CREATEWORKERDEPLOYMENTVERSIONREQUEST._serialized_end = 43442 + _CREATEWORKERDEPLOYMENTVERSIONRESPONSE._serialized_start = 43444 + _CREATEWORKERDEPLOYMENTVERSIONRESPONSE._serialized_end = 43483 + _DELETEWORKERDEPLOYMENTVERSIONREQUEST._serialized_start = 43486 + _DELETEWORKERDEPLOYMENTVERSIONREQUEST._serialized_end = 43686 + _DELETEWORKERDEPLOYMENTVERSIONRESPONSE._serialized_start = 43688 + _DELETEWORKERDEPLOYMENTVERSIONRESPONSE._serialized_end = 43727 + _DELETEWORKERDEPLOYMENTREQUEST._serialized_start = 43729 + _DELETEWORKERDEPLOYMENTREQUEST._serialized_end = 43822 + _DELETEWORKERDEPLOYMENTRESPONSE._serialized_start = 43824 + _DELETEWORKERDEPLOYMENTRESPONSE._serialized_end = 43856 + _UPDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST._serialized_start = 43859 + _UPDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST._serialized_end = 44375 + _UPDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST_COMPUTECONFIGSCALINGGROUPSENTRY._serialized_start = 44252 + _UPDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST_COMPUTECONFIGSCALINGGROUPSENTRY._serialized_end = 44375 + _UPDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGRESPONSE._serialized_start = 44377 + _UPDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGRESPONSE._serialized_end = 44429 + _VALIDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST._serialized_start = 44432 + _VALIDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST._serialized_end = 44932 + _VALIDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST_COMPUTECONFIGSCALINGGROUPSENTRY._serialized_start = 44252 + _VALIDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGREQUEST_COMPUTECONFIGSCALINGGROUPSENTRY._serialized_end = 44375 + _VALIDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGRESPONSE._serialized_start = 44934 + _VALIDATEWORKERDEPLOYMENTVERSIONCOMPUTECONFIGRESPONSE._serialized_end = 44988 + _UPDATEWORKERDEPLOYMENTVERSIONMETADATAREQUEST._serialized_start = 44991 + _UPDATEWORKERDEPLOYMENTVERSIONMETADATAREQUEST._serialized_end = 45409 + _UPDATEWORKERDEPLOYMENTVERSIONMETADATAREQUEST_UPSERTENTRIESENTRY._serialized_start = 45324 _UPDATEWORKERDEPLOYMENTVERSIONMETADATAREQUEST_UPSERTENTRIESENTRY._serialized_end = ( - 45311 + 45409 ) - _UPDATEWORKERDEPLOYMENTVERSIONMETADATARESPONSE._serialized_start = 45313 - _UPDATEWORKERDEPLOYMENTVERSIONMETADATARESPONSE._serialized_end = 45423 - _SETWORKERDEPLOYMENTMANAGERREQUEST._serialized_start = 45426 - _SETWORKERDEPLOYMENTMANAGERREQUEST._serialized_end = 45615 - _SETWORKERDEPLOYMENTMANAGERRESPONSE._serialized_start = 45617 - _SETWORKERDEPLOYMENTMANAGERRESPONSE._serialized_end = 45716 - _GETCURRENTDEPLOYMENTREQUEST._serialized_start = 45718 - _GETCURRENTDEPLOYMENTREQUEST._serialized_end = 45787 - _GETCURRENTDEPLOYMENTRESPONSE._serialized_start = 45789 - _GETCURRENTDEPLOYMENTRESPONSE._serialized_end = 45896 - _GETDEPLOYMENTREACHABILITYREQUEST._serialized_start = 45898 - _GETDEPLOYMENTREACHABILITYREQUEST._serialized_end = 46011 - _GETDEPLOYMENTREACHABILITYRESPONSE._serialized_start = 46014 - _GETDEPLOYMENTREACHABILITYRESPONSE._serialized_end = 46241 - _CREATEWORKFLOWRULEREQUEST._serialized_start = 46244 - _CREATEWORKFLOWRULEREQUEST._serialized_end = 46424 - _CREATEWORKFLOWRULERESPONSE._serialized_start = 46426 - _CREATEWORKFLOWRULERESPONSE._serialized_end = 46521 - _DESCRIBEWORKFLOWRULEREQUEST._serialized_start = 46523 - _DESCRIBEWORKFLOWRULEREQUEST._serialized_end = 46588 - _DESCRIBEWORKFLOWRULERESPONSE._serialized_start = 46590 - _DESCRIBEWORKFLOWRULERESPONSE._serialized_end = 46671 - _DELETEWORKFLOWRULEREQUEST._serialized_start = 46673 - _DELETEWORKFLOWRULEREQUEST._serialized_end = 46736 - _DELETEWORKFLOWRULERESPONSE._serialized_start = 46738 - _DELETEWORKFLOWRULERESPONSE._serialized_end = 46766 - _LISTWORKFLOWRULESREQUEST._serialized_start = 46768 - _LISTWORKFLOWRULESREQUEST._serialized_end = 46838 - _LISTWORKFLOWRULESRESPONSE._serialized_start = 46840 - _LISTWORKFLOWRULESRESPONSE._serialized_end = 46944 - _TRIGGERWORKFLOWRULEREQUEST._serialized_start = 46947 - _TRIGGERWORKFLOWRULEREQUEST._serialized_end = 47153 - _TRIGGERWORKFLOWRULERESPONSE._serialized_start = 47155 - _TRIGGERWORKFLOWRULERESPONSE._serialized_end = 47201 - _RECORDWORKERHEARTBEATREQUEST._serialized_start = 47204 - _RECORDWORKERHEARTBEATREQUEST._serialized_end = 47359 - _RECORDWORKERHEARTBEATRESPONSE._serialized_start = 47361 - _RECORDWORKERHEARTBEATRESPONSE._serialized_end = 47392 - _LISTWORKERSREQUEST._serialized_start = 47395 - _LISTWORKERSREQUEST._serialized_end = 47525 - _LISTWORKERSRESPONSE._serialized_start = 47528 - _LISTWORKERSRESPONSE._serialized_end = 47693 - _UPDATETASKQUEUECONFIGREQUEST._serialized_start = 47696 - _UPDATETASKQUEUECONFIGREQUEST._serialized_end = 48421 - _UPDATETASKQUEUECONFIGREQUEST_RATELIMITUPDATE._serialized_start = 48263 - _UPDATETASKQUEUECONFIGREQUEST_RATELIMITUPDATE._serialized_end = 48354 + _UPDATEWORKERDEPLOYMENTVERSIONMETADATARESPONSE._serialized_start = 45411 + _UPDATEWORKERDEPLOYMENTVERSIONMETADATARESPONSE._serialized_end = 45521 + _SETWORKERDEPLOYMENTMANAGERREQUEST._serialized_start = 45524 + _SETWORKERDEPLOYMENTMANAGERREQUEST._serialized_end = 45713 + _SETWORKERDEPLOYMENTMANAGERRESPONSE._serialized_start = 45715 + _SETWORKERDEPLOYMENTMANAGERRESPONSE._serialized_end = 45814 + _GETCURRENTDEPLOYMENTREQUEST._serialized_start = 45816 + _GETCURRENTDEPLOYMENTREQUEST._serialized_end = 45885 + _GETCURRENTDEPLOYMENTRESPONSE._serialized_start = 45887 + _GETCURRENTDEPLOYMENTRESPONSE._serialized_end = 45994 + _GETDEPLOYMENTREACHABILITYREQUEST._serialized_start = 45996 + _GETDEPLOYMENTREACHABILITYREQUEST._serialized_end = 46109 + _GETDEPLOYMENTREACHABILITYRESPONSE._serialized_start = 46112 + _GETDEPLOYMENTREACHABILITYRESPONSE._serialized_end = 46339 + _CREATEWORKFLOWRULEREQUEST._serialized_start = 46342 + _CREATEWORKFLOWRULEREQUEST._serialized_end = 46522 + _CREATEWORKFLOWRULERESPONSE._serialized_start = 46524 + _CREATEWORKFLOWRULERESPONSE._serialized_end = 46619 + _DESCRIBEWORKFLOWRULEREQUEST._serialized_start = 46621 + _DESCRIBEWORKFLOWRULEREQUEST._serialized_end = 46686 + _DESCRIBEWORKFLOWRULERESPONSE._serialized_start = 46688 + _DESCRIBEWORKFLOWRULERESPONSE._serialized_end = 46769 + _DELETEWORKFLOWRULEREQUEST._serialized_start = 46771 + _DELETEWORKFLOWRULEREQUEST._serialized_end = 46834 + _DELETEWORKFLOWRULERESPONSE._serialized_start = 46836 + _DELETEWORKFLOWRULERESPONSE._serialized_end = 46864 + _LISTWORKFLOWRULESREQUEST._serialized_start = 46866 + _LISTWORKFLOWRULESREQUEST._serialized_end = 46936 + _LISTWORKFLOWRULESRESPONSE._serialized_start = 46938 + _LISTWORKFLOWRULESRESPONSE._serialized_end = 47042 + _TRIGGERWORKFLOWRULEREQUEST._serialized_start = 47045 + _TRIGGERWORKFLOWRULEREQUEST._serialized_end = 47251 + _TRIGGERWORKFLOWRULERESPONSE._serialized_start = 47253 + _TRIGGERWORKFLOWRULERESPONSE._serialized_end = 47299 + _RECORDWORKERHEARTBEATREQUEST._serialized_start = 47302 + _RECORDWORKERHEARTBEATREQUEST._serialized_end = 47457 + _RECORDWORKERHEARTBEATRESPONSE._serialized_start = 47459 + _RECORDWORKERHEARTBEATRESPONSE._serialized_end = 47490 + _LISTWORKERSREQUEST._serialized_start = 47493 + _LISTWORKERSREQUEST._serialized_end = 47623 + _LISTWORKERSRESPONSE._serialized_start = 47626 + _LISTWORKERSRESPONSE._serialized_end = 47791 + _UPDATETASKQUEUECONFIGREQUEST._serialized_start = 47794 + _UPDATETASKQUEUECONFIGREQUEST._serialized_end = 48519 + _UPDATETASKQUEUECONFIGREQUEST_RATELIMITUPDATE._serialized_start = 48361 + _UPDATETASKQUEUECONFIGREQUEST_RATELIMITUPDATE._serialized_end = 48452 _UPDATETASKQUEUECONFIGREQUEST_SETFAIRNESSWEIGHTOVERRIDESENTRY._serialized_start = ( - 48356 + 48454 ) _UPDATETASKQUEUECONFIGREQUEST_SETFAIRNESSWEIGHTOVERRIDESENTRY._serialized_end = ( - 48421 + 48519 ) - _UPDATETASKQUEUECONFIGRESPONSE._serialized_start = 48423 - _UPDATETASKQUEUECONFIGRESPONSE._serialized_end = 48514 - _FETCHWORKERCONFIGREQUEST._serialized_start = 48517 - _FETCHWORKERCONFIGREQUEST._serialized_end = 48675 - _FETCHWORKERCONFIGRESPONSE._serialized_start = 48677 - _FETCHWORKERCONFIGRESPONSE._serialized_end = 48762 - _UPDATEWORKERCONFIGREQUEST._serialized_start = 48765 - _UPDATEWORKERCONFIGREQUEST._serialized_end = 49031 - _UPDATEWORKERCONFIGRESPONSE._serialized_start = 49033 - _UPDATEWORKERCONFIGRESPONSE._serialized_end = 49133 - _DESCRIBEWORKERREQUEST._serialized_start = 49135 - _DESCRIBEWORKERREQUEST._serialized_end = 49206 - _DESCRIBEWORKERRESPONSE._serialized_start = 49208 - _DESCRIBEWORKERRESPONSE._serialized_end = 49289 - _COUNTWORKERSREQUEST._serialized_start = 49291 - _COUNTWORKERSREQUEST._serialized_end = 49378 - _COUNTWORKERSRESPONSE._serialized_start = 49380 - _COUNTWORKERSRESPONSE._serialized_end = 49417 - _PAUSEWORKFLOWEXECUTIONREQUEST._serialized_start = 49420 - _PAUSEWORKFLOWEXECUTIONREQUEST._serialized_end = 49561 - _PAUSEWORKFLOWEXECUTIONRESPONSE._serialized_start = 49563 - _PAUSEWORKFLOWEXECUTIONRESPONSE._serialized_end = 49595 - _UNPAUSEWORKFLOWEXECUTIONREQUEST._serialized_start = 49598 - _UNPAUSEWORKFLOWEXECUTIONREQUEST._serialized_end = 49741 - _UNPAUSEWORKFLOWEXECUTIONRESPONSE._serialized_start = 49743 - _UNPAUSEWORKFLOWEXECUTIONRESPONSE._serialized_end = 49777 - _STARTACTIVITYEXECUTIONREQUEST._serialized_start = 49780 - _STARTACTIVITYEXECUTIONREQUEST._serialized_end = 50957 - _STARTACTIVITYEXECUTIONRESPONSE._serialized_start = 50959 - _STARTACTIVITYEXECUTIONRESPONSE._serialized_end = 51068 - _DESCRIBEACTIVITYEXECUTIONREQUEST._serialized_start = 51071 - _DESCRIBEACTIVITYEXECUTIONREQUEST._serialized_end = 51299 - _DESCRIBEACTIVITYEXECUTIONRESPONSE._serialized_start = 51302 - _DESCRIBEACTIVITYEXECUTIONRESPONSE._serialized_end = 51618 - _POLLACTIVITYEXECUTIONREQUEST._serialized_start = 51620 - _POLLACTIVITYEXECUTIONREQUEST._serialized_end = 51706 - _POLLACTIVITYEXECUTIONRESPONSE._serialized_start = 51708 - _POLLACTIVITYEXECUTIONRESPONSE._serialized_end = 51824 - _LISTACTIVITYEXECUTIONSREQUEST._serialized_start = 51826 - _LISTACTIVITYEXECUTIONSREQUEST._serialized_end = 51935 - _LISTACTIVITYEXECUTIONSRESPONSE._serialized_start = 51938 - _LISTACTIVITYEXECUTIONSRESPONSE._serialized_end = 52068 - _STARTNEXUSOPERATIONEXECUTIONREQUEST._serialized_start = 52071 - _STARTNEXUSOPERATIONEXECUTIONREQUEST._serialized_end = 52920 - _STARTNEXUSOPERATIONEXECUTIONREQUEST_NEXUSHEADERENTRY._serialized_start = 52870 - _STARTNEXUSOPERATIONEXECUTIONREQUEST_NEXUSHEADERENTRY._serialized_end = 52920 - _STARTNEXUSOPERATIONEXECUTIONRESPONSE._serialized_start = 52922 - _STARTNEXUSOPERATIONEXECUTIONRESPONSE._serialized_end = 52993 - _DESCRIBENEXUSOPERATIONEXECUTIONREQUEST._serialized_start = 52996 - _DESCRIBENEXUSOPERATIONEXECUTIONREQUEST._serialized_end = 53166 - _DESCRIBENEXUSOPERATIONEXECUTIONRESPONSE._serialized_start = 53169 - _DESCRIBENEXUSOPERATIONEXECUTIONRESPONSE._serialized_end = 53480 - _POLLNEXUSOPERATIONEXECUTIONREQUEST._serialized_start = 53483 - _POLLNEXUSOPERATIONEXECUTIONREQUEST._serialized_end = 53644 - _POLLNEXUSOPERATIONEXECUTIONRESPONSE._serialized_start = 53647 - _POLLNEXUSOPERATIONEXECUTIONRESPONSE._serialized_end = 53908 - _LISTNEXUSOPERATIONEXECUTIONSREQUEST._serialized_start = 53910 - _LISTNEXUSOPERATIONEXECUTIONSREQUEST._serialized_end = 54025 - _LISTNEXUSOPERATIONEXECUTIONSRESPONSE._serialized_start = 54028 - _LISTNEXUSOPERATIONEXECUTIONSRESPONSE._serialized_end = 54167 - _COUNTACTIVITYEXECUTIONSREQUEST._serialized_start = 54169 - _COUNTACTIVITYEXECUTIONSREQUEST._serialized_end = 54235 - _COUNTACTIVITYEXECUTIONSRESPONSE._serialized_start = 54238 - _COUNTACTIVITYEXECUTIONSRESPONSE._serialized_end = 54475 - _COUNTACTIVITYEXECUTIONSRESPONSE_AGGREGATIONGROUP._serialized_start = 20134 - _COUNTACTIVITYEXECUTIONSRESPONSE_AGGREGATIONGROUP._serialized_end = 20222 - _COUNTNEXUSOPERATIONEXECUTIONSREQUEST._serialized_start = 54477 - _COUNTNEXUSOPERATIONEXECUTIONSREQUEST._serialized_end = 54549 - _COUNTNEXUSOPERATIONEXECUTIONSRESPONSE._serialized_start = 54552 - _COUNTNEXUSOPERATIONEXECUTIONSRESPONSE._serialized_end = 54801 - _COUNTNEXUSOPERATIONEXECUTIONSRESPONSE_AGGREGATIONGROUP._serialized_start = 20134 - _COUNTNEXUSOPERATIONEXECUTIONSRESPONSE_AGGREGATIONGROUP._serialized_end = 20222 - _REQUESTCANCELACTIVITYEXECUTIONREQUEST._serialized_start = 54804 - _REQUESTCANCELACTIVITYEXECUTIONREQUEST._serialized_end = 54953 - _REQUESTCANCELACTIVITYEXECUTIONRESPONSE._serialized_start = 54955 - _REQUESTCANCELACTIVITYEXECUTIONRESPONSE._serialized_end = 54995 - _TERMINATEACTIVITYEXECUTIONREQUEST._serialized_start = 54998 - _TERMINATEACTIVITYEXECUTIONREQUEST._serialized_end = 55143 - _TERMINATEACTIVITYEXECUTIONRESPONSE._serialized_start = 55145 - _TERMINATEACTIVITYEXECUTIONRESPONSE._serialized_end = 55181 - _DELETEACTIVITYEXECUTIONREQUEST._serialized_start = 55183 - _DELETEACTIVITYEXECUTIONREQUEST._serialized_end = 55271 - _DELETEACTIVITYEXECUTIONRESPONSE._serialized_start = 55273 - _DELETEACTIVITYEXECUTIONRESPONSE._serialized_end = 55306 - _REQUESTCANCELNEXUSOPERATIONEXECUTIONREQUEST._serialized_start = 55309 - _REQUESTCANCELNEXUSOPERATIONEXECUTIONREQUEST._serialized_end = 55465 - _REQUESTCANCELNEXUSOPERATIONEXECUTIONRESPONSE._serialized_start = 55467 - _REQUESTCANCELNEXUSOPERATIONEXECUTIONRESPONSE._serialized_end = 55513 - _TERMINATENEXUSOPERATIONEXECUTIONREQUEST._serialized_start = 55516 - _TERMINATENEXUSOPERATIONEXECUTIONREQUEST._serialized_end = 55668 - _TERMINATENEXUSOPERATIONEXECUTIONRESPONSE._serialized_start = 55670 - _TERMINATENEXUSOPERATIONEXECUTIONRESPONSE._serialized_end = 55712 - _DELETENEXUSOPERATIONEXECUTIONREQUEST._serialized_start = 55714 - _DELETENEXUSOPERATIONEXECUTIONREQUEST._serialized_end = 55809 - _DELETENEXUSOPERATIONEXECUTIONRESPONSE._serialized_start = 55811 - _DELETENEXUSOPERATIONEXECUTIONRESPONSE._serialized_end = 55850 - _POLLWORKFLOWEXECUTIONTIMESKIPPINGREQUEST._serialized_start = 55853 - _POLLWORKFLOWEXECUTIONTIMESKIPPINGREQUEST._serialized_end = 56010 - _POLLWORKFLOWEXECUTIONTIMESKIPPINGRESPONSE._serialized_start = 56013 - _POLLWORKFLOWEXECUTIONTIMESKIPPINGRESPONSE._serialized_end = 56245 + _UPDATETASKQUEUECONFIGRESPONSE._serialized_start = 48521 + _UPDATETASKQUEUECONFIGRESPONSE._serialized_end = 48612 + _FETCHWORKERCONFIGREQUEST._serialized_start = 48615 + _FETCHWORKERCONFIGREQUEST._serialized_end = 48773 + _FETCHWORKERCONFIGRESPONSE._serialized_start = 48775 + _FETCHWORKERCONFIGRESPONSE._serialized_end = 48860 + _UPDATEWORKERCONFIGREQUEST._serialized_start = 48863 + _UPDATEWORKERCONFIGREQUEST._serialized_end = 49129 + _UPDATEWORKERCONFIGRESPONSE._serialized_start = 49131 + _UPDATEWORKERCONFIGRESPONSE._serialized_end = 49231 + _DESCRIBEWORKERREQUEST._serialized_start = 49233 + _DESCRIBEWORKERREQUEST._serialized_end = 49304 + _DESCRIBEWORKERRESPONSE._serialized_start = 49306 + _DESCRIBEWORKERRESPONSE._serialized_end = 49387 + _COUNTWORKERSREQUEST._serialized_start = 49389 + _COUNTWORKERSREQUEST._serialized_end = 49476 + _COUNTWORKERSRESPONSE._serialized_start = 49478 + _COUNTWORKERSRESPONSE._serialized_end = 49515 + _PAUSEWORKFLOWEXECUTIONREQUEST._serialized_start = 49518 + _PAUSEWORKFLOWEXECUTIONREQUEST._serialized_end = 49659 + _PAUSEWORKFLOWEXECUTIONRESPONSE._serialized_start = 49661 + _PAUSEWORKFLOWEXECUTIONRESPONSE._serialized_end = 49693 + _UNPAUSEWORKFLOWEXECUTIONREQUEST._serialized_start = 49696 + _UNPAUSEWORKFLOWEXECUTIONREQUEST._serialized_end = 49839 + _UNPAUSEWORKFLOWEXECUTIONRESPONSE._serialized_start = 49841 + _UNPAUSEWORKFLOWEXECUTIONRESPONSE._serialized_end = 49875 + _STARTACTIVITYEXECUTIONREQUEST._serialized_start = 49878 + _STARTACTIVITYEXECUTIONREQUEST._serialized_end = 51055 + _STARTACTIVITYEXECUTIONRESPONSE._serialized_start = 51057 + _STARTACTIVITYEXECUTIONRESPONSE._serialized_end = 51166 + _DESCRIBEACTIVITYEXECUTIONREQUEST._serialized_start = 51169 + _DESCRIBEACTIVITYEXECUTIONREQUEST._serialized_end = 51397 + _DESCRIBEACTIVITYEXECUTIONRESPONSE._serialized_start = 51400 + _DESCRIBEACTIVITYEXECUTIONRESPONSE._serialized_end = 51716 + _POLLACTIVITYEXECUTIONREQUEST._serialized_start = 51718 + _POLLACTIVITYEXECUTIONREQUEST._serialized_end = 51804 + _POLLACTIVITYEXECUTIONRESPONSE._serialized_start = 51806 + _POLLACTIVITYEXECUTIONRESPONSE._serialized_end = 51922 + _LISTACTIVITYEXECUTIONSREQUEST._serialized_start = 51924 + _LISTACTIVITYEXECUTIONSREQUEST._serialized_end = 52033 + _LISTACTIVITYEXECUTIONSRESPONSE._serialized_start = 52036 + _LISTACTIVITYEXECUTIONSRESPONSE._serialized_end = 52166 + _STARTNEXUSOPERATIONEXECUTIONREQUEST._serialized_start = 52169 + _STARTNEXUSOPERATIONEXECUTIONREQUEST._serialized_end = 53018 + _STARTNEXUSOPERATIONEXECUTIONREQUEST_NEXUSHEADERENTRY._serialized_start = 52968 + _STARTNEXUSOPERATIONEXECUTIONREQUEST_NEXUSHEADERENTRY._serialized_end = 53018 + _STARTNEXUSOPERATIONEXECUTIONRESPONSE._serialized_start = 53020 + _STARTNEXUSOPERATIONEXECUTIONRESPONSE._serialized_end = 53091 + _DESCRIBENEXUSOPERATIONEXECUTIONREQUEST._serialized_start = 53094 + _DESCRIBENEXUSOPERATIONEXECUTIONREQUEST._serialized_end = 53264 + _DESCRIBENEXUSOPERATIONEXECUTIONRESPONSE._serialized_start = 53267 + _DESCRIBENEXUSOPERATIONEXECUTIONRESPONSE._serialized_end = 53578 + _POLLNEXUSOPERATIONEXECUTIONREQUEST._serialized_start = 53581 + _POLLNEXUSOPERATIONEXECUTIONREQUEST._serialized_end = 53742 + _POLLNEXUSOPERATIONEXECUTIONRESPONSE._serialized_start = 53745 + _POLLNEXUSOPERATIONEXECUTIONRESPONSE._serialized_end = 54006 + _LISTNEXUSOPERATIONEXECUTIONSREQUEST._serialized_start = 54008 + _LISTNEXUSOPERATIONEXECUTIONSREQUEST._serialized_end = 54123 + _LISTNEXUSOPERATIONEXECUTIONSRESPONSE._serialized_start = 54126 + _LISTNEXUSOPERATIONEXECUTIONSRESPONSE._serialized_end = 54265 + _COUNTACTIVITYEXECUTIONSREQUEST._serialized_start = 54267 + _COUNTACTIVITYEXECUTIONSREQUEST._serialized_end = 54333 + _COUNTACTIVITYEXECUTIONSRESPONSE._serialized_start = 54336 + _COUNTACTIVITYEXECUTIONSRESPONSE._serialized_end = 54573 + _COUNTACTIVITYEXECUTIONSRESPONSE_AGGREGATIONGROUP._serialized_start = 20232 + _COUNTACTIVITYEXECUTIONSRESPONSE_AGGREGATIONGROUP._serialized_end = 20320 + _COUNTNEXUSOPERATIONEXECUTIONSREQUEST._serialized_start = 54575 + _COUNTNEXUSOPERATIONEXECUTIONSREQUEST._serialized_end = 54647 + _COUNTNEXUSOPERATIONEXECUTIONSRESPONSE._serialized_start = 54650 + _COUNTNEXUSOPERATIONEXECUTIONSRESPONSE._serialized_end = 54899 + _COUNTNEXUSOPERATIONEXECUTIONSRESPONSE_AGGREGATIONGROUP._serialized_start = 20232 + _COUNTNEXUSOPERATIONEXECUTIONSRESPONSE_AGGREGATIONGROUP._serialized_end = 20320 + _REQUESTCANCELACTIVITYEXECUTIONREQUEST._serialized_start = 54902 + _REQUESTCANCELACTIVITYEXECUTIONREQUEST._serialized_end = 55051 + _REQUESTCANCELACTIVITYEXECUTIONRESPONSE._serialized_start = 55053 + _REQUESTCANCELACTIVITYEXECUTIONRESPONSE._serialized_end = 55093 + _TERMINATEACTIVITYEXECUTIONREQUEST._serialized_start = 55096 + _TERMINATEACTIVITYEXECUTIONREQUEST._serialized_end = 55241 + _TERMINATEACTIVITYEXECUTIONRESPONSE._serialized_start = 55243 + _TERMINATEACTIVITYEXECUTIONRESPONSE._serialized_end = 55279 + _DELETEACTIVITYEXECUTIONREQUEST._serialized_start = 55281 + _DELETEACTIVITYEXECUTIONREQUEST._serialized_end = 55369 + _DELETEACTIVITYEXECUTIONRESPONSE._serialized_start = 55371 + _DELETEACTIVITYEXECUTIONRESPONSE._serialized_end = 55404 + _REQUESTCANCELNEXUSOPERATIONEXECUTIONREQUEST._serialized_start = 55407 + _REQUESTCANCELNEXUSOPERATIONEXECUTIONREQUEST._serialized_end = 55563 + _REQUESTCANCELNEXUSOPERATIONEXECUTIONRESPONSE._serialized_start = 55565 + _REQUESTCANCELNEXUSOPERATIONEXECUTIONRESPONSE._serialized_end = 55611 + _TERMINATENEXUSOPERATIONEXECUTIONREQUEST._serialized_start = 55614 + _TERMINATENEXUSOPERATIONEXECUTIONREQUEST._serialized_end = 55766 + _TERMINATENEXUSOPERATIONEXECUTIONRESPONSE._serialized_start = 55768 + _TERMINATENEXUSOPERATIONEXECUTIONRESPONSE._serialized_end = 55810 + _DELETENEXUSOPERATIONEXECUTIONREQUEST._serialized_start = 55812 + _DELETENEXUSOPERATIONEXECUTIONREQUEST._serialized_end = 55907 + _DELETENEXUSOPERATIONEXECUTIONRESPONSE._serialized_start = 55909 + _DELETENEXUSOPERATIONEXECUTIONRESPONSE._serialized_end = 55948 + _POLLWORKFLOWEXECUTIONTIMESKIPPINGREQUEST._serialized_start = 55951 + _POLLWORKFLOWEXECUTIONTIMESKIPPINGREQUEST._serialized_end = 56108 + _POLLWORKFLOWEXECUTIONTIMESKIPPINGRESPONSE._serialized_start = 56111 + _POLLWORKFLOWEXECUTIONTIMESKIPPINGRESPONSE._serialized_end = 56343 # @@protoc_insertion_point(module_scope) diff --git a/temporalio/api/workflowservice/v1/request_response_pb2.pyi b/temporalio/api/workflowservice/v1/request_response_pb2.pyi index eb9243716..e66c90384 100644 --- a/temporalio/api/workflowservice/v1/request_response_pb2.pyi +++ b/temporalio/api/workflowservice/v1/request_response_pb2.pyi @@ -46,6 +46,7 @@ import temporalio.api.schedule.v1.message_pb2 import temporalio.api.sdk.v1.task_complete_metadata_pb2 import temporalio.api.sdk.v1.user_metadata_pb2 import temporalio.api.sdk.v1.worker_config_pb2 +import temporalio.api.stream.v1.message_pb2 import temporalio.api.taskqueue.v1.message_pb2 import temporalio.api.update.v1.message_pb2 import temporalio.api.version.v1.message_pb2 @@ -1278,6 +1279,7 @@ class PollWorkflowTaskQueueResponse(google.protobuf.message.Message): POLLER_GROUP_ID_FIELD_NUMBER: builtins.int POLLER_GROUP_INFOS_FIELD_NUMBER: builtins.int POLLER_GROUPS_INFO_FIELD_NUMBER: builtins.int + STREAM_SLICES_FIELD_NUMBER: builtins.int task_token: builtins.bytes """A unique identifier for this task""" @property @@ -1392,6 +1394,15 @@ class PollWorkflowTaskQueueResponse(google.protobuf.message.Message): 3. If every group has some pending polls, assign the next poll to a group randomly according to the weights. """ + @property + def stream_slices( + self, + ) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[ + temporalio.api.stream.v1.message_pb2.StreamSlice + ]: + """Stream data attached to this task. Delivered out of band so the payloads + never enter History; only the offset ranges are recorded there. + """ def __init__( self, *, @@ -1427,6 +1438,10 @@ class PollWorkflowTaskQueueResponse(google.protobuf.message.Message): | None = ..., poller_groups_info: temporalio.api.taskqueue.v1.message_pb2.PollerGroupsInfo | None = ..., + stream_slices: collections.abc.Iterable[ + temporalio.api.stream.v1.message_pb2.StreamSlice + ] + | None = ..., ) -> None: ... def HasField( self, @@ -1484,6 +1499,8 @@ class PollWorkflowTaskQueueResponse(google.protobuf.message.Message): b"started_event_id", "started_time", b"started_time", + "stream_slices", + b"stream_slices", "task_token", b"task_token", "workflow_execution", From 4362c3167c5f771699a128548c309e8efea8bd84 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 01:54:17 -0700 Subject: [PATCH 52/96] Split the stream provider in two and made the proto the record. The registry, its string names and the private frame envelope go. A provider is an object that serves workers as a plugin and hands out handles outside them, and every store keeps the StreamRecord proto. --- temporalio/streams/__init__.py | 204 ++------- temporalio/streams/_frame.py | 72 --- temporalio/streams/_handles.py | 211 --------- temporalio/streams/_ids.py | 19 +- temporalio/streams/_policy.py | 33 +- temporalio/streams/_provider.py | 487 +++++++------------- temporalio/streams/_record.py | 58 ++- temporalio/streams/_wire.py | 181 ++++++++ temporalio/streams/providers/__init__.py | 79 +++- temporalio/streams/providers/memory.py | 545 +++++++++++++---------- 10 files changed, 832 insertions(+), 1057 deletions(-) delete mode 100644 temporalio/streams/_frame.py delete mode 100644 temporalio/streams/_handles.py create mode 100644 temporalio/streams/_wire.py diff --git a/temporalio/streams/__init__.py b/temporalio/streams/__init__.py index 0b43830cb..30d1f9a98 100644 --- a/temporalio/streams/__init__.py +++ b/temporalio/streams/__init__.py @@ -1,69 +1,58 @@ -"""A stream a workflow can read, decide on, and write. +"""Streams: a channel a workflow reads, decides on, and writes. + +.. warning:: + This module is experimental and may change in future versions. The + design is meant to be the shape that goes GA; the label is the SDK's + release convention, not a licence to break it. The contract, in five statements: -1. **A workflow publishes only to its own stream, and it publishes - transactionally.** :meth:`StreamWriter.publish` returns without the record - being visible. The record appears when the workflow task is accepted, and - never if the task fails, so no reader can see a decision the workflow did - not commit. +1. **A workflow publishes only to topics of its own stream, and it publishes + transactionally.** :meth:`temporalio.workflow.StreamWriter.publish` + returns at once. The record is visible when the Workflow Task is accepted, + and never if the task fails, so no reader can see a decision the workflow + did not commit. 2. **Reading is an observation, and the SDK records it.** What - :class:`StreamReader` handed to workflow code, including the boundary where - it found nothing, is committed with the commands that reading produced. - Recovery re-supplies the same records in the same order. -3. **Anything that does I/O publishes on its own account.** An activity, a - local activity, or an outside process writes through :func:`producer`, - either into one of the workflow's inbound streams or onto a topic of the - stream the workflow publishes, and its records are visible as soon as they - are written. It carries a producer id, an attempt and a sequence so a retry - can be told from a new generation. + :class:`temporalio.workflow.StreamReader` handed to workflow code, + including the boundary where it found nothing, is committed with the + commands that reading produced. Recovery re-supplies the same records in + the same order. +3. **Anything that does I/O publishes on its own account.** An activity or an + outside process writes through a :class:`StreamProducer` with a producer + id, an attempt and a sequence, and its records are visible as soon as the + store accepts them. Those three let a reader tell a retry from a new + generation. 4. **A cursor is opaque and belongs to its provider.** Hand it back to resume - after the record it names. Do not compare two cursors or do arithmetic on - one: one provider numbers records with integers and another with a - millisecond-and-sequence pair. A reader that wants to follow from now asks - the consumer for :meth:`Consumer.latest` instead of guessing a position. -5. **A workflow names its streams relative to itself, and the provider - resolves them.** ``reader("inputs")`` is this workflow's inbound stream - called ``inputs``; ``writer("decisions")`` is a topic it publishes on. One - provider stores that as a namespace-level stream id and another as a name - under the run's chain key, and workflow code does not have to know which. - The provider itself is chosen when the worker is built. - -Outside a workflow there is no worker to carry the choice, so a process calls -:func:`configure` once before it opens a :func:`producer` or a -:func:`consumer`, and passes :func:`worker_options` to its ``Worker`` and -``Replayer``. Those two calls are where a provider is named. Nothing else in -this module mentions one. Code that needs a second provider next to the -process default, such as a handler that serves one store while its process -talks to another, holds one from :func:`instance`. - -Three lifecycle hooks let a provider borrow the caller's lifetime, and each -is a no-op on a provider that needs nothing, so portable code calls all -three unconditionally: :func:`prepare` from the workflow's constructor, for a -transport that registers handlers on the workflow; :func:`drain` before the -workflow returns, for one that parks readers against the run; and -:func:`close`, awaited when the process is done with streams, for one that -holds connections. - -Reading somebody else's stream is out of scope for the first release. It is -the topology no provider has evidence for, and leaving it out is what lets -every provider implement the rest. + strictly after the record it names; :meth:`StreamHandle.latest` positions a + follower. Do not compare two cursors or do arithmetic on one. +5. **A workflow addresses its streams relative to itself, by topic.** A topic + can be written by the workflow and by outside producers, and read by the + workflow and by outside consumers; which of those happen is the + application's business. + +Workflow-side entry points live in :mod:`temporalio.workflow`: +:func:`temporalio.workflow.stream_reader` and +:func:`temporalio.workflow.stream_writer`. Outside a workflow, a handle comes +from a provider instance: ``provider.get_stream_handle(client, workflow_id)``. +A provider is an object; when it serves workers it is also a +:class:`temporalio.worker.Plugin`, passed as ``Worker(plugins=[provider])`` +and ``Replayer(plugins=[provider])``. This module keeps the shared types, the +errors and the protocols a provider implements; nothing here that workflow +code imports does I/O. What the contract does not promise: that a :attr:`RecordKind.FINISH` record means the writing activity succeeded, that a superseded attempt's records can be withdrawn, or that a stream outlives the retention its provider is -configured for. +configured for. Reading somebody else's stream is out of scope for this +release. -The public names are the proposal; providers live under -:mod:`temporalio.streams.providers`, one module each, registered by name and -chosen by :func:`configure`. Everything else is shared. +The record on the wire is ``temporal.api.stream.v1.StreamRecord`` on every +provider, with the user's value in ``body`` as an ordinary payload, so a +reader in any language decodes the same bytes and a payload codec applies. """ from __future__ import annotations -from datetime import timedelta -from typing import Any, TypeVar, overload - from temporalio.streams._errors import ( StreamCursorError, StreamError, @@ -71,27 +60,13 @@ StreamProducerError, StreamUnsupportedError, ) -from temporalio.streams._handles import ( - ReadSource, - StreamReader, - StreamWriter, - WriteSink, -) from temporalio.streams._provider import ( - Consumer, - Producer, + ReadSource, + StreamHandle, + StreamProducer, StreamProvider, - StreamProviderLifecycle, - close, - configure, - consumer, - drain, - instance, - open_read, - open_write, - prepare, - producer, - worker_options, + WorkflowStreamProvider, + WriteSink, ) from temporalio.streams._record import ( BEGINNING, @@ -103,100 +78,19 @@ __all__ = [ "BEGINNING", - "Consumer", "Cursor", - "Producer", "ReadSource", "RecordKind", "StreamCursorError", "StreamError", + "StreamHandle", "StreamNotFoundError", + "StreamProducer", "StreamProducerError", "StreamProvider", - "StreamProviderLifecycle", - "StreamReader", "StreamRecord", "StreamUnsupportedError", - "StreamWriter", "Supersession", + "WorkflowStreamProvider", "WriteSink", - "close", - "configure", - "consumer", - "drain", - "instance", - "prepare", - "producer", - "reader", - "worker_options", - "writer", ] - -T = TypeVar("T") - - -@overload -def reader( - stream: str, - *, - type: type[T], - after: Cursor = ..., - idle_timeout: timedelta | None = ..., -) -> StreamReader[T]: ... - - -@overload -def reader( - stream: str, - *, - type: None = None, - after: Cursor = ..., - idle_timeout: timedelta | None = ..., -) -> StreamReader[Any]: ... - - -def reader( - stream: str, - *, - type: type | None = None, - after: Cursor = BEGINNING, - idle_timeout: timedelta | None = None, -) -> StreamReader[Any]: - """Subscribe this workflow to its inbound stream ``stream``. - - Each call is a separate subscription with its own cursor, so adding, - removing or reordering one renumbers the waits after it. Gate a change - behind :func:`temporalio.workflow.patched` as you would for a timer. - - An inbound stream has no topics: its name is the whole address, and - every record on it arrives with an empty ``topic``. Topics belong to the - stream the workflow publishes, which :func:`consumer` reads from outside. - - Args: - stream: The inbound stream's name, relative to this workflow. - type: The value type, used as the decode hint. - after: Resume after this record. Only honoured on the first - subscription of a run, because after that the recorded cursor - decides. - idle_timeout: How long the workflow waits with nothing arriving before - the provider is allowed to release the worker. ``None`` takes the - provider's default. - """ - return StreamReader( - open_read(stream, after=after, idle_timeout=idle_timeout), type=type - ) - - -def writer(topic: str) -> StreamWriter[Any]: - """Publish to ``topic`` on the stream this workflow owns. - - Args: - topic: The topic name. Encoding follows each published value. - """ - return StreamWriter(open_write(topic), topic) - - -# Importing the package registers every provider whose dependencies are -# present in this tree. Import order matters: the registry above must exist -# before a provider module can register with it. -from temporalio.streams import providers as providers # noqa: E402 diff --git a/temporalio/streams/_frame.py b/temporalio/streams/_frame.py deleted file mode 100644 index df4b4dbd6..000000000 --- a/temporalio/streams/_frame.py +++ /dev/null @@ -1,72 +0,0 @@ -"""The record envelope every provider puts on the wire. - -A provider stores an opaque body. The contract needs more than a body: which -topic a record belongs to, whether it is data or a terminal marker, and which -producer attempt wrote it. No provider carries all of that in a channel of -its own, so the interface carries it here and each provider stores the result -as an ordinary body. - -Encoding is synchronous and deterministic because a workflow publishes on the -workflow thread. The header is JSON with sorted keys for that reason, and -because a stored record that a person can read is worth more during an -incident than a few saved bytes. -""" - -from __future__ import annotations - -import json -from typing import Any - -from temporalio.streams._record import RecordKind - -__all__ = ["decode", "encode"] - -_MAGIC = b"tsf1" - - -def encode( - *, - topic: str, - kind: RecordKind, - producer: str, - attempt: int, - sequence: int, - body: bytes, -) -> bytes: - """Wrap one already-encoded body with the contract's metadata.""" - header: dict[str, Any] = {"k": int(kind), "t": topic} - # Omitted rather than written as a default, so a workflow's own publish - # does not carry three fields that only mean something for a producer. - if producer: - header["p"] = producer - if attempt: - header["a"] = attempt - if sequence >= 0: - header["s"] = sequence - raw = json.dumps(header, sort_keys=True, separators=(",", ":")).encode() - return _MAGIC + len(raw).to_bytes(4, "big") + raw + body - - -def decode(frame: bytes) -> tuple[RecordKind, str, str, int, int, bytes]: - """Split a frame into ``(kind, topic, producer, attempt, sequence, body)``. - - Raises: - ValueError: The bytes are not a frame this interface wrote. Reading a - stream someone else populated is a real case, so it fails with a - name rather than an index error. - """ - if len(frame) < 8 or frame[:4] != _MAGIC: - raise ValueError( - "this record was not written through the stream interface, so its " - "topic and producer are unknown" - ) - size = int.from_bytes(frame[4:8], "big") - header = json.loads(frame[8 : 8 + size]) - return ( - RecordKind(header.get("k", int(RecordKind.DATA))), - header.get("t", ""), - header.get("p", ""), - int(header.get("a", 0)), - int(header.get("s", -1)), - frame[8 + size :], - ) diff --git a/temporalio/streams/_handles.py b/temporalio/streams/_handles.py deleted file mode 100644 index 6d821f32c..000000000 --- a/temporalio/streams/_handles.py +++ /dev/null @@ -1,211 +0,0 @@ -"""The handles workflow code holds. - -Everything provider-specific sits behind the two protocols at the top. A -handle converts values, frames records and synthesizes supersession; a -provider only moves bytes. -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from typing import Any, Generic, Protocol, TypeVar, cast - -from temporalio import workflow -from temporalio.api.common.v1 import Payload -from temporalio.streams import _frame -from temporalio.streams._policy import AttemptTracker -from temporalio.streams._record import Cursor, RecordKind, StreamRecord - -__all__ = ["ReadSource", "StreamReader", "StreamWriter", "WriteSink"] - -T = TypeVar("T") - - -class ReadSource(Protocol): - """One subscription, as a provider supplies it.""" - - async def next_batch(self) -> list[tuple[Cursor, bytes]]: - """The next records, waiting until there is at least one. - - A batch rather than a record because delivery boundaries are what a - provider actually records, and flattening them here keeps that out of - the contract. - - Raises: - StopAsyncIteration: This subscription has ended. - """ - ... - - def close(self) -> None: - """End the subscription. Idempotent.""" - ... - - -class WriteSink(Protocol): - """One topic on the stream the running workflow owns.""" - - async def publish(self, frame: bytes) -> None: - """Accept one framed record into this workflow task's output. - - Awaitable because a provider may hold the publisher back when its - batch is full. It does not mean the record is visible: it becomes - visible when the workflow task is accepted. - """ - ... - - -def _encode_value(value: Any) -> bytes: - converter = workflow.payload_converter() - payload = value if isinstance(value, Payload) else converter.to_payloads([value])[0] - return payload.SerializeToString() - - -def _decode_value(body: bytes, as_type: type | None) -> Any: - converter = workflow.payload_converter() - payload = Payload() - payload.ParseFromString(body) - if as_type is None: - return converter.from_payloads([payload])[0] - return converter.from_payloads([payload], [as_type])[0] - - -class StreamWriter(Generic[T]): - """Publishes to a topic on the stream this workflow owns. - - A workflow can only publish transactionally to a stream it owns, on - every provider. Writing to somebody else's stream is an activity's job, - and it gets the weaker guarantee that goes with doing I/O. - """ - - def __init__(self, sink: WriteSink, topic: str) -> None: - """Prefer :func:`temporalio.streams.writer`.""" - self._sink = sink - self._topic = topic - self._finished = False - - @property - def topic(self) -> str: - """The topic this writer is bound to.""" - return self._topic - - async def publish(self, value: T) -> None: - """Append ``value`` to this topic. - - Returning does not mean the record is visible. It becomes visible when - this workflow task is accepted, and never at all if the task fails, so - a reader cannot see a decision the workflow did not commit. - """ - if self._finished: - raise RuntimeError(f"topic {self._topic!r} was already finished") - await self._sink.publish( - _frame.encode( - topic=self._topic, - kind=RecordKind.DATA, - producer="", - attempt=0, - sequence=-1, - body=_encode_value(value), - ) - ) - - async def finish(self) -> None: - """Declare this topic complete. - - Says the writer has nothing more to send. It does not say the workflow - or the activity behind it succeeded, and a reader that treats it that - way will accept output from an attempt that later timed out. - """ - if self._finished: - return - self._finished = True - await self._sink.publish( - _frame.encode( - topic=self._topic, - kind=RecordKind.FINISH, - producer="", - attempt=0, - sequence=-1, - body=b"", - ) - ) - - -class StreamReader(Generic[T]): - """Reads an inbound stream from inside workflow code. - - Iterating yields every kind of record, including the supersession the - reader synthesizes when a producer's newer attempt appears. Check - ``record.kind``, or use :meth:`values` when the workflow genuinely only - wants data. - """ - - def __init__(self, source: ReadSource, *, type: type | None = None) -> None: - """Prefer :func:`temporalio.streams.reader`.""" - self._source = source - self._type = type - self._pending: list[StreamRecord[Any]] = [] - self._attempts = AttemptTracker() - self._closed = False - - def __aiter__(self) -> AsyncIterator[StreamRecord[T]]: - """Iterate records until the reader is closed.""" - return self._iterate() - - async def _iterate(self) -> AsyncIterator[StreamRecord[T]]: - while not self._closed: - try: - record = await self._next() - except StopAsyncIteration: - # The provider ended the subscription. Iteration stops rather - # than raising, so a workflow that reads to the end of a - # finished stream leaves the loop instead of failing its task. - return - yield record - - async def values(self) -> AsyncIterator[T]: - """Iterate the data values, dropping control records.""" - async for record in self: - if record.kind is RecordKind.DATA: - yield cast("T", record.value) - - async def _next(self) -> StreamRecord[T]: - while not self._pending: - await self._fill() - return self._pending.pop(0) - - async def _fill(self) -> None: - for cursor, frame in await self._source.next_batch(): - try: - kind, topic, producer, attempt, sequence, body = _frame.decode(frame) - except ValueError as error: - # Skipped rather than raised: a poisoned record would - # otherwise fail this task on every retry and pin the - # workflow, while an outside reader of the same stream - # skips it. The two readers agree on the answer. - workflow.logger.warning( - "skipping stream record at %s: %s", cursor, error - ) - continue - superseded = self._attempts.note(producer, attempt, cursor) - if superseded is not None: - self._pending.append(superseded) - self._pending.append( - StreamRecord( - value=( - _decode_value(body, self._type) - if kind is RecordKind.DATA - else None - ), - cursor=cursor, - kind=kind, - topic=topic, - producer=producer, - attempt=attempt, - sequence=sequence, - ) - ) - - def close(self) -> None: - """End the subscription. Idempotent.""" - self._closed = True - self._source.close() diff --git a/temporalio/streams/_ids.py b/temporalio/streams/_ids.py index 5f4161053..2a76dcf19 100644 --- a/temporalio/streams/_ids.py +++ b/temporalio/streams/_ids.py @@ -1,14 +1,14 @@ -"""The store key a provider derives from a workflow id and a stream name. +"""The store key a provider derives from a workflow id and a topic. A workflow id may contain any character, ``:`` included, so joining the pair with a bare ``:`` is ambiguous: ``("a:b", "c")`` and ``("a", "b:c")`` would land in one store. Every provider that keys a store by the pair goes through -:func:`inbound_stream_id`, so they all agree and none of them collides. +:func:`topic_key`, so they all agree and none of them collides. """ from __future__ import annotations -__all__ = ["inbound_stream_id"] +__all__ = ["topic_key"] def _escape(component: str) -> str: @@ -17,15 +17,10 @@ def _escape(component: str) -> str: return component.replace("%", "%25").replace(":", "%3A") -def inbound_stream_id(workflow_id: str, stream: str) -> str: - """The store id for inbound ``stream`` of ``workflow_id``. - - With an empty ``stream`` it is the id of the stream the workflow itself - publishes, so one function keys both. +def topic_key(workflow_id: str, topic: str) -> str: + """The store key for ``topic`` of ``workflow_id``'s stream. Both components are percent-encoded before joining, so the only bare ``:`` - in the result is the separator, and an owner-stream key has none. + in the result is the separator. """ - if not stream: - return _escape(workflow_id) - return f"{_escape(workflow_id)}:{_escape(stream)}" + return f"{_escape(workflow_id)}:{_escape(topic)}" diff --git a/temporalio/streams/_policy.py b/temporalio/streams/_policy.py index 73114bbdb..81949e4fc 100644 --- a/temporalio/streams/_policy.py +++ b/temporalio/streams/_policy.py @@ -12,6 +12,8 @@ from __future__ import annotations +from typing import Any + from temporalio.streams._record import ( Cursor, RecordKind, @@ -30,28 +32,35 @@ def __init__(self) -> None: self._attempts: dict[str, int] = {} def note( - self, producer: str, attempt: int, cursor: Cursor - ) -> StreamRecord[Supersession] | None: + self, producer_id: str, attempt: int, *, topic: str, previous: Cursor + ) -> StreamRecord[Any] | None: """A supersession record when this record starts a newer attempt. + ``previous`` is the cursor of the last record delivered before the one + being noted, or the cursor the read started from. The synthesized + record carries it, so a consumer that checkpoints the supersession and + resumes after it is handed the new attempt's first record next rather + than skipping it. + A producer that declares no attempt supersedes nothing, because there is no generation to compare. That is the same answer as an unnumbered record: the interface reports what it was told and invents nothing. """ - if not producer or attempt <= 0: + if not producer_id or attempt <= 0: return None - previous = self._attempts.get(producer, 0) - if attempt <= previous: + seen = self._attempts.get(producer_id, 0) + if attempt <= seen: return None - self._attempts[producer] = attempt - if previous == 0: + self._attempts[producer_id] = attempt + if seen == 0: return None return StreamRecord( - value=Supersession( - producer=producer, previous_attempt=previous, attempt=attempt - ), - cursor=cursor, kind=RecordKind.SUPERSEDED, - producer=producer, + cursor=previous, + topic=topic, + producer_id=producer_id, attempt=attempt, + supersession=Supersession( + producer_id=producer_id, previous_attempt=seen, attempt=attempt + ), ) diff --git a/temporalio/streams/_provider.py b/temporalio/streams/_provider.py index 89e32a2c7..6992f0a14 100644 --- a/temporalio/streams/_provider.py +++ b/temporalio/streams/_provider.py @@ -1,418 +1,253 @@ -"""The provider registry: where a process names its stream provider. - -A provider only moves bytes. The handles, records, framing and supersession -around it are shared, so every provider module is small: it implements -:class:`StreamProvider` and registers a factory under a short name when it is -imported. One :func:`configure` call decides which factory serves this -process; :func:`worker_options` makes the same choice for a worker when -exactly one provider is registered. +"""What a provider implements, in two halves. + +:class:`WorkflowStreamProvider` runs on the workflow thread and must keep the +contract's first two rules: publishes commit with the Workflow Task, and reads +are recorded observations. Nothing it needs may do I/O. :class:`StreamProvider` +is the half a process holds: it makes the workflow half for a worker and hands +out :class:`StreamHandle` objects to code outside a workflow. A Python provider +usually implements both on one class; the split is what lets a language whose +workflow code is bundled separately name the two halves in two packages. + +A provider only moves ``temporal.api.stream.v1.StreamRecord`` protos. The +handles around it convert values, synthesize supersession and mint cursors. """ from __future__ import annotations from collections.abc import AsyncGenerator -from datetime import timedelta -from typing import Any, Callable, Protocol, TypeVar, overload +from typing import TYPE_CHECKING, Any, Protocol, TypeVar, overload -from temporalio import activity -from temporalio.streams._handles import ReadSource, WriteSink +from temporalio.api.stream.v1 import StreamRecord as WireRecord from temporalio.streams._record import BEGINNING, Cursor, StreamRecord +if TYPE_CHECKING: + from temporalio.client import Client + __all__ = [ - "Consumer", - "Producer", + "ReadSource", + "StreamHandle", + "StreamProducer", "StreamProvider", - "StreamProviderLifecycle", - "check_topic", - "close", - "configure", - "consumer", - "drain", - "instance", - "open_read", - "open_write", - "prepare", - "producer", - "register", - "registered", - "worker_options", + "WorkflowStreamProvider", + "WriteSink", ] T = TypeVar("T") -class Producer(Protocol): - """Appends to a stream from outside workflow code. +class StreamProducer(Protocol): + """Appends to one topic from outside workflow code. - Every append is visible as soon as the provider accepts it, and carries - the producer id, attempt and sequence that let a reader tell a retried - append from a new generation. + Every append is visible as soon as the store accepts it, and carries the + producer id, attempt and sequence that let a reader tell a retried append + from a new generation. """ + @property + def producer_id(self) -> str: + """Who this producer writes as.""" + ... + @property def attempt(self) -> int: - """The generation this producer is writing.""" + """The generation this producer is writing, or 0 when undeclared.""" ... async def append(self, *values: Any) -> Cursor | None: - """Append values and return the cursor of the last one written. - - The last rather than the first, so ``read(after=appended)`` yields - only what came later. ``None`` when nothing was written, because the - batch was empty or a repeat the provider dropped, and on a transport - that learns positions only at read time; a caller that needs to - position itself on such a transport asks :meth:`Consumer.latest`. + """Append ``values`` and return the cursor of the last record as the store holds it. + + A repeat of an earlier append (same producer, attempt and sequence) is + written once and returns the position the original landed at. An + empty call writes nothing and returns the same value a repeat would: + the position of this producer's last record, or ``BEGINNING`` when it + has written none. ``None`` means one thing only: this provider learns + positions at read time, and a caller that needs one positions itself + with :meth:`StreamHandle.latest`. + + Raises: + StreamProducerError: The attempt or sequence conflicts with what + the store holds. """ ... async def finish(self) -> None: - """Declare this producer's output complete.""" + """Write ``FINISH`` for this producer on this topic. + + Says this producer has nothing more to send. It does not say the + activity behind it succeeded, and it does not end anyone's read. + """ ... -class Consumer(Protocol): - """Reads a stream from outside workflow code, resumably.""" +class StreamHandle(Protocol): + """One workflow's stream, addressed by topic, from outside workflow code. + + A handle follows the workflow's execution chain unless it was opened with + a ``run_id``, in which case it is pinned to that run. A transport failure + surfaces as :class:`temporalio.service.RPCError`, never as the transport's + own exception type. + """ @overload def read( - self, - *, - after: Cursor = ..., - topic: str | None = ..., - type: type[T], + self, *, topic: str, after: Cursor = ..., result_type: type[T] ) -> AsyncGenerator[StreamRecord[T], None]: ... @overload def read( - self, - *, - after: Cursor = ..., - topic: str | None = ..., - type: None = None, + self, *, topic: str, after: Cursor = ..., result_type: None = None ) -> AsyncGenerator[StreamRecord[Any], None]: ... def read( self, *, + topic: str, after: Cursor = BEGINNING, - topic: str | None = None, - type: type | None = None, + result_type: type | None = None, ) -> AsyncGenerator[StreamRecord[Any], None]: - """Yield the records after ``after`` as they arrive. - - ``BEGINNING`` yields everything the stream retains. Any other cursor - came from a record this or another reader saw, and reading resumes - just past it, so a reader that stores the last cursor it handled and - hands it back sees every record exactly once. ``topic`` filters the - stream the workflow publishes; an inbound stream has no topics, and - naming one there raises ``ValueError``. The result is a generator so - a caller that stops early can ``aclose()`` it and release whatever - the provider parked against the store. + """Yield the records on ``topic`` after ``after`` as they arrive. + + ``BEGINNING`` yields everything the topic retains. Any other cursor + came from a record a reader saw, and reading resumes just past it, so + a reader that stores the last cursor it handled and hands it back + sees every record exactly once. The read ends when the owning + execution, or its chain, is closed and every retained record after + ``after`` has been delivered; until then it waits. The result is a + generator, so a caller that stops early can ``aclose()`` it and + release whatever the provider parked against the store. + + Raises: + StreamCursorError: ``after`` came from another provider or names + a record no longer retained. Raised by this call, not by the + first iteration. + StreamNotFoundError: The workflow or topic does not exist or is + past retention. """ ... - async def latest(self, *, topic: str | None = None) -> Cursor: - """The cursor of the newest record, or ``BEGINNING`` when there is none. + async def latest(self, *, topic: str) -> Cursor: + """The cursor of the newest record on ``topic``, or ``BEGINNING`` when empty. For a reader that wants to follow from now: ``read(after=latest())`` yields only what is published after this call returned, which is how a client that is about to send a message positions itself before - sending, without the workflow having to report a position. ``topic`` - is for the provider that keeps each topic in its own store. + sending, without the workflow having to report a position. """ ... + def producer( + self, *, topic: str, producer_id: str = "", attempt: int = 0 + ) -> StreamProducer: + """A producer on ``topic``. -class StreamProvider(Protocol): - """What a provider module implements. + Inside an activity, leave ``producer_id`` and ``attempt`` unset: the + activity's own id and attempt are the right answer, and they are what + let a reader tell a retry from a new generation. Outside one, + ``producer_id`` is required and an empty one raises ``ValueError``. + """ + ... - The workflow-side pair (:meth:`open_read`, :meth:`open_write`) runs inside - workflow code and must keep the contract's first two rules: publishes - commit with the workflow task, and reads are recorded observations. The - outside pair (:meth:`producer`, :meth:`consumer`) runs anywhere and moves - framed bytes. A transport-only provider may serve just the outside pair - and raise on the workflow side, naming the provider a worker should use. - A provider whose transport parks something against the running workflow - also implements :class:`StreamProviderLifecycle`. - """ +class ReadSource(Protocol): + """One subscription, as a provider supplies it to the workflow thread.""" - name: str + async def next_batch(self) -> list[tuple[Cursor, WireRecord]]: + """The next records with their positions, waiting until there is at least one. - def configure(self, **options: Any) -> None: - """Accept this provider's process-level options.""" - ... + A batch rather than a record because delivery boundaries are what a + provider actually records, and flattening them here keeps that out of + the contract. A record that cannot be parsed into a ``StreamRecord`` + proto is the provider's to skip. - def worker_options(self) -> dict[str, Any]: - """What a ``Worker`` or ``Replayer`` needs to serve this provider.""" + Raises: + StopAsyncIteration: This subscription has ended. + """ ... - def open_read( - self, - stream: str, - *, - after: Cursor = BEGINNING, - idle_timeout: timedelta | None = None, - ) -> ReadSource: - """Subscribe the running workflow to its inbound stream ``stream``.""" + def close(self) -> None: + """End the subscription. Idempotent.""" ... - def open_write(self, topic: str) -> WriteSink: - """Bind ``topic`` on the stream the running workflow owns.""" - ... - async def producer( - self, - client: Any, - *, - workflow_id: str, - stream: str = "", - topic: str = "", - producer_id: str = "", - attempt: int = 0, - ) -> Producer: - """Open a producer that appends on ``workflow_id``'s account. - - ``stream`` names one of the workflow's inbound streams. With no - ``stream``, ``topic`` names a topic on the stream the workflow itself - publishes, which is how an activity puts its live output next to the - workflow's own records for the same outside reader. ``producer_id`` - is never empty here: the package resolved it, from the activity - context when the caller left it unset. - """ - ... +class WriteSink(Protocol): + """One topic of the running workflow's stream, as a provider binds it.""" + + def publish(self, record: WireRecord) -> None: + """Take one record into this Workflow Task's output. - async def consumer( - self, client: Any, *, workflow_id: str, stream: str = "" - ) -> Consumer: - """Open a reader on ``workflow_id``'s own stream, or its inbound ``stream``.""" + Synchronous: there is nothing to wait for inside a task, because the + task is the visibility boundary. The provider commits what it buffered + when the task completes and drops it when the task fails. A record + the provider cannot stage raises :class:`temporalio.streams.StreamError` + and fails the task, loudly. + """ ... -class StreamProviderLifecycle(Protocol): - """The hooks a provider adds when it needs the workflow's own lifetime. +class WorkflowStreamProvider(Protocol): + """The half of a provider that runs on the workflow thread. - Separate from :class:`StreamProvider` because most transports need none - of them, and a provider is not asked to carry empty methods to say so. - :func:`prepare`, :func:`drain` and :func:`close` each call the hook when - the provider defines it. + Imports nothing that does I/O. The worker creates one per workflow + instance through :meth:`StreamProvider.workflow_provider`, so state kept + here dies with the instance the way handlers do. """ - def prepare(self) -> None: - """Install whatever this provider needs before the workflow runs. + def open_reader(self, topic: str, *, after: Cursor) -> ReadSource: + """Subscribe the running workflow to ``topic`` of its own stream. - A provider that serves outside readers through handlers on the - workflow itself has to register them before the first task completes, - or a reader that arrives early finds nothing to talk to. + Raises: + StreamCursorError: ``after`` was minted by another provider. """ ... - def drain(self) -> None: - """Release anything this provider parked on the workflow's behalf. - - A provider that parks an outside reader against the running workflow, - as the Workflow Streams transport does with its long-poll update, has - to let go before the workflow can return. - """ + def open_writer(self, topic: str) -> WriteSink: + """Bind ``topic`` of the running workflow's stream for publishing.""" ... - async def close(self) -> None: - """Release what this provider holds for the process. + def on_workflow_start(self) -> None: + """Called before the workflow function runs. - A provider that keeps a connection pool or an HTTP session open needs - a moment where the process says it is done; this is it. + A provider that serves outside readers through handlers on the + workflow registers them here, before the first task completes. """ ... + async def on_workflow_finish(self) -> None: + """Called after the workflow function returns, raises or continues as new. -_factories: dict[str, Callable[[], StreamProvider]] = {} -_active: StreamProvider | None = None - - -def register(name: str, factory: Callable[[], StreamProvider]) -> None: - """Make ``factory`` selectable as ``configure(provider=name)``.""" - _factories[name] = factory - - -def registered() -> list[str]: - """The provider names this process can configure.""" - return sorted(_factories) - - -def _make(name: str | None) -> StreamProvider: - if name is None: - if len(_factories) == 1: - name = next(iter(_factories)) - else: - raise RuntimeError( - f"name a provider: configure(provider=...) with one of {registered()}" - ) - factory = _factories.get(name) - if factory is None: - raise RuntimeError( - f"no stream provider {name!r} is registered; available: {registered()}" - ) - return factory() - - -def configure(provider: str | None = None, **options: Any) -> None: - """Name the provider for this process and hand it its options. - - ``provider`` may be omitted when exactly one provider is registered. - """ - global _active - _active = instance(provider, **options) - - -def instance(provider: str | None = None, **options: Any) -> StreamProvider: - """A configured provider that is not installed as the process default. - - For code that serves one provider while the process is configured with - another, such as a Nexus stream handler delegating to its store. - """ - chosen = _make(provider) - chosen.configure(**options) - return chosen - - -def _current() -> StreamProvider: - if _active is None: - # Building a provider is process setup. Doing it on the first - # workflow's thread would race a second workflow thread for the - # global and hide the choice inside workflow code. - raise RuntimeError( - "no stream provider is configured; call streams.configure() or " - "streams.worker_options() before opening a stream" - ) - return _active - - -def worker_options() -> dict[str, Any]: - """What a ``Worker`` or ``Replayer`` needs to serve this provider. - - Every worker calls this, so it is where the default is resolved when - :func:`configure` was not called and exactly one provider is registered. - """ - if _active is None: - configure() - return _current().worker_options() - - -def open_read( - stream: str, - *, - after: Cursor = BEGINNING, - idle_timeout: timedelta | None = None, -) -> ReadSource: - """Subscribe the running workflow to its inbound stream ``stream``.""" - return _current().open_read(stream, after=after, idle_timeout=idle_timeout) - - -def open_write(topic: str) -> WriteSink: - """Bind ``topic`` on the stream the running workflow owns.""" - return _current().open_write(topic) - - -def prepare() -> None: - """Let the provider install what it needs, before the workflow runs. - - Call it from the workflow's constructor. It is a no-op on providers that - need nothing, so workflow code can call it unconditionally and stay - portable. - """ - provider = _current() - install = getattr(provider, "prepare", None) - if install is not None: - install() + A provider that parked an outside reader against the run lets go + here, so the workflow can close. + """ + ... -def drain() -> None: - """Release anything the provider parked on this workflow's behalf. +class StreamProvider(Protocol): + """What a store ships. Also a :class:`temporalio.worker.Plugin` when it serves workers. - Call it before a workflow that read a stream returns. It is a no-op on - providers that park nothing, so workflow code can call it unconditionally - and stay portable. + Construct one, pass it to ``Worker(plugins=[provider])`` and + ``Replayer(plugins=[provider])``, and open handles from it anywhere else. + Nothing is global: two workers in one process may hold two providers. """ - provider = _current() - release = getattr(provider, "drain", None) - if release is not None: - release() - -async def close() -> None: - """Release what the provider holds for this process. + def workflow_provider(self) -> WorkflowStreamProvider: + """The half that serves one workflow instance on its thread.""" + ... - Await it when the process is done with streams. It is a no-op on - providers that hold nothing, and on a process that never configured one, - so a shutdown path can call it unconditionally. - """ - if _active is None: - return - release = getattr(_active, "close", None) - if release is not None: - await release() + def get_stream_handle( + self, client: Client, workflow_id: str, *, run_id: str | None = None + ) -> StreamHandle: + """A handle on ``workflow_id``'s stream. + Without ``run_id`` it follows the execution chain, so a consumer keeps + reading across continue-as-new; with one it is pinned to that run. + """ + ... -def check_topic(stream: str, topic: str | None) -> None: - """Reject a topic filter on an inbound stream. + async def close(self) -> None: + """Release what this provider holds for the process. - An inbound record carries no topic; the stream's name is its whole - address, so a filter there could only ever match nothing. Every provider - calls this at the top of its ``read`` so the answer is the same on all - of them. - """ - if stream and topic is not None: - raise ValueError( - f"inbound stream {stream!r} has no topics; topic= applies to the " - "stream the workflow publishes" - ) - - -async def producer( - client: Any, - *, - workflow_id: str, - stream: str = "", - topic: str = "", - producer_id: str = "", - attempt: int = 0, -) -> Producer: - """Open a producer that appends on ``workflow_id``'s account. - - Name either an inbound ``stream`` of the workflow, or a ``topic`` on the - stream the workflow publishes. Inside an activity, leave ``producer_id`` - and ``attempt`` unset: the activity's own id and attempt are the right - answer, and they are what let a reader tell a retry from a new generation. - """ - if bool(stream) == bool(topic): - raise ValueError( - "name exactly one of stream (an inbound stream of the workflow) or " - "topic (a topic on the stream the workflow publishes)" - ) - if not producer_id: - if not activity.in_activity(): - raise ValueError( - "producer_id is required outside an activity; inside one it " - "defaults to the activity's id and attempt" - ) - info = activity.info() - producer_id = info.activity_id - attempt = attempt or info.attempt - return await _current().producer( - client, - workflow_id=workflow_id, - stream=stream, - topic=topic, - producer_id=producer_id, - attempt=attempt, - ) - - -async def consumer(client: Any, *, workflow_id: str, stream: str = "") -> Consumer: - """Open a reader on ``workflow_id``'s own stream, or its inbound ``stream``. - - With no ``stream`` the reader follows what the workflow publishes, topic - by topic. With one, it follows the inbound stream of that name, which is - how a process watches what producers hand the workflow. - """ - return await _current().consumer(client, workflow_id=workflow_id, stream=stream) + A provider that keeps a connection pool or an HTTP session open needs + a moment where the process says it is done; this is it. A provider + that holds nothing returns at once. + """ + ... diff --git a/temporalio/streams/_record.py b/temporalio/streams/_record.py index 1f08302a8..fef63e51c 100644 --- a/temporalio/streams/_record.py +++ b/temporalio/streams/_record.py @@ -23,25 +23,38 @@ @enum.unique class RecordKind(enum.IntEnum): - """What a record is.""" + """What a record is. + + Mirrors ``temporal.api.stream.v1.StreamRecordKind`` value for value, so a + record's kind crosses the wire as the integer the proto holds. + """ + + UNSPECIFIED = 0 + """The proto's zero value. + + A stored record whose writer set no kind is read as :attr:`DATA`, as the + proto defines it, so a reader never sees this kind on a record. + """ DATA = 1 """Carries a value published by a workflow or a producer.""" FINISH = 2 - """The writer of this topic declared it complete. + """The producer named in ``producer_id`` will write nothing more on this topic. - Separate from the activity or workflow that wrote it having succeeded. A - reader that treats it as proof of success is wrong: the activity can still - time out after writing it. + An empty ``producer_id`` names the owning workflow. It does not end a + read, which ends when the owning execution or its chain is closed and the + retained tail has been delivered, and it says nothing about the producer's + outcome: an activity can still time out after writing it. """ SUPERSEDED = 3 """A later attempt of the same producer started writing. - Synthesized by the reader from what it observed, so every provider - delivers it identically and replay reproduces it without the provider's - help. + Synthesized by the reader from what it observed, never stored, so every + provider delivers it identically and replay reproduces it without the + provider's help. Its cursor is the position before the new attempt's + first record, so resuming after it delivers that record next. """ @@ -52,7 +65,9 @@ class Cursor: Opaque on purpose. One provider numbers records with integers and another with a millisecond-and-sequence pair, so comparing tokens here would be right for one and wrong for the other. Hand a cursor back to resume after - the record it names; nothing here advances one. + the record it names; nothing here advances one. The token starts with the + name of the provider that minted it, and a provider refuses a token from + another with :class:`temporalio.streams.StreamCursorError`. """ token: str @@ -68,30 +83,33 @@ def __str__(self) -> str: @dataclass(frozen=True) class Supersession: - """The body of a :attr:`RecordKind.SUPERSEDED` record.""" + """What a :attr:`RecordKind.SUPERSEDED` record reports.""" - producer: str + producer_id: str previous_attempt: int attempt: int @dataclass(frozen=True) class StreamRecord(Generic[T]): - """One record as workflow code sees it. + """One record as a reader sees it. - ``value`` follows ``kind``: a data record carries a ``T``, a supersession - carries the :class:`Supersession` it reports, and a finish marker carries - ``None``. Check ``kind`` before reading it. + ``value`` is set on a :attr:`RecordKind.DATA` record and ``supersession`` + on a :attr:`RecordKind.SUPERSEDED` one; every other kind carries neither. + Each field means one thing, so a consumer narrows on ``kind`` and reads + the field that kind promises. """ - value: T | Supersession | None + kind: RecordKind cursor: Cursor - kind: RecordKind = RecordKind.DATA - topic: str = "" - """The topic on the owner's stream, or empty for an inbound record.""" - producer: str = "" + topic: str + producer_id: str = "" """Who wrote it, or empty when the owning workflow wrote it itself.""" attempt: int = 0 """The producer's attempt, or 0 when it did not declare one.""" sequence: int = -1 """The producer's position within its attempt, or -1 when unnumbered.""" + value: T | None = None + """The published value. Set on ``DATA`` only.""" + supersession: Supersession | None = None + """The attempt change being reported. Set on ``SUPERSEDED`` only.""" diff --git a/temporalio/streams/_wire.py b/temporalio/streams/_wire.py new file mode 100644 index 000000000..52299f44e --- /dev/null +++ b/temporalio/streams/_wire.py @@ -0,0 +1,181 @@ +"""How a record crosses a provider: the proto is the record. + +``temporal.api.stream.v1.StreamRecord`` is the wire format on every provider. +A store that keeps bytes keeps ``SerializeToString()`` of it, the native +server stores the proto it is handed, and a reader in any language parses the +same bytes. ``body`` is the user's payload, produced and consumed through the +payload converter, so a codec applies to it like any other payload and a +pre-encoded :class:`temporalio.common.RawValue` passes through untouched. + +Cursors are self-describing: a token starts with the name of the provider that +minted it, so a provider can refuse a foreign one at the call rather than +misreading it deep in a generator. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import temporalio.converter +from temporalio.api.stream.v1 import StreamRecord as WireRecord +from temporalio.api.stream.v1 import StreamRecordKind +from temporalio.streams._errors import StreamCursorError +from temporalio.streams._policy import AttemptTracker +from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord + +__all__ = [ + "RecordDecoder", + "WireRecord", + "cursor_position", + "from_wire", + "mint_cursor", + "producer_identity", + "to_wire", +] + + +def to_wire( + converter: temporalio.converter.PayloadConverter, + *, + topic: str, + kind: RecordKind, + value: Any = None, + producer_id: str = "", + attempt: int = 0, + sequence: int = -1, +) -> WireRecord: + """Build the record a provider stores or ships. + + Only a ``DATA`` record carries a body; the converter encodes ``value`` + into it, which is where a pre-encoded ``RawValue`` passes through. + """ + record = WireRecord( + topic=topic, + kind=StreamRecordKind.ValueType(int(kind)), + producer_id=producer_id, + attempt=attempt, + sequence=sequence, + ) + if kind is RecordKind.DATA: + record.body.CopyFrom(converter.to_payloads([value])[0]) + return record + + +def from_wire( + converter: temporalio.converter.PayloadConverter, + cursor: Cursor, + wire: WireRecord, + result_type: type | None, +) -> StreamRecord[Any]: + """Turn a stored record into the record a reader yields. + + Raises: + ValueError: The kind is one no store may hold, such as a synthesized + ``SUPERSEDED`` or a value this SDK does not know. + """ + kind = RecordKind(wire.kind) + if kind is RecordKind.SUPERSEDED: + raise ValueError("a SUPERSEDED record is synthesized by readers, never stored") + if kind is RecordKind.UNSPECIFIED: + # The proto defines an unset kind as DATA, so every reader agrees. + kind = RecordKind.DATA + value: Any = None + if kind is RecordKind.DATA and wire.HasField("body"): + hints = [result_type] if result_type is not None else None + value = converter.from_payloads([wire.body], hints)[0] + return StreamRecord( + kind=kind, + cursor=cursor, + topic=wire.topic, + producer_id=wire.producer_id, + attempt=wire.attempt, + sequence=wire.sequence, + value=value, + ) + + +class RecordDecoder: + """Turns the records a provider hands over into the records a reader yields. + + One per read. It synthesizes supersession from the attempts it observes, + positions each synthesized record at the cursor before the record that + triggered it, and skips a record it cannot decode with a warning rather + than raising, so a poisoned record cannot pin a workflow on every retry + while an outside reader of the same stream moves past it. + """ + + def __init__( + self, + converter: temporalio.converter.PayloadConverter, + result_type: type | None, + *, + after: Cursor, + warn: Callable[[str], None], + ) -> None: + """Decode with ``converter`` into ``result_type``, resuming after ``after``.""" + self._converter = converter + self._result_type = result_type + self._previous = after + self._warn = warn + self._attempts = AttemptTracker() + + def decode(self, cursor: Cursor, wire: WireRecord) -> list[StreamRecord[Any]]: + """The records to yield for one stored record, in order.""" + try: + record = from_wire(self._converter, cursor, wire, self._result_type) + except Exception as error: + self._warn(f"skipping stream record at {cursor}: {error}") + # The skipped record still holds its position, so a resume after + # it moves on rather than tripping over it again. + self._previous = cursor + return [] + out: list[StreamRecord[Any]] = [] + superseded = self._attempts.note( + wire.producer_id, wire.attempt, topic=wire.topic, previous=self._previous + ) + if superseded is not None: + out.append(superseded) + out.append(record) + self._previous = cursor + return out + + +def mint_cursor(provider: str, position: str) -> Cursor: + """A cursor that names ``position`` and the provider that understands it.""" + return Cursor(f"{provider}:{position}") + + +def cursor_position(cursor: Cursor, *, provider: str) -> str | None: + """The position inside a cursor ``provider`` minted, or ``None`` for BEGINNING. + + Raises: + StreamCursorError: The cursor came from another provider. + """ + if cursor == BEGINNING: + return None + prefix = f"{provider}:" + if not cursor.token.startswith(prefix): + raise StreamCursorError( + f"cursor {cursor.token!r} was not minted by the {provider} stream provider" + ) + return cursor.token[len(prefix) :] + + +def producer_identity(producer_id: str, attempt: int) -> tuple[str, int]: + """Resolve who a producer is, defaulting to the running activity. + + Imported lazily so the module workflow code imports carries no activity + machinery; the default only means anything inside an activity anyway. + """ + if producer_id: + return producer_id, attempt + import temporalio.activity + + if not temporalio.activity.in_activity(): + raise ValueError( + "producer_id is required outside an activity; inside one it defaults " + "to the activity's id and attempt" + ) + info = temporalio.activity.info() + return info.activity_id, attempt or info.attempt diff --git a/temporalio/streams/providers/__init__.py b/temporalio/streams/providers/__init__.py index c254db0fb..911620e6c 100644 --- a/temporalio/streams/providers/__init__.py +++ b/temporalio/streams/providers/__init__.py @@ -1,25 +1,66 @@ """Stream providers, one module each. -Importing this package registers every provider whose dependencies are -present. A tree that lacks a provider's dependencies simply does not offer -that name; nothing else changes, because everything above the provider is -shared. +A provider that serves workers is a :class:`temporalio.worker.Plugin`. +:class:`ProviderPlugin` is the plugin half the providers in this tree share: +it hands the provider to the worker and the replayer as their +``stream_provider`` and leaves their execution alone. A provider that holds +connections closes them through its own ``close()``, not with the worker, +because the same provider serves handles outside any worker. """ from __future__ import annotations -_KNOWN = ("memory", "workflow_streams", "native", "redis", "nexus") - -for _name in _KNOWN: - try: - __import__(f"{__name__}.{_name}") - except ModuleNotFoundError as error: - # Two things may be missing: the provider module itself, or a - # dependency from outside this package. A name missing inside - # temporalio is a broken provider, and hiding that would turn its - # traceback into "no such provider". - missing = error.name or "" - if missing != f"{__name__}.{_name}" and ( - not missing or missing.startswith("temporalio") - ): - raise +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import AbstractAsyncContextManager + +import temporalio.worker +from temporalio.client import WorkflowHistory +from temporalio.streams._provider import StreamProvider +from temporalio.worker import ( + Replayer, + ReplayerConfig, + Worker, + WorkerConfig, + WorkflowReplayResult, +) + +__all__ = ["ProviderPlugin"] + + +class ProviderPlugin(StreamProvider, temporalio.worker.Plugin): + """The worker plugin every provider in this tree is built on. + + Subclasses implement :class:`temporalio.streams.StreamProvider`; this + class supplies the four plugin hooks, so ``Worker(plugins=[provider])`` + and ``Replayer(plugins=[provider])`` reach the provider through their + ``stream_provider`` option and the worker installs the interceptor that + calls the workflow half's lifecycle hooks. + """ + + def configure_worker(self, config: WorkerConfig) -> WorkerConfig: + """Set this provider as the worker's ``stream_provider``.""" + config["stream_provider"] = self + return config + + def configure_replayer(self, config: ReplayerConfig) -> ReplayerConfig: + """Set this provider as the replayer's ``stream_provider``.""" + config["stream_provider"] = self + return config + + async def run_worker( + self, worker: Worker, next: Callable[[Worker], Awaitable[None]] + ) -> None: + """Run the worker unchanged.""" + await next(worker) + + def run_replayer( + self, + replayer: Replayer, + histories: AsyncIterator[WorkflowHistory], + next: Callable[ + [Replayer, AsyncIterator[WorkflowHistory]], + AbstractAsyncContextManager[AsyncIterator[WorkflowReplayResult]], + ], + ) -> AbstractAsyncContextManager[AsyncIterator[WorkflowReplayResult]]: + """Run the replayer unchanged.""" + return next(replayer, histories) diff --git a/temporalio/streams/providers/memory.py b/temporalio/streams/providers/memory.py index 4a3cf3298..2a6087b2d 100644 --- a/temporalio/streams/providers/memory.py +++ b/temporalio/streams/providers/memory.py @@ -1,17 +1,25 @@ """The in-process reference provider. Exists so the conformance suite can exercise the whole surface without a -store, and to document in one file what a provider owes. Two honest limits, -both stated so nobody mistakes this for evidence: +store, and to document in one file what a provider owes. Its limits, stated +so nobody mistakes it for evidence: - It is not replay-safe. Workflow-side state lives in plain process memory, so run it with a warm workflow cache and do not use it to demonstrate recovery. - A workflow's publish becomes visible at ``publish`` time rather than at - task acceptance, so it only approximates rule 1 of the contract. - -The outside surface (producer, consumer, dedupe, supersession, cursors) is -faithful, which is what the conformance tests lean on. + task acceptance, and a failed task's records stay, so it only approximates + rule 1 of the contract. +- Topics are keyed by workflow id rather than by run, so a successor run's + reader from ``BEGINNING`` sees the chain's records. A ``run_id`` on a + handle only decides which run's close ends a read. +- It learns that a workflow closed by describing it, so a handle opened + without a client reads until the caller closes it. + +The outside surface (producer identity, retry deduplication, positions, +supersession, cursors) is faithful, which is what the conformance tests lean +on. One list per topic; a topic is written by the workflow and by outside +producers alike and read from either side. """ from __future__ import annotations @@ -22,16 +30,29 @@ from datetime import timedelta from typing import Any +from google.protobuf.message import DecodeError + import temporalio.converter from temporalio import workflow -from temporalio.api.common.v1 import Payload -from temporalio.streams import _frame, _provider -from temporalio.streams._handles import ReadSource, WriteSink -from temporalio.streams._ids import inbound_stream_id -from temporalio.streams._policy import AttemptTracker +from temporalio.client import Client, WorkflowExecutionStatus +from temporalio.service import RPCError, RPCStatusCode +from temporalio.streams._errors import StreamCursorError +from temporalio.streams._ids import topic_key +from temporalio.streams._provider import ReadSource, WriteSink from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord +from temporalio.streams._wire import ( + RecordDecoder, + WireRecord, + cursor_position, + mint_cursor, + producer_identity, + to_wire, +) +from temporalio.streams.providers import ProviderPlugin + +__all__ = ["MemoryProducer", "MemoryStreamHandle", "MemoryStreams"] -_DEFAULT_POLL = timedelta(milliseconds=100) +_PROVIDER = "memory" logger = logging.getLogger(__name__) @@ -41,12 +62,15 @@ def _wake(future: asyncio.Future[None]) -> None: future.set_result(None) -class _MemoryStream: +class _Topic: + """One topic's records, and the waiters parked on its tail.""" + def __init__(self) -> None: - self.frames: list[bytes] = [] + self.records: list[bytes] = [] # Dedupe identity is (producer#attempt, first sequence of the append), - # the same pair the storage providers use. - self.seen: dict[tuple[str, int], int] = {} + # the same pair the storage providers use, mapped to where the batch + # landed so a repeat can answer with the original position. + self.seen: dict[tuple[str, int], tuple[int, int]] = {} # Each waiter is parked with the loop it belongs to. A workflow's # publish runs on the workflow thread, and waking a foreign loop's # future from there needs call_soon_threadsafe or the loop stays @@ -54,47 +78,50 @@ def __init__(self) -> None: self._waiters: list[tuple[asyncio.AbstractEventLoop, asyncio.Future[None]]] = [] def append( - self, frames: list[bytes], *, producer_id: str, sequence: int - ) -> int | None: - """Store ``frames`` and return the first one's offset, or ``None`` for a repeat.""" - key = (producer_id, sequence) - if key in self.seen: - return None - offset = len(self.frames) - self.frames.extend(frames) - self.seen[key] = offset + self, + wires: list[WireRecord], + *, + writer: str | None = None, + sequence: int = 0, + ) -> tuple[int, int]: + """Store ``wires`` and return where they landed as ``(first offset, count)``. + + With a ``writer``, a repeat of ``(writer, sequence)`` stores nothing + and returns where the original landed. + """ + key = (writer or "", sequence) + if writer is not None and key in self.seen: + return self.seen[key] + first = len(self.records) + self.records.extend(wire.SerializeToString() for wire in wires) + if writer is not None: + self.seen[key] = (first, len(wires)) waiters, self._waiters = self._waiters, [] for loop, future in waiters: loop.call_soon_threadsafe(_wake, future) - return offset - - async def wait_past(self, offset: int) -> None: - while len(self.frames) <= offset: - loop = asyncio.get_running_loop() - future: asyncio.Future[None] = loop.create_future() - self._waiters.append((loop, future)) - await future - - -_streams: dict[str, _MemoryStream] = {} - - -def _stream(stream_id: str) -> _MemoryStream: - found = _streams.get(stream_id) - if found is None: - found = _streams[stream_id] = _MemoryStream() - return found - - -def reset() -> None: - """Drop every stream. For tests.""" - _streams.clear() - - -def _converter(client: Any) -> Any: - if client is None: - return temporalio.converter.DataConverter.default.payload_converter - return client.data_converter.payload_converter + return first, len(wires) + + async def wait_past(self, offset: int, timeout: float | None) -> None: + """Wait until a record exists at ``offset``, or ``timeout`` passes.""" + if len(self.records) > offset: + return + loop = asyncio.get_running_loop() + future: asyncio.Future[None] = loop.create_future() + self._waiters.append((loop, future)) + try: + await asyncio.wait_for(future, timeout) + except asyncio.TimeoutError: + self._waiters = [w for w in self._waiters if w[1] is not future] + + +def _parse(cursor: Cursor, raw: bytes, warn: Any) -> WireRecord | None: + try: + return WireRecord.FromString(raw) + except DecodeError as error: + # Same answer as an undecodable body: skip and say so, so one bad + # record cannot pin a reader. + warn("skipping stream record at %s: %s", cursor, error) + return None class _MemReadSource: @@ -104,22 +131,26 @@ class _MemReadSource: having no delivery path, and it is why this provider is for tests. """ - def __init__(self, store: _MemoryStream, start: int, poll: timedelta) -> None: + def __init__(self, store: _Topic, start: int, poll: timedelta) -> None: self._store = store - self._cursor = start + self._offset = start self._poll = poll self._closed = False - async def next_batch(self) -> list[tuple[Cursor, bytes]]: + async def next_batch(self) -> list[tuple[Cursor, WireRecord]]: while not self._closed: - frames = self._store.frames - if len(frames) > self._cursor: - batch = [ - (Cursor(str(offset)), frames[offset]) - for offset in range(self._cursor, len(frames)) - ] - self._cursor = len(frames) - return batch + records = self._store.records + if len(records) > self._offset: + batch: list[tuple[Cursor, WireRecord]] = [] + for offset in range(self._offset, len(records)): + cursor = mint_cursor(_PROVIDER, str(offset)) + wire = _parse(cursor, records[offset], workflow.logger.warning) + if wire is not None: + batch.append((cursor, wire)) + self._offset = len(records) + if batch: + return batch + continue await workflow.sleep(self._poll) raise StopAsyncIteration @@ -128,18 +159,34 @@ def close(self) -> None: class _MemWriteSink: - def __init__(self, store: _MemoryStream, topic: str) -> None: + def __init__(self, store: _Topic) -> None: self._store = store - self._topic = topic - self._sequence = 0 - async def publish(self, frame: bytes) -> None: - self._store.append( - [frame], - producer_id=f"__workflow__:{self._topic}", - sequence=self._sequence, - ) - self._sequence += 1 + def publish(self, record: WireRecord) -> None: + # Visible at once rather than at task acceptance: the documented gap + # between this provider and rule 1. + self._store.append([record]) + + +class _MemoryWorkflowProvider: + """The workflow half. Nothing to install and nothing to release.""" + + def __init__(self, streams: MemoryStreams) -> None: + self._streams = streams + + def open_reader(self, topic: str, *, after: Cursor) -> ReadSource: + start = self._streams._offset_after(after) + store = self._streams._topic(workflow.info().workflow_id, topic) + return _MemReadSource(store, start, self._streams._poll) + + def open_writer(self, topic: str) -> WriteSink: + return _MemWriteSink(self._streams._topic(workflow.info().workflow_id, topic)) + + def on_workflow_start(self) -> None: + pass + + async def on_workflow_finish(self) -> None: + pass class MemoryProducer: @@ -147,19 +194,25 @@ class MemoryProducer: def __init__( self, - store: _MemoryStream, - converter: Any, + store: _Topic, + converter: temporalio.converter.PayloadConverter, topic: str, producer_id: str, attempt: int, ) -> None: - """Bind this producer to ``topic`` on ``store``.""" + """Bind this producer to ``topic``'s ``store``.""" self._store = store self._converter = converter self._topic = topic self._producer_id = producer_id self._attempt = attempt self._sequence = 0 + self._last = BEGINNING + + @property + def producer_id(self) -> str: + """Who this producer writes as.""" + return self._producer_id @property def attempt(self) -> int: @@ -167,192 +220,224 @@ def attempt(self) -> int: return self._attempt @property - def _provider_id(self) -> str: + def _writer(self) -> str: return ( f"{self._producer_id}#{self._attempt}" if self._attempt else self._producer_id ) - async def append(self, *values: Any) -> Cursor | None: - """Append ``values`` and return the last one's cursor, or ``None`` if nothing landed.""" + async def append(self, *values: Any) -> Cursor: + """Append ``values`` and return the cursor of the last record as stored. + + A repeat returns where the original landed; an empty call returns + the position of this producer's last record. + """ if not values: - return None - frames = [] - for value in values: - frames.append( - _frame.encode( + return self._last + return self._write( + [ + to_wire( + self._converter, topic=self._topic, kind=RecordKind.DATA, - producer=self._producer_id, + value=value, + producer_id=self._producer_id, attempt=self._attempt, - sequence=self._sequence, - body=self._encode(value), + sequence=self._sequence + index, ) - ) - self._sequence += 1 - offset = self._store.append( - frames, - producer_id=self._provider_id, - sequence=self._sequence - len(frames), + for index, value in enumerate(values) + ] ) - if offset is None: - return None - return Cursor(str(offset + len(frames) - 1)) async def finish(self) -> None: - """Mark this producer done, so a reader stops waiting on it.""" - frame = _frame.encode( - topic=self._topic, - kind=RecordKind.FINISH, - producer=self._producer_id, - attempt=self._attempt, - sequence=self._sequence, - body=b"", - ) - self._sequence += 1 - self._store.append( - [frame], producer_id=self._provider_id, sequence=self._sequence - 1 + """Write ``FINISH`` for this producer on this topic.""" + self._write( + [ + to_wire( + self._converter, + topic=self._topic, + kind=RecordKind.FINISH, + producer_id=self._producer_id, + attempt=self._attempt, + sequence=self._sequence, + ) + ] ) - def _encode(self, value: Any) -> bytes: - payload = ( - value - if isinstance(value, Payload) - else self._converter.to_payloads([value])[0] + def _write(self, wires: list[WireRecord]) -> Cursor: + first, count = self._store.append( + wires, writer=self._writer, sequence=self._sequence ) - return payload.SerializeToString() + self._sequence += len(wires) + self._last = mint_cursor(_PROVIDER, str(first + count - 1)) + return self._last -class MemoryConsumer: - """The outside reader, with the shared supersession rule.""" +class MemoryStreamHandle: + """One workflow's stream from outside, with the shared reader rules.""" - def __init__(self, store: _MemoryStream, converter: Any, stream: str) -> None: - """Read whatever ``store`` holds, now and as it grows.""" - self._store = store - self._converter = converter - self._stream = stream + def __init__( + self, + streams: MemoryStreams, + client: Client | None, + workflow_id: str, + run_id: str | None, + ) -> None: + """Address ``workflow_id``'s topics in ``streams``.""" + self._streams = streams + self._client = client + self._workflow_id = workflow_id + self._run_id = run_id + self._converter = ( + client.data_converter.payload_converter + if client is not None + else temporalio.converter.DataConverter.default.payload_converter + ) - async def read( + def read( self, *, + topic: str, after: Cursor = BEGINNING, - topic: str | None = None, - type: type | None = None, + result_type: type | None = None, ) -> AsyncGenerator[StreamRecord[Any], None]: - """Yield records after ``after``, waiting for ones not written yet.""" - _provider.check_topic(self._stream, topic) - attempts = AttemptTracker() - offset = int(after.token) + 1 if after.token else 0 + """Yield records on ``topic`` after ``after`` until the workflow closes.""" + store = self._streams._topic(self._workflow_id, topic) + # Parsed here so a foreign cursor fails this call, not the first + # iteration of the generator. + start = self._streams._offset_after(after) + return self._read(store, start, after, result_type) + + async def _read( + self, + store: _Topic, + offset: int, + after: Cursor, + result_type: type | None, + ) -> AsyncGenerator[StreamRecord[Any], None]: + decoder = RecordDecoder( + self._converter, result_type, after=after, warn=logger.warning + ) + closed = False while True: - await self._store.wait_past(offset) - frames = self._store.frames - while offset < len(frames): - cursor = Cursor(str(offset)) - frame = frames[offset] + records = store.records + while offset < len(records): + cursor = mint_cursor(_PROVIDER, str(offset)) + wire = _parse(cursor, records[offset], logger.warning) offset += 1 - try: - kind, frame_topic, source, attempt, sequence, body = _frame.decode( - frame - ) - except ValueError as error: - # Same answer as the workflow-side reader: skip and say so. - logger.warning("skipping stream record at %s: %s", cursor, error) + if wire is None: continue - if topic is not None and frame_topic != topic: - continue - superseded = attempts.note(source, attempt, cursor) - if superseded is not None: - yield superseded - yield StreamRecord( - value=self._decode(body, type) if kind is RecordKind.DATA else None, - cursor=cursor, - kind=kind, - topic=frame_topic, - producer=source, - attempt=attempt, - sequence=sequence, + for record in decoder.decode(cursor, wire): + yield record + if closed: + return + # One more pass after learning the workflow closed, so a record + # that landed between the scan and the describe is not lost. + closed = await self._closed() + if not closed: + await store.wait_past( + offset, + None + if self._client is None + else self._streams._poll.total_seconds(), ) - async def latest(self, *, topic: str | None = None) -> Cursor: - """The cursor of the last record written, for following from now.""" - del topic # one store per stream, so the position is topic-independent - count = len(self._store.frames) - return Cursor(str(count - 1)) if count else BEGINNING - - def _decode(self, body: bytes, as_type: type | None) -> Any: - payload = Payload() - payload.ParseFromString(body) - if as_type is None: - return self._converter.from_payloads([payload])[0] - return self._converter.from_payloads([payload], [as_type])[0] - - -class _MemoryProvider: - name = "memory" - - def __init__(self) -> None: - self._poll = _DEFAULT_POLL - - def configure(self, **options: Any) -> None: - poll = options.pop("poll_interval", None) - if poll is not None: - self._poll = poll - if options: - raise TypeError( - f"the memory provider takes only poll_interval, got {sorted(options)}" - ) - - def worker_options(self) -> dict[str, Any]: - return {} - - def open_read( - self, - stream: str, - *, - after: Cursor = BEGINNING, - idle_timeout: timedelta | None = None, - ) -> ReadSource: - # Ignored: this provider never releases the worker, it polls. Reading - # idle_timeout as the poll period would give the parameter a second - # meaning that a port copying the reference would copy too. - del idle_timeout - store = _stream(inbound_stream_id(workflow.info().workflow_id, stream)) - return _MemReadSource( - store, int(after.token) + 1 if after.token else 0, self._poll + async def _closed(self) -> bool: + if self._client is None: + return False + handle = self._client.get_workflow_handle( + self._workflow_id, run_id=self._run_id + ) + try: + description = await handle.describe() + except RPCError as error: + if error.status == RPCStatusCode.NOT_FOUND: + # A producer may write before the workflow exists; there is + # nothing to follow yet, so keep waiting. + return False + raise + status = description.status + if status is None or status == WorkflowExecutionStatus.RUNNING: + return False + # Following the chain, a run that continued as new is not the end: + # the next describe without a run id finds its successor. + return not ( + self._run_id is None and status == WorkflowExecutionStatus.CONTINUED_AS_NEW ) - def open_write(self, topic: str) -> WriteSink: - store = _stream(inbound_stream_id(workflow.info().workflow_id, "")) - return _MemWriteSink(store, topic) + async def latest(self, *, topic: str) -> Cursor: + """The cursor of the newest record on ``topic``, for following from now.""" + count = len(self._streams._topic(self._workflow_id, topic).records) + return mint_cursor(_PROVIDER, str(count - 1)) if count else BEGINNING - async def producer( - self, - client: Any, - *, - workflow_id: str, - stream: str = "", - topic: str = "", - producer_id: str = "", - attempt: int = 0, + def producer( + self, *, topic: str, producer_id: str = "", attempt: int = 0 ) -> MemoryProducer: - # With no inbound stream named, the target is the store the workflow's - # own writer appends to, and the frame carries the topic. An inbound - # record carries none: the stream's name is its whole address. - return MemoryProducer( - _stream(inbound_stream_id(workflow_id, stream)), - _converter(client), - topic, - producer_id, - attempt, - ) + """A producer on ``topic``; inside an activity its identity is the activity's.""" + store = self._streams._topic(self._workflow_id, topic) + producer_id, attempt = producer_identity(producer_id, attempt) + return MemoryProducer(store, self._converter, topic, producer_id, attempt) - async def consumer( - self, client: Any, *, workflow_id: str, stream: str = "" - ) -> MemoryConsumer: - return MemoryConsumer( - _stream(inbound_stream_id(workflow_id, stream)), _converter(client), stream - ) +class MemoryStreams(ProviderPlugin): + """The in-memory provider, one list per topic. -_provider.register("memory", _MemoryProvider) + Construct one and pass the same instance to the worker and to the code + that opens handles; two instances share nothing. + """ + + def __init__( + self, *, poll_interval: timedelta = timedelta(milliseconds=100) + ) -> None: + """Create an empty provider. + + Args: + poll_interval: How often a workflow-side reader with nothing to + read checks again, and how often an outside reader asks + whether the workflow closed. + """ + self._poll = poll_interval + self._topics: dict[str, _Topic] = {} + + def reset(self) -> None: + """Drop every topic. For tests.""" + self._topics.clear() + + def workflow_provider(self) -> _MemoryWorkflowProvider: + """The workflow half, over this provider's topics.""" + return _MemoryWorkflowProvider(self) + + def get_stream_handle( + self, client: Client | None, workflow_id: str, *, run_id: str | None = None + ) -> MemoryStreamHandle: + """A handle on ``workflow_id``'s topics. + + ``client`` may be ``None`` here, unlike on a storage provider; then + the handle cannot see the workflow close and a read waits until the + caller closes it. + """ + return MemoryStreamHandle(self, client, workflow_id, run_id) + + async def close(self) -> None: + """Nothing to release: the provider holds no connection.""" + + def _topic(self, workflow_id: str, topic: str) -> _Topic: + if not topic: + raise ValueError("topic must not be empty") + key = topic_key(workflow_id, topic) + found = self._topics.get(key) + if found is None: + found = self._topics[key] = _Topic() + return found + + def _offset_after(self, after: Cursor) -> int: + position = cursor_position(after, provider=_PROVIDER) + if position is None: + return 0 + try: + return int(position) + 1 + except ValueError: + raise StreamCursorError( + f"cursor {after.token!r} does not name a position on the memory provider" + ) from None From e05e65198fc18cc1e36c20dcaeafe6930d9d99f4 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 01:54:17 -0700 Subject: [PATCH 53/96] Carried the stream provider from the worker into the workflow runtime. workflow.stream_reader() and stream_writer() reach it the way payload_converter() does, and the worker brackets the workflow function with the provider's lifecycle hooks so no workflow code has to. --- temporalio/worker/_replayer.py | 7 +- temporalio/worker/_worker.py | 9 + temporalio/worker/_workflow.py | 27 +++ temporalio/worker/_workflow_instance.py | 19 ++ temporalio/workflow/__init__.py | 10 + temporalio/workflow/_context.py | 4 + temporalio/workflow/_streams.py | 257 ++++++++++++++++++++++++ 7 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 temporalio/workflow/_streams.py diff --git a/temporalio/worker/_replayer.py b/temporalio/worker/_replayer.py index 55cec4ca7..1387face7 100644 --- a/temporalio/worker/_replayer.py +++ b/temporalio/worker/_replayer.py @@ -18,6 +18,7 @@ import temporalio.client import temporalio.converter import temporalio.runtime +import temporalio.streams import temporalio.worker import temporalio.workflow @@ -58,13 +59,14 @@ def __init__( disable_safe_workflow_eviction: bool = False, header_codec_behavior: HeaderCodecBehavior = HeaderCodecBehavior.NO_CODEC, external_stream_backend: Any | None = None, + stream_provider: temporalio.streams.StreamProvider | None = None, ) -> None: """Create a replayer to replay workflows from history. See :py:meth:`temporalio.worker.Worker.__init__` for a description of most of the arguments. Most of the same arguments need to be passed to the replayer that were passed to the worker when the workflow originally - ran. + ran, ``stream_provider`` included when the workflow used streams. Note, unlike the worker, for the replayer the workflow_task_executor will default to a new thread pool executor with no max_workers set that @@ -96,6 +98,7 @@ def __init__( disable_safe_workflow_eviction=disable_safe_workflow_eviction, header_codec_behavior=header_codec_behavior, external_stream_backend=external_stream_backend, + stream_provider=stream_provider, ) self._initial_config = self._config.copy() self._default_workflow_logic_flags = set(_DEFAULT_ENABLED_WORKFLOW_LOGIC_FLAGS) @@ -298,6 +301,7 @@ def on_eviction_hook( default_workflow_logic_flags=frozenset( self._default_workflow_logic_flags ), + stream_provider=self._config.get("stream_provider"), ) external_storage = data_converter.external_storage storage_driver_types = ( @@ -445,6 +449,7 @@ class ReplayerConfig(TypedDict, total=False): disable_safe_workflow_eviction: bool header_codec_behavior: HeaderCodecBehavior external_stream_backend: Any | None + stream_provider: temporalio.streams.StreamProvider | None @dataclass(frozen=True) diff --git a/temporalio/worker/_worker.py b/temporalio/worker/_worker.py index 4da7c13b3..889c063e2 100644 --- a/temporalio/worker/_worker.py +++ b/temporalio/worker/_worker.py @@ -24,6 +24,7 @@ import temporalio.common import temporalio.runtime import temporalio.service +import temporalio.streams from temporalio.common import ( HeaderCodecBehavior, VersioningBehavior, @@ -153,6 +154,7 @@ def __init__( disable_payload_error_limit: bool = False, max_workflow_task_external_storage_concurrency: int = _DEFAULT_WORKFLOW_TASK_EXTERNAL_STORAGE_CONCURRENCY, external_stream_backend: Any | None = None, + stream_provider: temporalio.streams.StreamProvider | None = None, ) -> None: """Create a worker to process workflows and/or activities. @@ -354,6 +356,10 @@ def __init__( Defaults to 3. Adjust this value based on your workload's needs. Please report any issues you encounter with this setting or if you feel the default should be changed. + stream_provider: Experimental. The stream provider that workflows + on this worker read and publish through, see + :py:mod:`temporalio.streams`. A provider that is also a + :py:class:`Plugin` sets this itself when passed in ``plugins``. WARNING: This setting is experimental. """ @@ -404,6 +410,7 @@ def __init__( disable_payload_error_limit=disable_payload_error_limit, max_workflow_task_external_storage_concurrency=max_workflow_task_external_storage_concurrency, external_stream_backend=external_stream_backend, + stream_provider=stream_provider, ) plugins_from_client = cast( @@ -593,6 +600,7 @@ def check_activity(activity: str): != HeaderCodecBehavior.NO_CODEC, max_workflow_task_external_storage_concurrency=max_workflow_task_external_storage_concurrency, external_stream_backend=self._external_stream_backend, + stream_provider=config.get("stream_provider"), ) tuner = config.get("tuner") @@ -1080,6 +1088,7 @@ class WorkerConfig(TypedDict, total=False): disable_payload_error_limit: bool max_workflow_task_external_storage_concurrency: int external_stream_backend: Any | None + stream_provider: temporalio.streams.StreamProvider | None def _warn_if_activity_executor_max_workers_is_inconsistent( diff --git a/temporalio/worker/_workflow.py b/temporalio/worker/_workflow.py index 42fb07ff8..481a0a86d 100644 --- a/temporalio/worker/_workflow.py +++ b/temporalio/worker/_workflow.py @@ -27,6 +27,7 @@ import temporalio.converter import temporalio.converter._extstore import temporalio.exceptions +import temporalio.streams import temporalio.workflow from temporalio.bridge.worker import PollShutdownError from temporalio.converter import StorageDriverStoreContext, StorageDriverWorkflowInfo @@ -38,6 +39,7 @@ _relax_sandbox_for_debugger, ) from ._interceptor import ( + ExecuteWorkflowInput, Interceptor, WorkflowInboundInterceptor, WorkflowInterceptorClassInput, @@ -59,6 +61,24 @@ LOG_PROTOS = False +class _StreamHooksInterceptor(WorkflowInboundInterceptor): + """Brackets the workflow function with the stream provider's lifecycle hooks. + + Installed by the worker when it has a stream provider, so no workflow + code has to call anything before it runs or before it returns. The finish + hook runs however the function ends, continue-as-new included, because + a provider that parked a reader against the run has to let go either way. + """ + + async def execute_workflow(self, input: ExecuteWorkflowInput) -> Any: + provider = temporalio.workflow._Runtime.current().workflow_streams().provider + provider.on_workflow_start() + try: + return await self.next.execute_workflow(input) + finally: + await provider.on_workflow_finish() + + # Value was chosen abitrarily as a small number that allows some concurrency and prevents # large numbers of concurrent external storage operations causing resource contention. # This default limit is per workflow task activation and does not limit the total number @@ -158,6 +178,7 @@ def __init__( default_workflow_logic_flags: frozenset[_WorkflowLogicFlag] | None = None, external_stream_backend: Any | None = None, client: Any = None, + stream_provider: temporalio.streams.StreamProvider | None = None, ) -> 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")) @@ -216,6 +237,11 @@ def __init__( __temporal_assert_local_activity_valid=assert_local_activity_valid, ) ) + self._stream_provider = stream_provider + if stream_provider is not None: + # Innermost, so the lifecycle hooks bracket the workflow function + # itself, after every user interceptor has done its own setup. + self._interceptor_classes.append(_StreamHooksInterceptor) # External Workflow Streams. The manager is per-Worker and owns the # backend connection and watcher tasks; it is created lazily on the @@ -968,6 +994,7 @@ def _create_workflow_instance( default_workflow_logic_flags=frozenset(self._default_workflow_logic_flags), external_stream_runtime=runtime, external_streams_configured=self._external_streams_configured, + stream_provider=self._stream_provider, ) if defn.sandboxed: return self._workflow_runner.create_instance(det) diff --git a/temporalio/worker/_workflow_instance.py b/temporalio/worker/_workflow_instance.py index 88e7a891b..992bf7ed9 100644 --- a/temporalio/worker/_workflow_instance.py +++ b/temporalio/worker/_workflow_instance.py @@ -58,7 +58,9 @@ import temporalio.converter import temporalio.exceptions import temporalio.nexus.system +import temporalio.streams import temporalio.workflow +import temporalio.workflow._streams from temporalio.converter import StorageDriverStoreContext, StorageDriverWorkflowInfo from temporalio.service import __version__ @@ -198,6 +200,7 @@ class WorkflowInstanceDetails: """ external_streams_configured: bool = False """Whether Workflow code may use the runtime for new subscriptions.""" + stream_provider: temporalio.streams.StreamProvider | None = None class WorkflowInstance(ABC): @@ -328,6 +331,8 @@ def __init__(self, det: WorkflowInstanceDetails) -> None: det.worker_level_failure_exception_types ) self._patch_activation_callback = det.patch_activation_callback + self._stream_provider = det.stream_provider + self._streams: temporalio.workflow._streams._WorkflowStreams | None = None self._default_workflow_logic_flags = det.default_workflow_logic_flags self._primary_task: asyncio.Task[None] | None = None self._cancel_primary_task_pending = False @@ -2079,6 +2084,20 @@ async def workflow_start_nexus_operation( ) ) + def workflow_streams(self) -> temporalio.workflow._streams._WorkflowStreams: + if self._streams is None: + if self._stream_provider is None: + raise RuntimeError( + "no stream provider is configured on this worker; pass one with " + "Worker(plugins=[provider]) or stream_provider=" + ) + # The workflow half is made per instance, so whatever it keeps + # dies with the instance the way handlers do. + self._streams = temporalio.workflow._streams._WorkflowStreams( + self._stream_provider.workflow_provider() + ) + return self._streams + def workflow_time_ns(self) -> int: return self._time_ns diff --git a/temporalio/workflow/__init__.py b/temporalio/workflow/__init__.py index fa2681139..f63135bf4 100644 --- a/temporalio/workflow/__init__.py +++ b/temporalio/workflow/__init__.py @@ -147,6 +147,12 @@ logger, unsafe, ) +from ._streams import ( + StreamReader, + StreamWriter, + stream_reader, + stream_writer, +) from ._workflow_ops import ( ChildWorkflowCancellationType, ChildWorkflowConfig, @@ -252,6 +258,10 @@ "SandboxImportNotificationPolicy", "logger", "unsafe", + "StreamReader", + "StreamWriter", + "stream_reader", + "stream_writer", "ChildWorkflowCancellationType", "ChildWorkflowConfig", "ChildWorkflowHandle", diff --git a/temporalio/workflow/_context.py b/temporalio/workflow/_context.py index b33f83150..25523836a 100644 --- a/temporalio/workflow/_context.py +++ b/temporalio/workflow/_context.py @@ -24,6 +24,7 @@ from ._activities import ActivityCancellationType, ActivityHandle from ._exceptions import ContinueAsNewVersioningBehavior, VersioningIntent from ._nexus import NexusOperationCancellationType, NexusOperationHandle + from ._streams import _WorkflowStreams from ._workflow_ops import ( ChildWorkflowCancellationType, ChildWorkflowHandle, @@ -475,6 +476,9 @@ async def workflow_start_nexus_operation( summary: str | None, ) -> NexusOperationHandle[OutputT]: ... + @abstractmethod + def workflow_streams(self) -> _WorkflowStreams: ... + @abstractmethod def workflow_time_ns(self) -> int: ... diff --git a/temporalio/workflow/_streams.py b/temporalio/workflow/_streams.py new file mode 100644 index 000000000..029da59a1 --- /dev/null +++ b/temporalio/workflow/_streams.py @@ -0,0 +1,257 @@ +"""Streams from inside workflow code. + +.. warning:: + This module is experimental and may change in future versions. + +The reader and writer here are the same on every provider. They convert +values, synthesize supersession and buffer nothing the provider did not hand +them; everything provider-specific sits behind the ``ReadSource`` and +``WriteSink`` that the provider's workflow half opens. The provider itself +comes from the worker, the way the payload converter does. +""" + +from __future__ import annotations + +import asyncio +from collections import deque +from collections.abc import AsyncIterator, Callable +from typing import Any, Generic, TypeVar, cast, overload + +from temporalio.streams._provider import ReadSource, WorkflowStreamProvider, WriteSink +from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord +from temporalio.streams._wire import RecordDecoder, to_wire +from temporalio.workflow._context import _Runtime, payload_converter +from temporalio.workflow._sandbox import logger + +__all__ = ["StreamReader", "StreamWriter", "stream_reader", "stream_writer"] + +T = TypeVar("T") + + +class _WorkflowStreams: + """The stream state a workflow instance carries: its provider half and open readers.""" + + def __init__(self, provider: WorkflowStreamProvider) -> None: + self.provider = provider + self.readers: dict[str, StreamReader[Any]] = {} + + +class StreamReader(Generic[T]): + """Reads one topic of this workflow's stream from inside workflow code. + + The reader is the async iterator: ``async for record in reader`` yields + every kind of record, including the supersession the reader synthesizes + when a producer's newer attempt appears. Check ``record.kind``, or + iterate :meth:`values` when the workflow only wants data. Iteration ends + when the reader is closed or the provider ends the subscription. One loop + per reader: two loops on one reader share its buffer and interleave. + """ + + def __init__( + self, + source: ReadSource, + *, + topic: str, + result_type: type | None, + after: Cursor, + on_close: Callable[[], None], + ) -> None: + """Prefer :func:`temporalio.workflow.stream_reader`.""" + self._source = source + self._topic = topic + self._result_type = result_type + self._decoder = RecordDecoder( + payload_converter(), result_type, after=after, warn=logger.warning + ) + self._pending: deque[StreamRecord[T]] = deque() + self._lock = asyncio.Lock() + self._closed = False + self._ended = False + self._on_close = on_close + + @property + def topic(self) -> str: + """The topic this reader is subscribed to.""" + return self._topic + + def __aiter__(self) -> StreamReader[T]: + """The reader is its own iterator.""" + return self + + async def __anext__(self) -> StreamRecord[T]: + """The next record, waiting for one to arrive.""" + while True: + if self._pending: + return self._pending.popleft() + if self._closed or self._ended: + raise StopAsyncIteration + await self._fill() + + async def _fill(self) -> None: + # Two loops on one reader must not race the source, so one batch + # fetch is in flight at a time and the second loop takes what the + # first one buffered. + async with self._lock: + if self._pending or self._closed or self._ended: + return + try: + batch = await self._source.next_batch() + except StopAsyncIteration: + # The provider ended the subscription. Iteration stops rather + # than raising, so a workflow that reads to the end of a + # finished stream leaves the loop instead of failing its task. + self._ended = True + return + for cursor, wire in batch: + self._pending.extend(self._decoder.decode(cursor, wire)) + + async def values(self) -> AsyncIterator[T]: + """Iterate the data values, dropping control records.""" + async for record in self: + if record.kind is RecordKind.DATA: + yield cast("T", record.value) + + def close(self) -> None: + """End the subscription. Idempotent. + + A later :func:`temporalio.workflow.stream_reader` on the same topic + opens a new subscription, which is a new command. + """ + if self._closed: + return + self._closed = True + self._source.close() + self._on_close() + + +class StreamWriter: + """Publishes to one topic of this workflow's stream. + + A workflow can only publish transactionally to its own stream, on every + provider. Writing to somebody else's stream is an activity's job, and it + gets the weaker guarantee that goes with doing I/O. + """ + + def __init__(self, sink: WriteSink, topic: str) -> None: + """Prefer :func:`temporalio.workflow.stream_writer`.""" + self._sink = sink + self._topic = topic + self._finished = False + + @property + def topic(self) -> str: + """The topic this writer is bound to.""" + return self._topic + + def publish(self, value: Any) -> None: + """Append ``value`` to this topic. + + Synchronous, because there is nothing to wait for inside a task: the + record becomes visible when this Workflow Task is accepted, and never + at all if the task fails, so a reader cannot see a decision the + workflow did not commit. A :class:`temporalio.common.RawValue` passes + through pre-encoded. + + Raises: + ValueError: :meth:`finish` was already called on this writer. + """ + if self._finished: + raise ValueError(f"topic {self._topic!r} was already finished") + self._sink.publish( + to_wire( + payload_converter(), + topic=self._topic, + kind=RecordKind.DATA, + value=value, + ) + ) + + def finish(self) -> None: + """Write ``FINISH`` for this workflow on this topic. Idempotent. + + Says this workflow has nothing more to send on the topic. It does not + say the workflow succeeded, and it does not end anyone's read. + """ + if self._finished: + return + self._finished = True + self._sink.publish( + to_wire(payload_converter(), topic=self._topic, kind=RecordKind.FINISH) + ) + + +@overload +def stream_reader( + topic: str, *, result_type: type[T], after: Cursor = ... +) -> StreamReader[T]: ... + + +@overload +def stream_reader( + topic: str, *, result_type: None = None, after: Cursor = ... +) -> StreamReader[Any]: ... + + +def stream_reader( + topic: str, *, result_type: type | None = None, after: Cursor = BEGINNING +) -> StreamReader[Any]: + """Subscribe this workflow to ``topic`` of its own stream. + + One subscription per topic per run. A second call for the same topic + returns the reader already open on it, so records go to whichever loop + pulls first; such a call may pass neither ``after`` nor a different + ``result_type``. Adding a reader on a new topic is a new command, so gate + it with :func:`temporalio.workflow.patched` as you would a timer. A + reader in a successor run starts a new subscription: nothing crosses + continue-as-new implicitly. + + Args: + topic: The topic, relative to this workflow's stream. + result_type: The value type, used as the decode hint. + :class:`temporalio.common.RawValue` returns the payload untouched. + after: Resume strictly after this record. Honoured on the first + subscription of a run, because after that the recorded + observations decide. + + Raises: + ValueError: ``topic`` is empty, or a reader on it is already open and + this call asked for a different position or type. + temporalio.streams.StreamCursorError: ``after`` was minted by another + provider. + """ + if not topic: + raise ValueError("topic must not be empty") + state: _WorkflowStreams = _Runtime.current().workflow_streams() + existing = state.readers.get(topic) + if existing is not None: + if after != BEGINNING or result_type is not existing._result_type: + raise ValueError( + f"topic {topic!r} already has a reader in this run; a second " + "stream_reader shares it and takes no after= or other result_type=" + ) + return existing + source = state.provider.open_reader(topic, after=after) + + def forget() -> None: + state.readers.pop(topic, None) + + reader: StreamReader[Any] = StreamReader( + source, topic=topic, result_type=result_type, after=after, on_close=forget + ) + state.readers[topic] = reader + return reader + + +def stream_writer(topic: str) -> StreamWriter: + """Publish to ``topic`` of this workflow's stream. + + Args: + topic: The topic name. Encoding follows each published value. + + Raises: + ValueError: ``topic`` is empty. + """ + if not topic: + raise ValueError("topic must not be empty") + provider = _Runtime.current().workflow_streams().provider + return StreamWriter(provider.open_writer(topic), topic) From d37f8ade2c96c335f5cedda99e462ba85081ba55 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 01:54:17 -0700 Subject: [PATCH 54/96] Moved the stream conformance suite onto the new surface. Adds the cases for supersession cursors, append positions, a read that ends when the chain closes, chain following, foreign cursors, the lifecycle hooks and a subscription shared by two readers. --- tests/streams/conftest.py | 6 +- tests/streams/test_streams_conformance.py | 595 ++++++++++------------ tests/streams/test_streams_workflow.py | 381 +++++++++++--- 3 files changed, 568 insertions(+), 414 deletions(-) diff --git a/tests/streams/conftest.py b/tests/streams/conftest.py index 1758f2598..12663fa23 100644 --- a/tests/streams/conftest.py +++ b/tests/streams/conftest.py @@ -4,9 +4,5 @@ def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line( "markers", - "inbound_stream: the case writes a workflow's inbound stream from outside", - ) - config.addinivalue_line( - "markers", - "unfiltered_read: the case reads the owner's stream without naming a topic", + "reports_positions: the case needs append() to return where records landed", ) diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index 1cffded48..451fe4391 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -2,37 +2,49 @@ Written against the public surface, parametrised over the providers this tree can stand up. The memory provider always runs, with no server and no -store. A storage provider adds itself to ``SETUPS`` behind its own -``STREAMS_LIVE`` gate: its setup configures the provider, hands back the -client the cases should use and a workflow that exists for the case to -address, and says which capabilities it lacks, so the cases marked -``inbound_stream`` or ``unfiltered_read`` are skipped with a reason on a -provider that cannot do them. Every other case reads with a topic, which is -what a store that keys by topic needs. - -What this file pins down is the contract: framing, producer identity, retry -deduplication, supersession, topic filtering, cursor resumption, and store -keys that cannot collide. The workflow-side handles and the two rules about -workflow tasks live in ``test_streams_workflow``. +store. A storage provider adds itself to ``SETUPS``, behind its own +``STREAMS_LIVE`` gate when it needs a store the test environment does not +start: its setup receives the environment's client and hands back a provider +instance, the client the cases should use, a ``host`` that starts the +workflow owning a stream when the store lives inside a running workflow, and +which capabilities it lacks, so the cases marked ``reports_positions`` are +skipped with a reason on a provider whose ``append()`` learns positions at +read time. + +What this file pins down is the contract: the record on the wire, producer +identity, retry deduplication, positions, supersession, topic addressing, +cursor resumption, cursor ownership, and store keys that cannot collide. The +workflow-side handles and the two rules about Workflow Tasks live in +``test_streams_workflow``. """ from __future__ import annotations import asyncio -import os import uuid -from collections.abc import AsyncIterator, Callable +from collections.abc import AsyncIterator, Awaitable, Callable from dataclasses import dataclass -from typing import Any +from typing import Any, cast import pytest -from temporalio import streams +from temporalio.api.common.v1 import Payload from temporalio.client import Client -from temporalio.streams import _frame, _ids, _provider +from temporalio.common import RawValue +from temporalio.converter import DataConverter +from temporalio.streams import ( + BEGINNING, + Cursor, + RecordKind, + StreamCursorError, + StreamHandle, + StreamProvider, + Supersession, + _ids, + _wire, +) from temporalio.streams._policy import AttemptTracker -from temporalio.streams._record import Cursor, RecordKind -from temporalio.streams.providers import memory +from temporalio.streams.providers.memory import MemoryStreams @dataclass @@ -40,81 +52,50 @@ class ProviderCase: """One provider under test, and what the cases may ask of it.""" name: str - client: Any = None - workflow_id: str = "" - """The workflow whose streams the case addresses. - - A store keeps a workflow's stream with the workflow, so the setup makes - the execution exist before the case opens a producer on its account. - """ - inbound_streams: bool = True - """Outside producers may write a workflow's inbound stream.""" - unfiltered_reads: bool = True - """A consumer may read the owner's stream without naming a topic.""" - reports_dropped_repeats: bool = True - """``append`` returns ``None`` for a repeat it dropped. - - A store that answers a repeat with the original position instead still - holds the record once; it just cannot say at append time that this call - wrote nothing. - """ - - -async def _memory_case() -> AsyncIterator[ProviderCase]: - memory.reset() - streams.configure(provider="memory") - yield ProviderCase("memory", workflow_id=new_workflow_id()) - memory.reset() - - -async def _redis_case() -> AsyncIterator[ProviderCase]: - # A prefix per case, because the store keeps what earlier cases wrote. - streams.configure( - provider="redis", key_prefix=f"streams-conformance-{uuid.uuid4().hex}" - ) - client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) - # The store keys a workflow's streams under its chain, which is read from - # the execution, so one has to exist; no worker ever picks its task up. - owner = await client.start_workflow( - "StreamOwner", - id=new_workflow_id(), - task_queue="streams-conformance-unserved", - ) - try: - yield ProviderCase( - "redis", - client, - workflow_id=owner.id, - # Each topic is its own store, and an inbound stream is not - # readable from outside; a dropped repeat answers with the - # original position because the store's seam does not say. - inbound_streams=False, - unfiltered_reads=False, - reports_dropped_repeats=False, + provider: StreamProvider + client: Client | None = None + reports_positions: bool = True + """``append()`` returns where the records landed.""" + host: Callable[[str], Awaitable[None]] | None = None + """Starts the workflow that owns ``workflow_id``'s stream, when a store needs one.""" + + async def open( + self, workflow_id: str, *, run_id: str | None = None + ) -> StreamHandle: + if self.host is not None: + await self.host(workflow_id) + # The memory provider takes no client; every storage provider's setup + # supplies one, so the cast only ever lies for the provider that + # does not read it. + return self.provider.get_stream_handle( + cast(Client, self.client), workflow_id, run_id=run_id ) - finally: - await owner.terminate("conformance case finished") - await streams.close() -SETUPS: dict[str, Callable[[], AsyncIterator[ProviderCase]]] = {"memory": _memory_case} -if os.environ.get("STREAMS_LIVE") == "redis": - # Needs a dev server at TEMPORAL_ADDRESS and a Redis at TEMPORAL_TEST_REDIS_URL. - SETUPS["redis"] = _redis_case +async def _memory_case(_client: Client) -> AsyncIterator[ProviderCase]: + provider = MemoryStreams() + yield ProviderCase("memory", provider) + provider.reset() + + +SETUPS: dict[str, Callable[[Client], AsyncIterator[ProviderCase]]] = { + "memory": _memory_case +} _CAPABILITIES = { - "inbound_stream": lambda case: case.inbound_streams, - "unfiltered_read": lambda case: case.unfiltered_reads, + "reports_positions": lambda case: case.reports_positions, } @pytest.fixture(params=sorted(SETUPS)) -async def provider(request: pytest.FixtureRequest) -> AsyncIterator[ProviderCase]: - async for case in SETUPS[request.param](): +async def case( + request: pytest.FixtureRequest, client: Client +) -> AsyncIterator[ProviderCase]: + async for provider_case in SETUPS[request.param](client): for marker, supported in _CAPABILITIES.items(): - if request.node.get_closest_marker(marker) and not supported(case): - pytest.skip(f"the {case.name} provider does not support {marker}") - yield case + if request.node.get_closest_marker(marker) and not supported(provider_case): + pytest.skip(f"the {provider_case.name} provider does not {marker}") + yield provider_case def new_workflow_id() -> str: @@ -136,343 +117,281 @@ async def _collect() -> None: return out -def test_frame_roundtrip(): - frame = _frame.encode( +def test_record_roundtrips_through_the_wire(): + converter = DataConverter.default.payload_converter + wire = _wire.to_wire( + converter, topic="decisions", kind=RecordKind.DATA, - producer="model", + value={"n": 1}, + producer_id="model", attempt=3, sequence=7, - body=b"payload-bytes", - ) - kind, topic, producer, attempt, sequence, body = _frame.decode(frame) - assert (kind, topic, producer, attempt, sequence, body) == ( - RecordKind.DATA, - "decisions", - "model", - 3, - 7, - b"payload-bytes", ) + parsed = _wire.WireRecord.FromString(wire.SerializeToString()) + record = _wire.from_wire(converter, Cursor("memory:0"), parsed, dict) + assert ( + record.kind, + record.topic, + record.producer_id, + record.attempt, + record.sequence, + record.value, + ) == (RecordKind.DATA, "decisions", "model", 3, 7, {"n": 1}) + assert record.supersession is None + finish = _wire.to_wire(converter, topic="decisions", kind=RecordKind.FINISH) + assert not finish.HasField("body") + assert _wire.from_wire(converter, Cursor("memory:1"), finish, dict).value is None + + +def test_a_stored_supersession_is_not_a_record(): + converter = DataConverter.default.payload_converter + wire = _wire.WireRecord(topic="t", kind=int(RecordKind.SUPERSEDED)) # type: ignore[arg-type] + with pytest.raises(ValueError, match="synthesized"): + _wire.from_wire(converter, Cursor("memory:0"), wire, None) + + +def test_an_unset_kind_is_read_as_data(): + converter = DataConverter.default.payload_converter + wire = _wire.WireRecord(topic="t", body=converter.to_payloads([{"n": 1}])[0]) + record = _wire.from_wire(converter, Cursor("memory:0"), wire, dict) + assert record.kind is RecordKind.DATA + assert record.value == {"n": 1} def test_supersession_is_synthesized_from_observations(): attempts = AttemptTracker() - assert attempts.note("model", 1, Cursor("0")) is None - superseded = attempts.note("model", 2, Cursor("1")) + assert attempts.note("model", 1, topic="t", previous=BEGINNING) is None + superseded = attempts.note("model", 2, topic="t", previous=Cursor("memory:0")) assert superseded is not None assert superseded.kind is RecordKind.SUPERSEDED - assert isinstance(superseded.value, streams.Supersession) - assert superseded.value.previous_attempt == 1 + assert superseded.supersession == Supersession("model", 1, 2) + assert superseded.value is None + # Positioned before the triggering record, so a resume after it delivers + # that record next. + assert superseded.cursor == Cursor("memory:0") # The same attempt again is not a new generation. - assert attempts.note("model", 2, Cursor("2")) is None + assert attempts.note("model", 2, topic="t", previous=Cursor("memory:1")) is None -def test_inbound_stream_ids_cannot_collide(): +def test_topic_keys_cannot_collide(): # A colon in a workflow id must not make two addresses one key. - assert _ids.inbound_stream_id("a:b", "c") != _ids.inbound_stream_id("a", "b:c") - assert _ids.inbound_stream_id("a:b", "") != _ids.inbound_stream_id("a", "b") - assert _ids.inbound_stream_id("a%3Ab", "c") != _ids.inbound_stream_id("a:b", "c") - assert _ids.inbound_stream_id("wf", "inputs") == "wf:inputs" - - -async def test_append_read_roundtrip(provider: ProviderCase): - workflow_id = provider.workflow_id - producer = await streams.producer( - provider.client, - workflow_id=workflow_id, - topic="out", - producer_id="model", - attempt=1, - ) + assert _ids.topic_key("a:b", "c") != _ids.topic_key("a", "b:c") + assert _ids.topic_key("a%3Ab", "c") != _ids.topic_key("a:b", "c") + assert _ids.topic_key("wf", "inputs") == "wf:inputs" + + +def test_cursors_name_their_provider(): + assert _wire.cursor_position(BEGINNING, provider="memory") is None + assert _wire.cursor_position(Cursor("memory:42"), provider="memory") == "42" + with pytest.raises(StreamCursorError): + _wire.cursor_position(Cursor("redis:1700000000000-0"), provider="memory") + + +async def test_append_read_roundtrip(case: ProviderCase): + workflow_id = new_workflow_id() + stream = await case.open(workflow_id) + producer = stream.producer(topic="out", producer_id="model", attempt=1) + assert (producer.producer_id, producer.attempt) == ("model", 1) await producer.append({"id": "r1"}, {"id": "r2"}) await producer.finish() - consumer = await streams.consumer(provider.client, workflow_id=workflow_id) - records = await take(consumer.read(type=dict, topic="out"), 3) + records = await take(stream.read(topic="out", result_type=dict), 3) assert [r.kind for r in records] == [ RecordKind.DATA, RecordKind.DATA, RecordKind.FINISH, ] assert [r.value for r in records[:2]] == [{"id": "r1"}, {"id": "r2"}] - assert all(r.producer == "model" and r.attempt == 1 for r in records) + assert records[2].value is None + assert all(r.producer_id == "model" and r.attempt == 1 for r in records) assert [r.sequence for r in records] == [0, 1, 2] assert all(r.topic == "out" for r in records) -@pytest.mark.inbound_stream -async def test_inbound_records_carry_no_topic(provider: ProviderCase): - workflow_id = provider.workflow_id - producer = await streams.producer( - provider.client, - workflow_id=workflow_id, - stream="inputs", - producer_id="model", - attempt=1, - ) - await producer.append({"id": "r1"}) - await producer.finish() +async def test_raw_values_pass_through_untouched(case: ProviderCase): + workflow_id = new_workflow_id() + stream = await case.open(workflow_id) + payload = Payload(metadata={"encoding": b"binary/plain"}, data=b"\x00\x01raw") + producer = stream.producer(topic="out", producer_id="model", attempt=1) + await producer.append(RawValue(payload)) + + records = await take(stream.read(topic="out", result_type=RawValue), 1) + assert isinstance(records[0].value, RawValue) + assert records[0].value.payload == payload - consumer = await streams.consumer( - provider.client, workflow_id=workflow_id, stream="inputs" - ) - records = await take(consumer.read(type=dict), 2) - assert [r.kind for r in records] == [RecordKind.DATA, RecordKind.FINISH] - assert records[0].value == {"id": "r1"} - # An inbound stream has no topics; its name is the whole address. - assert all(r.topic == "" for r in records) - with pytest.raises(ValueError, match="inbound stream 'inputs' has no topics"): - await take(consumer.read(type=dict, topic="inputs"), 1) - - -async def test_retried_append_is_deduplicated(provider: ProviderCase): - workflow_id = provider.workflow_id - first = await streams.producer( - provider.client, - workflow_id=workflow_id, - topic="out", - producer_id="model", - attempt=1, - ) - await first.append({"id": "r1"}) - # The retry of the same attempt starts its sequence over and appends the - # same record. The provider must not store it twice, and says so by - # returning no position for what it dropped, or the original one when - # its store cannot tell the two apart at append time. - retry = await streams.producer( - provider.client, - workflow_id=workflow_id, - topic="out", - producer_id="model", - attempt=1, - ) - dropped = await retry.append({"id": "r1"}) - consumer = await streams.consumer(provider.client, workflow_id=workflow_id) - records = await take(consumer.read(type=dict, topic="out"), 1) +@pytest.mark.reports_positions +async def test_retried_append_returns_the_original_position(case: ProviderCase): + workflow_id = new_workflow_id() + stream = await case.open(workflow_id) + first = stream.producer(topic="out", producer_id="model", attempt=1) + landed = await first.append({"id": "r1"}) + assert landed is not None + # The retry of the same attempt starts its sequence over and appends the + # same record. The provider stores it once and answers with where the + # original landed, so the retry can checkpoint the same position. + retry = stream.producer(topic="out", producer_id="model", attempt=1) + assert await retry.append({"id": "r1"}) == landed + # An empty call writes nothing and answers the same way. + assert await retry.append() == landed + + records = await take(stream.read(topic="out", result_type=dict), 1) assert records[0].value == {"id": "r1"} - if provider.reports_dropped_repeats: - assert dropped is None - else: - assert dropped == records[0].cursor + assert records[0].cursor == landed # The store holds exactly the one record: the newest position is its cursor. - assert await consumer.latest(topic="out") == records[0].cursor + assert await stream.latest(topic="out") == landed -async def test_new_attempt_supersedes_the_old_one(provider: ProviderCase): - workflow_id = provider.workflow_id - first = await streams.producer( - provider.client, - workflow_id=workflow_id, - topic="out", - producer_id="model", - attempt=1, - ) +async def test_retried_append_is_stored_once(case: ProviderCase): + workflow_id = new_workflow_id() + stream = await case.open(workflow_id) + first = stream.producer(topic="out", producer_id="model", attempt=1) + await first.append({"id": "r1"}) + retry = stream.producer(topic="out", producer_id="model", attempt=1) + await retry.append({"id": "r1"}) + await retry.append({"id": "r2"}) + + records = await take(stream.read(topic="out", result_type=dict), 2) + assert [r.value for r in records] == [{"id": "r1"}, {"id": "r2"}] + + +async def test_new_attempt_supersedes_the_old_one(case: ProviderCase): + workflow_id = new_workflow_id() + stream = await case.open(workflow_id) + first = stream.producer(topic="out", producer_id="model", attempt=1) await first.append({"text": "The capital of"}) - second = await streams.producer( - provider.client, - workflow_id=workflow_id, - topic="out", - producer_id="model", - attempt=2, - ) + second = stream.producer(topic="out", producer_id="model", attempt=2) await second.append({"text": "Paris is the capital"}) - consumer = await streams.consumer(provider.client, workflow_id=workflow_id) - records = await take(consumer.read(type=dict, topic="out"), 3) + records = await take(stream.read(topic="out", result_type=dict), 3) assert records[0].kind is RecordKind.DATA and records[0].attempt == 1 assert records[1].kind is RecordKind.SUPERSEDED - assert isinstance(records[1].value, streams.Supersession) - assert records[1].value.previous_attempt == 1 + assert records[1].supersession == Supersession("model", 1, 2) + assert records[1].value is None assert records[2].kind is RecordKind.DATA and records[2].attempt == 2 -async def test_topic_filter_on_the_owners_stream(provider: ProviderCase): - # Two producers on two topics of the stream the workflow publishes. The - # filter is only meaningful on a store that mixes topics, which is this - # one; an inbound stream carries none. - workflow_id = provider.workflow_id - on_a = await streams.producer( - provider.client, - workflow_id=workflow_id, - topic="a", - producer_id="tool-a", - attempt=1, - ) - await on_a.append({"n": 1}) - on_b = await streams.producer( - provider.client, - workflow_id=workflow_id, - topic="b", - producer_id="tool-b", - attempt=1, +async def test_a_superseded_record_resumes_to_the_triggering_record( + case: ProviderCase, +): + workflow_id = new_workflow_id() + stream = await case.open(workflow_id) + first = stream.producer(topic="out", producer_id="model", attempt=1) + await first.append({"n": 1}) + second = stream.producer(topic="out", producer_id="model", attempt=2) + await second.append({"n": 2}) + + records = await take(stream.read(topic="out", result_type=dict), 3) + superseded = records[1] + assert superseded.kind is RecordKind.SUPERSEDED + # The synthesized record sits at the position before the new attempt's + # first record, so a consumer that checkpoints it and restarts is handed + # that record rather than skipping it. + assert superseded.cursor == records[0].cursor + resumed = await take( + stream.read(topic="out", result_type=dict, after=superseded.cursor), 1 ) - await on_b.append({"n": 2}) - - consumer = await streams.consumer(provider.client, workflow_id=workflow_id) - only_a = await take(consumer.read(type=dict, topic="a"), 1) - assert [(r.topic, r.value) for r in only_a] == [("a", {"n": 1})] - only_b = await take(consumer.read(type=dict, topic="b"), 1) - assert [(r.topic, r.value) for r in only_b] == [("b", {"n": 2})] + assert resumed[0].kind is RecordKind.DATA + assert resumed[0].value == {"n": 2} -@pytest.mark.unfiltered_read -async def test_a_read_without_a_topic_sees_every_topic(provider: ProviderCase): - workflow_id = provider.workflow_id - on_a = await streams.producer( - provider.client, - workflow_id=workflow_id, - topic="a", - producer_id="tool-a", - attempt=1, - ) +async def test_topics_are_addressed_by_name(case: ProviderCase): + # Two producers on two topics of the same workflow's stream: each read + # names its topic and sees only that topic's records. + workflow_id = new_workflow_id() + stream = await case.open(workflow_id) + on_a = stream.producer(topic="a", producer_id="tool-a", attempt=1) await on_a.append({"n": 1}) - on_b = await streams.producer( - provider.client, - workflow_id=workflow_id, - topic="b", - producer_id="tool-b", - attempt=1, - ) + on_b = stream.producer(topic="b", producer_id="tool-b", attempt=1) await on_b.append({"n": 2}) - consumer = await streams.consumer(provider.client, workflow_id=workflow_id) - both = await take(consumer.read(type=dict), 2) - assert [(r.topic, r.value) for r in both] == [("a", {"n": 1}), ("b", {"n": 2})] + only_a = await take(stream.read(topic="a", result_type=dict), 1) + assert [(r.topic, r.value) for r in only_a] == [("a", {"n": 1})] + only_b = await take(stream.read(topic="b", result_type=dict), 1) + assert [(r.topic, r.value) for r in only_b] == [("b", {"n": 2})] -async def test_cursor_resumes_where_it_points(provider: ProviderCase): - workflow_id = provider.workflow_id - producer = await streams.producer( - provider.client, - workflow_id=workflow_id, - topic="out", - producer_id="model", - attempt=1, - ) +async def test_cursor_resumes_where_it_points(case: ProviderCase): + workflow_id = new_workflow_id() + stream = await case.open(workflow_id) + producer = stream.producer(topic="out", producer_id="model", attempt=1) await producer.append({"n": 1}, {"n": 2}, {"n": 3}) - consumer = await streams.consumer(provider.client, workflow_id=workflow_id) - records = await take(consumer.read(type=dict, topic="out"), 3) + records = await take(stream.read(topic="out", result_type=dict), 3) checkpoint = records[0].cursor # Resuming after a record hands back everything past it and nothing # twice, without the reader ever advancing a cursor itself. - resumed = await streams.consumer(provider.client, workflow_id=workflow_id) - again = await take(resumed.read(type=dict, topic="out", after=checkpoint), 2) + again = await take(stream.read(topic="out", result_type=dict, after=checkpoint), 2) assert [r.value for r in again] == [{"n": 2}, {"n": 3}] -async def test_append_cursor_names_the_last_record_of_the_batch( - provider: ProviderCase, -): - workflow_id = provider.workflow_id - producer = await streams.producer( - provider.client, - workflow_id=workflow_id, - topic="out", - producer_id="model", - attempt=1, - ) +@pytest.mark.reports_positions +async def test_append_cursor_names_the_last_record_of_the_batch(case: ProviderCase): + workflow_id = new_workflow_id() + stream = await case.open(workflow_id) + producer = stream.producer(topic="out", producer_id="model", attempt=1) appended = await producer.append({"n": 1}, {"n": 2}, {"n": 3}) - if appended is None: - pytest.skip(f"the {provider.name} provider learns positions at read time") - await producer.append({"n": 4}) + assert appended is not None + then = await producer.append({"n": 4}) # A producer that resumes a reader after its own append must see only # what came later, not the tail of the batch it just wrote. - consumer = await streams.consumer(provider.client, workflow_id=workflow_id) - records = await take(consumer.read(type=dict, topic="out", after=appended), 1) + records = await take(stream.read(topic="out", result_type=dict, after=appended), 1) assert [r.value for r in records] == [{"n": 4}] - assert await producer.append() is None + assert records[0].cursor == then + assert await producer.append() == then -async def test_latest_positions_a_reader_at_the_end(provider: ProviderCase): - workflow_id = provider.workflow_id - producer = await streams.producer( - provider.client, - workflow_id=workflow_id, - topic="out", - producer_id="model", - attempt=1, - ) - consumer = await streams.consumer(provider.client, workflow_id=workflow_id) - assert await consumer.latest(topic="out") == streams.BEGINNING +async def test_latest_positions_a_reader_at_the_end(case: ProviderCase): + workflow_id = new_workflow_id() + stream = await case.open(workflow_id) + producer = stream.producer(topic="out", producer_id="model", attempt=1) + assert await stream.latest(topic="out") == BEGINNING await producer.append({"n": 1}, {"n": 2}) - since = await consumer.latest(topic="out") + since = await stream.latest(topic="out") await producer.append({"n": 3}) # A reader that positioned itself before the last append sees only what # came after, which is how a client follows a turn it is about to start. - records = await take(consumer.read(type=dict, topic="out", after=since), 1) + records = await take(stream.read(topic="out", result_type=dict, after=since), 1) assert [r.value for r in records] == [{"n": 3}] -@pytest.mark.inbound_stream -async def test_stream_addresses_with_colons_do_not_share_a_store( - provider: ProviderCase, -): +async def test_topic_addresses_with_colons_do_not_share_a_store(case: ProviderCase): # ("wf:x", "y") and ("wf", "x:y") differ only in where the colon sits. - base = provider.workflow_id - left = await streams.producer( - provider.client, workflow_id=f"{base}:x", stream="y", producer_id="l", attempt=1 + base = new_workflow_id() + left = await case.open(f"{base}:x") + right = await case.open(base) + await left.producer(topic="y", producer_id="l", attempt=1).append({"side": "left"}) + await right.producer(topic="x:y", producer_id="r", attempt=1).append( + {"side": "right"} ) - right = await streams.producer( - provider.client, workflow_id=base, stream="x:y", producer_id="r", attempt=1 - ) - await left.append({"side": "left"}) - await right.append({"side": "right"}) - seen_left = await streams.consumer( - provider.client, workflow_id=f"{base}:x", stream="y" - ) - only_left = await take(seen_left.read(type=dict), 1) + only_left = await take(left.read(topic="y", result_type=dict), 1) assert [r.value for r in only_left] == [{"side": "left"}] - assert await seen_left.latest() == only_left[0].cursor - seen_right = await streams.consumer(provider.client, workflow_id=base, stream="x:y") - only_right = await take(seen_right.read(type=dict), 1) + assert await left.latest(topic="y") == only_left[0].cursor + only_right = await take(right.read(topic="x:y", result_type=dict), 1) assert [r.value for r in only_right] == [{"side": "right"}] - assert await seen_right.latest() == only_right[0].cursor + assert await right.latest(topic="x:y") == only_right[0].cursor -async def test_producer_needs_exactly_one_address_and_an_identity( - provider: ProviderCase, -): - workflow_id = provider.workflow_id +async def test_a_foreign_cursor_is_refused_at_the_call(case: ProviderCase): + stream = await case.open(new_workflow_id()) + # Refused by read() itself, not by the first iteration of its generator, + # so the caller's except clause is where the mistake surfaces. + with pytest.raises(StreamCursorError): + stream.read(topic="out", after=Cursor("elsewhere:42")) + + +async def test_argument_mistakes_are_value_errors(case: ProviderCase): + stream = await case.open(new_workflow_id()) with pytest.raises(ValueError): - await streams.producer( - provider.client, workflow_id=workflow_id, producer_id="model", attempt=1 - ) + stream.read(topic="") with pytest.raises(ValueError): - await streams.producer( - provider.client, - workflow_id=workflow_id, - stream="inputs", - topic="out", - producer_id="model", - attempt=1, - ) + stream.producer(topic="", producer_id="model", attempt=1) # Outside an activity there is no identity to fall back on. with pytest.raises(ValueError, match="producer_id is required"): - await streams.producer(provider.client, workflow_id=workflow_id, topic="out") - - -def test_unknown_provider_is_a_clear_error(): - with pytest.raises(RuntimeError, match="no stream provider 'nope'"): - streams.configure(provider="nope") - - -async def test_opening_a_stream_needs_a_configured_provider(): - # Building a provider is process setup, so the first workflow's thread - # is not allowed to do it as a side effect of opening a stream. - _provider._active = None - with pytest.raises(RuntimeError, match="streams.configure"): - await streams.consumer(None, workflow_id="wf") - # Closing with nothing configured is allowed, so a shutdown path can - # call it unconditionally. - await streams.close() - streams.configure(provider="memory") - assert await streams.consumer(None, workflow_id="wf") is not None - await streams.close() + stream.producer(topic="out") diff --git a/tests/streams/test_streams_workflow.py b/tests/streams/test_streams_workflow.py index 40487686d..58fb274ef 100644 --- a/tests/streams/test_streams_workflow.py +++ b/tests/streams/test_streams_workflow.py @@ -1,25 +1,37 @@ """Workflow-side conformance for the stream contract. -Runs the reader and writer handles inside a real workflow on the memory -provider with a warm cache, and states the two rules about workflow tasks as -tests: a publish commits with its task (rule 1), and reads are recorded -observations that replay re-supplies (rule 2). The memory provider keeps -neither and says so in its docstring, so those two are strict expected -failures here. A storage provider that runs this module turns them into -passes; that is the measurement they exist for. +Runs the reader and writer inside a real workflow on the memory provider +with a warm cache, and states the two rules about Workflow Tasks as tests: a +publish commits with its task (rule 1), and reads are recorded observations +that replay re-supplies (rule 2). The memory provider keeps neither and says +so in its docstring, so those two are strict expected failures here. A +storage provider that runs this module turns them into passes; that is the +measurement they exist for. + +The rest is what the portable surface promises on every provider: the +lifecycle hooks the worker calls, one subscription per topic per run, a read +that ends when the chain closes, a handle that follows continue-as-new, and +a topic shared by the workflow and an outside producer. """ from __future__ import annotations +import asyncio import uuid from typing import Any import pytest -from temporalio import streams, workflow +from temporalio import workflow from temporalio.client import Client -from temporalio.streams import RecordKind -from temporalio.streams.providers import memory +from temporalio.streams import ( + Cursor, + ReadSource, + RecordKind, + StreamCursorError, + WriteSink, +) +from temporalio.streams.providers.memory import MemoryStreams from temporalio.testing import WorkflowEnvironment from temporalio.worker import Replayer from tests.helpers import new_worker @@ -29,49 +41,46 @@ DECISIONS = "decisions" -@pytest.fixture(autouse=True) -def _fresh_memory_provider(env: WorkflowEnvironment): # pyright: ignore[reportUnusedFunction] +@pytest.fixture +def provider(env: WorkflowEnvironment): # pyright: ignore[reportUnusedFunction] if env.supports_time_skipping: pytest.skip( "the memory provider polls on a timer, which time skipping turns into a spin" ) - memory.reset() - streams.configure(provider="memory") - yield - memory.reset() + streams = MemoryStreams() + yield streams + streams.reset() @workflow.defn class ContractLoop: """Reads ``inputs``, publishes a decision per value, reports control records.""" - def __init__(self) -> None: - streams.prepare() - @workflow.run async def run(self) -> list[dict[str, Any]]: - inputs = streams.reader(INPUTS, type=dict) - decisions = streams.writer(DECISIONS) + inputs = workflow.stream_reader(INPUTS, result_type=dict) + decisions = workflow.stream_writer(DECISIONS) trace: list[dict[str, Any]] = [] try: async for record in inputs: - if isinstance(record.value, streams.Supersession): + if record.kind is RecordKind.SUPERSEDED: + assert record.supersession is not None trace.append( { "kind": "superseded", - "replaced": record.value.previous_attempt, - "attempt": record.value.attempt, + "replaced": record.supersession.previous_attempt, + "attempt": record.supersession.attempt, } ) - await decisions.publish( - {"retracting_attempt": record.value.previous_attempt} + decisions.publish( + {"retracting_attempt": record.supersession.previous_attempt} ) continue if record.kind is RecordKind.FINISH: - trace.append({"kind": "finish", "producer": record.producer}) + trace.append({"kind": "finish", "producer": record.producer_id}) break - assert isinstance(record.value, dict) - await decisions.publish({"decided": record.value["n"]}) + assert record.value is not None + decisions.publish({"decided": record.value["n"]}) trace.append( { "kind": "decision", @@ -81,42 +90,34 @@ async def run(self) -> list[dict[str, Any]]: ) finally: inputs.close() - await decisions.finish() + decisions.finish() # Twice on purpose: a finished topic stays finished, with one marker. - await decisions.finish() - streams.drain() + decisions.finish() return trace -async def _run_the_loop(client: Client) -> tuple[Any, list[dict[str, Any]]]: +async def _run_the_loop( + client: Client, provider: MemoryStreams +) -> tuple[Any, list[dict[str, Any]]]: workflow_id = f"streams-wf-{uuid.uuid4().hex}" - async with new_worker(client, ContractLoop, **streams.worker_options()) as worker: + async with new_worker(client, ContractLoop, plugins=[provider]) as worker: handle = await client.start_workflow( ContractLoop.run, id=workflow_id, task_queue=worker.task_queue ) - first = await streams.producer( - client, - workflow_id=workflow_id, - stream=INPUTS, - producer_id="model", - attempt=1, - ) + stream = provider.get_stream_handle(client, workflow_id) + first = stream.producer(topic=INPUTS, producer_id="model", attempt=1) await first.append({"n": 1}, {"n": 2}) - second = await streams.producer( - client, - workflow_id=workflow_id, - stream=INPUTS, - producer_id="model", - attempt=2, - ) + second = stream.producer(topic=INPUTS, producer_id="model", attempt=2) await second.append({"n": 3}) await second.finish() trace = await handle.result() return handle, trace -async def test_workflow_reads_decides_and_publishes(client: Client): - handle, trace = await _run_the_loop(client) +async def test_workflow_reads_decides_and_publishes( + client: Client, provider: MemoryStreams +): + handle, trace = await _run_the_loop(client, provider) assert trace == [ {"kind": "decision", "n": 1, "attempt": 1}, {"kind": "decision", "n": 2, "attempt": 1}, @@ -126,8 +127,8 @@ async def test_workflow_reads_decides_and_publishes(client: Client): ] # The outside view of what the workflow published, on its own topic. - consumer = await streams.consumer(client, workflow_id=handle.id) - records = await take(consumer.read(type=dict, topic=DECISIONS), 5) + stream = provider.get_stream_handle(client, handle.id) + records = await take(stream.read(topic=DECISIONS, result_type=dict), 5) assert [(r.kind, r.value) for r in records] == [ (RecordKind.DATA, {"decided": 1}), (RecordKind.DATA, {"decided": 2}), @@ -135,9 +136,30 @@ async def test_workflow_reads_decides_and_publishes(client: Client): (RecordKind.DATA, {"decided": 3}), (RecordKind.FINISH, None), ] - assert all(r.producer == "" and r.topic == DECISIONS for r in records) + assert all(r.producer_id == "" and r.topic == DECISIONS for r in records) # The second finish() wrote nothing: the marker is the newest record. - assert await consumer.latest() == records[-1].cursor + assert await stream.latest(topic=DECISIONS) == records[-1].cursor + + +async def test_read_ends_when_the_workflow_closes_and_the_tail_is_delivered( + client: Client, provider: MemoryStreams +): + handle, _ = await _run_the_loop(client, provider) + stream = provider.get_stream_handle(client, handle.id) + + async def read_everything() -> list[Any]: + return [r.value async for r in stream.read(topic=DECISIONS, result_type=dict)] + + # No count and no early break: the read ends by itself once the workflow + # is closed and everything it retained has been handed over. + values = await asyncio.wait_for(read_everything(), 30) + assert values == [ + {"decided": 1}, + {"decided": 2}, + {"retracting_attempt": 1}, + {"decided": 3}, + None, + ] @pytest.mark.xfail( @@ -148,12 +170,14 @@ async def test_workflow_reads_decides_and_publishes(client: Client): "provider reads live process memory instead" ), ) -async def test_replay_without_the_store_resupplies_the_records(client: Client): - handle, _ = await _run_the_loop(client) +async def test_replay_without_the_store_resupplies_the_records( + client: Client, provider: MemoryStreams +): + handle, _ = await _run_the_loop(client, provider) history = await handle.fetch_history() - memory.reset() - replayer = Replayer(workflows=[ContractLoop], **streams.worker_options()) + provider.reset() + replayer = Replayer(workflows=[ContractLoop], plugins=[provider]) await replayer.replay_workflow(history) @@ -167,20 +191,16 @@ async def test_replay_without_the_store_resupplies_the_records(client: Client): class PublishThenFail: """Publishes, then fails its first workflow task; the retry publishes again.""" - def __init__(self) -> None: - streams.prepare() - @workflow.run async def run(self) -> None: - decisions = streams.writer(DECISIONS) + decisions = workflow.stream_writer(DECISIONS) run_id = workflow.info().run_id committed = run_id in _failed_once - await decisions.publish({"committed": committed}) + decisions.publish({"committed": committed}) if not committed: _failed_once.add(run_id) raise RuntimeError("the first task fails after publishing") - await decisions.finish() - streams.drain() + decisions.finish() @pytest.mark.xfail( @@ -191,18 +211,237 @@ async def run(self) -> None: "visible at publish time" ), ) -async def test_a_failed_task_publishes_nothing(client: Client): +async def test_a_failed_task_publishes_nothing(client: Client, provider: MemoryStreams): workflow_id = f"streams-wf-{uuid.uuid4().hex}" - async with new_worker( - client, PublishThenFail, **streams.worker_options() - ) as worker: + async with new_worker(client, PublishThenFail, plugins=[provider]) as worker: handle = await client.start_workflow( PublishThenFail.run, id=workflow_id, task_queue=worker.task_queue ) - consumer = await streams.consumer(client, workflow_id=workflow_id) - records = await take(consumer.read(type=dict, topic=DECISIONS), 2, timeout=30) + stream = provider.get_stream_handle(client, workflow_id) + records = await take( + stream.read(topic=DECISIONS, result_type=dict), 2, timeout=30 + ) await handle.result() assert [(r.kind, r.value) for r in records] == [ (RecordKind.DATA, {"committed": True}), (RecordKind.FINISH, None), ] + + +@workflow.defn +class Relay: + """Publishes one record per run and continues as new once.""" + + @workflow.run + async def run(self, run: int) -> None: + decisions = workflow.stream_writer(DECISIONS) + decisions.publish({"run": run}) + if run == 0: + workflow.continue_as_new(run + 1) + decisions.finish() + + +class _RecordingHalf: + """A workflow half that logs the hooks the worker calls, then delegates.""" + + def __init__(self, inner: Any, calls: list[tuple[str, str]]) -> None: + self._inner = inner + self._calls = calls + + def open_reader(self, topic: str, *, after: Cursor) -> ReadSource: + return self._inner.open_reader(topic, after=after) + + def open_writer(self, topic: str) -> WriteSink: + return self._inner.open_writer(topic) + + def on_workflow_start(self) -> None: + self._calls.append(("start", workflow.info().run_id)) + + async def on_workflow_finish(self) -> None: + self._calls.append(("finish", workflow.info().run_id)) + + +class HookedMemory(MemoryStreams): + """The memory provider with its lifecycle hooks made visible.""" + + def __init__(self) -> None: + super().__init__() + self.calls: list[tuple[str, str]] = [] + + def workflow_provider(self) -> Any: + return _RecordingHalf(super().workflow_provider(), self.calls) + + +@pytest.mark.usefixtures("provider") +async def test_the_worker_calls_the_lifecycle_hooks_around_every_run(client: Client): + hooked = HookedMemory() + workflow_id = f"streams-wf-{uuid.uuid4().hex}" + async with new_worker(client, Relay, plugins=[hooked]) as worker: + handle = await client.start_workflow( + Relay.run, 0, id=workflow_id, task_queue=worker.task_queue + ) + await handle.result() + # Start before the function, finish after it, on both runs: the finish + # hook runs on the continue-as-new exit too, so a provider that parked + # something against the first run can let go before the successor starts. + kinds = [kind for kind, _ in hooked.calls] + assert kinds == ["start", "finish", "start", "finish"] + runs = [run_id for _, run_id in hooked.calls] + assert runs[0] == runs[1] and runs[2] == runs[3] and runs[0] != runs[2] + + +async def test_a_handle_without_a_run_id_reads_across_continue_as_new( + client: Client, provider: MemoryStreams +): + workflow_id = f"streams-wf-{uuid.uuid4().hex}" + async with new_worker(client, Relay, plugins=[provider]) as worker: + handle = await client.start_workflow( + Relay.run, 0, id=workflow_id, task_queue=worker.task_queue + ) + stream = provider.get_stream_handle(client, workflow_id) + + async def read_everything() -> list[Any]: + return [ + (r.kind, r.value) + async for r in stream.read(topic=DECISIONS, result_type=dict) + ] + + records = await asyncio.wait_for(read_everything(), 30) + await handle.result() + # The chain is followed: the successor's records arrive on the same read, + # and the read ends only when the last run of the chain is closed. + assert records == [ + (RecordKind.DATA, {"run": 0}), + (RecordKind.DATA, {"run": 1}), + (RecordKind.FINISH, None), + ] + + +@workflow.defn +class SharedReaders: + """Opens the same topic twice and pulls from both readers in turn.""" + + @workflow.run + async def run(self) -> list[Any]: + first = workflow.stream_reader(INPUTS, result_type=dict) + second = workflow.stream_reader(INPUTS, result_type=dict) + trace: list[Any] = ["shared" if first is second else "separate"] + try: + workflow.stream_reader(INPUTS, result_type=dict, after=Cursor("memory:0")) + except ValueError: + trace.append("after-rejected") + try: + workflow.stream_reader(INPUTS, result_type=list) + except ValueError: + trace.append("type-rejected") + trace.append((await first.__anext__()).value) + trace.append((await second.__anext__()).value) + first.close() + return trace + + +async def test_a_second_reader_on_a_topic_shares_the_subscription( + client: Client, provider: MemoryStreams +): + workflow_id = f"streams-wf-{uuid.uuid4().hex}" + async with new_worker(client, SharedReaders, plugins=[provider]) as worker: + handle = await client.start_workflow( + SharedReaders.run, id=workflow_id, task_queue=worker.task_queue + ) + stream = provider.get_stream_handle(client, workflow_id) + await stream.producer(topic=INPUTS, producer_id="model", attempt=1).append( + {"n": 1}, {"n": 2} + ) + assert await handle.result() == [ + "shared", + "after-rejected", + "type-rejected", + {"n": 1}, + {"n": 2}, + ] + + +@workflow.defn +class ForeignCursor: + """Resumes from a cursor another provider minted.""" + + @workflow.run + async def run(self) -> str: + try: + workflow.stream_reader(INPUTS, after=Cursor("elsewhere:1")) + except StreamCursorError: + return "refused" + return "accepted" + + +async def test_the_workflow_reader_refuses_a_foreign_cursor( + client: Client, provider: MemoryStreams +): + async with new_worker(client, ForeignCursor, plugins=[provider]) as worker: + result = await client.execute_workflow( + ForeignCursor.run, + id=f"streams-wf-{uuid.uuid4().hex}", + task_queue=worker.task_queue, + ) + assert result == "refused" + + +@workflow.defn +class NoProvider: + """Opens a stream on a worker that has no provider.""" + + @workflow.run + async def run(self) -> str: + try: + workflow.stream_reader(INPUTS) + except RuntimeError as error: + return str(error) + return "opened" + + +async def test_a_worker_without_a_provider_says_so(client: Client): + async with new_worker(client, NoProvider) as worker: + result = await client.execute_workflow( + NoProvider.run, + id=f"streams-wf-{uuid.uuid4().hex}", + task_queue=worker.task_queue, + ) + assert "no stream provider is configured" in result + + +@workflow.defn +class OneLine: + """Publishes one record and finishes the topic.""" + + @workflow.run + async def run(self) -> None: + decisions = workflow.stream_writer(DECISIONS) + decisions.publish({"from": "workflow"}) + decisions.finish() + + +async def test_an_outside_producer_and_the_workflow_share_a_topic( + client: Client, provider: MemoryStreams +): + workflow_id = f"streams-wf-{uuid.uuid4().hex}" + async with new_worker(client, OneLine, plugins=[provider]) as worker: + stream = provider.get_stream_handle(client, workflow_id) + await stream.producer(topic=DECISIONS, producer_id="tool", attempt=1).append( + {"from": "producer"} + ) + handle = await client.start_workflow( + OneLine.run, id=workflow_id, task_queue=worker.task_queue + ) + await handle.result() + + async def read_everything() -> list[Any]: + return [r async for r in stream.read(topic=DECISIONS, result_type=dict)] + + records = await asyncio.wait_for(read_everything(), 30) + # Both writers land on one topic in one order, each under its own + # identity: the producer's records carry its id, the workflow's carry none. + assert [(r.producer_id, r.kind, r.value) for r in records] == [ + ("tool", RecordKind.DATA, {"from": "producer"}), + ("", RecordKind.DATA, {"from": "workflow"}), + ("", RecordKind.FINISH, None), + ] From 1ea907cb038c4bf7961bec175836ddca04c9af89 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 01:54:17 -0700 Subject: [PATCH 55/96] Moved the stream demo and changelog entry onto the new surface. --- CHANGELOG.md | 7 ++++++ streams_demo/agent_loop.py | 42 +++++++++++++--------------------- streams_demo/provider_setup.py | 26 +++++++++++++-------- streams_demo/run_demo.py | 29 ++++++++++------------- 4 files changed, 51 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 796548c8c..6865fa6c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,13 @@ to include examples, links to docs, or any other relevant information. Workflow Streams. - `ExternalStreamSubscription.records()` yields each value with the provider offset it was read from, for a reader that has to name where it got to. + can read, decide on, and write. `workflow.stream_reader()` and + `workflow.stream_writer()` are the workflow-side entry points, a provider is a + worker plugin passed as `Worker(plugins=[provider])`, and + `provider.get_stream_handle()` reads and appends from outside. The record on + the wire is `temporal.api.stream.v1.StreamRecord` on every provider. + `temporalio.streams.providers.memory.MemoryStreams` is the in-memory + reference provider the conformance tests run against. - Added experimental External Workflow Streams in `temporalio.contrib.external_workflow_streams`. Workflow stream payloads are diff --git a/streams_demo/agent_loop.py b/streams_demo/agent_loop.py index f6f14b873..330cb54c2 100644 --- a/streams_demo/agent_loop.py +++ b/streams_demo/agent_loop.py @@ -14,7 +14,8 @@ from datetime import timedelta from typing import Any -from temporalio import activity, streams, workflow +from temporalio import activity, workflow +from temporalio.streams import RecordKind DECISIONS = "decisions" INPUTS = "inputs" @@ -41,50 +42,42 @@ def decide(token: dict[str, Any]) -> dict[str, Any]: class AgentLoop: """Read, decide, write, until the producer says it has finished.""" - def __init__(self) -> None: - """Let the provider install what it needs before the first task. - - A no-op except on a transport that serves outside readers through - handlers on this workflow, which has to register them before the - first task completes or an early reader finds nothing to talk to. - """ - streams.prepare() - @workflow.run async def run(self, limit: int) -> list[dict[str, Any]]: """Decide on at most ``limit`` inputs, then return the trace.""" - inputs = streams.reader(INPUTS, type=dict, idle_timeout=timedelta(seconds=1)) - decisions = streams.writer(DECISIONS) + inputs = workflow.stream_reader(INPUTS, result_type=dict) + decisions = workflow.stream_writer(DECISIONS) trace: list[dict[str, Any]] = [] accepted = 0 try: async for record in inputs: - if isinstance(record.value, streams.Supersession): + if record.kind is RecordKind.SUPERSEDED: # A newer attempt of the same producer started writing. The # decisions already published stand, so the workflow says so # rather than pretending they can be withdrawn. + assert record.supersession is not None trace.append( { "kind": "superseded", - "producer": record.producer, - "replaced": record.value.previous_attempt, - "attempt": record.value.attempt, + "producer": record.producer_id, + "replaced": record.supersession.previous_attempt, + "attempt": record.supersession.attempt, } ) - await decisions.publish( - {"retracting_attempt": record.value.previous_attempt} + decisions.publish( + {"retracting_attempt": record.supersession.previous_attempt} ) continue - if record.kind is streams.RecordKind.FINISH: + if record.kind is RecordKind.FINISH: # The producer says it is done, which is what ends the loop. # Counting decisions instead would leave the terminal record # unread and let the workflow finish while its producer is # still writing. - trace.append({"kind": "finish", "producer": record.producer}) + trace.append({"kind": "finish", "producer": record.producer_id}) break - assert isinstance(record.value, dict) + assert record.value is not None decision = decide(record.value) - await decisions.publish(decision) + decisions.publish(decision) receipt = await workflow.execute_activity( record_decision, decision, @@ -101,8 +94,5 @@ async def run(self, limit: int) -> list[dict[str, Any]]: break finally: inputs.close() - await decisions.finish() - # Lets go of anything the provider parked against this run, so a - # transport that holds a long poll open can let the workflow return. - streams.drain() + decisions.finish() return trace diff --git a/streams_demo/provider_setup.py b/streams_demo/provider_setup.py index aab3b6cfc..2e4bcc350 100644 --- a/streams_demo/provider_setup.py +++ b/streams_demo/provider_setup.py @@ -1,14 +1,17 @@ """Pick the provider for a demo run from the environment. -``STREAMS_PROVIDER`` names any registered provider; this tree carries -``memory`` and ``redis``. The demo needs a Temporal server to run the -workflow either way; ``TEMPORAL_ADDRESS`` points at it. +``STREAMS_PROVIDER`` names a provider; this base tree carries only ``memory``, +and each provider branch adds its own name here. The demo needs a Temporal +server to run the workflow either way; ``TEMPORAL_ADDRESS`` points at it. """ from __future__ import annotations import os +from temporalio.streams.providers import ProviderPlugin +from temporalio.streams.providers.memory import MemoryStreams + NAME = os.environ.get("STREAMS_PROVIDER", "memory") # The memory provider is not replay-safe, so its demo keeps the cache warm. @@ -16,15 +19,18 @@ WORKFLOW_CACHE = int(os.environ.get("STREAMS_WORKFLOW_CACHE", "512")) -async def open() -> tuple[str, dict]: - """The server to connect to and the options :func:`configure` takes.""" - return os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), {"provider": NAME} +async def open() -> tuple[str, ProviderPlugin]: + """The server to connect to and the provider the worker and the client share.""" + if NAME != "memory": + raise SystemExit(f"this tree carries no stream provider named {NAME!r}") + return os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), MemoryStreams() -async def close() -> None: +async def close(provider: ProviderPlugin) -> None: """Let go of whatever :func:`open` acquired. - Nothing here: the memory provider holds no connection. A provider branch - that opens one closes it here, so the demo's teardown reads the same on - every provider. + The memory provider holds no connection, so this is its ``close()`` and + nothing more. A provider branch that opens one closes it the same way, so + the demo's teardown reads the same on every provider. """ + await provider.close() diff --git a/streams_demo/run_demo.py b/streams_demo/run_demo.py index c4fc8e042..1238fad11 100644 --- a/streams_demo/run_demo.py +++ b/streams_demo/run_demo.py @@ -7,7 +7,7 @@ the run as it allows is rebuilt rather than remembered; - a producer whose second attempt supersedes its first, which the reader has to report and the workflow has to act on; -- an outside consumer reading what the workflow published. +- an outside reader following what the workflow published. Byte-identical in every tree. ``provider_setup`` is what differs, and it is the only import here that names a provider. @@ -23,24 +23,23 @@ from pathlib import Path from typing import Any -from temporalio import streams from temporalio.api.enums.v1 import EventType from temporalio.client import Client +from temporalio.streams import StreamHandle from temporalio.worker import Worker sys.path.insert(0, str(Path(__file__).resolve().parent)) import provider_setup # noqa: E402 -from agent_loop import INPUTS, AgentLoop, record_decision # noqa: E402 +from agent_loop import DECISIONS, INPUTS, AgentLoop, record_decision # noqa: E402 DECISION_LIMIT = 8 EXPECTED_OUTPUT = 6 -async def collect_output(client: Client, workflow_id: str, want: int) -> list[dict]: +async def collect_output(stream: StreamHandle, want: int) -> list[dict]: """Read ``want`` decisions off the workflow's stream from outside it.""" - reader = await streams.consumer(client, workflow_id=workflow_id) seen: list[dict] = [] - async for record in reader.read(type=dict, topic="decisions"): + async for record in stream.read(topic=DECISIONS, result_type=dict): seen.append({"kind": record.kind.name, "value": record.value}) if len(seen) >= want: break @@ -51,9 +50,8 @@ async def main() -> int: """Run the demo once and write what happened next to this file.""" out = Path(__file__).resolve().parent / f"results-{provider_setup.NAME}" out.mkdir(exist_ok=True) - target, options = await provider_setup.open() + target, provider = await provider_setup.open() client = await Client.connect(target, namespace="default") - streams.configure(**options) uid = f"ai198-contract-{provider_setup.NAME}-" + uuid.uuid4().hex record: dict[str, Any] = { @@ -69,24 +67,21 @@ async def main() -> int: workflows=[AgentLoop], activities=[record_decision], max_cached_workflows=provider_setup.WORKFLOW_CACHE, - **streams.worker_options(), + plugins=[provider], ): handle = await client.start_workflow( AgentLoop.run, DECISION_LIMIT, id=uid, task_queue=uid ) - output = asyncio.create_task(collect_output(client, uid, EXPECTED_OUTPUT)) + stream = provider.get_stream_handle(client, uid) + output = asyncio.create_task(collect_output(stream, EXPECTED_OUTPUT)) # The first attempt writes two records and then stops, as a failed # activity would. The second writes different inputs under the same # logical producer, which is what the reader has to report. - first = await streams.producer( - client, workflow_id=uid, stream=INPUTS, producer_id="model", attempt=1 - ) + first = stream.producer(topic=INPUTS, producer_id="model", attempt=1) await first.append({"id": "r1", "value": 1}, {"id": "r2", "value": 2}) await asyncio.sleep(0.5) - second = await streams.producer( - client, workflow_id=uid, stream=INPUTS, producer_id="model", attempt=2 - ) + second = stream.producer(topic=INPUTS, producer_id="model", attempt=2) await second.append({"id": "r3", "value": 3}, {"id": "r4", "value": 4}) await second.finish() @@ -146,7 +141,7 @@ async def main() -> int: history = await handle.fetch_history() record["history_events"] = len(history.events) (out / "history.json").write_text(history.to_json()) - await provider_setup.close() + await provider_setup.close(provider) (out / "results.json").write_text(json.dumps(record, indent=2) + "\n") print(json.dumps(record, indent=2)) return 0 if record["outcome"] == "completed" else 1 From 2659a1784994f4e727c55c5e3a42ff7cfbc10034 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 02:00:38 -0700 Subject: [PATCH 56/96] Rewrote the Redis provider with an input and an output stream per topic. The transport keys every stream by direction, so an outside producer appends each record to the input stream that wakes a subscribed workflow and to the output stream where the workflow's promoted batches land. A workflow's publish is synchronous and stages with the task; an outside read ends with the workflow. --- CHANGELOG.md | 13 +- streams_demo/provider_setup.py | 26 +- temporalio/streams/providers/redis.py | 819 ++++++++++++++-------- tests/streams/test_redis_provider.py | 45 ++ tests/streams/test_redis_replay.py | 134 +++- tests/streams/test_streams_conformance.py | 53 +- 6 files changed, 742 insertions(+), 348 deletions(-) create mode 100644 tests/streams/test_redis_provider.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6865fa6c2..a084569bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,20 +36,17 @@ to include examples, links to docs, or any other relevant information. ### Added - **Experimental**: `temporalio.streams` defines one stream interface a workflow - can read, decide on, and write, with a registry that picks the provider when - the worker is built. `temporalio.streams.providers.memory` is the in-memory - reference provider the conformance tests run against, and - `temporalio.streams.providers.redis` serves the same interface over External - Workflow Streams. -- `ExternalStreamSubscription.records()` yields each value with the provider - offset it was read from, for a reader that has to name where it got to. can read, decide on, and write. `workflow.stream_reader()` and `workflow.stream_writer()` are the workflow-side entry points, a provider is a worker plugin passed as `Worker(plugins=[provider])`, and `provider.get_stream_handle()` reads and appends from outside. The record on the wire is `temporal.api.stream.v1.StreamRecord` on every provider. `temporalio.streams.providers.memory.MemoryStreams` is the in-memory - reference provider the conformance tests run against. + reference provider the conformance tests run against, and + `temporalio.streams.providers.redis.RedisStreams` serves the same interface + over External Workflow Streams, one topic as an input and an output stream. +- `ExternalStreamSubscription.records()` yields each value with the provider + offset it was read from, for a reader that has to name where it got to. - Added experimental External Workflow Streams in `temporalio.contrib.external_workflow_streams`. Workflow stream payloads are diff --git a/streams_demo/provider_setup.py b/streams_demo/provider_setup.py index 2e4bcc350..7fbf9316c 100644 --- a/streams_demo/provider_setup.py +++ b/streams_demo/provider_setup.py @@ -1,8 +1,9 @@ """Pick the provider for a demo run from the environment. -``STREAMS_PROVIDER`` names a provider; this base tree carries only ``memory``, -and each provider branch adds its own name here. The demo needs a Temporal -server to run the workflow either way; ``TEMPORAL_ADDRESS`` points at it. +``STREAMS_PROVIDER`` names a provider; this tree carries ``memory`` and +``redis``. The demo needs a Temporal server to run the workflow either way; +``TEMPORAL_ADDRESS`` points at it, and the Redis demo reads its store from +``AI198_REDIS_URL`` and ``AI198_REDIS_PREFIX``. """ from __future__ import annotations @@ -21,16 +22,23 @@ async def open() -> tuple[str, ProviderPlugin]: """The server to connect to and the provider the worker and the client share.""" - if NAME != "memory": - raise SystemExit(f"this tree carries no stream provider named {NAME!r}") - return os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), MemoryStreams() + address = os.environ.get("TEMPORAL_ADDRESS", "localhost:7233") + if NAME == "memory": + return address, MemoryStreams() + if NAME == "redis": + from temporalio.streams.providers.redis import RedisStreams + + return address, RedisStreams( + url=os.environ.get("AI198_REDIS_URL", "redis://127.0.0.1:6379"), + key_prefix=os.environ.get("AI198_REDIS_PREFIX", "ai198-contract"), + ) + raise SystemExit(f"this tree carries no stream provider named {NAME!r}") async def close(provider: ProviderPlugin) -> None: """Let go of whatever :func:`open` acquired. - The memory provider holds no connection, so this is its ``close()`` and - nothing more. A provider branch that opens one closes it the same way, so - the demo's teardown reads the same on every provider. + The memory provider holds no connection and the Redis provider closes + the client it opened, so the demo's teardown reads the same on both. """ await provider.close() diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 3cc24e538..552e3d904 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -1,81 +1,238 @@ """The client-side (Redis) provider. -Streams live in a store the customer runs, Redis here. A workflow's publish is -buffered by the worker, staged invisibly under a token, and promoted only once -a marker in History proves the workflow task that produced it was accepted. -Consumption is recorded the same way, as ranges and boundaries in History. - -``configure`` takes ``url`` and ``key_prefix`` (defaulting to -``TEMPORAL_TEST_REDIS_URL``, then ``AI198_REDIS_URL``, and -``AI198_REDIS_PREFIX``), or a constructed ``backend`` when the caller wants to -own its lifecycle. The Redis client is opened on first use, so it belongs to -the loop that uses it, and released by ``close``. +Streams live in a store the customer runs, Redis here, behind the external +workflow streams transport. A workflow's publish is buffered by the worker, +staged invisibly under a token when the Workflow Task completes, and promoted +only once a marker in History proves the task was accepted. Consumption is +recorded the same way, as ranges and boundaries in History. + +The mapping, in one place: + +- The transport keeps two Redis streams per topic, one the workflow reads and + one outside readers read, because the direction is part of every key it + derives. An outside producer therefore appends each record to both: the + input stream, which wakes a workflow subscribed to the topic, and the + output stream, where the workflow's own promoted batches land, so an + outside reader sees the producer's records and the workflow's in one order + and a workflow reading the topic misses nothing however early the producer + started. The cost is one more append per record and one wake per batch. +- A record rides as the transport's payload: the serialized ``StreamRecord`` + proto as a ``binary/plain`` value, which the worker's codec encodes and + decodes like any other payload. +- Producer identity dedupes through the transport's idempotency: the session + is ``producer#attempt`` and every record carries a sequence, so a retried + batch is dropped with the original position and a new attempt passes. +- Cursors are ``redis:-`` on the outside surface and name a position + in the output stream. A workflow-side record carries ``redis:in:-`` + for information only: the transport starts a run's subscription where the + chain left off, so a workflow reader cannot resume from a cursor. +- Streams are keyed by the chain's first run, so a handle follows continue-as- + new by construction and ``run_id`` only decides whose close ends a read. +- A task's publishes are staged as one batch. The transport's own per-task + batch limits are lifted for this provider, because a synchronous publish + cannot wait for the worker to stage a full batch; a batch it cannot stage + fails the task. """ from __future__ import annotations import asyncio import logging -import os +import re import time -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Coroutine from datetime import timedelta from typing import Any -from temporalio.api.common.v1 import Payload +from google.protobuf.message import DecodeError + +from temporalio import workflow from temporalio.client import Client, WorkflowExecutionStatus from temporalio.contrib.external_workflow_streams import ( AFTER, - ExternalOutputStreamClient, + AppendConflictError, + AppendNotAcknowledgedError, + ChainKeyMismatchError, ExternalOutputStreamProducer, ExternalStreamProducer, Offset, - OutputStreamRecord, + OutputAppendNotAcknowledgedError, + StreamDirection, WakeNotAcknowledgedError, WorkflowChainKey, external_output_stream, external_stream, ) from temporalio.contrib.external_workflow_streams import ( - BEGINNING as PROVIDER_BEGINNING, + BEGINNING as TRANSPORT_BEGINNING, +) +from temporalio.contrib.external_workflow_streams import ( + RecordKind as TransportRecordKind, +) +from temporalio.contrib.external_workflow_streams import ( + StreamError as TransportStreamError, ) from temporalio.contrib.external_workflow_streams._backend import DEFAULT_WATCH_BLOCK -from temporalio.streams import _frame, _provider -from temporalio.streams._handles import ReadSource, WriteSink -from temporalio.streams._policy import AttemptTracker +from temporalio.contrib.external_workflow_streams._codec import StreamPayloadCodec +from temporalio.contrib.external_workflow_streams._output_client import ( + _reconcile_output_stage, +) +from temporalio.converter import WorkflowSerializationContext +from temporalio.service import RPCError, RPCStatusCode +from temporalio.streams._errors import ( + StreamCursorError, + StreamError, + StreamNotFoundError, + StreamProducerError, + StreamUnsupportedError, +) +from temporalio.streams._provider import ReadSource, WriteSink from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord +from temporalio.streams._wire import ( + RecordDecoder, + WireRecord, + cursor_position, + mint_cursor, + producer_identity, + to_wire, +) +from temporalio.streams.providers import ProviderPlugin +from temporalio.worker import ReplayerConfig, WorkerConfig + +__all__ = ["RedisProducer", "RedisStreamHandle", "RedisStreams"] + +_PROVIDER = "redis" +_INPUT_PREFIX = "in:" +_REDIS_ID = re.compile(r"\d+-\d+") +_READ_BATCH = 256 + +# The transport's per-task batch limits are a backpressure point that awaits +# inside the workflow, and a synchronous publish has nowhere to await. Lifted +# to where they cannot fire, so a task's publishes stage as one batch. +_UNBOUNDED_OUTPUT = external_output_stream.with_options( + max_records=1 << 30, max_logical_bytes=1 << 40 +) logger = logging.getLogger(__name__) -class _ExternalReadSource: +def _require_topic(topic: str) -> None: + if not topic: + raise ValueError("topic must not be empty") + + +def _outside_position(after: Cursor) -> Offset | None: + """The output-stream offset an outside cursor names, or ``None`` for BEGINNING.""" + token = cursor_position(after, provider=_PROVIDER) + if token is None: + return None + if token.startswith(_INPUT_PREFIX): + raise StreamCursorError( + f"cursor {after.token!r} names a position in the workflow's input log, " + "which only the workflow reads" + ) + if not _REDIS_ID.fullmatch(token): + raise StreamCursorError( + f"cursor {after.token!r} does not name a Redis stream position" + ) + return Offset(token) + + +def _drive(coroutine: Coroutine[Any, Any, None]) -> None: + """Run a transport publish to completion without yielding to the loop. + + The transport's publish only awaits when the task's batch is full. A + synchronous publish has nowhere to wait, so a publish that would have + waited fails the task instead, loudly. + """ + try: + coroutine.send(None) + except StopIteration: + return + coroutine.close() + raise StreamError( + "this Workflow Task's output batch is full, and a synchronous publish " + "cannot wait for the worker to stage it" + ) + + +def _parse(cursor: Cursor, raw: bytes, warn: Any) -> WireRecord | None: + try: + return WireRecord.FromString(raw) + except DecodeError as error: + # Same answer as an undecodable body: skip and say so, so one bad + # record cannot pin a reader. + warn("skipping stream record at %s: %s", cursor, error) + return None + + +class _RedisReadSource: + """One subscription of the running workflow, over the transport's input stream.""" + def __init__(self, subscription: Any) -> None: self._subscription = subscription self._records = subscription.records() - async def next_batch(self) -> list[tuple[Cursor, bytes]]: - # One record per batch. The provider reports readiness per record, so a - # batch here would be this binding inventing a boundary the provider - # never observed, and replay records boundaries. - offset, body = await self._records.__anext__() - return [(Cursor(str(offset)), body)] + async def next_batch(self) -> list[tuple[Cursor, WireRecord]]: + while True: + # One record per batch: the transport reports readiness per record, + # and a batch here would invent a boundary replay never observed. + offset, body = await self._records.__anext__() + cursor = mint_cursor(_PROVIDER, f"{_INPUT_PREFIX}{offset.token}") + wire = _parse(cursor, body, workflow.logger.warning) + if wire is not None: + return [(cursor, wire)] def close(self) -> None: self._subscription.close() -class _ExternalWriteSink: - def __init__(self, topic: Any) -> None: - self._topic = topic +class _RedisWriteSink: + def __init__(self, topic: str) -> None: + self._topic = _UNBOUNDED_OUTPUT.topic(topic, type=bytes) - async def publish(self, frame: bytes) -> None: - # Awaited for real here. The worker holds a bounded output batch, and a - # publisher that has filled it waits for room rather than growing it. - await self._topic.publish(frame) + def publish(self, record: WireRecord) -> None: + # Buffered in the transport's per-task batch, staged by the worker when + # the task completes and promoted once History proves the task was + # accepted: rule 1 through the transport's own commit. + _drive(self._topic.publish(record.SerializeToString())) -async def _chain_key(client: Client, workflow_id: str) -> WorkflowChainKey: - description = await client.get_workflow_handle(workflow_id).describe() +class _RedisWorkflowProvider: + """The workflow half: the transport's subscriptions and staged output.""" + + def __init__(self, idle_timeout: timedelta) -> None: + self._input = external_stream.with_options(idle_timeout=idle_timeout) + + def open_reader(self, topic: str, *, after: Cursor) -> ReadSource: + _require_topic(topic) + if cursor_position(after, provider=_PROVIDER) is not None: + raise StreamUnsupportedError( + "the redis provider starts a run's subscription where the chain " + "left off; it cannot resume a workflow reader from a cursor" + ) + return _RedisReadSource(self._input.topic(topic, type=bytes).subscribe()) + + def open_writer(self, topic: str) -> WriteSink: + _require_topic(topic) + return _RedisWriteSink(topic) + + def on_workflow_start(self) -> None: + pass + + async def on_workflow_finish(self) -> None: + pass + + +async def _chain(client: Client, workflow_id: str) -> WorkflowChainKey: + try: + description = await client.get_workflow_handle(workflow_id).describe() + except RPCError as error: + if error.status == RPCStatusCode.NOT_FOUND: + raise StreamNotFoundError( + f"workflow {workflow_id!r} was not found" + ) from error + raise return WorkflowChainKey( client.namespace, workflow_id, @@ -83,230 +240,371 @@ async def _chain_key(client: Client, workflow_id: str) -> WorkflowChainKey: ) -def _placed(result: Any) -> Offset: - # An inbound topic answers a publish with the offset. The owner's output - # stream answers with the whole record, whose text form is not a position. - return result.offset if isinstance(result, OutputStreamRecord) else result +def _storage_error(error: Exception, what: str) -> StreamError: + return StreamError(f"{what}: {error}") class RedisProducer: - """Appends to a stream from outside workflow code. + """Appends to a topic from outside workflow code. - Every append is visible as soon as it is written. That is the point for an - activity streaming model output, and it is why an activity carries its own - identity: the retry of a failed attempt has no commit boundary to sort it - out afterwards. + Every append is visible as soon as the store accepts it. Each record goes + to the topic's input stream, waking a workflow subscribed to it, and to + its output stream, where outside readers and the workflow's own records + meet; the cursor returned names the output position. """ def __init__( self, - topic: Any, - converter: Any, - frame_topic: str, + streams: RedisStreams, + client: Client, + workflow_id: str, + topic: str, producer_id: str, attempt: int, - client: Client | None = None, - workflow_id: str = "", ) -> None: - """Bind this producer to ``topic`` on the external store. - - ``frame_topic`` is what each record carries: the topic name on the - owner's stream, empty on an inbound stream. - """ + """Bind this producer to ``topic`` of ``workflow_id``'s stream.""" + self._streams = streams + self._client = client + self._workflow_id = workflow_id self._topic = topic - self._converter = converter - self._frame_topic = frame_topic self._producer_id = producer_id self._attempt = attempt - self._client = client - self._workflow_id = workflow_id + self._converter = client.data_converter.payload_converter self._sequence = 0 + self._last = BEGINNING + self._input: Any = None + self._output: Any = None + + @property + def producer_id(self) -> str: + """Who this producer writes as.""" + return self._producer_id @property def attempt(self) -> int: """The generation this producer is writing.""" return self._attempt - async def append(self, *values: Any) -> Cursor | None: - """Append ``values`` and return the last one's cursor, or ``None`` for none. + @property + def _session(self) -> str: + # The transport dedupes on this and the sequence. The attempt is part + # of it so a retried append is dropped while a new generation writing + # different words at the same sequence is not. + return ( + f"{self._producer_id}#{self._attempt}" + if self._attempt + else self._producer_id + ) + + async def append(self, *values: Any) -> Cursor: + """Append ``values`` and return the cursor of the last record as stored. - A repeat of an earlier append comes back with the original position. - The store drops the duplicate, but this seam does not say so, and a - reader resuming after that position still sees only what came later. + A repeat returns where the original landed, because the transport + answers a byte-identical repeat with the original offset; an empty + call returns the position of this producer's last record. """ - last: Offset | None = None - for value in values: - frame = _frame.encode( - topic=self._frame_topic, - kind=RecordKind.DATA, - producer=self._producer_id, - attempt=self._attempt, - sequence=self._sequence, - body=self._encode(value), - ) - self._sequence += 1 - last = _placed(await self._topic.publish(frame)) - return None if last is None else Cursor(last.token) + if not values: + return self._last + return await self._write( + [ + to_wire( + self._converter, + topic=self._topic, + kind=RecordKind.DATA, + value=value, + producer_id=self._producer_id, + attempt=self._attempt, + sequence=self._sequence + index, + ) + for index, value in enumerate(values) + ] + ) async def finish(self) -> None: - """Declare this stream complete.""" - frame = _frame.encode( - topic=self._frame_topic, - kind=RecordKind.FINISH, - producer=self._producer_id, - attempt=self._attempt, - sequence=self._sequence, - body=b"", + """Write ``FINISH`` for this producer on this topic. + + Written as an ordinary record rather than the transport's own + terminal, which would end every outside read of the topic; a + ``FINISH`` here only says this producer is done. + """ + await self._write( + [ + to_wire( + self._converter, + topic=self._topic, + kind=RecordKind.FINISH, + producer_id=self._producer_id, + attempt=self._attempt, + sequence=self._sequence, + ) + ] ) - self._sequence += 1 + + async def _connect(self) -> None: + if self._output is not None: + return + backend = self._streams._require_backend() + chain = await _chain(self._client, self._workflow_id) + try: + output = await ExternalOutputStreamProducer.connect( + backend=backend, + workflow=chain, + client=self._client, + session_id=self._session, + ) + input_ = await ExternalStreamProducer.connect( + backend=backend, + workflow=chain, + client=self._client, + session_id=self._session, + ) + except ChainKeyMismatchError as error: + raise StreamNotFoundError( + f"workflow {self._workflow_id!r} is not the chain this producer " + f"was opened on: {error}" + ) from error + self._output = output.topic(self._topic, type=bytes) + self._input = input_.topic(self._topic, type=bytes) + + async def _write(self, records: list[WireRecord]) -> Cursor: + await self._connect() + last: Offset | None = None + try: + for record in records: + frame = record.SerializeToString() + await self._input.publish(frame, wake=False) + placed = await self._output.publish(frame) + last = placed.offset + await self._wake() + except AppendConflictError as error: + raise StreamProducerError( + f"producer {self._session!r} already wrote a different record at " + f"sequence {error.key.sequence}" + ) from error + except (AppendNotAcknowledgedError, OutputAppendNotAcknowledgedError) as error: + raise _storage_error(error, "an append was not acknowledged") from error + except TransportStreamError as error: + raise _storage_error(error, "the store refused an append") from error + self._sequence += len(records) + assert last is not None + self._last = mint_cursor(_PROVIDER, last.token) + return self._last + + async def _wake(self) -> None: try: - await self._topic.publish(frame) + await self._input.wake() except WakeNotAcknowledgedError: - # The record is appended; what failed is telling a consumer that is - # no longer there to be told. A terminal record most often races the - # consumer acting on it, so treating an absent consumer as a failed - # publish would make the ordinary ending look like an error. + # The records are appended; what failed is telling a consumer that + # is no longer there to be told. A terminal record most often races + # the consumer acting on it, so an absent consumer is the ordinary + # ending rather than an error. if not await self._consumer_has_gone(): raise + except TransportStreamError as error: + raise _storage_error(error, "the wake could not be sent") from error async def _consumer_has_gone(self) -> bool: """Whether the consuming execution is closing or closed. - Asked rather than assumed, and asked for a few seconds rather than - once: the server refuses the wake while the execution is closing, and - at that moment its status is still the running one. A single look would - read "running" and turn the ordinary ending into an error. + Asked for a few seconds rather than once: the server refuses the wake + while the execution is closing, and at that moment its status is + still the running one. """ - if self._client is None or not self._workflow_id: - return False handle = self._client.get_workflow_handle(self._workflow_id) deadline = time.monotonic() + 5 while True: - description = await handle.describe() - status = description.status + try: + status = (await handle.describe()).status + except RPCError as error: + if error.status == RPCStatusCode.NOT_FOUND: + return True + raise if status is not None and status != WorkflowExecutionStatus.RUNNING: return True if time.monotonic() >= deadline: return False await asyncio.sleep(0.1) - def _encode(self, value: Any) -> bytes: - payload = ( - value - if isinstance(value, Payload) - else self._converter.to_payloads([value])[0] - ) - return payload.SerializeToString() - -class RedisConsumer: - """Reads a stream from outside workflow code, resumably.""" +class RedisStreamHandle: + """One workflow's topics from outside, over the transport's output streams.""" - def __init__(self, client: Any, converter: Any, workflow_id: str) -> None: - """Read what ``client`` reaches in the external store.""" + def __init__( + self, + streams: RedisStreams, + client: Client, + workflow_id: str, + run_id: str | None, + ) -> None: + """Address ``workflow_id``'s topics; ``run_id`` decides whose close ends a read.""" + self._streams = streams self._client = client - self._converter = converter self._workflow_id = workflow_id + self._run_id = run_id + self._converter = client.data_converter.payload_converter + self._codec: StreamPayloadCodec[bytes] = StreamPayloadCodec( + client.data_converter.with_context( + WorkflowSerializationContext( + namespace=client.namespace, workflow_id=workflow_id + ) + ), + bytes, + ) - async def read( + def read( self, *, + topic: str, after: Cursor = BEGINNING, - topic: str | None = None, - type: type | None = None, + result_type: type | None = None, ) -> AsyncGenerator[StreamRecord[Any], None]: - """Yield the records after ``after`` as they arrive. - - Applies the same supersession rule as a workflow reader, so a browser - and a workflow watching one activity agree on which attempt is current. - """ - # Only the owner's stream is readable from here, so the inbound check - # can never fire; it runs so every provider answers a topic the same way. - _provider.check_topic("", topic) - name = self._require_topic(topic) - attempts = AttemptTracker() - boundary = AFTER(Offset(after.token)) if after.token else PROVIDER_BEGINNING - handle = self._client.topic(name, type=bytes) - async for item in handle.subscribe(after=boundary): - cursor = Cursor(str(item.offset)) + """Yield records on ``topic`` after ``after`` until the chain, or the pinned run, closes.""" + _require_topic(topic) + # Parsed here so a foreign cursor fails this call, not the first + # iteration of the generator. + position = _outside_position(after) + return self._read(topic, position, after, result_type) + + async def _read( + self, + topic: str, + position: Offset | None, + after: Cursor, + result_type: type | None, + ) -> AsyncGenerator[StreamRecord[Any], None]: + decoder = RecordDecoder( + self._converter, result_type, after=after, warn=logger.warning + ) + backend = self._streams._require_backend() + chain = await _chain(self._client, self._workflow_id) + key = chain.stream_key(topic, direction=StreamDirection.OUTPUT) + cursor = TRANSPORT_BEGINNING if position is None else AFTER(position) + closed = False + while True: try: - kind, frame_topic, source, attempt, sequence, body = _frame.decode( - item.data - ) - except ValueError as error: - # Same answer as the workflow-side reader: skip and say so. - logger.warning( - "skipping record %s of %s topic %s: %s", + result = await backend.read_output_after( + key, cursor, - self._workflow_id, - name, - error, + max_records=_READ_BATCH, + block=self._streams._poll, + ) + except TransportStreamError as error: + raise _storage_error(error, "the store could not be read") from error + for placed in result.records: + cursor = AFTER(placed.offset) + if placed.kind is not TransportRecordKind.DATA: + continue + minted = mint_cursor(_PROVIDER, placed.offset.token) + wire = _parse( + minted, await self._codec.decode(placed.payload), logger.warning + ) + if wire is None: + continue + for record in decoder.decode(minted, wire): + yield record + if result.pending is not None: + # A staged batch whose task History has not settled yet is a + # barrier: nothing past it is read until History says whether + # the task was accepted. + resolved = await _reconcile_output_stage( + backend=backend, + client=self._client, + workflow_id=self._workflow_id, + manifest=result.pending.manifest, ) + if not resolved: + await asyncio.sleep(self._streams._poll.total_seconds()) continue - superseded = attempts.note(source, attempt, cursor) - if superseded is not None: - yield superseded - yield StreamRecord( - value=self._decode(body, type) if kind is RecordKind.DATA else None, - cursor=cursor, - kind=kind, - topic=frame_topic, - producer=source, - attempt=attempt, - sequence=sequence, - ) + if result.records: + continue + if closed: + return + # One more pass after learning the workflow closed, so a batch + # promoted between the read and the describe is not lost. + closed = await self._closed() + + async def _closed(self) -> bool: + handle = self._client.get_workflow_handle( + self._workflow_id, run_id=self._run_id + ) + try: + status = (await handle.describe()).status + except RPCError as error: + if error.status == RPCStatusCode.NOT_FOUND: + # The run's History is gone; nothing more can be promoted. + return True + raise + if status is None or status == WorkflowExecutionStatus.RUNNING: + return False + # The streams are keyed by the chain, so a run that continued as new + # is not the end unless the handle was pinned to it. + return not ( + self._run_id is None and status == WorkflowExecutionStatus.CONTINUED_AS_NEW + ) - async def latest(self, *, topic: str | None = None) -> Cursor: - """The cursor of the last record written, for following from now.""" - tail = await self._client.topic(self._require_topic(topic), type=bytes).tail() + async def latest(self, *, topic: str) -> Cursor: + """The cursor of the newest committed record on ``topic``, for following from now.""" + _require_topic(topic) + backend = self._streams._require_backend() + chain = await _chain(self._client, self._workflow_id) + try: + tail = await backend.output_tail( + chain.stream_key(topic, direction=StreamDirection.OUTPUT) + ) + except TransportStreamError as error: + raise _storage_error(error, "the store's tail could not be read") from error if tail.is_beginning: return BEGINNING - return Cursor(tail.offset.token) - - @staticmethod - def _require_topic(topic: str | None) -> str: - if topic is None: - raise ValueError( - "this provider stores each topic separately, so an outside " - "reader has to name one" - ) - return topic + assert tail.offset is not None + return mint_cursor(_PROVIDER, tail.offset.token) - def _decode(self, body: bytes, as_type: type | None) -> Any: - payload = Payload() - payload.ParseFromString(body) - if as_type is None: - return self._converter.from_payloads([payload])[0] - return self._converter.from_payloads([payload], [as_type])[0] + def producer( + self, *, topic: str, producer_id: str = "", attempt: int = 0 + ) -> RedisProducer: + """A producer on ``topic``; inside an activity its identity is the activity's.""" + _require_topic(topic) + producer_id, attempt = producer_identity(producer_id, attempt) + return RedisProducer( + self._streams, self._client, self._workflow_id, topic, producer_id, attempt + ) -class _RedisProvider: - name = "redis" +class RedisStreams(ProviderPlugin): + """The client-side provider over Redis streams. - def __init__(self) -> None: - self._backend: Any = None - self._owned_client: Any = None - self._url = "" - self._key_prefix = "" - - def configure(self, **options: Any) -> None: - backend = options.pop("backend", None) - url = options.pop("url", None) - key_prefix = options.pop("key_prefix", None) - if options: - raise TypeError( - "the redis provider takes backend, url and key_prefix, got " - f"{sorted(options)}" - ) + Construct one, pass it to the worker as a plugin and open handles from + it anywhere else. The Redis client is opened on first use, so it belongs + to the loop that uses it, and released by :meth:`close`; a ``backend`` + the caller hands in stays the caller's to close. + """ + + def __init__( + self, + *, + url: str = "redis://127.0.0.1:6379", + key_prefix: str = "temporal-streams", + idle_timeout: timedelta = timedelta(seconds=1), + backend: Any | None = None, + poll_interval: timedelta = timedelta(milliseconds=500), + ) -> None: + """Create the provider. + + Args: + url: The Redis to connect to when no ``backend`` is given. + key_prefix: Prepended to every key, so one Redis serves several + deployments. + idle_timeout: How long a workflow reader with nothing to read + holds its Workflow Task open before the worker parks it. + backend: A transport backend the caller constructed and owns. + poll_interval: How long an outside reader that is caught up waits + for a record before asking whether the workflow closed. + """ + self._url = url + self._key_prefix = key_prefix + self._idle_timeout = idle_timeout self._backend = backend - self._url = ( - url - or os.environ.get("TEMPORAL_TEST_REDIS_URL") - or os.environ.get("AI198_REDIS_URL", "redis://127.0.0.1:6379") - ) - self._key_prefix = key_prefix or os.environ.get( - "AI198_REDIS_PREFIX", "ai198-contract" - ) + self._owned_client: Any = None + self._poll = poll_interval def _require_backend(self) -> Any: if self._backend is None: @@ -318,7 +616,7 @@ def _require_backend(self) -> Any: # A dead peer would otherwise hold a blocking read open forever. # Several block periods, so a healthy socket that is merely idle - # inside one XREAD window is never abandoned. + # inside one read window is never abandoned. self._owned_client = redis.asyncio.from_url( self._url, decode_responses=False, @@ -329,116 +627,31 @@ def _require_backend(self) -> Any: ) return self._backend - async def close(self) -> None: - """Release the Redis connections this provider opened. + def configure_worker(self, config: WorkerConfig) -> WorkerConfig: + """Set this provider and its transport backend on the worker.""" + config = super().configure_worker(config) + config["external_stream_backend"] = self._require_backend() + return config - A backend the caller handed in stays the caller's to close. - """ + def configure_replayer(self, config: ReplayerConfig) -> ReplayerConfig: + """Set this provider and its transport backend on the replayer.""" + config = super().configure_replayer(config) + config["external_stream_backend"] = self._require_backend() + return config + + def workflow_provider(self) -> _RedisWorkflowProvider: + """The workflow half, over the transport's subscriptions and staged output.""" + return _RedisWorkflowProvider(self._idle_timeout) + + def get_stream_handle( + self, client: Client, workflow_id: str, *, run_id: str | None = None + ) -> RedisStreamHandle: + """A handle on ``workflow_id``'s topics; it follows the chain by construction.""" + return RedisStreamHandle(self, client, workflow_id, run_id) + + async def close(self) -> None: + """Release the Redis connections this provider opened.""" client, self._owned_client = self._owned_client, None if client is not None: self._backend = None await client.aclose() - - def worker_options(self) -> dict[str, Any]: - return {"external_stream_backend": self._require_backend()} - - def open_read( - self, - stream: str, - *, - after: Cursor = BEGINNING, - idle_timeout: timedelta | None = None, - ) -> ReadSource: - # The provider resolves the name under this run's chain key, so a start - # position is only meaningful to a reader that already has one, and this - # provider's first subscription always starts at the beginning of the - # stream it minted. - if after.token: - raise ValueError( - "the client-side provider starts a new subscription at the " - "beginning of the stream it owns; resuming elsewhere is not " - "supported yet" - ) - options = external_stream - if idle_timeout is not None: - options = options.with_options(idle_timeout=idle_timeout) - return _ExternalReadSource(options.topic(stream, type=bytes).subscribe()) - - def open_write(self, topic: str) -> WriteSink: - return _ExternalWriteSink(external_output_stream.topic(topic, type=bytes)) - - async def producer( - self, - client: Client, - *, - workflow_id: str, - stream: str = "", - topic: str = "", - producer_id: str = "", - attempt: int = 0, - ) -> RedisProducer: - """Open a producer on ``workflow_id``'s account. - - ``stream`` names an inbound stream; with none, ``topic`` names a topic - on the workflow's output stream, appended directly rather than through - the workflow's staged commit. An inbound record carries no topic: the - stream's name is its whole address. - """ - # The attempt is part of it. Deduplication answers "is this the - # same append again", and a second attempt writing different words - # at the same sequence is not: this provider rejects that outright. - # Folding the attempt in keeps a retried append idempotent without - # letting a new generation be refused as a conflicting duplicate. - session_id = f"{producer_id}#{attempt}" - chain = await _chain_key(client, workflow_id) - if stream: - session: Any = await ExternalStreamProducer.connect( - backend=self._require_backend(), - workflow=chain, - client=client, - session_id=session_id, - ) - else: - session = await ExternalOutputStreamProducer.connect( - backend=self._require_backend(), - workflow=chain, - client=client, - session_id=session_id, - ) - return RedisProducer( - session.topic(stream or topic, type=bytes), - client.data_converter.payload_converter, - topic, - producer_id, - attempt, - client, - workflow_id, - ) - - async def consumer( - self, client: Client, *, workflow_id: str, stream: str = "" - ) -> RedisConsumer: - """Open a reader for what ``workflow_id`` publishes. - - An empty ``stream`` reads what the workflow wrote through - :func:`temporalio.streams.writer`. This provider keeps inbound and - outbound records in separate stores, so naming an inbound stream is - not supported from here yet. - """ - if stream: - raise ValueError( - "this provider separates inbound and outbound storage, so an " - "outside reader cannot follow an inbound stream yet" - ) - return RedisConsumer( - await ExternalOutputStreamClient.connect( - backend=self._require_backend(), - workflow=await _chain_key(client, workflow_id), - client=client, - ), - client.data_converter.payload_converter, - workflow_id, - ) - - -_provider.register("redis", _RedisProvider) diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py new file mode 100644 index 000000000..994ef5309 --- /dev/null +++ b/tests/streams/test_redis_provider.py @@ -0,0 +1,45 @@ +"""What the Redis provider decides without a store: cursors and the sync publish.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from temporalio.streams import BEGINNING, Cursor, StreamCursorError, StreamError +from temporalio.streams.providers.redis import _drive, _outside_position + + +def test_outside_cursors_name_output_positions(): + assert _outside_position(BEGINNING) is None + position = _outside_position(Cursor("redis:1700000000000-3")) + assert position is not None and position.token == "1700000000000-3" + # A workflow-side cursor names the input log, which outside code cannot + # read from, and a token another provider minted is refused the same way. + with pytest.raises(StreamCursorError): + _outside_position(Cursor("redis:in:1700000000000-3")) + with pytest.raises(StreamCursorError): + _outside_position(Cursor("memory:3")) + with pytest.raises(StreamCursorError): + _outside_position(Cursor("redis:not-an-id")) + + +def test_a_publish_that_completes_at_once_is_driven_to_the_end(): + done = [] + + async def publish() -> None: + done.append(True) + + _drive(publish()) + assert done == [True] + + +def test_a_publish_that_would_wait_fails_loudly(): + async def publish() -> None: + await asyncio.get_running_loop().create_future() + + async def run() -> None: + with pytest.raises(StreamError, match="batch is full"): + _drive(publish()) + + asyncio.run(run()) diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 63fbe5796..b3c4e3ee3 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -2,26 +2,35 @@ The conformance suite covers the outside surface when ``STREAMS_LIVE=redis``. This module runs the interface loop inside a workflow over the staged commit, -then queries a completed run, which replays it. Both need a dev server -(``TEMPORAL_ADDRESS``) and a Redis (``TEMPORAL_TEST_REDIS_URL`` or -``AI198_REDIS_URL``). The worker keeps a warm cache because the provider holds -the task open between records. +lets a read end with the workflow, shares a topic between an outside producer +and the workflow, and queries a completed run, which replays it. All need a +dev server (``TEMPORAL_ADDRESS``) and a Redis (``TEMPORAL_TEST_REDIS_URL`` or +``AI198_REDIS_URL``). The worker keeps a warm cache because the transport +holds the task open between records. """ from __future__ import annotations +import asyncio import os import uuid from collections.abc import AsyncIterator +from typing import Any import pytest -from temporalio import streams, workflow +from temporalio import workflow from temporalio.client import Client from temporalio.streams import RecordKind +from temporalio.streams.providers.redis import RedisStreams from temporalio.worker import Worker from tests.streams.test_streams_conformance import take -from tests.streams.test_streams_workflow import DECISIONS, INPUTS, ContractLoop +from tests.streams.test_streams_workflow import ( + DECISIONS, + INPUTS, + ContractLoop, + OneLine, +) pytestmark = pytest.mark.skipif( os.environ.get("STREAMS_LIVE") != "redis", @@ -29,41 +38,50 @@ ) +def redis_url() -> str: + return os.environ.get("TEMPORAL_TEST_REDIS_URL") or os.environ.get( + "AI198_REDIS_URL", "redis://127.0.0.1:6379" + ) + + @pytest.fixture -async def live_client() -> AsyncIterator[Client]: +async def provider() -> AsyncIterator[RedisStreams]: # A prefix per case, because the store keeps what earlier cases wrote. - streams.configure(provider="redis", key_prefix=f"streams-redis-{uuid.uuid4().hex}") + streams = RedisStreams( + url=redis_url(), key_prefix=f"streams-redis-{uuid.uuid4().hex}" + ) try: - yield await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + yield streams finally: await streams.close() -async def test_interface_loop_over_redis(live_client: Client): +@pytest.fixture +async def live_client(client: Client) -> Client: + # The test environment's own server, unless TEMPORAL_ADDRESS names another. + address = os.environ.get("TEMPORAL_ADDRESS") + return await Client.connect(address) if address else client + + +async def test_interface_loop_over_redis(live_client: Client, provider: RedisStreams): workflow_id = f"streams-redis-live-{uuid.uuid4().hex}" async with Worker( live_client, task_queue=f"tq-{workflow_id}", workflows=[ContractLoop], + plugins=[provider], max_cached_workflows=100, - **streams.worker_options(), ): handle = await live_client.start_workflow( ContractLoop.run, id=workflow_id, task_queue=f"tq-{workflow_id}" ) - producer = await streams.producer( - live_client, - workflow_id=workflow_id, - stream=INPUTS, - producer_id="model", - attempt=1, - ) + stream = provider.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="model", attempt=1) await producer.append({"n": 1}, {"n": 2}) await producer.append({"n": 3}) await producer.finish() - consumer = await streams.consumer(live_client, workflow_id=workflow_id) - records = await take(consumer.read(type=dict, topic=DECISIONS), 4, timeout=60) + records = await take(stream.read(topic=DECISIONS, result_type=dict), 4, 60) assert [r.kind for r in records] == [ RecordKind.DATA, RecordKind.DATA, @@ -71,6 +89,7 @@ async def test_interface_loop_over_redis(live_client: Client): RecordKind.FINISH, ] assert [r.value["decided"] for r in records[:3]] == [1, 2, 3] + assert all(r.producer_id == "" and r.topic == DECISIONS for r in records) assert await handle.result() == [ {"kind": "decision", "n": 1, "attempt": 1}, {"kind": "decision", "n": 2, "attempt": 1}, @@ -78,6 +97,63 @@ async def test_interface_loop_over_redis(live_client: Client): {"kind": "finish", "producer": "model"}, ] + # The read ends by itself once the workflow is closed and every + # promoted record has been handed over. + async def read_everything() -> list[Any]: + return [ + r.value async for r in stream.read(topic=DECISIONS, result_type=dict) + ] + + assert await asyncio.wait_for(read_everything(), 60) == [ + {"decided": 1}, + {"decided": 2}, + {"decided": 3}, + None, + ] + # The producer's own records are readable from outside as well, on + # the topic it wrote. + inputs = await take(stream.read(topic=INPUTS, result_type=dict), 4, 60) + assert [r.value for r in inputs[:3]] == [{"n": 1}, {"n": 2}, {"n": 3}] + assert inputs[3].kind is RecordKind.FINISH + assert all(r.producer_id == "model" and r.attempt == 1 for r in inputs) + + +async def test_an_outside_producer_and_the_workflow_share_a_topic( + live_client: Client, provider: RedisStreams +): + workflow_id = f"streams-redis-shared-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[OneLine], + plugins=[provider], + ): + handle = await live_client.start_workflow( + OneLine.run, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + stream = provider.get_stream_handle(live_client, workflow_id) + await stream.producer(topic=DECISIONS, producer_id="tool", attempt=1).append( + {"from": "producer"} + ) + await handle.result() + + async def read_everything() -> list[Any]: + return [r async for r in stream.read(topic=DECISIONS, result_type=dict)] + + records = await asyncio.wait_for(read_everything(), 60) + # Both writers land on one topic, each under its own identity. The order + # between them is whatever the store took first. + assert sorted( + ((r.producer_id, r.kind, r.value) for r in records), key=str + ) == sorted( + [ + ("tool", RecordKind.DATA, {"from": "producer"}), + ("", RecordKind.DATA, {"from": "workflow"}), + ("", RecordKind.FINISH, None), + ], + key=str, + ) + @workflow.defn class SignalWokenPublish: @@ -92,11 +168,11 @@ def close(self) -> None: @workflow.run async def run(self) -> int: - out = streams.writer("out") - await out.publish({"n": 0}) + out = workflow.stream_writer("out") + out.publish({"n": 0}) await workflow.wait_condition(lambda: self._closed) for n in range(1, 4): - await out.publish({"n": n}) + out.publish({"n": n}) return 4 @workflow.query @@ -104,7 +180,9 @@ def probe(self) -> int: return 1 -async def test_query_after_completion_replays_the_final_task(live_client: Client): +async def test_query_after_completion_replays_the_final_task( + live_client: Client, provider: RedisStreams +): # A query against a completed run replays it. The final task's publishes # were woken by a signal, which the activation applies before the replay # marker, so the marker's expectations have to be installed before that @@ -115,13 +193,15 @@ async def test_query_after_completion_replays_the_final_task(live_client: Client live_client, task_queue=f"tq-{workflow_id}", workflows=[SignalWokenPublish], - **streams.worker_options(), + plugins=[provider], ): handle = await live_client.start_workflow( SignalWokenPublish.run, id=workflow_id, task_queue=f"tq-{workflow_id}" ) - consumer = await streams.consumer(live_client, workflow_id=workflow_id) - await take(consumer.read(type=dict, topic="out"), 1, timeout=60) + stream = provider.get_stream_handle(live_client, workflow_id) + await take(stream.read(topic="out", result_type=dict), 1, timeout=60) await handle.signal(SignalWokenPublish.close) assert await handle.result() == 4 assert await handle.query(SignalWokenPublish.probe) == 1 + values = [r.value async for r in stream.read(topic="out", result_type=dict)] + assert values == [{"n": 0}, {"n": 1}, {"n": 2}, {"n": 3}] diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index 451fe4391..addfe5689 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -21,6 +21,7 @@ from __future__ import annotations import asyncio +import os import uuid from collections.abc import AsyncIterator, Awaitable, Callable from dataclasses import dataclass @@ -28,8 +29,9 @@ import pytest +from temporalio import workflow from temporalio.api.common.v1 import Payload -from temporalio.client import Client +from temporalio.client import Client, WorkflowHandle from temporalio.common import RawValue from temporalio.converter import DataConverter from temporalio.streams import ( @@ -45,6 +47,8 @@ ) from temporalio.streams._policy import AttemptTracker from temporalio.streams.providers.memory import MemoryStreams +from temporalio.streams.providers.redis import RedisStreams +from tests.helpers import new_worker @dataclass @@ -78,9 +82,56 @@ async def _memory_case(_client: Client) -> AsyncIterator[ProviderCase]: provider.reset() +@workflow.defn +class StreamHost: + """Owns a stream and lingers, so outside code has a running workflow to address.""" + + def __init__(self) -> None: + self._released = False + + @workflow.signal + def release(self) -> None: + self._released = True + + @workflow.run + async def run(self) -> None: + await workflow.wait_condition(lambda: self._released) + + +async def _redis_case(client: Client) -> AsyncIterator[ProviderCase]: + # The store is a Redis the test environment does not start; the server + # is the environment's own unless TEMPORAL_ADDRESS names another. + address = os.environ.get("TEMPORAL_ADDRESS") + if address: + client = await Client.connect( + address, namespace=os.environ.get("TEMPORAL_NAMESPACE", "default") + ) + provider = RedisStreams( + url=os.environ.get("TEMPORAL_TEST_REDIS_URL") + or os.environ.get("AI198_REDIS_URL", "redis://127.0.0.1:6379"), + # A prefix per setup, because the store keeps what earlier runs wrote. + key_prefix=f"streams-conformance-{uuid.uuid4().hex}", + ) + hosts: dict[str, WorkflowHandle[Any, Any]] = {} + async with new_worker(client, StreamHost, plugins=[provider]) as worker: + + async def host(workflow_id: str) -> None: + if workflow_id not in hosts: + hosts[workflow_id] = await client.start_workflow( + StreamHost.run, id=workflow_id, task_queue=worker.task_queue + ) + + yield ProviderCase("redis", provider, client, host=host) + for handle in hosts.values(): + await handle.terminate() + await provider.close() + + SETUPS: dict[str, Callable[[Client], AsyncIterator[ProviderCase]]] = { "memory": _memory_case } +if os.environ.get("STREAMS_LIVE") == "redis": + SETUPS["redis"] = _redis_case _CAPABILITIES = { "reports_positions": lambda case: case.reports_positions, From 88a62988062c5d8790194c5b5ded7e71efde6321 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 03:45:36 -0700 Subject: [PATCH 57/96] Kept the stream finish hook off evicted runs and collected coroutines. Neither is the workflow ending. An evicted run's state is not to be touched, and at collection time the runtime on the thread belongs to whichever workflow is running, so the hook acted on that one. --- temporalio/worker/_workflow.py | 29 +++++-- tests/streams/test_stream_hooks.py | 133 +++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 tests/streams/test_stream_hooks.py diff --git a/temporalio/worker/_workflow.py b/temporalio/worker/_workflow.py index 481a0a86d..d646ebdac 100644 --- a/temporalio/worker/_workflow.py +++ b/temporalio/worker/_workflow.py @@ -66,17 +66,36 @@ class _StreamHooksInterceptor(WorkflowInboundInterceptor): Installed by the worker when it has a stream provider, so no workflow code has to call anything before it runs or before it returns. The finish - hook runs however the function ends, continue-as-new included, because + hook runs when the function returns, raises or continues as new, because a provider that parked a reader against the run has to let go either way. + It does not run when the run is being evicted from the cache or when the + abandoned coroutine is collected: neither is the workflow ending, the + instance's state is not to be touched during eviction, and at collection + time the runtime on the thread belongs to whichever workflow happens to + be running, so the hook would act on that one. """ async def execute_workflow(self, input: ExecuteWorkflowInput) -> Any: - provider = temporalio.workflow._Runtime.current().workflow_streams().provider + runtime = temporalio.workflow._Runtime.current() + provider = runtime.workflow_streams().provider provider.on_workflow_start() try: - return await self.next.execute_workflow(input) - finally: - await provider.on_workflow_finish() + result = await self.next.execute_workflow(input) + except GeneratorExit: + raise + except BaseException: + if not _evicting(runtime): + await provider.on_workflow_finish() + raise + await provider.on_workflow_finish() + return result + + +def _evicting(runtime: temporalio.workflow._Runtime) -> bool: + # Eviction cancels the primary task the same way a workflow cancellation + # does; the flag the instance sets before cancelling is what tells them + # apart, and only the cancellation is a run ending. + return bool(getattr(runtime, "_deleting", False)) # Value was chosen abitrarily as a small number that allows some concurrency and prevents diff --git a/tests/streams/test_stream_hooks.py b/tests/streams/test_stream_hooks.py new file mode 100644 index 000000000..be7b6e9d4 --- /dev/null +++ b/tests/streams/test_stream_hooks.py @@ -0,0 +1,133 @@ +"""The lifecycle interceptor's rules about when the finish hook runs. + +Driven directly, with a stand-in runtime on the loop, because the two cases +that matter here are the ones a workflow test cannot stage on purpose: a +coroutine collected after its worker went away, and a run evicted from the +cache. Both used to reach the finish hook, and at collection time the hook +acted on whichever workflow was running on the thread. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from temporalio import workflow +from temporalio.worker._interceptor import ( + ExecuteWorkflowInput, + WorkflowInboundInterceptor, +) +from temporalio.worker._workflow import _StreamHooksInterceptor + + +class _Provider: + def __init__(self) -> None: + self.calls: list[str] = [] + + def on_workflow_start(self) -> None: + self.calls.append("start") + + async def on_workflow_finish(self) -> None: + self.calls.append("finish") + + +class _Streams: + def __init__(self, provider: _Provider) -> None: + self.provider = provider + + +class _FakeRuntime: + """Only what the interceptor reads: the stream state and the eviction flag.""" + + def __init__(self, provider: _Provider, *, deleting: bool = False) -> None: + self._streams = _Streams(provider) + self._deleting = deleting + + def workflow_streams(self) -> _Streams: + return self._streams + + +class _Body(WorkflowInboundInterceptor): + """The workflow function's stand-in: returns, raises or parks forever.""" + + def __init__(self, outcome: Any) -> None: # type: ignore[reportMissingSuperCall] + self._outcome = outcome + + async def execute_workflow(self, input: ExecuteWorkflowInput) -> Any: + del input + if self._outcome is _PARK: + await asyncio.Event().wait() + if isinstance(self._outcome, BaseException): + raise self._outcome + return self._outcome + + +_PARK = object() +_INPUT = ExecuteWorkflowInput(type=object, run_fn=lambda: None, args=(), headers={}) + + +@pytest.fixture +async def provider() -> Any: + fake = _Provider() + loop = asyncio.get_running_loop() + workflow._Runtime.set_on_loop(loop, _FakeRuntime(fake)) # type: ignore[arg-type] + yield fake + workflow._Runtime.set_on_loop(loop, None) + + +def _evicting(fake: _Provider) -> None: + loop = asyncio.get_running_loop() + workflow._Runtime.set_on_loop(loop, _FakeRuntime(fake, deleting=True)) # type: ignore[arg-type] + + +async def test_the_finish_hook_runs_on_return(provider: _Provider): + assert ( + await _StreamHooksInterceptor(_Body("done")).execute_workflow(_INPUT) == "done" + ) + assert provider.calls == ["start", "finish"] + + +async def test_the_finish_hook_runs_when_the_function_raises(provider: _Provider): + with pytest.raises(RuntimeError, match="boom"): + await _StreamHooksInterceptor(_Body(RuntimeError("boom"))).execute_workflow( + _INPUT + ) + assert provider.calls == ["start", "finish"] + + +async def test_the_finish_hook_runs_on_continue_as_new(provider: _Provider): + error = workflow.ContinueAsNewError.__new__(workflow.ContinueAsNewError) + with pytest.raises(workflow.ContinueAsNewError): + await _StreamHooksInterceptor(_Body(error)).execute_workflow(_INPUT) + assert provider.calls == ["start", "finish"] + + +async def test_the_finish_hook_runs_on_a_workflow_cancellation(provider: _Provider): + # A cancelled primary task is the run ending, so the provider lets go. + with pytest.raises(asyncio.CancelledError): + await _StreamHooksInterceptor(_Body(asyncio.CancelledError())).execute_workflow( + _INPUT + ) + assert provider.calls == ["start", "finish"] + + +async def test_the_finish_hook_does_not_run_when_the_coroutine_is_collected( + provider: _Provider, +): + coroutine = _StreamHooksInterceptor(_Body(_PARK)).execute_workflow(_INPUT) + # Run up to the park, the way a worker that shut down without evicting + # leaves the primary task, then close it as garbage collection would. + coroutine.send(None) + coroutine.close() + assert provider.calls == ["start"] + + +async def test_the_finish_hook_does_not_run_during_eviction(provider: _Provider): + _evicting(provider) + with pytest.raises(asyncio.CancelledError): + await _StreamHooksInterceptor(_Body(asyncio.CancelledError())).execute_workflow( + _INPUT + ) + assert provider.calls == ["start"] From d11aa015e8d94e887be68254d59e94109fd38e0a Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 03:46:44 -0700 Subject: [PATCH 58/96] Typed the hook test's workflow input as awaitable. --- tests/streams/test_stream_hooks.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/streams/test_stream_hooks.py b/tests/streams/test_stream_hooks.py index be7b6e9d4..9f98f75f6 100644 --- a/tests/streams/test_stream_hooks.py +++ b/tests/streams/test_stream_hooks.py @@ -65,7 +65,13 @@ async def execute_workflow(self, input: ExecuteWorkflowInput) -> Any: _PARK = object() -_INPUT = ExecuteWorkflowInput(type=object, run_fn=lambda: None, args=(), headers={}) + + +async def _unused_run_fn() -> None: + pass + + +_INPUT = ExecuteWorkflowInput(type=object, run_fn=_unused_run_fn, args=(), headers={}) @pytest.fixture From 4995cf1a6e2c909609f98045beb35d1ae97a5c94 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 10:24:46 -0700 Subject: [PATCH 59/96] Added activity.stream_handle and Client.get_stream_handle. A provider is registered once, as a plugin on the client, and every context asks for its stream the same way: the worker inherits the client's provider, an activity's handle is its own workflow pinned to its run, and a client's handle mirrors get_workflow_handle. --- temporalio/activity.py | 48 +++++++++ temporalio/client/_client.py | 41 +++++++ temporalio/streams/__init__.py | 23 ++-- temporalio/streams/providers/__init__.py | 45 +++++--- temporalio/worker/_activity.py | 6 ++ temporalio/worker/_worker.py | 8 +- tests/streams/test_stream_accessors.py | 125 ++++++++++++++++++++++ tests/streams/test_streams_conformance.py | 14 ++- 8 files changed, 283 insertions(+), 27 deletions(-) create mode 100644 tests/streams/test_stream_accessors.py diff --git a/temporalio/activity.py b/temporalio/activity.py index 3f69bc17f..271f3e9e7 100644 --- a/temporalio/activity.py +++ b/temporalio/activity.py @@ -29,6 +29,7 @@ import temporalio.bridge.proto.activity_task import temporalio.common import temporalio.converter +import temporalio.streams from temporalio.converter._payload_converter import ( _TemporalTransferTypePayloadConverter, ) @@ -209,6 +210,7 @@ class _Context: runtime_metric_meter: temporalio.common.MetricMeter | None client: Client | None cancellation_details: _ActivityCancellationDetailsHolder + stream_provider: temporalio.streams.StreamProvider | None = None _logger_details: Mapping[str, Any] | None = None _payload_converter: temporalio.converter.PayloadConverter | None = None _metric_meter: temporalio.common.MetricMeter | None = None @@ -298,6 +300,52 @@ def client() -> Client: return client +def stream_handle( + workflow_id: str | None = None, *, run_id: str | None = None +) -> temporalio.streams.StreamHandle: + """Return a stream handle from the provider the worker was given. + + With no arguments the handle is on this activity's own workflow, pinned to + the run the activity belongs to, so a producer opened from it writes onto + that run's stream and a read follows that run. Name a ``workflow_id`` to + address another workflow; ``run_id`` then pins the handle to one run and + its absence follows the execution chain. See :py:mod:`temporalio.streams`. + + Like :py:func:`client`, this is only available in ``async def`` + activities. + + Returns: + :py:class:`temporalio.streams.StreamHandle` for use in the current + activity. + + Raises: + temporalio.streams.StreamUnsupportedError: The worker has no stream + provider. Register one with ``Client.connect(plugins=[provider])`` + or ``Worker(plugins=[provider])``. + RuntimeError: When the client is not available, or when the activity + has no workflow and no ``workflow_id`` was given. + ValueError: ``run_id`` was given without ``workflow_id``. + """ + context = _Context.current() + provider = context.stream_provider + if provider is None: + raise temporalio.streams.StreamUnsupportedError( + "no stream provider is configured on this worker; register one with " + "Client.connect(plugins=[provider]) or Worker(plugins=[provider])" + ) + if workflow_id is None: + if run_id is not None: + raise ValueError("run_id needs a workflow_id") + info = context.info() + if info.workflow_id is None: + raise RuntimeError( + "this activity belongs to no workflow, so name the workflow_id to " + "address" + ) + workflow_id, run_id = info.workflow_id, info.workflow_run_id + return provider.get_stream_handle(client(), workflow_id, run_id=run_id) + + def in_activity() -> bool: """Whether the current code is inside an activity. diff --git a/temporalio/client/_client.py b/temporalio/client/_client.py index 5acdfe476..75e768167 100644 --- a/temporalio/client/_client.py +++ b/temporalio/client/_client.py @@ -28,6 +28,7 @@ import temporalio.converter import temporalio.runtime import temporalio.service +import temporalio.streams import temporalio.workflow from temporalio.service import ( ConnectConfig, @@ -157,6 +158,7 @@ async def connect( grpc_compression: GrpcCompression = GrpcCompression.GZIP, payload_limits: PayloadLimitsConfig = PayloadLimitsConfig(), header_codec_behavior: HeaderCodecBehavior = HeaderCodecBehavior.NO_CODEC, + stream_provider: temporalio.streams.StreamProvider | None = None, ) -> Self: """Connect to a Temporal server. @@ -222,6 +224,11 @@ async def connect( payload_limits: Warning thresholds for outbound payload/memo sizes. Over-threshold fields are logged but still sent. Set a threshold to 0 to disable it. header_codec_behavior: Encoding behavior for headers sent by the client. + stream_provider: Experimental. The stream provider + :py:meth:`get_stream_handle` opens handles from, see + :py:mod:`temporalio.streams`. A provider that is also a + :py:class:`Plugin` sets this itself when passed in ``plugins``, + and workers built from this client inherit it. """ connect_config = temporalio.service.ConnectConfig( target_host=target_host, @@ -258,6 +265,7 @@ def make_lambda( default_workflow_query_reject_condition=default_workflow_query_reject_condition, header_codec_behavior=header_codec_behavior, plugins=plugins, + stream_provider=stream_provider, ) def __init__( @@ -271,6 +279,7 @@ def __init__( default_workflow_query_reject_condition: None | (temporalio.common.QueryRejectCondition) = None, header_codec_behavior: HeaderCodecBehavior = HeaderCodecBehavior.NO_CODEC, + stream_provider: temporalio.streams.StreamProvider | None = None, ): """Create a Temporal client from a service client. @@ -285,6 +294,7 @@ def __init__( interceptors=interceptors, default_workflow_query_reject_condition=default_workflow_query_reject_condition, header_codec_behavior=header_codec_behavior, + stream_provider=stream_provider, ) self._initial_config = config.copy() @@ -897,6 +907,36 @@ def get_workflow_handle( result_type=result_type, ) + def get_stream_handle( + self, workflow_id: str, *, run_id: str | None = None + ) -> temporalio.streams.StreamHandle: + """Get a handle on a workflow's stream from the provider registered on this client. + + Mirrors :py:meth:`get_workflow_handle`: without ``run_id`` the handle + follows the workflow's execution chain across continue-as-new, with + one it is pinned to that run. The provider is the one registered with + ``plugins=[provider]`` at :py:meth:`connect`, or passed as + ``stream_provider``. See :py:mod:`temporalio.streams`. + + Args: + workflow_id: Workflow ID whose stream to get a handle to. + run_id: Run ID to pin the handle to. + + Returns: + The stream handle. + + Raises: + temporalio.streams.StreamUnsupportedError: No stream provider is + registered on this client. + """ + provider = self._config.get("stream_provider") + if provider is None: + raise temporalio.streams.StreamUnsupportedError( + "no stream provider is registered on this client; connect with " + "plugins=[provider]" + ) + return provider.get_stream_handle(self, workflow_id, run_id=run_id) + def get_workflow_handle_for( self, workflow: ( @@ -3070,3 +3110,4 @@ class ClientConfig(TypedDict, total=False): temporalio.common.QueryRejectCondition | None ] header_codec_behavior: Required[HeaderCodecBehavior] + stream_provider: temporalio.streams.StreamProvider | None diff --git a/temporalio/streams/__init__.py b/temporalio/streams/__init__.py index 30d1f9a98..c185c35ce 100644 --- a/temporalio/streams/__init__.py +++ b/temporalio/streams/__init__.py @@ -30,15 +30,20 @@ workflow and by outside consumers; which of those happen is the application's business. -Workflow-side entry points live in :mod:`temporalio.workflow`: -:func:`temporalio.workflow.stream_reader` and -:func:`temporalio.workflow.stream_writer`. Outside a workflow, a handle comes -from a provider instance: ``provider.get_stream_handle(client, workflow_id)``. -A provider is an object; when it serves workers it is also a -:class:`temporalio.worker.Plugin`, passed as ``Worker(plugins=[provider])`` -and ``Replayer(plugins=[provider])``. This module keeps the shared types, the -errors and the protocols a provider implements; nothing here that workflow -code imports does I/O. +A provider is an object, registered once as a plugin: +``Client.connect(plugins=[provider])``; workers built from that client inherit +it, and ``Worker(plugins=[provider])`` or ``Replayer(plugins=[provider])`` +registers it on a worker alone. Each context then asks for its stream the +same way. Workflow code uses :func:`temporalio.workflow.stream_reader` and +:func:`temporalio.workflow.stream_writer`. An activity uses +:func:`temporalio.activity.stream_handle`, which is its own workflow pinned +to its run unless told otherwise. Any process holding a client uses +:meth:`temporalio.client.Client.get_stream_handle`, which mirrors +``get_workflow_handle``. The explicit form, +``provider.get_stream_handle(client, workflow_id)``, stays for a process that +talks to two stores. This module keeps the shared types, the errors and the +protocols a provider implements; nothing here that workflow code imports does +I/O. What the contract does not promise: that a :attr:`RecordKind.FINISH` record means the writing activity succeeded, that a superseded attempt's records can diff --git a/temporalio/streams/providers/__init__.py b/temporalio/streams/providers/__init__.py index 911620e6c..7aaa5a9b1 100644 --- a/temporalio/streams/providers/__init__.py +++ b/temporalio/streams/providers/__init__.py @@ -1,11 +1,14 @@ """Stream providers, one module each. -A provider that serves workers is a :class:`temporalio.worker.Plugin`. +A provider that serves workers is a :class:`temporalio.worker.Plugin`, and +one registered on a client is a :class:`temporalio.client.Plugin` too. :class:`ProviderPlugin` is the plugin half the providers in this tree share: -it hands the provider to the worker and the replayer as their -``stream_provider`` and leaves their execution alone. A provider that holds -connections closes them through its own ``close()``, not with the worker, -because the same provider serves handles outside any worker. +it hands the provider to the client, the worker and the replayer as their +``stream_provider`` and leaves their execution alone, so +``Client.connect(plugins=[provider])`` registers it once and every worker +built from that client inherits it. A provider that holds connections closes +them through its own ``close()``, not with the worker, because the same +provider serves handles outside any worker. """ from __future__ import annotations @@ -13,8 +16,10 @@ from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import AbstractAsyncContextManager +import temporalio.client import temporalio.worker -from temporalio.client import WorkflowHistory +from temporalio.client import ClientConfig, WorkflowHistory +from temporalio.service import ConnectConfig, ServiceClient from temporalio.streams._provider import StreamProvider from temporalio.worker import ( Replayer, @@ -27,16 +32,32 @@ __all__ = ["ProviderPlugin"] -class ProviderPlugin(StreamProvider, temporalio.worker.Plugin): - """The worker plugin every provider in this tree is built on. +class ProviderPlugin( + StreamProvider, temporalio.client.Plugin, temporalio.worker.Plugin +): + """The plugin every provider in this tree is built on. Subclasses implement :class:`temporalio.streams.StreamProvider`; this - class supplies the four plugin hooks, so ``Worker(plugins=[provider])`` - and ``Replayer(plugins=[provider])`` reach the provider through their - ``stream_provider`` option and the worker installs the interceptor that - calls the workflow half's lifecycle hooks. + class supplies the plugin hooks, so ``Client.connect(plugins=[provider])``, + ``Worker(plugins=[provider])`` and ``Replayer(plugins=[provider])`` reach + the provider through their ``stream_provider`` option. A worker built from + a client that carries the plugin inherits it, and the worker installs the + interceptor that calls the workflow half's lifecycle hooks. """ + def configure_client(self, config: ClientConfig) -> ClientConfig: + """Set this provider as the client's ``stream_provider``.""" + config["stream_provider"] = self + return config + + async def connect_service_client( + self, + config: ConnectConfig, + next: Callable[[ConnectConfig], Awaitable[ServiceClient]], + ) -> ServiceClient: + """Connect unchanged.""" + return await next(config) + def configure_worker(self, config: WorkerConfig) -> WorkerConfig: """Set this provider as the worker's ``stream_provider``.""" config["stream_provider"] = self diff --git a/temporalio/worker/_activity.py b/temporalio/worker/_activity.py index ded3047fc..10bf45bd2 100644 --- a/temporalio/worker/_activity.py +++ b/temporalio/worker/_activity.py @@ -33,6 +33,7 @@ import temporalio.common import temporalio.converter import temporalio.exceptions +import temporalio.streams from temporalio.converter import ( StorageDriverActivityInfo, StorageDriverStoreContext, @@ -63,9 +64,11 @@ def __init__( metric_meter: temporalio.common.MetricMeter, client: temporalio.client.Client, encode_headers: bool, + stream_provider: temporalio.streams.StreamProvider | None = None, ) -> None: self._bridge_worker = bridge_worker self._task_queue = task_queue + self._stream_provider = stream_provider self._activity_executor = activity_executor self._shared_state_manager = shared_state_manager self._running_activities: dict[bytes, _RunningActivity] = {} @@ -666,6 +669,9 @@ async def _execute_activity( runtime_metric_meter=None if sync_non_threaded else self._metric_meter, client=self._client if not running_activity.sync else None, cancellation_details=running_activity.cancellation_details, + stream_provider=( + self._stream_provider if not running_activity.sync else None + ), ) ) temporalio.activity.logger.debug("Starting activity") diff --git a/temporalio/worker/_worker.py b/temporalio/worker/_worker.py index 889c063e2..04dfdd562 100644 --- a/temporalio/worker/_worker.py +++ b/temporalio/worker/_worker.py @@ -494,6 +494,11 @@ def _init_from_config(self, client: temporalio.client.Client, config: WorkerConf # Prepend applicable client interceptors to the given ones client_config = config["client"].config(active_config=True) # type: ignore[reportTypedDictNotRequiredAccess] + # A provider registered on the client serves its workers too, so one + # registration covers every context that asks for a stream. + stream_provider = config.get("stream_provider") or client_config.get( + "stream_provider" + ) interceptors_from_client = cast( list[Interceptor], [i for i in client_config["interceptors"] if isinstance(i, Interceptor)], @@ -531,6 +536,7 @@ def _init_from_config(self, client: temporalio.client.Client, config: WorkerConf interceptors=interceptors, metric_meter=self._runtime.metric_meter, client=client, + stream_provider=stream_provider, encode_headers=( client_config["header_codec_behavior"] == HeaderCodecBehavior.CODEC ), @@ -600,7 +606,7 @@ def check_activity(activity: str): != HeaderCodecBehavior.NO_CODEC, max_workflow_task_external_storage_concurrency=max_workflow_task_external_storage_concurrency, external_stream_backend=self._external_stream_backend, - stream_provider=config.get("stream_provider"), + stream_provider=stream_provider, ) tuner = config.get("tuner") diff --git a/tests/streams/test_stream_accessors.py b/tests/streams/test_stream_accessors.py new file mode 100644 index 000000000..73436dea2 --- /dev/null +++ b/tests/streams/test_stream_accessors.py @@ -0,0 +1,125 @@ +"""The accessors each context asks for its stream, with one registration. + +The provider is registered on the client alone. A worker built from that +client inherits it, an activity on that worker reaches its own workflow's +stream through ``activity.stream_handle()``, and any code holding the client +reaches a stream through ``client.get_stream_handle()``. Without a +registration, both say so with the same error. +""" + +from __future__ import annotations + +import asyncio +import uuid +from datetime import timedelta +from typing import Any + +import pytest + +from temporalio import activity, workflow +from temporalio.client import Client +from temporalio.streams import RecordKind, StreamUnsupportedError +from temporalio.streams.providers.memory import MemoryStreams +from temporalio.testing import ActivityEnvironment, WorkflowEnvironment +from tests.helpers import new_worker + +INPUTS = "inputs" +DECISIONS = "decisions" + + +@pytest.fixture +def provider(env: WorkflowEnvironment): # pyright: ignore[reportUnusedFunction] + if env.supports_time_skipping: + pytest.skip( + "the memory provider polls on a timer, which time skipping turns into a spin" + ) + streams = MemoryStreams() + yield streams + streams.reset() + + +def _with_provider(client: Client, provider: MemoryStreams) -> Client: + # The same connection, with the provider registered the way an + # application registers it: once, on the client. + config = client.config() + config["plugins"] = [provider] + return Client(**config) + + +@activity.defn +async def emit(count: int) -> str: + # No workflow id and no run id: the handle is this activity's own + # workflow, pinned to its run, and the producer's identity is the + # activity's. + model = activity.stream_handle().producer(topic=INPUTS) + for n in range(count): + await model.append({"n": n}) + await model.finish() + return activity.info().activity_id + + +@workflow.defn +class Echo: + """Runs the emitting activity and echoes what arrives on ``inputs``.""" + + @workflow.run + async def run(self, count: int) -> list[Any]: + inputs = workflow.stream_reader(INPUTS, result_type=dict) + decisions = workflow.stream_writer(DECISIONS) + emitting = workflow.start_activity( + emit, count, start_to_close_timeout=timedelta(seconds=30) + ) + seen: list[Any] = [] + async for record in inputs: + if record.kind is RecordKind.FINISH: + seen.append(("finish", record.producer_id)) + break + assert record.value is not None + seen.append(record.value["n"]) + decisions.publish({"echo": record.value["n"]}) + decisions.finish() + producer_id = await emitting + return [*seen, ("activity", producer_id)] + + +async def test_one_registration_on_the_client_serves_every_context( + client: Client, provider: MemoryStreams +): + registered = _with_provider(client, provider) + workflow_id = f"streams-wf-{uuid.uuid4().hex}" + # No plugins on the worker: it inherits the client's provider. + async with new_worker(registered, Echo, activities=[emit]) as worker: + handle = await registered.start_workflow( + Echo.run, 2, id=workflow_id, task_queue=worker.task_queue + ) + result = await handle.result() + # The activity wrote as itself onto its own workflow's topic. + assert result[:2] == [0, 1] + assert result[2] == ["finish", result[3][1]] + + stream = registered.get_stream_handle(workflow_id) + + async def read_everything() -> list[Any]: + return [ + (r.kind, r.value) + async for r in stream.read(topic=DECISIONS, result_type=dict) + ] + + assert await asyncio.wait_for(read_everything(), 30) == [ + (RecordKind.DATA, {"echo": 0}), + (RecordKind.DATA, {"echo": 1}), + (RecordKind.FINISH, None), + ] + + +async def test_get_stream_handle_needs_a_registered_provider(client: Client): + with pytest.raises(StreamUnsupportedError, match="plugins="): + client.get_stream_handle("wf") + + +async def test_stream_handle_needs_a_provider_on_the_worker(): + async def ask() -> None: + activity.stream_handle() + + with pytest.raises(StreamUnsupportedError, match="plugins="): + await ActivityEnvironment().run(ask) diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index addfe5689..9a0de2f0d 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -25,7 +25,7 @@ import uuid from collections.abc import AsyncIterator, Awaitable, Callable from dataclasses import dataclass -from typing import Any, cast +from typing import Any import pytest @@ -68,11 +68,15 @@ async def open( ) -> StreamHandle: if self.host is not None: await self.host(workflow_id) - # The memory provider takes no client; every storage provider's setup - # supplies one, so the cast only ever lies for the provider that - # does not read it. + if self.client is not None: + # A storage provider's setup registers the provider on the client, + # so the cases go through the accessor an application uses. + return self.client.get_stream_handle(workflow_id, run_id=run_id) + # Only the memory provider gets here, and it takes no client. return self.provider.get_stream_handle( - cast(Client, self.client), workflow_id, run_id=run_id + None, # type: ignore[arg-type] + workflow_id, + run_id=run_id, ) From dc32947c2b8f77a5dbef9bebe68bae5ec8e8fffe Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 10:24:46 -0700 Subject: [PATCH 60/96] Registered the demo's provider on the client and reported from the activity. --- CHANGELOG.md | 12 +++++++----- streams_demo/agent_loop.py | 22 ++++++++++++++++------ streams_demo/run_demo.py | 37 +++++++++++++++++++++++++++++-------- 3 files changed, 52 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a084569bc..357a7ba66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,11 +36,13 @@ to include examples, links to docs, or any other relevant information. ### Added - **Experimental**: `temporalio.streams` defines one stream interface a workflow - can read, decide on, and write. `workflow.stream_reader()` and - `workflow.stream_writer()` are the workflow-side entry points, a provider is a - worker plugin passed as `Worker(plugins=[provider])`, and - `provider.get_stream_handle()` reads and appends from outside. The record on - the wire is `temporal.api.stream.v1.StreamRecord` on every provider. + can read, decide on, and write. A provider is registered once as a plugin, + `Client.connect(plugins=[provider])`, and workers built from that client + inherit it; each context then asks for its stream the same way: + `workflow.stream_reader()` and `workflow.stream_writer()` in workflow code, + `activity.stream_handle()` in an activity, and `client.get_stream_handle()` + anywhere a client is held. The record on the wire is + `temporal.api.stream.v1.StreamRecord` on every provider. `temporalio.streams.providers.memory.MemoryStreams` is the in-memory reference provider the conformance tests run against, and `temporalio.streams.providers.redis.RedisStreams` serves the same interface diff --git a/streams_demo/agent_loop.py b/streams_demo/agent_loop.py index 330cb54c2..a76b35ced 100644 --- a/streams_demo/agent_loop.py +++ b/streams_demo/agent_loop.py @@ -1,12 +1,14 @@ """One agent loop, written once, run on every stream provider. Reads input, decides on it, publishes the decision, and runs an ordinary -Activity in the same workflow task. Also handles the two control records the -contract defines, so a retried producer and a finished topic are exercised -rather than described. +Activity in the same workflow task; the Activity reports on its own +workflow's stream in turn. Also handles the two control records the contract +defines, so a retried producer and a finished topic are exercised rather than +described. This file is byte-identical in the server-side tree and the client-side tree. -Only the worker that runs it differs, and only in which provider it is given. +Nothing in it names a provider: the workflow asks its runtime, the Activity +asks its context, and the process that runs them registered the provider once. """ from __future__ import annotations @@ -19,12 +21,20 @@ DECISIONS = "decisions" INPUTS = "inputs" +RECEIPTS = "receipts" @activity.defn(name="RecordDecision") async def record_decision(decision: dict[str, Any]) -> str: - """An ordinary command in the same task as the publish.""" - return f"recorded:{decision['source']}:{decision['branch']}" + """An ordinary command in the same task as the publish. + + Appends its receipt onto its own workflow's stream too, under the + Activity's own identity, so a reader outside sees the decision and the + record of it side by side. + """ + receipt = f"recorded:{decision['source']}:{decision['branch']}" + await activity.stream_handle().producer(topic=RECEIPTS).append({"receipt": receipt}) + return receipt def decide(token: dict[str, Any]) -> dict[str, Any]: diff --git a/streams_demo/run_demo.py b/streams_demo/run_demo.py index 1238fad11..0b06e9395 100644 --- a/streams_demo/run_demo.py +++ b/streams_demo/run_demo.py @@ -7,10 +7,13 @@ the run as it allows is rebuilt rather than remembered; - a producer whose second attempt supersedes its first, which the reader has to report and the workflow has to act on; -- an outside reader following what the workflow published. +- an outside reader following what the workflow published, and the receipts + the Activity appended on the workflow's stream from inside its own context. -Byte-identical in every tree. ``provider_setup`` is what differs, and it is -the only import here that names a provider. +The provider is registered once, on the client; the worker inherits it and +every context asks for its stream without naming it. Byte-identical in every +tree. ``provider_setup`` is what differs, and it is the only import here that +names a provider. """ from __future__ import annotations @@ -25,12 +28,18 @@ from temporalio.api.enums.v1 import EventType from temporalio.client import Client -from temporalio.streams import StreamHandle +from temporalio.streams import RecordKind, StreamHandle from temporalio.worker import Worker sys.path.insert(0, str(Path(__file__).resolve().parent)) import provider_setup # noqa: E402 -from agent_loop import DECISIONS, INPUTS, AgentLoop, record_decision # noqa: E402 +from agent_loop import ( # noqa: E402 + DECISIONS, + INPUTS, + RECEIPTS, + AgentLoop, + record_decision, +) DECISION_LIMIT = 8 EXPECTED_OUTPUT = 6 @@ -51,7 +60,7 @@ async def main() -> int: out = Path(__file__).resolve().parent / f"results-{provider_setup.NAME}" out.mkdir(exist_ok=True) target, provider = await provider_setup.open() - client = await Client.connect(target, namespace="default") + client = await Client.connect(target, namespace="default", plugins=[provider]) uid = f"ai198-contract-{provider_setup.NAME}-" + uuid.uuid4().hex record: dict[str, Any] = { @@ -67,12 +76,11 @@ async def main() -> int: workflows=[AgentLoop], activities=[record_decision], max_cached_workflows=provider_setup.WORKFLOW_CACHE, - plugins=[provider], ): handle = await client.start_workflow( AgentLoop.run, DECISION_LIMIT, id=uid, task_queue=uid ) - stream = provider.get_stream_handle(client, uid) + stream = client.get_stream_handle(uid) output = asyncio.create_task(collect_output(stream, EXPECTED_OUTPUT)) # The first attempt writes two records and then stops, as a failed @@ -138,6 +146,19 @@ async def main() -> int: output.cancel() record["observed_output"] = "timed_out" + async def receipts() -> list[Any]: + # Ends by itself once the workflow is closed and the tail served. + return [ + r.value + async for r in stream.read(topic=RECEIPTS, result_type=dict) + if r.kind is RecordKind.DATA + ] + + try: + record["receipts"] = await asyncio.wait_for(receipts(), timeout=20) + except asyncio.TimeoutError: + record["receipts"] = "timed_out" + history = await handle.fetch_history() record["history_events"] = len(history.events) (out / "history.json").write_text(history.to_json()) From a6676402f8d33f061372152d3f8f488e0e41da67 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 10:29:01 -0700 Subject: [PATCH 61/96] Registered the Redis conformance provider on the client. --- tests/streams/test_streams_conformance.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index 9a0de2f0d..19759aec1 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -116,8 +116,13 @@ async def _redis_case(client: Client) -> AsyncIterator[ProviderCase]: # A prefix per setup, because the store keeps what earlier runs wrote. key_prefix=f"streams-conformance-{uuid.uuid4().hex}", ) + # Registered once, on the client: the host's worker inherits it and the + # cases open handles through client.get_stream_handle. + config = client.config() + config["plugins"] = [provider] + client = Client(**config) hosts: dict[str, WorkflowHandle[Any, Any]] = {} - async with new_worker(client, StreamHost, plugins=[provider]) as worker: + async with new_worker(client, StreamHost) as worker: async def host(workflow_id: str) -> None: if workflow_id not in hosts: From be743fd0de3327e5bffd65278986a47437f78340 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 10:51:07 -0700 Subject: [PATCH 62/96] Added typed topic definitions. A topic is defined once with streams.topic(name, type) and shared by the workflow, its activities and the backend, so the record and value types follow from the definition; a plain string still names a topic decided at runtime. The wire and every provider see only the name. --- CHANGELOG.md | 6 +- streams_demo/agent_loop.py | 12 +-- streams_demo/run_demo.py | 4 +- temporalio/streams/__init__.py | 9 +- temporalio/streams/_provider.py | 62 +++++++++---- temporalio/streams/_topic.py | 92 ++++++++++++++++++++ temporalio/streams/providers/memory.py | 32 ++++--- temporalio/workflow/_streams.py | 80 ++++++++++------- tests/streams/test_stream_accessors.py | 13 ++- tests/streams/test_streams_conformance.py | 101 +++++++++++++--------- tests/streams/test_streams_workflow.py | 32 +++---- 11 files changed, 309 insertions(+), 134 deletions(-) create mode 100644 temporalio/streams/_topic.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 357a7ba66..6da9470b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,8 +41,10 @@ to include examples, links to docs, or any other relevant information. inherit it; each context then asks for its stream the same way: `workflow.stream_reader()` and `workflow.stream_writer()` in workflow code, `activity.stream_handle()` in an activity, and `client.get_stream_handle()` - anywhere a client is held. The record on the wire is - `temporal.api.stream.v1.StreamRecord` on every provider. + anywhere a client is held. A topic is a typed definition, + `streams.topic("inputs", Token)`, shared by workflow, activity and client + code; a plain string names a topic decided at runtime. The record on the wire + is `temporal.api.stream.v1.StreamRecord` on every provider. `temporalio.streams.providers.memory.MemoryStreams` is the in-memory reference provider the conformance tests run against, and `temporalio.streams.providers.redis.RedisStreams` serves the same interface diff --git a/streams_demo/agent_loop.py b/streams_demo/agent_loop.py index a76b35ced..df5eac4d6 100644 --- a/streams_demo/agent_loop.py +++ b/streams_demo/agent_loop.py @@ -16,12 +16,14 @@ from datetime import timedelta from typing import Any -from temporalio import activity, workflow +from temporalio import activity, streams, workflow from temporalio.streams import RecordKind -DECISIONS = "decisions" -INPUTS = "inputs" -RECEIPTS = "receipts" +# Defined once and shared by the workflow, the Activity and the demo's +# reader, so the type each topic carries is stated in one place. +DECISIONS = streams.topic("decisions", dict[str, Any]) +INPUTS = streams.topic("inputs", dict[str, Any]) +RECEIPTS = streams.topic("receipts", dict[str, Any]) @activity.defn(name="RecordDecision") @@ -55,7 +57,7 @@ class AgentLoop: @workflow.run async def run(self, limit: int) -> list[dict[str, Any]]: """Decide on at most ``limit`` inputs, then return the trace.""" - inputs = workflow.stream_reader(INPUTS, result_type=dict) + inputs = workflow.stream_reader(INPUTS) decisions = workflow.stream_writer(DECISIONS) trace: list[dict[str, Any]] = [] accepted = 0 diff --git a/streams_demo/run_demo.py b/streams_demo/run_demo.py index 0b06e9395..e91bf2e31 100644 --- a/streams_demo/run_demo.py +++ b/streams_demo/run_demo.py @@ -48,7 +48,7 @@ async def collect_output(stream: StreamHandle, want: int) -> list[dict]: """Read ``want`` decisions off the workflow's stream from outside it.""" seen: list[dict] = [] - async for record in stream.read(topic=DECISIONS, result_type=dict): + async for record in stream.read(topic=DECISIONS): seen.append({"kind": record.kind.name, "value": record.value}) if len(seen) >= want: break @@ -150,7 +150,7 @@ async def receipts() -> list[Any]: # Ends by itself once the workflow is closed and the tail served. return [ r.value - async for r in stream.read(topic=RECEIPTS, result_type=dict) + async for r in stream.read(topic=RECEIPTS) if r.kind is RecordKind.DATA ] diff --git a/temporalio/streams/__init__.py b/temporalio/streams/__init__.py index c185c35ce..b23c95be4 100644 --- a/temporalio/streams/__init__.py +++ b/temporalio/streams/__init__.py @@ -28,7 +28,10 @@ 5. **A workflow addresses its streams relative to itself, by topic.** A topic can be written by the workflow and by outside producers, and read by the workflow and by outside consumers; which of those happen is the - application's business. + application's business. A topic is defined once with :func:`topic`, with + the type its records decode to, and that definition is shared by the + workflow, its activities and the backend; a plain string names a topic + decided at runtime. A provider is an object, registered once as a plugin: ``Client.connect(plugins=[provider])``; workers built from that client inherit @@ -80,6 +83,7 @@ StreamRecord, Supersession, ) +from temporalio.streams._topic import StreamTopic, resolve_topic, topic __all__ = [ "BEGINNING", @@ -94,8 +98,11 @@ "StreamProducerError", "StreamProvider", "StreamRecord", + "StreamTopic", "StreamUnsupportedError", "Supersession", "WorkflowStreamProvider", "WriteSink", + "resolve_topic", + "topic", ] diff --git a/temporalio/streams/_provider.py b/temporalio/streams/_provider.py index 6992f0a14..8c142080d 100644 --- a/temporalio/streams/_provider.py +++ b/temporalio/streams/_provider.py @@ -9,7 +9,9 @@ workflow code is bundled separately name the two halves in two packages. A provider only moves ``temporal.api.stream.v1.StreamRecord`` protos. The -handles around it convert values, synthesize supersession and mint cursors. +handles around it convert values, synthesize supersession and mint cursors, +and turn a :class:`temporalio.streams.StreamTopic` into the plain name the +provider sees, through :func:`temporalio.streams.resolve_topic`. """ from __future__ import annotations @@ -19,6 +21,7 @@ from temporalio.api.stream.v1 import StreamRecord as WireRecord from temporalio.streams._record import BEGINNING, Cursor, StreamRecord +from temporalio.streams._topic import StreamTopic if TYPE_CHECKING: from temporalio.client import Client @@ -33,14 +36,16 @@ ] T = TypeVar("T") +T_contra = TypeVar("T_contra", contravariant=True) -class StreamProducer(Protocol): +class StreamProducer(Protocol[T_contra]): """Appends to one topic from outside workflow code. Every append is visible as soon as the store accepts it, and carries the producer id, attempt and sequence that let a reader tell a retried append - from a new generation. + from a new generation. The type parameter is the topic definition's + value type; a producer on a string-named topic takes any value. """ @property @@ -53,7 +58,7 @@ def attempt(self) -> int: """The generation this producer is writing, or 0 when undeclared.""" ... - async def append(self, *values: Any) -> Cursor | None: + async def append(self, *values: T_contra) -> Cursor | None: """Append ``values`` and return the cursor of the last record as the store holds it. A repeat of an earlier append (same producer, attempt and sequence) is @@ -83,11 +88,19 @@ class StreamHandle(Protocol): """One workflow's stream, addressed by topic, from outside workflow code. A handle follows the workflow's execution chain unless it was opened with - a ``run_id``, in which case it is pinned to that run. A transport failure - surfaces as :class:`temporalio.service.RPCError`, never as the transport's - own exception type. + a ``run_id``, in which case it is pinned to that run. A topic is a + :class:`temporalio.streams.StreamTopic` definition, which carries the + record type, or a plain string with ``result_type=`` for a name decided + at runtime. A transport failure surfaces as + :class:`temporalio.service.RPCError`, never as the transport's own + exception type. """ + @overload + def read( + self, *, topic: StreamTopic[T], after: Cursor = ... + ) -> AsyncGenerator[StreamRecord[T], None]: ... + @overload def read( self, *, topic: str, after: Cursor = ..., result_type: type[T] @@ -101,7 +114,7 @@ def read( def read( self, *, - topic: str, + topic: str | StreamTopic[Any], after: Cursor = BEGINNING, result_type: type | None = None, ) -> AsyncGenerator[StreamRecord[Any], None]: @@ -117,6 +130,8 @@ def read( release whatever the provider parked against the store. Raises: + ValueError: ``result_type`` was passed with a topic definition, + or the topic is empty. StreamCursorError: ``after`` came from another provider or names a record no longer retained. Raised by this call, not by the first iteration. @@ -125,7 +140,7 @@ def read( """ ... - async def latest(self, *, topic: str) -> Cursor: + async def latest(self, *, topic: str | StreamTopic[Any]) -> Cursor: """The cursor of the newest record on ``topic``, or ``BEGINNING`` when empty. For a reader that wants to follow from now: ``read(after=latest())`` @@ -135,9 +150,23 @@ async def latest(self, *, topic: str) -> Cursor: """ ... + @overload + def producer( + self, *, topic: StreamTopic[T], producer_id: str = ..., attempt: int = ... + ) -> StreamProducer[T]: ... + + @overload + def producer( + self, *, topic: str, producer_id: str = ..., attempt: int = ... + ) -> StreamProducer[Any]: ... + def producer( - self, *, topic: str, producer_id: str = "", attempt: int = 0 - ) -> StreamProducer: + self, + *, + topic: str | StreamTopic[Any], + producer_id: str = "", + attempt: int = 0, + ) -> StreamProducer[Any]: """A producer on ``topic``. Inside an activity, leave ``producer_id`` and ``attempt`` unset: the @@ -189,7 +218,8 @@ class WorkflowStreamProvider(Protocol): Imports nothing that does I/O. The worker creates one per workflow instance through :meth:`StreamProvider.workflow_provider`, so state kept - here dies with the instance the way handlers do. + here dies with the instance the way handlers do. It sees topics by name; + the definitions are resolved before it is called. """ def open_reader(self, topic: str, *, after: Cursor) -> ReadSource: @@ -224,9 +254,11 @@ async def on_workflow_finish(self) -> None: class StreamProvider(Protocol): """What a store ships. Also a :class:`temporalio.worker.Plugin` when it serves workers. - Construct one, pass it to ``Worker(plugins=[provider])`` and - ``Replayer(plugins=[provider])``, and open handles from it anywhere else. - Nothing is global: two workers in one process may hold two providers. + Construct one, pass it to ``Client.connect(plugins=[provider])`` so the + client and the workers built from it carry it, or to + ``Worker(plugins=[provider])`` and ``Replayer(plugins=[provider])`` for a + worker alone, and open handles from it anywhere else. Nothing is global: + two workers in one process may hold two providers. """ def workflow_provider(self) -> WorkflowStreamProvider: diff --git a/temporalio/streams/_topic.py b/temporalio/streams/_topic.py new file mode 100644 index 000000000..173291fad --- /dev/null +++ b/temporalio/streams/_topic.py @@ -0,0 +1,92 @@ +"""Typed topic definitions. + +Temporal's idiom is to define once and refer by reference: signals, queries +and updates are decorated methods, activities and workflows are functions, +Nexus operations are typed definitions. A topic follows the same rule. It is +defined once, at module level, with the type its records decode to, and the +workflow, its activities and the backend all refer to that one definition. A +plain string names a topic too, the way a string names a signal chosen at +runtime; then the decode hint travels as ``result_type=`` on each call. + +The wire does not change: a topic is a string on the proto and in every +store, and :attr:`temporalio.streams.StreamRecord.topic` is that string. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Generic, TypeVar, overload + +__all__ = ["StreamTopic", "resolve_topic", "topic"] + +T = TypeVar("T") + + +@dataclass(frozen=True) +class StreamTopic(Generic[T]): + """A topic of a workflow's stream, with the type its records decode to. + + Made with :func:`topic`. Hand it to :func:`temporalio.workflow.stream_reader`, + :func:`temporalio.workflow.stream_writer`, and to a handle's ``read``, + ``latest`` and ``producer``, and the record and value types follow from + it; ``result_type=`` is not passed alongside a definition. + """ + + name: str + result_type: type[T] | None = None + """The type records decode to, or ``None`` for the converter's default.""" + + +@overload +def topic(name: str, result_type: type[T]) -> StreamTopic[T]: ... + + +@overload +def topic(name: str, result_type: None = None) -> StreamTopic[Any]: ... + + +def topic(name: str, result_type: type | None = None) -> StreamTopic[Any]: + """Define a topic of a workflow's stream. + + Define it once, at module level, and share it: the workflow reads or + publishes it, an activity or a backend produces onto it or reads it, and + the type it carries is inferred wherever it is used. Use a plain string + instead only when the name is decided at runtime. + + Args: + name: The topic's name, as it appears on every record. + result_type: The type records on this topic decode to. Without one, + the payload converter's default applies. + + Raises: + ValueError: ``name`` is empty. + """ + if not name: + raise ValueError("topic name must not be empty") + return StreamTopic(name, result_type) + + +def resolve_topic( + topic: str | StreamTopic[Any], result_type: type | None = None +) -> tuple[str, type | None]: + """The name and decode hint a call means, from either form of topic. + + Providers call this once at the top of ``read``, ``latest`` and + ``producer``, so a definition and a string are the same to the store. + + Raises: + ValueError: A definition was given together with ``result_type``, + which would name two types for one topic, or the name is empty. + """ + if isinstance(topic, StreamTopic): + if result_type is not None: + raise ValueError( + f"topic {topic.name!r} already carries its type; do not pass " + "result_type= with a definition" + ) + name, result_type = topic.name, topic.result_type + else: + name = topic + if not name: + raise ValueError("topic must not be empty") + return name, result_type diff --git a/temporalio/streams/providers/memory.py b/temporalio/streams/providers/memory.py index 2a6087b2d..f175570a8 100644 --- a/temporalio/streams/providers/memory.py +++ b/temporalio/streams/providers/memory.py @@ -28,7 +28,7 @@ import logging from collections.abc import AsyncGenerator from datetime import timedelta -from typing import Any +from typing import Any, Generic, TypeVar from google.protobuf.message import DecodeError @@ -40,6 +40,7 @@ from temporalio.streams._ids import topic_key from temporalio.streams._provider import ReadSource, WriteSink from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord +from temporalio.streams._topic import StreamTopic, resolve_topic from temporalio.streams._wire import ( RecordDecoder, WireRecord, @@ -54,6 +55,8 @@ _PROVIDER = "memory" +T = TypeVar("T") + logger = logging.getLogger(__name__) @@ -189,7 +192,7 @@ async def on_workflow_finish(self) -> None: pass -class MemoryProducer: +class MemoryProducer(Generic[T]): """The outside producer, faithful to the contract.""" def __init__( @@ -227,7 +230,7 @@ def _writer(self) -> str: else self._producer_id ) - async def append(self, *values: Any) -> Cursor: + async def append(self, *values: T) -> Cursor: """Append ``values`` and return the cursor of the last record as stored. A repeat returns where the original landed; an empty call returns @@ -298,12 +301,13 @@ def __init__( def read( self, *, - topic: str, + topic: str | StreamTopic[Any], after: Cursor = BEGINNING, result_type: type | None = None, ) -> AsyncGenerator[StreamRecord[Any], None]: """Yield records on ``topic`` after ``after`` until the workflow closes.""" - store = self._streams._topic(self._workflow_id, topic) + name, result_type = resolve_topic(topic, result_type) + store = self._streams._topic(self._workflow_id, name) # Parsed here so a foreign cursor fails this call, not the first # iteration of the generator. start = self._streams._offset_after(after) @@ -366,18 +370,24 @@ async def _closed(self) -> bool: self._run_id is None and status == WorkflowExecutionStatus.CONTINUED_AS_NEW ) - async def latest(self, *, topic: str) -> Cursor: + async def latest(self, *, topic: str | StreamTopic[Any]) -> Cursor: """The cursor of the newest record on ``topic``, for following from now.""" - count = len(self._streams._topic(self._workflow_id, topic).records) + name, _ = resolve_topic(topic) + count = len(self._streams._topic(self._workflow_id, name).records) return mint_cursor(_PROVIDER, str(count - 1)) if count else BEGINNING def producer( - self, *, topic: str, producer_id: str = "", attempt: int = 0 - ) -> MemoryProducer: + self, + *, + topic: str | StreamTopic[Any], + producer_id: str = "", + attempt: int = 0, + ) -> MemoryProducer[Any]: """A producer on ``topic``; inside an activity its identity is the activity's.""" - store = self._streams._topic(self._workflow_id, topic) + name, _ = resolve_topic(topic) + store = self._streams._topic(self._workflow_id, name) producer_id, attempt = producer_identity(producer_id, attempt) - return MemoryProducer(store, self._converter, topic, producer_id, attempt) + return MemoryProducer(store, self._converter, name, producer_id, attempt) class MemoryStreams(ProviderPlugin): diff --git a/temporalio/workflow/_streams.py b/temporalio/workflow/_streams.py index 029da59a1..5d319d50a 100644 --- a/temporalio/workflow/_streams.py +++ b/temporalio/workflow/_streams.py @@ -19,6 +19,7 @@ from temporalio.streams._provider import ReadSource, WorkflowStreamProvider, WriteSink from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord +from temporalio.streams._topic import StreamTopic, resolve_topic from temporalio.streams._wire import RecordDecoder, to_wire from temporalio.workflow._context import _Runtime, payload_converter from temporalio.workflow._sandbox import logger @@ -71,7 +72,7 @@ def __init__( @property def topic(self) -> str: - """The topic this reader is subscribed to.""" + """The name of the topic this reader is subscribed to.""" return self._topic def __aiter__(self) -> StreamReader[T]: @@ -124,12 +125,14 @@ def close(self) -> None: self._on_close() -class StreamWriter: +class StreamWriter(Generic[T]): """Publishes to one topic of this workflow's stream. A workflow can only publish transactionally to its own stream, on every provider. Writing to somebody else's stream is an activity's job, and it - gets the weaker guarantee that goes with doing I/O. + gets the weaker guarantee that goes with doing I/O. The type parameter is + the topic definition's value type; a writer on a string-named topic takes + any value. """ def __init__(self, sink: WriteSink, topic: str) -> None: @@ -140,10 +143,10 @@ def __init__(self, sink: WriteSink, topic: str) -> None: @property def topic(self) -> str: - """The topic this writer is bound to.""" + """The name of the topic this writer is bound to.""" return self._topic - def publish(self, value: Any) -> None: + def publish(self, value: T) -> None: """Append ``value`` to this topic. Synchronous, because there is nothing to wait for inside a task: the @@ -180,6 +183,10 @@ def finish(self) -> None: ) +@overload +def stream_reader(topic: StreamTopic[T], *, after: Cursor = ...) -> StreamReader[T]: ... + + @overload def stream_reader( topic: str, *, result_type: type[T], after: Cursor = ... @@ -193,65 +200,80 @@ def stream_reader( def stream_reader( - topic: str, *, result_type: type | None = None, after: Cursor = BEGINNING + topic: str | StreamTopic[Any], + *, + result_type: type | None = None, + after: Cursor = BEGINNING, ) -> StreamReader[Any]: """Subscribe this workflow to ``topic`` of its own stream. - One subscription per topic per run. A second call for the same topic - returns the reader already open on it, so records go to whichever loop - pulls first; such a call may pass neither ``after`` nor a different - ``result_type``. Adding a reader on a new topic is a new command, so gate - it with :func:`temporalio.workflow.patched` as you would a timer. A + ``topic`` is a :func:`temporalio.streams.topic` definition, which carries + the record type, or a plain string with ``result_type=`` for a name + decided at runtime. One subscription per topic per run. A second call + for the same topic returns the reader already open on it, so records go + to whichever loop pulls first; such a call may pass neither ``after`` nor + a different type. Adding a reader on a new topic is a new command, so + gate it with :func:`temporalio.workflow.patched` as you would a timer. A reader in a successor run starts a new subscription: nothing crosses continue-as-new implicitly. Args: topic: The topic, relative to this workflow's stream. - result_type: The value type, used as the decode hint. - :class:`temporalio.common.RawValue` returns the payload untouched. + result_type: The value type for a string-named topic, used as the + decode hint. :class:`temporalio.common.RawValue` returns the + payload untouched. after: Resume strictly after this record. Honoured on the first subscription of a run, because after that the recorded observations decide. Raises: - ValueError: ``topic`` is empty, or a reader on it is already open and - this call asked for a different position or type. + ValueError: ``topic`` is empty, ``result_type`` was passed with a + definition, or a reader on the topic is already open and this + call asked for a different position or type. temporalio.streams.StreamCursorError: ``after`` was minted by another provider. """ - if not topic: - raise ValueError("topic must not be empty") + name, result_type = resolve_topic(topic, result_type) state: _WorkflowStreams = _Runtime.current().workflow_streams() - existing = state.readers.get(topic) + existing = state.readers.get(name) if existing is not None: if after != BEGINNING or result_type is not existing._result_type: raise ValueError( - f"topic {topic!r} already has a reader in this run; a second " - "stream_reader shares it and takes no after= or other result_type=" + f"topic {name!r} already has a reader in this run; a second " + "stream_reader shares it and takes no after= or other type" ) return existing - source = state.provider.open_reader(topic, after=after) + source = state.provider.open_reader(name, after=after) def forget() -> None: - state.readers.pop(topic, None) + state.readers.pop(name, None) reader: StreamReader[Any] = StreamReader( - source, topic=topic, result_type=result_type, after=after, on_close=forget + source, topic=name, result_type=result_type, after=after, on_close=forget ) - state.readers[topic] = reader + state.readers[name] = reader return reader -def stream_writer(topic: str) -> StreamWriter: +@overload +def stream_writer(topic: StreamTopic[T]) -> StreamWriter[T]: ... + + +@overload +def stream_writer(topic: str) -> StreamWriter[Any]: ... + + +def stream_writer(topic: str | StreamTopic[Any]) -> StreamWriter[Any]: """Publish to ``topic`` of this workflow's stream. Args: - topic: The topic name. Encoding follows each published value. + topic: A :func:`temporalio.streams.topic` definition, whose value type + the writer's ``publish`` takes, or a plain string for a name + decided at runtime. Encoding follows each published value. Raises: ValueError: ``topic`` is empty. """ - if not topic: - raise ValueError("topic must not be empty") + name, _ = resolve_topic(topic) provider = _Runtime.current().workflow_streams().provider - return StreamWriter(provider.open_writer(topic), topic) + return StreamWriter(provider.open_writer(name), name) diff --git a/tests/streams/test_stream_accessors.py b/tests/streams/test_stream_accessors.py index 73436dea2..eb733ba5b 100644 --- a/tests/streams/test_stream_accessors.py +++ b/tests/streams/test_stream_accessors.py @@ -18,13 +18,13 @@ from temporalio import activity, workflow from temporalio.client import Client -from temporalio.streams import RecordKind, StreamUnsupportedError +from temporalio.streams import RecordKind, StreamUnsupportedError, topic from temporalio.streams.providers.memory import MemoryStreams from temporalio.testing import ActivityEnvironment, WorkflowEnvironment from tests.helpers import new_worker -INPUTS = "inputs" -DECISIONS = "decisions" +INPUTS = topic("inputs", dict) +DECISIONS = topic("decisions", dict) @pytest.fixture @@ -64,7 +64,7 @@ class Echo: @workflow.run async def run(self, count: int) -> list[Any]: - inputs = workflow.stream_reader(INPUTS, result_type=dict) + inputs = workflow.stream_reader(INPUTS) decisions = workflow.stream_writer(DECISIONS) emitting = workflow.start_activity( emit, count, start_to_close_timeout=timedelta(seconds=30) @@ -100,10 +100,7 @@ async def test_one_registration_on_the_client_serves_every_context( stream = registered.get_stream_handle(workflow_id) async def read_everything() -> list[Any]: - return [ - (r.kind, r.value) - async for r in stream.read(topic=DECISIONS, result_type=dict) - ] + return [(r.kind, r.value) async for r in stream.read(topic=DECISIONS)] assert await asyncio.wait_for(read_everything(), 30) == [ (RecordKind.DATA, {"echo": 0}), diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index 19759aec1..e4da91255 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -44,12 +44,21 @@ Supersession, _ids, _wire, + topic, ) from temporalio.streams._policy import AttemptTracker from temporalio.streams.providers.memory import MemoryStreams from temporalio.streams.providers.redis import RedisStreams from tests.helpers import new_worker +# Defined once and shared by every case, the way an application shares them +# between its workflow, its activities and its backend. +OUT = topic("out", dict) +A = topic("a", dict) +B = topic("b", dict) +Y = topic("y", dict) +XY = topic("x:y", dict) + @dataclass class ProviderCase: @@ -251,12 +260,12 @@ def test_cursors_name_their_provider(): async def test_append_read_roundtrip(case: ProviderCase): workflow_id = new_workflow_id() stream = await case.open(workflow_id) - producer = stream.producer(topic="out", producer_id="model", attempt=1) + producer = stream.producer(topic=OUT, producer_id="model", attempt=1) assert (producer.producer_id, producer.attempt) == ("model", 1) await producer.append({"id": "r1"}, {"id": "r2"}) await producer.finish() - records = await take(stream.read(topic="out", result_type=dict), 3) + records = await take(stream.read(topic=OUT), 3) assert [r.kind for r in records] == [ RecordKind.DATA, RecordKind.DATA, @@ -266,14 +275,14 @@ async def test_append_read_roundtrip(case: ProviderCase): assert records[2].value is None assert all(r.producer_id == "model" and r.attempt == 1 for r in records) assert [r.sequence for r in records] == [0, 1, 2] - assert all(r.topic == "out" for r in records) + assert all(r.topic == OUT.name for r in records) async def test_raw_values_pass_through_untouched(case: ProviderCase): workflow_id = new_workflow_id() stream = await case.open(workflow_id) payload = Payload(metadata={"encoding": b"binary/plain"}, data=b"\x00\x01raw") - producer = stream.producer(topic="out", producer_id="model", attempt=1) + producer = stream.producer(topic=OUT.name, producer_id="model", attempt=1) await producer.append(RawValue(payload)) records = await take(stream.read(topic="out", result_type=RawValue), 1) @@ -285,46 +294,46 @@ async def test_raw_values_pass_through_untouched(case: ProviderCase): async def test_retried_append_returns_the_original_position(case: ProviderCase): workflow_id = new_workflow_id() stream = await case.open(workflow_id) - first = stream.producer(topic="out", producer_id="model", attempt=1) + first = stream.producer(topic=OUT, producer_id="model", attempt=1) landed = await first.append({"id": "r1"}) assert landed is not None # The retry of the same attempt starts its sequence over and appends the # same record. The provider stores it once and answers with where the # original landed, so the retry can checkpoint the same position. - retry = stream.producer(topic="out", producer_id="model", attempt=1) + retry = stream.producer(topic=OUT, producer_id="model", attempt=1) assert await retry.append({"id": "r1"}) == landed # An empty call writes nothing and answers the same way. assert await retry.append() == landed - records = await take(stream.read(topic="out", result_type=dict), 1) + records = await take(stream.read(topic=OUT), 1) assert records[0].value == {"id": "r1"} assert records[0].cursor == landed # The store holds exactly the one record: the newest position is its cursor. - assert await stream.latest(topic="out") == landed + assert await stream.latest(topic=OUT) == landed async def test_retried_append_is_stored_once(case: ProviderCase): workflow_id = new_workflow_id() stream = await case.open(workflow_id) - first = stream.producer(topic="out", producer_id="model", attempt=1) + first = stream.producer(topic=OUT, producer_id="model", attempt=1) await first.append({"id": "r1"}) - retry = stream.producer(topic="out", producer_id="model", attempt=1) + retry = stream.producer(topic=OUT, producer_id="model", attempt=1) await retry.append({"id": "r1"}) await retry.append({"id": "r2"}) - records = await take(stream.read(topic="out", result_type=dict), 2) + records = await take(stream.read(topic=OUT), 2) assert [r.value for r in records] == [{"id": "r1"}, {"id": "r2"}] async def test_new_attempt_supersedes_the_old_one(case: ProviderCase): workflow_id = new_workflow_id() stream = await case.open(workflow_id) - first = stream.producer(topic="out", producer_id="model", attempt=1) + first = stream.producer(topic=OUT, producer_id="model", attempt=1) await first.append({"text": "The capital of"}) - second = stream.producer(topic="out", producer_id="model", attempt=2) + second = stream.producer(topic=OUT, producer_id="model", attempt=2) await second.append({"text": "Paris is the capital"}) - records = await take(stream.read(topic="out", result_type=dict), 3) + records = await take(stream.read(topic=OUT), 3) assert records[0].kind is RecordKind.DATA and records[0].attempt == 1 assert records[1].kind is RecordKind.SUPERSEDED assert records[1].supersession == Supersession("model", 1, 2) @@ -337,21 +346,19 @@ async def test_a_superseded_record_resumes_to_the_triggering_record( ): workflow_id = new_workflow_id() stream = await case.open(workflow_id) - first = stream.producer(topic="out", producer_id="model", attempt=1) + first = stream.producer(topic=OUT, producer_id="model", attempt=1) await first.append({"n": 1}) - second = stream.producer(topic="out", producer_id="model", attempt=2) + second = stream.producer(topic=OUT, producer_id="model", attempt=2) await second.append({"n": 2}) - records = await take(stream.read(topic="out", result_type=dict), 3) + records = await take(stream.read(topic=OUT), 3) superseded = records[1] assert superseded.kind is RecordKind.SUPERSEDED # The synthesized record sits at the position before the new attempt's # first record, so a consumer that checkpoints it and restarts is handed # that record rather than skipping it. assert superseded.cursor == records[0].cursor - resumed = await take( - stream.read(topic="out", result_type=dict, after=superseded.cursor), 1 - ) + resumed = await take(stream.read(topic=OUT, after=superseded.cursor), 1) assert resumed[0].kind is RecordKind.DATA assert resumed[0].value == {"n": 2} @@ -361,29 +368,29 @@ async def test_topics_are_addressed_by_name(case: ProviderCase): # names its topic and sees only that topic's records. workflow_id = new_workflow_id() stream = await case.open(workflow_id) - on_a = stream.producer(topic="a", producer_id="tool-a", attempt=1) + on_a = stream.producer(topic=A, producer_id="tool-a", attempt=1) await on_a.append({"n": 1}) - on_b = stream.producer(topic="b", producer_id="tool-b", attempt=1) + on_b = stream.producer(topic=B, producer_id="tool-b", attempt=1) await on_b.append({"n": 2}) - only_a = await take(stream.read(topic="a", result_type=dict), 1) + only_a = await take(stream.read(topic=A), 1) assert [(r.topic, r.value) for r in only_a] == [("a", {"n": 1})] - only_b = await take(stream.read(topic="b", result_type=dict), 1) + only_b = await take(stream.read(topic=B), 1) assert [(r.topic, r.value) for r in only_b] == [("b", {"n": 2})] async def test_cursor_resumes_where_it_points(case: ProviderCase): workflow_id = new_workflow_id() stream = await case.open(workflow_id) - producer = stream.producer(topic="out", producer_id="model", attempt=1) + producer = stream.producer(topic=OUT, producer_id="model", attempt=1) await producer.append({"n": 1}, {"n": 2}, {"n": 3}) - records = await take(stream.read(topic="out", result_type=dict), 3) + records = await take(stream.read(topic=OUT), 3) checkpoint = records[0].cursor # Resuming after a record hands back everything past it and nothing # twice, without the reader ever advancing a cursor itself. - again = await take(stream.read(topic="out", result_type=dict, after=checkpoint), 2) + again = await take(stream.read(topic=OUT, after=checkpoint), 2) assert [r.value for r in again] == [{"n": 2}, {"n": 3}] @@ -391,14 +398,14 @@ async def test_cursor_resumes_where_it_points(case: ProviderCase): async def test_append_cursor_names_the_last_record_of_the_batch(case: ProviderCase): workflow_id = new_workflow_id() stream = await case.open(workflow_id) - producer = stream.producer(topic="out", producer_id="model", attempt=1) + producer = stream.producer(topic=OUT, producer_id="model", attempt=1) appended = await producer.append({"n": 1}, {"n": 2}, {"n": 3}) assert appended is not None then = await producer.append({"n": 4}) # A producer that resumes a reader after its own append must see only # what came later, not the tail of the batch it just wrote. - records = await take(stream.read(topic="out", result_type=dict, after=appended), 1) + records = await take(stream.read(topic=OUT, after=appended), 1) assert [r.value for r in records] == [{"n": 4}] assert records[0].cursor == then assert await producer.append() == then @@ -407,16 +414,16 @@ async def test_append_cursor_names_the_last_record_of_the_batch(case: ProviderCa async def test_latest_positions_a_reader_at_the_end(case: ProviderCase): workflow_id = new_workflow_id() stream = await case.open(workflow_id) - producer = stream.producer(topic="out", producer_id="model", attempt=1) - assert await stream.latest(topic="out") == BEGINNING + producer = stream.producer(topic=OUT, producer_id="model", attempt=1) + assert await stream.latest(topic=OUT) == BEGINNING await producer.append({"n": 1}, {"n": 2}) - since = await stream.latest(topic="out") + since = await stream.latest(topic=OUT) await producer.append({"n": 3}) # A reader that positioned itself before the last append sees only what # came after, which is how a client follows a turn it is about to start. - records = await take(stream.read(topic="out", result_type=dict, after=since), 1) + records = await take(stream.read(topic=OUT, after=since), 1) assert [r.value for r in records] == [{"n": 3}] @@ -425,17 +432,15 @@ async def test_topic_addresses_with_colons_do_not_share_a_store(case: ProviderCa base = new_workflow_id() left = await case.open(f"{base}:x") right = await case.open(base) - await left.producer(topic="y", producer_id="l", attempt=1).append({"side": "left"}) - await right.producer(topic="x:y", producer_id="r", attempt=1).append( - {"side": "right"} - ) + await left.producer(topic=Y, producer_id="l", attempt=1).append({"side": "left"}) + await right.producer(topic=XY, producer_id="r", attempt=1).append({"side": "right"}) - only_left = await take(left.read(topic="y", result_type=dict), 1) + only_left = await take(left.read(topic=Y), 1) assert [r.value for r in only_left] == [{"side": "left"}] - assert await left.latest(topic="y") == only_left[0].cursor - only_right = await take(right.read(topic="x:y", result_type=dict), 1) + assert await left.latest(topic=Y) == only_left[0].cursor + only_right = await take(right.read(topic=XY), 1) assert [r.value for r in only_right] == [{"side": "right"}] - assert await right.latest(topic="x:y") == only_right[0].cursor + assert await right.latest(topic=XY) == only_right[0].cursor async def test_a_foreign_cursor_is_refused_at_the_call(case: ProviderCase): @@ -443,7 +448,7 @@ async def test_a_foreign_cursor_is_refused_at_the_call(case: ProviderCase): # Refused by read() itself, not by the first iteration of its generator, # so the caller's except clause is where the mistake surfaces. with pytest.raises(StreamCursorError): - stream.read(topic="out", after=Cursor("elsewhere:42")) + stream.read(topic=OUT, after=Cursor("elsewhere:42")) async def test_argument_mistakes_are_value_errors(case: ProviderCase): @@ -454,4 +459,14 @@ async def test_argument_mistakes_are_value_errors(case: ProviderCase): stream.producer(topic="", producer_id="model", attempt=1) # Outside an activity there is no identity to fall back on. with pytest.raises(ValueError, match="producer_id is required"): - stream.producer(topic="out") + stream.producer(topic=OUT) + + +async def test_a_definition_carries_its_type_once(case: ProviderCase): + stream = await case.open(new_workflow_id()) + with pytest.raises(ValueError, match="already carries its type"): + stream.read(topic=OUT, result_type=dict) # type: ignore[call-overload] + with pytest.raises(ValueError): + topic("", dict) + # A string names a topic decided at runtime, and the hint rides the call. + assert await stream.latest(topic=OUT.name) == BEGINNING diff --git a/tests/streams/test_streams_workflow.py b/tests/streams/test_streams_workflow.py index 58fb274ef..71f2bb8bf 100644 --- a/tests/streams/test_streams_workflow.py +++ b/tests/streams/test_streams_workflow.py @@ -30,6 +30,7 @@ RecordKind, StreamCursorError, WriteSink, + topic, ) from temporalio.streams.providers.memory import MemoryStreams from temporalio.testing import WorkflowEnvironment @@ -37,8 +38,8 @@ from tests.helpers import new_worker from tests.streams.test_streams_conformance import take -INPUTS = "inputs" -DECISIONS = "decisions" +INPUTS = topic("inputs", dict) +DECISIONS = topic("decisions", dict) @pytest.fixture @@ -58,7 +59,7 @@ class ContractLoop: @workflow.run async def run(self) -> list[dict[str, Any]]: - inputs = workflow.stream_reader(INPUTS, result_type=dict) + inputs = workflow.stream_reader(INPUTS) decisions = workflow.stream_writer(DECISIONS) trace: list[dict[str, Any]] = [] try: @@ -128,7 +129,7 @@ async def test_workflow_reads_decides_and_publishes( # The outside view of what the workflow published, on its own topic. stream = provider.get_stream_handle(client, handle.id) - records = await take(stream.read(topic=DECISIONS, result_type=dict), 5) + records = await take(stream.read(topic=DECISIONS), 5) assert [(r.kind, r.value) for r in records] == [ (RecordKind.DATA, {"decided": 1}), (RecordKind.DATA, {"decided": 2}), @@ -136,7 +137,7 @@ async def test_workflow_reads_decides_and_publishes( (RecordKind.DATA, {"decided": 3}), (RecordKind.FINISH, None), ] - assert all(r.producer_id == "" and r.topic == DECISIONS for r in records) + assert all(r.producer_id == "" and r.topic == DECISIONS.name for r in records) # The second finish() wrote nothing: the marker is the newest record. assert await stream.latest(topic=DECISIONS) == records[-1].cursor @@ -148,7 +149,7 @@ async def test_read_ends_when_the_workflow_closes_and_the_tail_is_delivered( stream = provider.get_stream_handle(client, handle.id) async def read_everything() -> list[Any]: - return [r.value async for r in stream.read(topic=DECISIONS, result_type=dict)] + return [r.value async for r in stream.read(topic=DECISIONS)] # No count and no early break: the read ends by itself once the workflow # is closed and everything it retained has been handed over. @@ -218,9 +219,7 @@ async def test_a_failed_task_publishes_nothing(client: Client, provider: MemoryS PublishThenFail.run, id=workflow_id, task_queue=worker.task_queue ) stream = provider.get_stream_handle(client, workflow_id) - records = await take( - stream.read(topic=DECISIONS, result_type=dict), 2, timeout=30 - ) + records = await take(stream.read(topic=DECISIONS), 2, timeout=30) await handle.result() assert [(r.kind, r.value) for r in records] == [ (RecordKind.DATA, {"committed": True}), @@ -301,10 +300,7 @@ async def test_a_handle_without_a_run_id_reads_across_continue_as_new( stream = provider.get_stream_handle(client, workflow_id) async def read_everything() -> list[Any]: - return [ - (r.kind, r.value) - async for r in stream.read(topic=DECISIONS, result_type=dict) - ] + return [(r.kind, r.value) async for r in stream.read(topic=DECISIONS)] records = await asyncio.wait_for(read_everything(), 30) await handle.result() @@ -323,15 +319,15 @@ class SharedReaders: @workflow.run async def run(self) -> list[Any]: - first = workflow.stream_reader(INPUTS, result_type=dict) - second = workflow.stream_reader(INPUTS, result_type=dict) + first = workflow.stream_reader(INPUTS) + second = workflow.stream_reader(INPUTS) trace: list[Any] = ["shared" if first is second else "separate"] try: - workflow.stream_reader(INPUTS, result_type=dict, after=Cursor("memory:0")) + workflow.stream_reader(INPUTS, after=Cursor("memory:0")) except ValueError: trace.append("after-rejected") try: - workflow.stream_reader(INPUTS, result_type=list) + workflow.stream_reader(INPUTS.name, result_type=list) except ValueError: trace.append("type-rejected") trace.append((await first.__anext__()).value) @@ -435,7 +431,7 @@ async def test_an_outside_producer_and_the_workflow_share_a_topic( await handle.result() async def read_everything() -> list[Any]: - return [r async for r in stream.read(topic=DECISIONS, result_type=dict)] + return [r async for r in stream.read(topic=DECISIONS)] records = await asyncio.wait_for(read_everything(), 30) # Both writers land on one topic in one order, each under its own From c37b3515f6628cf388179f278190954d63a99753 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 10:54:51 -0700 Subject: [PATCH 63/96] Took topic definitions on the Redis handle. --- temporalio/streams/providers/redis.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 552e3d904..0a345ae98 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -42,7 +42,7 @@ import time from collections.abc import AsyncGenerator, Coroutine from datetime import timedelta -from typing import Any +from typing import Any, Generic, TypeVar from google.protobuf.message import DecodeError @@ -88,6 +88,7 @@ ) from temporalio.streams._provider import ReadSource, WriteSink from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord +from temporalio.streams._topic import StreamTopic, resolve_topic from temporalio.streams._wire import ( RecordDecoder, WireRecord, @@ -101,6 +102,8 @@ __all__ = ["RedisProducer", "RedisStreamHandle", "RedisStreams"] +T = TypeVar("T") + _PROVIDER = "redis" _INPUT_PREFIX = "in:" _REDIS_ID = re.compile(r"\d+-\d+") @@ -244,7 +247,7 @@ def _storage_error(error: Exception, what: str) -> StreamError: return StreamError(f"{what}: {error}") -class RedisProducer: +class RedisProducer(Generic[T]): """Appends to a topic from outside workflow code. Every append is visible as soon as the store accepts it. Each record goes @@ -296,7 +299,7 @@ def _session(self) -> str: else self._producer_id ) - async def append(self, *values: Any) -> Cursor: + async def append(self, *values: T) -> Cursor: """Append ``values`` and return the cursor of the last record as stored. A repeat returns where the original landed, because the transport @@ -454,12 +457,12 @@ def __init__( def read( self, *, - topic: str, + topic: str | StreamTopic[Any], after: Cursor = BEGINNING, result_type: type | None = None, ) -> AsyncGenerator[StreamRecord[Any], None]: """Yield records on ``topic`` after ``after`` until the chain, or the pinned run, closes.""" - _require_topic(topic) + topic, result_type = resolve_topic(topic, result_type) # Parsed here so a foreign cursor fails this call, not the first # iteration of the generator. position = _outside_position(after) @@ -542,9 +545,9 @@ async def _closed(self) -> bool: self._run_id is None and status == WorkflowExecutionStatus.CONTINUED_AS_NEW ) - async def latest(self, *, topic: str) -> Cursor: + async def latest(self, *, topic: str | StreamTopic[Any]) -> Cursor: """The cursor of the newest committed record on ``topic``, for following from now.""" - _require_topic(topic) + topic, _ = resolve_topic(topic) backend = self._streams._require_backend() chain = await _chain(self._client, self._workflow_id) try: @@ -559,10 +562,14 @@ async def latest(self, *, topic: str) -> Cursor: return mint_cursor(_PROVIDER, tail.offset.token) def producer( - self, *, topic: str, producer_id: str = "", attempt: int = 0 - ) -> RedisProducer: + self, + *, + topic: str | StreamTopic[Any], + producer_id: str = "", + attempt: int = 0, + ) -> RedisProducer[Any]: """A producer on ``topic``; inside an activity its identity is the activity's.""" - _require_topic(topic) + topic, _ = resolve_topic(topic) producer_id, attempt = producer_identity(producer_id, attempt) return RedisProducer( self._streams, self._client, self._workflow_id, topic, producer_id, attempt From a9bda0f4ccd1bf19c2f3770d061f2aec2f07dacb Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 14:03:15 -0700 Subject: [PATCH 64/96] Matched the Redis live module to typed topic definitions. A read that passes a definition may not also pass result_type, and a record's topic is the definition's name. --- tests/streams/test_redis_replay.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index b3c4e3ee3..4ee50e68b 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -81,7 +81,7 @@ async def test_interface_loop_over_redis(live_client: Client, provider: RedisStr await producer.append({"n": 3}) await producer.finish() - records = await take(stream.read(topic=DECISIONS, result_type=dict), 4, 60) + records = await take(stream.read(topic=DECISIONS), 4, 60) assert [r.kind for r in records] == [ RecordKind.DATA, RecordKind.DATA, @@ -89,7 +89,7 @@ async def test_interface_loop_over_redis(live_client: Client, provider: RedisStr RecordKind.FINISH, ] assert [r.value["decided"] for r in records[:3]] == [1, 2, 3] - assert all(r.producer_id == "" and r.topic == DECISIONS for r in records) + assert all(r.producer_id == "" and r.topic == DECISIONS.name for r in records) assert await handle.result() == [ {"kind": "decision", "n": 1, "attempt": 1}, {"kind": "decision", "n": 2, "attempt": 1}, @@ -101,7 +101,7 @@ async def test_interface_loop_over_redis(live_client: Client, provider: RedisStr # promoted record has been handed over. async def read_everything() -> list[Any]: return [ - r.value async for r in stream.read(topic=DECISIONS, result_type=dict) + r.value async for r in stream.read(topic=DECISIONS) ] assert await asyncio.wait_for(read_everything(), 60) == [ @@ -112,7 +112,7 @@ async def read_everything() -> list[Any]: ] # The producer's own records are readable from outside as well, on # the topic it wrote. - inputs = await take(stream.read(topic=INPUTS, result_type=dict), 4, 60) + inputs = await take(stream.read(topic=INPUTS), 4, 60) assert [r.value for r in inputs[:3]] == [{"n": 1}, {"n": 2}, {"n": 3}] assert inputs[3].kind is RecordKind.FINISH assert all(r.producer_id == "model" and r.attempt == 1 for r in inputs) @@ -138,7 +138,7 @@ async def test_an_outside_producer_and_the_workflow_share_a_topic( await handle.result() async def read_everything() -> list[Any]: - return [r async for r in stream.read(topic=DECISIONS, result_type=dict)] + return [r async for r in stream.read(topic=DECISIONS)] records = await asyncio.wait_for(read_everything(), 60) # Both writers land on one topic, each under its own identity. The order From 62b57e33e03b59e571537183331551154ff6ae61 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 14:17:38 -0700 Subject: [PATCH 65/96] Seeded a Redis workflow reader from a cursor. The runtime and the manager already take a start cursor and the marker header records it; only `subscribe()` had no way to pass one. The provider now seeds the subscription from a `redis:in:` cursor instead of refusing it, and refuses an outside cursor because the two streams number entries apart. --- .../contrib/external_workflow_streams/_api.py | 19 ++++- temporalio/streams/providers/redis.py | 43 +++++++--- .../external_workflow_streams/test_api.py | 18 +++++ tests/streams/test_redis_provider.py | 20 ++++- tests/streams/test_redis_replay.py | 81 ++++++++++++++++--- 5 files changed, 160 insertions(+), 21 deletions(-) diff --git a/temporalio/contrib/external_workflow_streams/_api.py b/temporalio/contrib/external_workflow_streams/_api.py index 0a373b10b..1da059d80 100644 --- a/temporalio/contrib/external_workflow_streams/_api.py +++ b/temporalio/contrib/external_workflow_streams/_api.py @@ -35,6 +35,7 @@ classify_read_failure, ) from temporalio.contrib.external_workflow_streams._record import ( + Cursor, Offset, StreamRecord, ) @@ -95,6 +96,7 @@ def register( wait_id: int, stream_key: StreamKey, idle_timeout: timedelta, + start_cursor: Cursor | None = None, ) -> None: """Registers a wait with the Worker's subscription manager. @@ -267,7 +269,9 @@ class ExternalStreamTopic(Generic[AnyType]): value_type: type[AnyType] | None options: ExternalStreamOptions - def subscribe(self) -> ExternalStreamSubscription[AnyType]: + def subscribe( + self, *, start_cursor: Cursor | None = None + ) -> ExternalStreamSubscription[AnyType]: """Starts a new subscription and returns its async iterator. Each call is an **independent** subscription with its own ``wait_id``, @@ -278,6 +282,13 @@ def subscribe(self) -> ExternalStreamSubscription[AnyType]: the same hazard class as timers and activities: inserting, removing, or reordering a ``subscribe()`` call renumbers every later wait in the Run and must be gated behind ``workflow.patched()``. + + Args: + start_cursor: The boundary the subscription begins after. ``None`` + resumes where the predecessor Run committed this wait, or at + ``BEGINNING`` on a first execution. A boundary the Workflow + names is recorded in the marker's header like the restored one, + so it must be derived deterministically: replay names it again. """ state = _run_state() if state.runtime is None: @@ -289,6 +300,11 @@ def subscribe(self) -> ExternalStreamSubscription[AnyType]: state.next_wait_id += 1 stream_key = state.runtime.stream_key(self.name) + # Only a named boundary travels; the runtime derives the default itself + # from the predecessor Run's continuation. + start: dict[str, Any] = ( + {} if start_cursor is None else {"start_cursor": start_cursor} + ) state.runtime.register( wait_id=wait_id, stream_key=stream_key, @@ -299,6 +315,7 @@ def subscribe(self) -> ExternalStreamSubscription[AnyType]: # `with_options` was given, so no configured value can ever reach # the reduction and every set parks after one second. idle_timeout=self.options.idle_timeout, + **start, ) # Registering a wait is not blocking on one. The quiescent snapshot is a # request to Core to retain the Workflow Task and, once the idle timer diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 0a345ae98..c6972cc84 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -23,9 +23,11 @@ is ``producer#attempt`` and every record carries a sequence, so a retried batch is dropped with the original position and a new attempt passes. - Cursors are ``redis:-`` on the outside surface and name a position - in the output stream. A workflow-side record carries ``redis:in:-`` - for information only: the transport starts a run's subscription where the - chain left off, so a workflow reader cannot resume from a cursor. + in the output stream. A workflow-side record carries ``redis:in:-``, + a position in the input stream, and only that form seeds a workflow reader: + the two streams number their entries independently, so an outside cursor + cannot stand in for one. A reader opened without a cursor starts where the + chain's predecessor run committed, which is the transport's own rule. - Streams are keyed by the chain's first run, so a handle follows continue-as- new by construction and ``run_id`` only decides whose close ends a read. - A task's publishes are staged as one batch. The transport's own per-task @@ -84,7 +86,6 @@ StreamError, StreamNotFoundError, StreamProducerError, - StreamUnsupportedError, ) from temporalio.streams._provider import ReadSource, WriteSink from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord @@ -141,6 +142,26 @@ def _outside_position(after: Cursor) -> Offset | None: return Offset(token) +def _workflow_position(after: Cursor) -> Offset | None: + """The input-stream offset a workflow reader's cursor names, or ``None`` for BEGINNING.""" + token = cursor_position(after, provider=_PROVIDER) + if token is None: + return None + if token.startswith(_INPUT_PREFIX): + entry = token[len(_INPUT_PREFIX) :] + if _REDIS_ID.fullmatch(entry): + return Offset(entry) + elif _REDIS_ID.fullmatch(token): + raise StreamCursorError( + f"cursor {after.token!r} names a position in the topic's output stream; " + "a workflow reader follows the input stream, whose entry ids differ, so " + "pass a cursor a workflow reader returned" + ) + raise StreamCursorError( + f"cursor {after.token!r} does not name a Redis stream position" + ) + + def _drive(coroutine: Coroutine[Any, Any, None]) -> None: """Run a transport publish to completion without yielding to the loop. @@ -209,12 +230,14 @@ def __init__(self, idle_timeout: timedelta) -> None: def open_reader(self, topic: str, *, after: Cursor) -> ReadSource: _require_topic(topic) - if cursor_position(after, provider=_PROVIDER) is not None: - raise StreamUnsupportedError( - "the redis provider starts a run's subscription where the chain " - "left off; it cannot resume a workflow reader from a cursor" - ) - return _RedisReadSource(self._input.topic(topic, type=bytes).subscribe()) + position = _workflow_position(after) + # Without a position the transport resumes where the chain's + # predecessor run committed; with one, that is where the wait starts + # and what the marker's header records. + start = None if position is None else AFTER(position) + return _RedisReadSource( + self._input.topic(topic, type=bytes).subscribe(start_cursor=start) + ) def open_writer(self, topic: str) -> WriteSink: _require_topic(topic) diff --git a/tests/contrib/external_workflow_streams/test_api.py b/tests/contrib/external_workflow_streams/test_api.py index 236d0b614..18095c310 100644 --- a/tests/contrib/external_workflow_streams/test_api.py +++ b/tests/contrib/external_workflow_streams/test_api.py @@ -30,6 +30,8 @@ ) from temporalio.contrib.external_workflow_streams._manager import PreparedRecord from temporalio.contrib.external_workflow_streams._record import ( + AFTER, + Cursor, Offset, RecordKind, StreamRecord, @@ -48,6 +50,7 @@ def __init__(self) -> None: #: `wait_id -> configured idle timeout`, so a test can see that the #: value `with_options` was given actually reached the Worker. self.idle_timeouts: dict[int, timedelta] = {} + self.start_cursors: dict[int, Cursor | None] = {} self.buffers: dict[int, list[StreamRecord]] = {} self.deliveries: list[tuple[int, StreamRecord]] = [] self.consumed: list[tuple[int, StreamRecord]] = [] @@ -72,9 +75,11 @@ def register( wait_id: int, stream_key: StreamKey, idle_timeout: timedelta, + start_cursor: Cursor | None = None, ) -> None: self.registrations.append((wait_id, stream_key)) self.idle_timeouts[wait_id] = idle_timeout + self.start_cursors[wait_id] = start_cursor def drain(self, wait_id: int, max_records: int | None = None) -> list[StreamRecord]: buffered = self.buffers.get(wait_id, []) @@ -192,6 +197,19 @@ def test_topics_inherit_their_options( assert subscription.idle_timeout == timedelta(seconds=3) +def test_a_subscription_may_name_the_boundary_it_starts_after( + runtime: FakeRuntime, +) -> None: + """A named boundary reaches the runtime; an unnamed one leaves the default to it.""" + boundary = AFTER(Offset("1700000000000-3")) + + seeded = external_stream.topic("tokens").subscribe(start_cursor=boundary) + default = external_stream.topic("tokens").subscribe() + + assert runtime.start_cursors[seeded.wait_id] == boundary + assert runtime.start_cursors[default.wait_id] is None + + # --- topics ------------------------------------------------------------------- diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py index 994ef5309..d4e215882 100644 --- a/tests/streams/test_redis_provider.py +++ b/tests/streams/test_redis_provider.py @@ -7,7 +7,11 @@ import pytest from temporalio.streams import BEGINNING, Cursor, StreamCursorError, StreamError -from temporalio.streams.providers.redis import _drive, _outside_position +from temporalio.streams.providers.redis import ( + _drive, + _outside_position, + _workflow_position, +) def test_outside_cursors_name_output_positions(): @@ -43,3 +47,17 @@ async def run() -> None: _drive(publish()) asyncio.run(run()) + + +def test_workflow_cursors_name_input_positions(): + assert _workflow_position(BEGINNING) is None + position = _workflow_position(Cursor("redis:in:1700000000000-3")) + assert position is not None and position.token == "1700000000000-3" + # An outside cursor names the output stream, whose entry ids are not the + # input stream's, so it cannot seed a workflow reader. + with pytest.raises(StreamCursorError, match="output stream"): + _workflow_position(Cursor("redis:1700000000000-3")) + with pytest.raises(StreamCursorError): + _workflow_position(Cursor("memory:3")) + with pytest.raises(StreamCursorError): + _workflow_position(Cursor("redis:in:not-an-id")) diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 4ee50e68b..4871e1cfa 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -3,10 +3,10 @@ The conformance suite covers the outside surface when ``STREAMS_LIVE=redis``. This module runs the interface loop inside a workflow over the staged commit, lets a read end with the workflow, shares a topic between an outside producer -and the workflow, and queries a completed run, which replays it. All need a -dev server (``TEMPORAL_ADDRESS``) and a Redis (``TEMPORAL_TEST_REDIS_URL`` or -``AI198_REDIS_URL``). The worker keeps a warm cache because the transport -holds the task open between records. +and the workflow, queries a completed run, which replays it, and seeds a +workflow reader from a cursor. All need a dev server (``TEMPORAL_ADDRESS``) +and a Redis (``TEMPORAL_TEST_REDIS_URL`` or ``AI198_REDIS_URL``). The worker +keeps a warm cache because the transport holds the task open between records. """ from __future__ import annotations @@ -21,9 +21,9 @@ from temporalio import workflow from temporalio.client import Client -from temporalio.streams import RecordKind +from temporalio.streams import BEGINNING, Cursor, RecordKind from temporalio.streams.providers.redis import RedisStreams -from temporalio.worker import Worker +from temporalio.worker import Replayer, Worker from tests.streams.test_streams_conformance import take from tests.streams.test_streams_workflow import ( DECISIONS, @@ -100,9 +100,7 @@ async def test_interface_loop_over_redis(live_client: Client, provider: RedisStr # The read ends by itself once the workflow is closed and every # promoted record has been handed over. async def read_everything() -> list[Any]: - return [ - r.value async for r in stream.read(topic=DECISIONS) - ] + return [r.value async for r in stream.read(topic=DECISIONS)] assert await asyncio.wait_for(read_everything(), 60) == [ {"decided": 1}, @@ -205,3 +203,68 @@ async def test_query_after_completion_replays_the_final_task( assert await handle.query(SignalWokenPublish.probe) == 1 values = [r.value async for r in stream.read(topic="out", result_type=dict)] assert values == [{"n": 0}, {"n": 1}, {"n": 2}, {"n": 3}] + + +@workflow.defn +class ResumeAfter: + """Reads ``inputs``; the first run hands its first record's cursor to the next.""" + + def __init__(self) -> None: + self._seen: list[int] = [] + + @workflow.query + def seen(self) -> list[int]: + return self._seen + + @workflow.run + async def run(self, after: str | None) -> list[int]: + reader = workflow.stream_reader( + INPUTS, after=Cursor(after) if after else BEGINNING + ) + first: str | None = None + async for record in reader: + if record.kind is RecordKind.FINISH: + break + assert record.value is not None + self._seen.append(record.value["n"]) + first = first or record.cursor.token + if after is None and len(self._seen) == 2: + workflow.continue_as_new(first) + return self._seen + + +async def test_a_workflow_reader_started_from_a_cursor_skips_the_earlier_records( + live_client: Client, provider: RedisStreams +): + # The first run reads two records and continues as new with the cursor of + # the first. Without the cursor the successor would resume after the + # second, which is where the chain left off; with it, it reads the second + # again and then the rest. + workflow_id = f"streams-redis-resume-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[ResumeAfter], + plugins=[provider], + max_cached_workflows=100, + ): + handle = await live_client.start_workflow( + ResumeAfter.run, None, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + stream = provider.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="model", attempt=1) + await producer.append({"n": 1}, {"n": 2}, {"n": 3}) + await producer.finish() + assert await handle.result() == [2, 3] + # The completed run is evicted, so the query replays it cold with the + # seeded position and the recorded ranges. + assert await handle.query(ResumeAfter.seen) == [2, 3] + + # Both runs replay offline against the store, the seeded one included. + replayer = Replayer(workflows=[ResumeAfter], plugins=[provider]) + await replayer.replay_workflow( + await live_client.get_workflow_handle( + workflow_id, run_id=handle.first_execution_run_id + ).fetch_history() + ) + await replayer.replay_workflow(await handle.fetch_history()) From c7e30af23aa29ab043c4fb84f4b739746371257f Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 14:17:39 -0700 Subject: [PATCH 66/96] Passed a backend's integrity loss through the replay read. A backend that can tell a recorded range is gone reports the loss itself. The wrapper filed it under a transient storage failure, which is retried and clears on its own; a trimmed range does neither. --- temporalio/contrib/external_workflow_streams/_replay.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/temporalio/contrib/external_workflow_streams/_replay.py b/temporalio/contrib/external_workflow_streams/_replay.py index f136c9cbb..ac293c172 100644 --- a/temporalio/contrib/external_workflow_streams/_replay.py +++ b/temporalio/contrib/external_workflow_streams/_replay.py @@ -252,7 +252,9 @@ async def _read_range( """ try: return await backend.read_range(key, run.first_offset, run.last_offset) - except StreamStorageError: + except (StreamStorageError, StreamIntegrityError): + # A backend that can tell the range is gone reports the loss itself; + # wrapping it would file a permanent loss under a transient failure. raise except Exception as err: # Not integrity loss: nothing has been shown to be missing, only From d87787d265d41af20414a1ba9ff55ce2170ca7a1 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 14:17:39 -0700 Subject: [PATCH 67/96] Trimmed the Redis streams by retention on every append. `RedisStreams(retention=, max_len=)` trims a topic's input and output keys behind every append the provider makes, exactly, because the approximate trim never fires on a stream shorter than a macro node. A replay past the window fails its task with the transport's integrity error, an outside cursor below it is refused, and a fully trimmed topic reads as empty. --- temporalio/streams/providers/redis.py | 167 +++++++++++++++++++++++-- tests/streams/test_redis_provider.py | 13 ++ tests/streams/test_redis_replay.py | 169 +++++++++++++++++++++++++- 3 files changed, 336 insertions(+), 13 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index c6972cc84..f67c11b6e 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -34,6 +34,11 @@ batch limits are lifted for this provider, because a synchronous publish cannot wait for the worker to stage a full batch; a batch it cannot stage fails the task. +- Retention is trimming, with no consumer floor. When ``retention`` or + ``max_len`` is set, every append the provider makes trims the key it wrote, + whatever any reader has reached. A replay that reaches a recorded range the + trim removed fails its Workflow Task, an outside cursor below the trim is + refused, and a fully trimmed topic reads as empty. """ from __future__ import annotations @@ -42,7 +47,7 @@ import logging import re import time -from collections.abc import AsyncGenerator, Coroutine +from collections.abc import AsyncGenerator, Coroutine, Sequence from datetime import timedelta from typing import Any, Generic, TypeVar @@ -74,11 +79,21 @@ from temporalio.contrib.external_workflow_streams import ( StreamError as TransportStreamError, ) -from temporalio.contrib.external_workflow_streams._backend import DEFAULT_WATCH_BLOCK +from temporalio.contrib.external_workflow_streams._backend import ( + DEFAULT_WATCH_BLOCK, + StreamKey, +) from temporalio.contrib.external_workflow_streams._codec import StreamPayloadCodec +from temporalio.contrib.external_workflow_streams._errors import StreamIntegrityError +from temporalio.contrib.external_workflow_streams._output_backend import ( + OutputStage, + OutputStageManifest, + StagedOutputRecord, +) from temporalio.contrib.external_workflow_streams._output_client import ( _reconcile_output_stage, ) +from temporalio.contrib.external_workflow_streams._redis import RedisStreamBackend from temporalio.converter import WorkflowSerializationContext from temporalio.service import RPCError, RPCStatusCode from temporalio.streams._errors import ( @@ -162,6 +177,101 @@ def _workflow_position(after: Cursor) -> Offset | None: ) +def _entry_id(token: str | bytes) -> tuple[int, int]: + """A Redis entry id as the ``(ms, seq)`` pair it orders by.""" + text = token.decode() if isinstance(token, bytes) else token + ms, _, seq = text.partition("-") + return int(ms), int(seq or 0) + + +class _RetainingBackend(RedisStreamBackend): + """The transport's Redis backend, trimming the key behind every append it makes. + + Trims are exact rather than approximate: Redis's approximate trim drops + whole macro nodes only, so a stream shorter than one node, a hundred + entries by default, would never trim and the window would not mean what + it says. Only the streams are trimmed; the idempotency and stage hashes + beside them keep one entry per record and stage. + """ + + def __init__( + self, + *, + client: Any, + key_prefix: str, + retention: timedelta | None, + max_len: int | None, + ) -> None: + super().__init__(client=client, key_prefix=key_prefix) + self._retention = retention + self._max_len = max_len + + def describe_window(self) -> str: + """The configured window, for messages.""" + parts = [] + if self._retention is not None: + parts.append(f"retention={self._retention}") + if self._max_len is not None: + parts.append(f"max_len={self._max_len}") + return ", ".join(parts) or "no retention" + + async def append(self, key: StreamKey, record: Any) -> Any: + placed = await super().append(key, record) + await self._trim(key) + return placed + + async def stage_output( + self, manifest: OutputStageManifest, records: Sequence[StagedOutputRecord] + ) -> OutputStage: + stage = await super().stage_output(manifest, records) + await self._trim(manifest.stream_key) + return stage + + async def read_range(self, key: StreamKey, first: Offset, last: Offset) -> Any: + # The replay read. Said here, where the trim is known, rather than left + # to the range checks, which can only report the record as missing. + if not await self.retains(key, first): + raise StreamIntegrityError( + f"the recorded range [{first}, {last}] on topic " + f"{key.stream_name!r} is past the redis provider's retention " + f"({self.describe_window()}): the records were trimmed, so this " + "run cannot be replayed" + ) + return await super().read_range(key, first, last) + + async def retains(self, key: StreamKey, offset: Offset) -> bool: + """Whether the record at ``offset`` survived trimming. + + A record at or after the first retained entry is there. On an emptied + stream the last id Redis generated says whether the record ever was. + """ + from redis.exceptions import ResponseError + + try: + info = await self._client.xinfo_stream(self.stream_key(key)) + except ResponseError as error: + if "no such key" in str(error).lower(): + return True + raise + wanted = _entry_id(offset.token) + first = info.get("first-entry") + if first: + return _entry_id(first[0]) <= wanted + return wanted > _entry_id(info["last-generated-id"]) + + async def _trim(self, key: StreamKey) -> None: + name = self.stream_key(key) + if self._retention is not None: + # The worker's clock names the floor, so a skewed worker shifts + # the window by its skew. + floor = int((time.time() - self._retention.total_seconds()) * 1000) + await self._client.xtrim( + name, minid=f"{max(floor, 0)}-0", approximate=False + ) + if self._max_len is not None: + await self._client.xtrim(name, maxlen=self._max_len, approximate=False) + + def _drive(coroutine: Coroutine[Any, Any, None]) -> None: """Run a transport publish to completion without yielding to the loop. @@ -504,6 +614,17 @@ async def _read( backend = self._streams._require_backend() chain = await _chain(self._client, self._workflow_id) key = chain.stream_key(topic, direction=StreamDirection.OUTPUT) + if ( + position is not None + and isinstance(backend, _RetainingBackend) + and not await backend.retains(key, position) + ): + # Refused rather than resumed from the first retained record, + # which would skip whatever the trim took in between. + raise StreamCursorError( + f"cursor {after.token!r} names a record on {topic!r} that the " + f"provider's retention has trimmed ({backend.describe_window()})" + ) cursor = TRANSPORT_BEGINNING if position is None else AFTER(position) closed = False while True: @@ -616,6 +737,8 @@ def __init__( idle_timeout: timedelta = timedelta(seconds=1), backend: Any | None = None, poll_interval: timedelta = timedelta(milliseconds=500), + retention: timedelta | None = None, + max_len: int | None = None, ) -> None: """Create the provider. @@ -625,25 +748,48 @@ def __init__( deployments. idle_timeout: How long a workflow reader with nothing to read holds its Workflow Task open before the worker parks it. - backend: A transport backend the caller constructed and owns. + backend: A transport backend the caller constructed and owns. It + is trimmed by its owner, so it takes neither ``retention`` nor + ``max_len``. poll_interval: How long an outside reader that is caught up waits for a record before asking whether the workflow closed. + retention: Trim records older than this from a topic's input and + output keys on every append the provider makes to them. This + is retention without a consumer floor: nothing holds a record + for a reader that has not reached it. A workflow whose replay + reaches a recorded range past the window fails its Workflow + Task with the transport's ``StreamIntegrityError`` until the + window is raised, an outside ``read(after=)`` below the window + raises ``StreamCursorError``, and a live reader that falls + behind the window misses records. The floor the server-side + provider keeps would need a consumer registry in Redis. + max_len: Keep at most this many entries per key, trimmed on the + same appends and with the same consequences. It must exceed + the largest batch a task publishes, or a stage is trimmed + before its commit. """ + if retention is not None and retention <= timedelta(0): + raise ValueError("retention must be positive") + if max_len is not None and max_len < 1: + raise ValueError("max_len must be positive") + if backend is not None and (retention is not None or max_len is not None): + raise ValueError( + "retention and max_len trim the backend this provider opens; a " + "backend handed in is trimmed by its owner" + ) self._url = url self._key_prefix = key_prefix self._idle_timeout = idle_timeout self._backend = backend self._owned_client: Any = None self._poll = poll_interval + self._retention = retention + self._max_len = max_len def _require_backend(self) -> Any: if self._backend is None: import redis.asyncio - from temporalio.contrib.external_workflow_streams._redis import ( - RedisStreamBackend, - ) - # A dead peer would otherwise hold a blocking read open forever. # Several block periods, so a healthy socket that is merely idle # inside one read window is never abandoned. @@ -652,8 +798,11 @@ def _require_backend(self) -> Any: decode_responses=False, socket_timeout=DEFAULT_WATCH_BLOCK.total_seconds() * 6, ) - self._backend = RedisStreamBackend( - client=self._owned_client, key_prefix=self._key_prefix + self._backend = _RetainingBackend( + client=self._owned_client, + key_prefix=self._key_prefix, + retention=self._retention, + max_len=self._max_len, ) return self._backend diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py index d4e215882..ac4919c81 100644 --- a/tests/streams/test_redis_provider.py +++ b/tests/streams/test_redis_provider.py @@ -3,11 +3,13 @@ from __future__ import annotations import asyncio +from datetime import timedelta import pytest from temporalio.streams import BEGINNING, Cursor, StreamCursorError, StreamError from temporalio.streams.providers.redis import ( + RedisStreams, _drive, _outside_position, _workflow_position, @@ -61,3 +63,14 @@ def test_workflow_cursors_name_input_positions(): _workflow_position(Cursor("memory:3")) with pytest.raises(StreamCursorError): _workflow_position(Cursor("redis:in:not-an-id")) + + +def test_retention_options_are_checked_at_construction(): + with pytest.raises(ValueError, match="retention"): + RedisStreams(retention=timedelta(0)) + with pytest.raises(ValueError, match="max_len"): + RedisStreams(max_len=0) + # A backend the caller owns is the caller's to trim. + with pytest.raises(ValueError, match="trimmed by its owner"): + RedisStreams(backend=object(), max_len=10) + RedisStreams(retention=timedelta(hours=1), max_len=10) diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 4871e1cfa..167b0e310 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -3,7 +3,8 @@ The conformance suite covers the outside surface when ``STREAMS_LIVE=redis``. This module runs the interface loop inside a workflow over the staged commit, lets a read end with the workflow, shares a topic between an outside producer -and the workflow, queries a completed run, which replays it, and seeds a +and the workflow, queries a completed run, which replays it, trims by +retention and shows what a replay and a read past the trim do, and seeds a workflow reader from a cursor. All need a dev server (``TEMPORAL_ADDRESS``) and a Redis (``TEMPORAL_TEST_REDIS_URL`` or ``AI198_REDIS_URL``). The worker keeps a warm cache because the transport holds the task open between records. @@ -15,16 +16,18 @@ import os import uuid from collections.abc import AsyncIterator +from datetime import timedelta from typing import Any import pytest from temporalio import workflow from temporalio.client import Client -from temporalio.streams import BEGINNING, Cursor, RecordKind -from temporalio.streams.providers.redis import RedisStreams +from temporalio.contrib.external_workflow_streams import StreamDirection +from temporalio.streams import BEGINNING, Cursor, RecordKind, StreamCursorError +from temporalio.streams.providers.redis import RedisStreams, _chain from temporalio.worker import Replayer, Worker -from tests.streams.test_streams_conformance import take +from tests.streams.test_streams_conformance import StreamHost, take from tests.streams.test_streams_workflow import ( DECISIONS, INPUTS, @@ -205,6 +208,164 @@ async def test_query_after_completion_replays_the_final_task( assert values == [{"n": 0}, {"n": 1}, {"n": 2}, {"n": 3}] +async def _key_lengths( + streams: RedisStreams, client: Client, workflow_id: str, topic: str +) -> tuple[int, int]: + """How many entries the topic's input and output keys hold.""" + import redis.asyncio + + backend = streams._require_backend() + chain = await _chain(client, workflow_id) + store = redis.asyncio.from_url(redis_url()) + try: + return ( + await store.xlen( + backend.stream_key( + chain.stream_key(topic, direction=StreamDirection.INPUT) + ) + ), + await store.xlen( + backend.stream_key( + chain.stream_key(topic, direction=StreamDirection.OUTPUT) + ) + ), + ) + finally: + await store.aclose() + + +async def _trim_everything( + streams: RedisStreams, client: Client, workflow_id: str, topic: str +) -> None: + """What retention elsewhere, or an operator, does to a topic's output key.""" + import redis.asyncio + + backend = streams._require_backend() + chain = await _chain(client, workflow_id) + store = redis.asyncio.from_url(redis_url()) + try: + await store.xtrim( + backend.stream_key( + chain.stream_key(topic, direction=StreamDirection.OUTPUT) + ), + maxlen=0, + approximate=False, + ) + finally: + await store.aclose() + + +async def test_a_replay_past_the_retention_window_fails_loudly(live_client: Client): + # Six entries per key: the loop's four input records stay while it runs, + # and six more appends afterwards push them out. + streams = RedisStreams( + url=redis_url(), key_prefix=f"streams-redis-{uuid.uuid4().hex}", max_len=6 + ) + workflow_id = f"streams-redis-retention-{uuid.uuid4().hex}" + try: + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[ContractLoop], + plugins=[streams], + max_cached_workflows=100, + ): + handle = await live_client.start_workflow( + ContractLoop.run, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + stream = streams.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="model", attempt=1) + await producer.append({"n": 1}, {"n": 2}, {"n": 3}) + await producer.finish() + assert len(await handle.result()) == 4 + before = await take(stream.read(topic=INPUTS), 4, 60) + history = await handle.fetch_history() + assert await _key_lengths(streams, live_client, workflow_id, INPUTS.name) == ( + 4, + 4, + ) + + # Inside the window the recorded ranges read back and the replay passes. + await Replayer(workflows=[ContractLoop], plugins=[streams]).replay_workflow( + history + ) + + late = stream.producer(topic=INPUTS, producer_id="late", attempt=1) + cursors = [await late.append({"n": n}) for n in range(10, 16)] + assert await _key_lengths(streams, live_client, workflow_id, INPUTS.name) == ( + 6, + 6, + ) + + # The recorded input ranges are gone, and the replay says so rather + # than delivering fewer records. + with pytest.raises(Exception) as failure: + await Replayer(workflows=[ContractLoop], plugins=[streams]).replay_workflow( + history + ) + # The task fails under the transport's integrity row: its type, the + # external storage cause, and a message that names the window. + message = str(failure.value) + assert "StreamIntegrityError" in message and "ExternalStorageFailure" in message + assert "past the redis provider's retention (max_len=6)" in message + + # An outside cursor below the trim is refused, not resumed from the + # first retained record. + with pytest.raises(StreamCursorError, match="retention has trimmed"): + await take(stream.read(topic=INPUTS, after=before[0].cursor), 1, 10) + + # Inside the window a read still works and lands where append said. + records = [r async for r in stream.read(topic=INPUTS, after=cursors[1])] + assert [r.value for r in records] == [{"n": n} for n in range(12, 16)] + assert [r.cursor for r in records] == cursors[2:] + finally: + await streams.close() + + +async def test_retention_by_age_trims_older_entries(live_client: Client): + streams = RedisStreams( + url=redis_url(), + key_prefix=f"streams-redis-{uuid.uuid4().hex}", + retention=timedelta(milliseconds=300), + ) + workflow_id = f"streams-redis-age-{uuid.uuid4().hex}" + try: + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[StreamHost], + plugins=[streams], + ): + handle = await live_client.start_workflow( + StreamHost.run, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + stream = streams.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="model", attempt=1) + first = await producer.append({"n": 1}) + await producer.append({"n": 2}) + await asyncio.sleep(0.6) + # The append that crosses the window is what trims the two before it. + third = await producer.append({"n": 3}) + assert await _key_lengths( + streams, live_client, workflow_id, INPUTS.name + ) == ( + 1, + 1, + ) + assert await stream.latest(topic=INPUTS) == third + with pytest.raises(StreamCursorError, match="retention has trimmed"): + await take(stream.read(topic=INPUTS, after=first), 1, 10) + + # A fully trimmed topic answers the way an empty one does. + await _trim_everything(streams, live_client, workflow_id, INPUTS.name) + assert await stream.latest(topic=INPUTS) == BEGINNING + await handle.signal(StreamHost.release) + await handle.result() + assert [r async for r in stream.read(topic=INPUTS)] == [] + finally: + await streams.close() + + @workflow.defn class ResumeAfter: """Reads ``inputs``; the first run hands its first record's cursor to the next.""" From 4b6d572cc101131072cf0af8546eeac69f86c62d Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 17:46:53 -0700 Subject: [PATCH 68/96] Listed stream_provider in ClientConnectConfig. `Client.connect` takes `stream_provider`, and the TypedDict that mirrors its keyword arguments has to name the same keys. The client test that compares the two failed on every branch since the keyword was added. (cherry picked from commit b06871231fdd4d75c39e9ac62393217faad4768a) --- temporalio/client/_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/temporalio/client/_client.py b/temporalio/client/_client.py index 75e768167..79617dff4 100644 --- a/temporalio/client/_client.py +++ b/temporalio/client/_client.py @@ -3096,6 +3096,7 @@ class ClientConnectConfig(TypedDict, total=False): grpc_compression: GrpcCompression payload_limits: PayloadLimitsConfig header_codec_behavior: HeaderCodecBehavior + stream_provider: temporalio.streams.StreamProvider | None class ClientConfig(TypedDict, total=False): From 6ec392fca6a9a48d5bff868db807d695ee30c6f1 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 17:54:57 -0700 Subject: [PATCH 69/96] Answered a legacy query without the stream snapshot beside it. A parked Run has no open Workflow Task, so the server dispatches a query on a task of its own and Core allows nothing beside the answer there. The instance still reported the registered wait set, Core refused the completion, and the query timed out. The test parks a Run and queries it. --- temporalio/worker/_workflow_instance.py | 14 ++++ .../test_worker_integration.py | 66 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/temporalio/worker/_workflow_instance.py b/temporalio/worker/_workflow_instance.py index 992bf7ed9..64cca2ea4 100644 --- a/temporalio/worker/_workflow_instance.py +++ b/temporalio/worker/_workflow_instance.py @@ -87,6 +87,9 @@ # Set to true to log all cases where we're ignoring things during delete LOG_IGNORE_DURING_DELETE = False +# Core answers a query carrying this id on the query's own task, alone. +_LEGACY_QUERY_ID = "legacy_query" + def _is_workflow_terminal_command( command: temporalio.bridge.proto.workflow_commands.workflow_commands_pb2.WorkflowCommand, @@ -2643,6 +2646,17 @@ def _emit_external_stream_commands(self) -> None: if runtime is None or self._deleting: return + # A legacy query is answered on a task of its own, and Core refuses any + # other command beside that answer. The activation ran no Workflow code, + # so the wait set it would report is the one the retained task already + # holds, and there is no observation delta to commit. + if any( + command.HasField("respond_to_query") + and command.respond_to_query.query_id == _LEGACY_QUERY_ID + for command in self._current_completion.successful.commands + ): + return + # First, because the boundary a Continue-As-New header has to carry is # the one this activation is about to commit, and it only stops moving # here. diff --git a/tests/contrib/external_workflow_streams/test_worker_integration.py b/tests/contrib/external_workflow_streams/test_worker_integration.py index cf797719f..f9ad6d5d7 100644 --- a/tests/contrib/external_workflow_streams/test_worker_integration.py +++ b/tests/contrib/external_workflow_streams/test_worker_integration.py @@ -301,6 +301,72 @@ async def test_a_blocked_workflow_retains_its_task_rather_than_completing_it( await handle.terminate() +@workflow.defn +class ParkedQueryWorkflow: + """Blocks on an empty stream with a short idle timeout, so the task parks.""" + + def __init__(self) -> None: + self._seen: list[str] = [] + + @workflow.run + async def run(self) -> list[str]: + tokens = external_stream.with_options(idle_timeout=timedelta(seconds=1)).topic( + "tokens", type=str + ) + async for token in tokens.subscribe(): + self._seen.append(token) + break + return self._seen + + @workflow.query + def seen(self) -> list[str]: + return self._seen + + +async def test_a_query_against_a_parked_workflow_is_answered( + client: Client, backend: MemoryStreamBackend +) -> None: + """A parked Run has no open Workflow Task, so its query travels alone. + + The server dispatches it on a task of its own, and Core allows nothing + beside the answer on that task. The wait set is still registered from the + parked task, so reporting it again with the answer made Core refuse the + completion, and the query never returned. + """ + task_queue = f"tq-{uuid.uuid4()}" + async with Worker( + client, + task_queue=task_queue, + workflows=[ParkedQueryWorkflow], + external_stream_backend=backend, + ): + handle = await client.start_workflow( + ParkedQueryWorkflow.run, + id=f"wf-{uuid.uuid4()}", + task_queue=task_queue, + ) + # Past the idle timeout the retained task is reported and the Run + # waits for a wake with no task open. + await asyncio.sleep(3) + + assert await asyncio.wait_for(handle.query(ParkedQueryWorkflow.seen), 10) == [] + + description = await handle.describe() + key = StreamKey( + client.namespace, + handle.id, + description.raw_description.workflow_execution_info.first_run_id, + "tokens", + ) + await publish(backend, key, ["first"]) + assert await asyncio.wait_for(handle.result(), 60) == ["first"] + + events = [e async for e in handle.fetch_history_events()] + assert not any( + e.HasField("workflow_task_failed_event_attributes") for e in events + ), "a Workflow Task failed while the query was outstanding" + + async def test_a_workflow_without_a_configured_backend_says_so( client: Client, ) -> None: From d3aeb56914279bbb876fe3ffa9fa9796ba7e2272 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 18:02:43 -0700 Subject: [PATCH 70/96] Repinned Core to the repairs head that carries the stream protos. The vendored `temporalio/api` now regenerates byte-for-byte from the pin, which is what the `check-protos` job asserts. --- temporalio/bridge/sdk-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 7df8a5b7a..c713b5e49 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 7df8a5b7a47b8112eb611c9777e04e44bf2d6fc6 +Subproject commit c713b5e49977b9f00485f7c94f2f944f6a295aee From b7be7b4aa309a4194054ba2743932e1b0660a069 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 25 Sep 2026 08:15:43 -0700 Subject: [PATCH 71/96] Added the Redis provider with an input and an output stream per topic. The transport keys every stream by direction, so an outside producer appends each record to the input stream that wakes a subscribed workflow and to the output stream where the workflow's promoted batches land. A workflow's publish is synchronous and stages with the task; an outside read ends with the workflow. --- CHANGELOG.md | 6 +- streams_demo/provider_setup.py | 26 +- .../contrib/external_workflow_streams/_api.py | 15 +- temporalio/streams/providers/redis.py | 664 ++++++++++++++++++ tests/streams/test_redis_provider.py | 45 ++ tests/streams/test_redis_replay.py | 207 ++++++ tests/streams/test_streams_conformance.py | 58 +- 7 files changed, 1009 insertions(+), 12 deletions(-) create mode 100644 temporalio/streams/providers/redis.py create mode 100644 tests/streams/test_redis_provider.py create mode 100644 tests/streams/test_redis_replay.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bbd734e5..6da9470b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,11 @@ to include examples, links to docs, or any other relevant information. code; a plain string names a topic decided at runtime. The record on the wire is `temporal.api.stream.v1.StreamRecord` on every provider. `temporalio.streams.providers.memory.MemoryStreams` is the in-memory - reference provider the conformance tests run against. + reference provider the conformance tests run against, and + `temporalio.streams.providers.redis.RedisStreams` serves the same interface + over External Workflow Streams, one topic as an input and an output stream. +- `ExternalStreamSubscription.records()` yields each value with the provider + offset it was read from, for a reader that has to name where it got to. - Added experimental External Workflow Streams in `temporalio.contrib.external_workflow_streams`. Workflow stream payloads are diff --git a/streams_demo/provider_setup.py b/streams_demo/provider_setup.py index 2e4bcc350..7fbf9316c 100644 --- a/streams_demo/provider_setup.py +++ b/streams_demo/provider_setup.py @@ -1,8 +1,9 @@ """Pick the provider for a demo run from the environment. -``STREAMS_PROVIDER`` names a provider; this base tree carries only ``memory``, -and each provider branch adds its own name here. The demo needs a Temporal -server to run the workflow either way; ``TEMPORAL_ADDRESS`` points at it. +``STREAMS_PROVIDER`` names a provider; this tree carries ``memory`` and +``redis``. The demo needs a Temporal server to run the workflow either way; +``TEMPORAL_ADDRESS`` points at it, and the Redis demo reads its store from +``AI198_REDIS_URL`` and ``AI198_REDIS_PREFIX``. """ from __future__ import annotations @@ -21,16 +22,23 @@ async def open() -> tuple[str, ProviderPlugin]: """The server to connect to and the provider the worker and the client share.""" - if NAME != "memory": - raise SystemExit(f"this tree carries no stream provider named {NAME!r}") - return os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), MemoryStreams() + address = os.environ.get("TEMPORAL_ADDRESS", "localhost:7233") + if NAME == "memory": + return address, MemoryStreams() + if NAME == "redis": + from temporalio.streams.providers.redis import RedisStreams + + return address, RedisStreams( + url=os.environ.get("AI198_REDIS_URL", "redis://127.0.0.1:6379"), + key_prefix=os.environ.get("AI198_REDIS_PREFIX", "ai198-contract"), + ) + raise SystemExit(f"this tree carries no stream provider named {NAME!r}") async def close(provider: ProviderPlugin) -> None: """Let go of whatever :func:`open` acquired. - The memory provider holds no connection, so this is its ``close()`` and - nothing more. A provider branch that opens one closes it the same way, so - the demo's teardown reads the same on every provider. + The memory provider holds no connection and the Redis provider closes + the client it opened, so the demo's teardown reads the same on both. """ await provider.close() diff --git a/temporalio/contrib/external_workflow_streams/_api.py b/temporalio/contrib/external_workflow_streams/_api.py index c89986954..0a373b10b 100644 --- a/temporalio/contrib/external_workflow_streams/_api.py +++ b/temporalio/contrib/external_workflow_streams/_api.py @@ -35,6 +35,7 @@ classify_read_failure, ) from temporalio.contrib.external_workflow_streams._record import ( + Offset, StreamRecord, ) from temporalio.types import AnyType @@ -594,6 +595,16 @@ def __aiter__(self) -> AsyncIterator[AnyType]: return self._iterate() async def _iterate(self) -> AsyncIterator[AnyType]: + async for _offset, value in self.records(): + yield value + + async def records(self) -> AsyncIterator[tuple[Offset, AnyType]]: + """Each value with the provider offset it was read from. + + Same delivery, same consumption, same commit order as iterating values. + A reader that has to name where it got to, or hand a position to + something outside the Workflow, cannot do it from the values alone. + """ while not self._finished: # Re-filled before *every* record rather than once per batch. A # record buffered while Workflow code was doing something else -- a @@ -623,8 +634,10 @@ async def _iterate(self) -> AsyncIterator[AnyType]: # becomes a value or raises, and neither outcome can leave the # ready list half-consumed. value = self._decode(record) + offset = record.offset self._commit(record) - yield value + assert offset is not None + yield offset, value continue await self._await_readiness() diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py new file mode 100644 index 000000000..0a345ae98 --- /dev/null +++ b/temporalio/streams/providers/redis.py @@ -0,0 +1,664 @@ +"""The client-side (Redis) provider. + +Streams live in a store the customer runs, Redis here, behind the external +workflow streams transport. A workflow's publish is buffered by the worker, +staged invisibly under a token when the Workflow Task completes, and promoted +only once a marker in History proves the task was accepted. Consumption is +recorded the same way, as ranges and boundaries in History. + +The mapping, in one place: + +- The transport keeps two Redis streams per topic, one the workflow reads and + one outside readers read, because the direction is part of every key it + derives. An outside producer therefore appends each record to both: the + input stream, which wakes a workflow subscribed to the topic, and the + output stream, where the workflow's own promoted batches land, so an + outside reader sees the producer's records and the workflow's in one order + and a workflow reading the topic misses nothing however early the producer + started. The cost is one more append per record and one wake per batch. +- A record rides as the transport's payload: the serialized ``StreamRecord`` + proto as a ``binary/plain`` value, which the worker's codec encodes and + decodes like any other payload. +- Producer identity dedupes through the transport's idempotency: the session + is ``producer#attempt`` and every record carries a sequence, so a retried + batch is dropped with the original position and a new attempt passes. +- Cursors are ``redis:-`` on the outside surface and name a position + in the output stream. A workflow-side record carries ``redis:in:-`` + for information only: the transport starts a run's subscription where the + chain left off, so a workflow reader cannot resume from a cursor. +- Streams are keyed by the chain's first run, so a handle follows continue-as- + new by construction and ``run_id`` only decides whose close ends a read. +- A task's publishes are staged as one batch. The transport's own per-task + batch limits are lifted for this provider, because a synchronous publish + cannot wait for the worker to stage a full batch; a batch it cannot stage + fails the task. +""" + +from __future__ import annotations + +import asyncio +import logging +import re +import time +from collections.abc import AsyncGenerator, Coroutine +from datetime import timedelta +from typing import Any, Generic, TypeVar + +from google.protobuf.message import DecodeError + +from temporalio import workflow +from temporalio.client import Client, WorkflowExecutionStatus +from temporalio.contrib.external_workflow_streams import ( + AFTER, + AppendConflictError, + AppendNotAcknowledgedError, + ChainKeyMismatchError, + ExternalOutputStreamProducer, + ExternalStreamProducer, + Offset, + OutputAppendNotAcknowledgedError, + StreamDirection, + WakeNotAcknowledgedError, + WorkflowChainKey, + external_output_stream, + external_stream, +) +from temporalio.contrib.external_workflow_streams import ( + BEGINNING as TRANSPORT_BEGINNING, +) +from temporalio.contrib.external_workflow_streams import ( + RecordKind as TransportRecordKind, +) +from temporalio.contrib.external_workflow_streams import ( + StreamError as TransportStreamError, +) +from temporalio.contrib.external_workflow_streams._backend import DEFAULT_WATCH_BLOCK +from temporalio.contrib.external_workflow_streams._codec import StreamPayloadCodec +from temporalio.contrib.external_workflow_streams._output_client import ( + _reconcile_output_stage, +) +from temporalio.converter import WorkflowSerializationContext +from temporalio.service import RPCError, RPCStatusCode +from temporalio.streams._errors import ( + StreamCursorError, + StreamError, + StreamNotFoundError, + StreamProducerError, + StreamUnsupportedError, +) +from temporalio.streams._provider import ReadSource, WriteSink +from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord +from temporalio.streams._topic import StreamTopic, resolve_topic +from temporalio.streams._wire import ( + RecordDecoder, + WireRecord, + cursor_position, + mint_cursor, + producer_identity, + to_wire, +) +from temporalio.streams.providers import ProviderPlugin +from temporalio.worker import ReplayerConfig, WorkerConfig + +__all__ = ["RedisProducer", "RedisStreamHandle", "RedisStreams"] + +T = TypeVar("T") + +_PROVIDER = "redis" +_INPUT_PREFIX = "in:" +_REDIS_ID = re.compile(r"\d+-\d+") +_READ_BATCH = 256 + +# The transport's per-task batch limits are a backpressure point that awaits +# inside the workflow, and a synchronous publish has nowhere to await. Lifted +# to where they cannot fire, so a task's publishes stage as one batch. +_UNBOUNDED_OUTPUT = external_output_stream.with_options( + max_records=1 << 30, max_logical_bytes=1 << 40 +) + +logger = logging.getLogger(__name__) + + +def _require_topic(topic: str) -> None: + if not topic: + raise ValueError("topic must not be empty") + + +def _outside_position(after: Cursor) -> Offset | None: + """The output-stream offset an outside cursor names, or ``None`` for BEGINNING.""" + token = cursor_position(after, provider=_PROVIDER) + if token is None: + return None + if token.startswith(_INPUT_PREFIX): + raise StreamCursorError( + f"cursor {after.token!r} names a position in the workflow's input log, " + "which only the workflow reads" + ) + if not _REDIS_ID.fullmatch(token): + raise StreamCursorError( + f"cursor {after.token!r} does not name a Redis stream position" + ) + return Offset(token) + + +def _drive(coroutine: Coroutine[Any, Any, None]) -> None: + """Run a transport publish to completion without yielding to the loop. + + The transport's publish only awaits when the task's batch is full. A + synchronous publish has nowhere to wait, so a publish that would have + waited fails the task instead, loudly. + """ + try: + coroutine.send(None) + except StopIteration: + return + coroutine.close() + raise StreamError( + "this Workflow Task's output batch is full, and a synchronous publish " + "cannot wait for the worker to stage it" + ) + + +def _parse(cursor: Cursor, raw: bytes, warn: Any) -> WireRecord | None: + try: + return WireRecord.FromString(raw) + except DecodeError as error: + # Same answer as an undecodable body: skip and say so, so one bad + # record cannot pin a reader. + warn("skipping stream record at %s: %s", cursor, error) + return None + + +class _RedisReadSource: + """One subscription of the running workflow, over the transport's input stream.""" + + def __init__(self, subscription: Any) -> None: + self._subscription = subscription + self._records = subscription.records() + + async def next_batch(self) -> list[tuple[Cursor, WireRecord]]: + while True: + # One record per batch: the transport reports readiness per record, + # and a batch here would invent a boundary replay never observed. + offset, body = await self._records.__anext__() + cursor = mint_cursor(_PROVIDER, f"{_INPUT_PREFIX}{offset.token}") + wire = _parse(cursor, body, workflow.logger.warning) + if wire is not None: + return [(cursor, wire)] + + def close(self) -> None: + self._subscription.close() + + +class _RedisWriteSink: + def __init__(self, topic: str) -> None: + self._topic = _UNBOUNDED_OUTPUT.topic(topic, type=bytes) + + def publish(self, record: WireRecord) -> None: + # Buffered in the transport's per-task batch, staged by the worker when + # the task completes and promoted once History proves the task was + # accepted: rule 1 through the transport's own commit. + _drive(self._topic.publish(record.SerializeToString())) + + +class _RedisWorkflowProvider: + """The workflow half: the transport's subscriptions and staged output.""" + + def __init__(self, idle_timeout: timedelta) -> None: + self._input = external_stream.with_options(idle_timeout=idle_timeout) + + def open_reader(self, topic: str, *, after: Cursor) -> ReadSource: + _require_topic(topic) + if cursor_position(after, provider=_PROVIDER) is not None: + raise StreamUnsupportedError( + "the redis provider starts a run's subscription where the chain " + "left off; it cannot resume a workflow reader from a cursor" + ) + return _RedisReadSource(self._input.topic(topic, type=bytes).subscribe()) + + def open_writer(self, topic: str) -> WriteSink: + _require_topic(topic) + return _RedisWriteSink(topic) + + def on_workflow_start(self) -> None: + pass + + async def on_workflow_finish(self) -> None: + pass + + +async def _chain(client: Client, workflow_id: str) -> WorkflowChainKey: + try: + description = await client.get_workflow_handle(workflow_id).describe() + except RPCError as error: + if error.status == RPCStatusCode.NOT_FOUND: + raise StreamNotFoundError( + f"workflow {workflow_id!r} was not found" + ) from error + raise + return WorkflowChainKey( + client.namespace, + workflow_id, + description.raw_description.workflow_execution_info.first_run_id, + ) + + +def _storage_error(error: Exception, what: str) -> StreamError: + return StreamError(f"{what}: {error}") + + +class RedisProducer(Generic[T]): + """Appends to a topic from outside workflow code. + + Every append is visible as soon as the store accepts it. Each record goes + to the topic's input stream, waking a workflow subscribed to it, and to + its output stream, where outside readers and the workflow's own records + meet; the cursor returned names the output position. + """ + + def __init__( + self, + streams: RedisStreams, + client: Client, + workflow_id: str, + topic: str, + producer_id: str, + attempt: int, + ) -> None: + """Bind this producer to ``topic`` of ``workflow_id``'s stream.""" + self._streams = streams + self._client = client + self._workflow_id = workflow_id + self._topic = topic + self._producer_id = producer_id + self._attempt = attempt + self._converter = client.data_converter.payload_converter + self._sequence = 0 + self._last = BEGINNING + self._input: Any = None + self._output: Any = None + + @property + def producer_id(self) -> str: + """Who this producer writes as.""" + return self._producer_id + + @property + def attempt(self) -> int: + """The generation this producer is writing.""" + return self._attempt + + @property + def _session(self) -> str: + # The transport dedupes on this and the sequence. The attempt is part + # of it so a retried append is dropped while a new generation writing + # different words at the same sequence is not. + return ( + f"{self._producer_id}#{self._attempt}" + if self._attempt + else self._producer_id + ) + + async def append(self, *values: T) -> Cursor: + """Append ``values`` and return the cursor of the last record as stored. + + A repeat returns where the original landed, because the transport + answers a byte-identical repeat with the original offset; an empty + call returns the position of this producer's last record. + """ + if not values: + return self._last + return await self._write( + [ + to_wire( + self._converter, + topic=self._topic, + kind=RecordKind.DATA, + value=value, + producer_id=self._producer_id, + attempt=self._attempt, + sequence=self._sequence + index, + ) + for index, value in enumerate(values) + ] + ) + + async def finish(self) -> None: + """Write ``FINISH`` for this producer on this topic. + + Written as an ordinary record rather than the transport's own + terminal, which would end every outside read of the topic; a + ``FINISH`` here only says this producer is done. + """ + await self._write( + [ + to_wire( + self._converter, + topic=self._topic, + kind=RecordKind.FINISH, + producer_id=self._producer_id, + attempt=self._attempt, + sequence=self._sequence, + ) + ] + ) + + async def _connect(self) -> None: + if self._output is not None: + return + backend = self._streams._require_backend() + chain = await _chain(self._client, self._workflow_id) + try: + output = await ExternalOutputStreamProducer.connect( + backend=backend, + workflow=chain, + client=self._client, + session_id=self._session, + ) + input_ = await ExternalStreamProducer.connect( + backend=backend, + workflow=chain, + client=self._client, + session_id=self._session, + ) + except ChainKeyMismatchError as error: + raise StreamNotFoundError( + f"workflow {self._workflow_id!r} is not the chain this producer " + f"was opened on: {error}" + ) from error + self._output = output.topic(self._topic, type=bytes) + self._input = input_.topic(self._topic, type=bytes) + + async def _write(self, records: list[WireRecord]) -> Cursor: + await self._connect() + last: Offset | None = None + try: + for record in records: + frame = record.SerializeToString() + await self._input.publish(frame, wake=False) + placed = await self._output.publish(frame) + last = placed.offset + await self._wake() + except AppendConflictError as error: + raise StreamProducerError( + f"producer {self._session!r} already wrote a different record at " + f"sequence {error.key.sequence}" + ) from error + except (AppendNotAcknowledgedError, OutputAppendNotAcknowledgedError) as error: + raise _storage_error(error, "an append was not acknowledged") from error + except TransportStreamError as error: + raise _storage_error(error, "the store refused an append") from error + self._sequence += len(records) + assert last is not None + self._last = mint_cursor(_PROVIDER, last.token) + return self._last + + async def _wake(self) -> None: + try: + await self._input.wake() + except WakeNotAcknowledgedError: + # The records are appended; what failed is telling a consumer that + # is no longer there to be told. A terminal record most often races + # the consumer acting on it, so an absent consumer is the ordinary + # ending rather than an error. + if not await self._consumer_has_gone(): + raise + except TransportStreamError as error: + raise _storage_error(error, "the wake could not be sent") from error + + async def _consumer_has_gone(self) -> bool: + """Whether the consuming execution is closing or closed. + + Asked for a few seconds rather than once: the server refuses the wake + while the execution is closing, and at that moment its status is + still the running one. + """ + handle = self._client.get_workflow_handle(self._workflow_id) + deadline = time.monotonic() + 5 + while True: + try: + status = (await handle.describe()).status + except RPCError as error: + if error.status == RPCStatusCode.NOT_FOUND: + return True + raise + if status is not None and status != WorkflowExecutionStatus.RUNNING: + return True + if time.monotonic() >= deadline: + return False + await asyncio.sleep(0.1) + + +class RedisStreamHandle: + """One workflow's topics from outside, over the transport's output streams.""" + + def __init__( + self, + streams: RedisStreams, + client: Client, + workflow_id: str, + run_id: str | None, + ) -> None: + """Address ``workflow_id``'s topics; ``run_id`` decides whose close ends a read.""" + self._streams = streams + self._client = client + self._workflow_id = workflow_id + self._run_id = run_id + self._converter = client.data_converter.payload_converter + self._codec: StreamPayloadCodec[bytes] = StreamPayloadCodec( + client.data_converter.with_context( + WorkflowSerializationContext( + namespace=client.namespace, workflow_id=workflow_id + ) + ), + bytes, + ) + + def read( + self, + *, + topic: str | StreamTopic[Any], + after: Cursor = BEGINNING, + result_type: type | None = None, + ) -> AsyncGenerator[StreamRecord[Any], None]: + """Yield records on ``topic`` after ``after`` until the chain, or the pinned run, closes.""" + topic, result_type = resolve_topic(topic, result_type) + # Parsed here so a foreign cursor fails this call, not the first + # iteration of the generator. + position = _outside_position(after) + return self._read(topic, position, after, result_type) + + async def _read( + self, + topic: str, + position: Offset | None, + after: Cursor, + result_type: type | None, + ) -> AsyncGenerator[StreamRecord[Any], None]: + decoder = RecordDecoder( + self._converter, result_type, after=after, warn=logger.warning + ) + backend = self._streams._require_backend() + chain = await _chain(self._client, self._workflow_id) + key = chain.stream_key(topic, direction=StreamDirection.OUTPUT) + cursor = TRANSPORT_BEGINNING if position is None else AFTER(position) + closed = False + while True: + try: + result = await backend.read_output_after( + key, + cursor, + max_records=_READ_BATCH, + block=self._streams._poll, + ) + except TransportStreamError as error: + raise _storage_error(error, "the store could not be read") from error + for placed in result.records: + cursor = AFTER(placed.offset) + if placed.kind is not TransportRecordKind.DATA: + continue + minted = mint_cursor(_PROVIDER, placed.offset.token) + wire = _parse( + minted, await self._codec.decode(placed.payload), logger.warning + ) + if wire is None: + continue + for record in decoder.decode(minted, wire): + yield record + if result.pending is not None: + # A staged batch whose task History has not settled yet is a + # barrier: nothing past it is read until History says whether + # the task was accepted. + resolved = await _reconcile_output_stage( + backend=backend, + client=self._client, + workflow_id=self._workflow_id, + manifest=result.pending.manifest, + ) + if not resolved: + await asyncio.sleep(self._streams._poll.total_seconds()) + continue + if result.records: + continue + if closed: + return + # One more pass after learning the workflow closed, so a batch + # promoted between the read and the describe is not lost. + closed = await self._closed() + + async def _closed(self) -> bool: + handle = self._client.get_workflow_handle( + self._workflow_id, run_id=self._run_id + ) + try: + status = (await handle.describe()).status + except RPCError as error: + if error.status == RPCStatusCode.NOT_FOUND: + # The run's History is gone; nothing more can be promoted. + return True + raise + if status is None or status == WorkflowExecutionStatus.RUNNING: + return False + # The streams are keyed by the chain, so a run that continued as new + # is not the end unless the handle was pinned to it. + return not ( + self._run_id is None and status == WorkflowExecutionStatus.CONTINUED_AS_NEW + ) + + async def latest(self, *, topic: str | StreamTopic[Any]) -> Cursor: + """The cursor of the newest committed record on ``topic``, for following from now.""" + topic, _ = resolve_topic(topic) + backend = self._streams._require_backend() + chain = await _chain(self._client, self._workflow_id) + try: + tail = await backend.output_tail( + chain.stream_key(topic, direction=StreamDirection.OUTPUT) + ) + except TransportStreamError as error: + raise _storage_error(error, "the store's tail could not be read") from error + if tail.is_beginning: + return BEGINNING + assert tail.offset is not None + return mint_cursor(_PROVIDER, tail.offset.token) + + def producer( + self, + *, + topic: str | StreamTopic[Any], + producer_id: str = "", + attempt: int = 0, + ) -> RedisProducer[Any]: + """A producer on ``topic``; inside an activity its identity is the activity's.""" + topic, _ = resolve_topic(topic) + producer_id, attempt = producer_identity(producer_id, attempt) + return RedisProducer( + self._streams, self._client, self._workflow_id, topic, producer_id, attempt + ) + + +class RedisStreams(ProviderPlugin): + """The client-side provider over Redis streams. + + Construct one, pass it to the worker as a plugin and open handles from + it anywhere else. The Redis client is opened on first use, so it belongs + to the loop that uses it, and released by :meth:`close`; a ``backend`` + the caller hands in stays the caller's to close. + """ + + def __init__( + self, + *, + url: str = "redis://127.0.0.1:6379", + key_prefix: str = "temporal-streams", + idle_timeout: timedelta = timedelta(seconds=1), + backend: Any | None = None, + poll_interval: timedelta = timedelta(milliseconds=500), + ) -> None: + """Create the provider. + + Args: + url: The Redis to connect to when no ``backend`` is given. + key_prefix: Prepended to every key, so one Redis serves several + deployments. + idle_timeout: How long a workflow reader with nothing to read + holds its Workflow Task open before the worker parks it. + backend: A transport backend the caller constructed and owns. + poll_interval: How long an outside reader that is caught up waits + for a record before asking whether the workflow closed. + """ + self._url = url + self._key_prefix = key_prefix + self._idle_timeout = idle_timeout + self._backend = backend + self._owned_client: Any = None + self._poll = poll_interval + + def _require_backend(self) -> Any: + if self._backend is None: + import redis.asyncio + + from temporalio.contrib.external_workflow_streams._redis import ( + RedisStreamBackend, + ) + + # A dead peer would otherwise hold a blocking read open forever. + # Several block periods, so a healthy socket that is merely idle + # inside one read window is never abandoned. + self._owned_client = redis.asyncio.from_url( + self._url, + decode_responses=False, + socket_timeout=DEFAULT_WATCH_BLOCK.total_seconds() * 6, + ) + self._backend = RedisStreamBackend( + client=self._owned_client, key_prefix=self._key_prefix + ) + return self._backend + + def configure_worker(self, config: WorkerConfig) -> WorkerConfig: + """Set this provider and its transport backend on the worker.""" + config = super().configure_worker(config) + config["external_stream_backend"] = self._require_backend() + return config + + def configure_replayer(self, config: ReplayerConfig) -> ReplayerConfig: + """Set this provider and its transport backend on the replayer.""" + config = super().configure_replayer(config) + config["external_stream_backend"] = self._require_backend() + return config + + def workflow_provider(self) -> _RedisWorkflowProvider: + """The workflow half, over the transport's subscriptions and staged output.""" + return _RedisWorkflowProvider(self._idle_timeout) + + def get_stream_handle( + self, client: Client, workflow_id: str, *, run_id: str | None = None + ) -> RedisStreamHandle: + """A handle on ``workflow_id``'s topics; it follows the chain by construction.""" + return RedisStreamHandle(self, client, workflow_id, run_id) + + async def close(self) -> None: + """Release the Redis connections this provider opened.""" + client, self._owned_client = self._owned_client, None + if client is not None: + self._backend = None + await client.aclose() diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py new file mode 100644 index 000000000..994ef5309 --- /dev/null +++ b/tests/streams/test_redis_provider.py @@ -0,0 +1,45 @@ +"""What the Redis provider decides without a store: cursors and the sync publish.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from temporalio.streams import BEGINNING, Cursor, StreamCursorError, StreamError +from temporalio.streams.providers.redis import _drive, _outside_position + + +def test_outside_cursors_name_output_positions(): + assert _outside_position(BEGINNING) is None + position = _outside_position(Cursor("redis:1700000000000-3")) + assert position is not None and position.token == "1700000000000-3" + # A workflow-side cursor names the input log, which outside code cannot + # read from, and a token another provider minted is refused the same way. + with pytest.raises(StreamCursorError): + _outside_position(Cursor("redis:in:1700000000000-3")) + with pytest.raises(StreamCursorError): + _outside_position(Cursor("memory:3")) + with pytest.raises(StreamCursorError): + _outside_position(Cursor("redis:not-an-id")) + + +def test_a_publish_that_completes_at_once_is_driven_to_the_end(): + done = [] + + async def publish() -> None: + done.append(True) + + _drive(publish()) + assert done == [True] + + +def test_a_publish_that_would_wait_fails_loudly(): + async def publish() -> None: + await asyncio.get_running_loop().create_future() + + async def run() -> None: + with pytest.raises(StreamError, match="batch is full"): + _drive(publish()) + + asyncio.run(run()) diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py new file mode 100644 index 000000000..4ee50e68b --- /dev/null +++ b/tests/streams/test_redis_replay.py @@ -0,0 +1,207 @@ +"""Live checks for the client-side (Redis) provider inside a workflow. + +The conformance suite covers the outside surface when ``STREAMS_LIVE=redis``. +This module runs the interface loop inside a workflow over the staged commit, +lets a read end with the workflow, shares a topic between an outside producer +and the workflow, and queries a completed run, which replays it. All need a +dev server (``TEMPORAL_ADDRESS``) and a Redis (``TEMPORAL_TEST_REDIS_URL`` or +``AI198_REDIS_URL``). The worker keeps a warm cache because the transport +holds the task open between records. +""" + +from __future__ import annotations + +import asyncio +import os +import uuid +from collections.abc import AsyncIterator +from typing import Any + +import pytest + +from temporalio import workflow +from temporalio.client import Client +from temporalio.streams import RecordKind +from temporalio.streams.providers.redis import RedisStreams +from temporalio.worker import Worker +from tests.streams.test_streams_conformance import take +from tests.streams.test_streams_workflow import ( + DECISIONS, + INPUTS, + ContractLoop, + OneLine, +) + +pytestmark = pytest.mark.skipif( + os.environ.get("STREAMS_LIVE") != "redis", + reason="needs a live server and redis; run with STREAMS_LIVE=redis", +) + + +def redis_url() -> str: + return os.environ.get("TEMPORAL_TEST_REDIS_URL") or os.environ.get( + "AI198_REDIS_URL", "redis://127.0.0.1:6379" + ) + + +@pytest.fixture +async def provider() -> AsyncIterator[RedisStreams]: + # A prefix per case, because the store keeps what earlier cases wrote. + streams = RedisStreams( + url=redis_url(), key_prefix=f"streams-redis-{uuid.uuid4().hex}" + ) + try: + yield streams + finally: + await streams.close() + + +@pytest.fixture +async def live_client(client: Client) -> Client: + # The test environment's own server, unless TEMPORAL_ADDRESS names another. + address = os.environ.get("TEMPORAL_ADDRESS") + return await Client.connect(address) if address else client + + +async def test_interface_loop_over_redis(live_client: Client, provider: RedisStreams): + workflow_id = f"streams-redis-live-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[ContractLoop], + plugins=[provider], + max_cached_workflows=100, + ): + handle = await live_client.start_workflow( + ContractLoop.run, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + stream = provider.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="model", attempt=1) + await producer.append({"n": 1}, {"n": 2}) + await producer.append({"n": 3}) + await producer.finish() + + records = await take(stream.read(topic=DECISIONS), 4, 60) + assert [r.kind for r in records] == [ + RecordKind.DATA, + RecordKind.DATA, + RecordKind.DATA, + RecordKind.FINISH, + ] + assert [r.value["decided"] for r in records[:3]] == [1, 2, 3] + assert all(r.producer_id == "" and r.topic == DECISIONS.name for r in records) + assert await handle.result() == [ + {"kind": "decision", "n": 1, "attempt": 1}, + {"kind": "decision", "n": 2, "attempt": 1}, + {"kind": "decision", "n": 3, "attempt": 1}, + {"kind": "finish", "producer": "model"}, + ] + + # The read ends by itself once the workflow is closed and every + # promoted record has been handed over. + async def read_everything() -> list[Any]: + return [ + r.value async for r in stream.read(topic=DECISIONS) + ] + + assert await asyncio.wait_for(read_everything(), 60) == [ + {"decided": 1}, + {"decided": 2}, + {"decided": 3}, + None, + ] + # The producer's own records are readable from outside as well, on + # the topic it wrote. + inputs = await take(stream.read(topic=INPUTS), 4, 60) + assert [r.value for r in inputs[:3]] == [{"n": 1}, {"n": 2}, {"n": 3}] + assert inputs[3].kind is RecordKind.FINISH + assert all(r.producer_id == "model" and r.attempt == 1 for r in inputs) + + +async def test_an_outside_producer_and_the_workflow_share_a_topic( + live_client: Client, provider: RedisStreams +): + workflow_id = f"streams-redis-shared-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[OneLine], + plugins=[provider], + ): + handle = await live_client.start_workflow( + OneLine.run, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + stream = provider.get_stream_handle(live_client, workflow_id) + await stream.producer(topic=DECISIONS, producer_id="tool", attempt=1).append( + {"from": "producer"} + ) + await handle.result() + + async def read_everything() -> list[Any]: + return [r async for r in stream.read(topic=DECISIONS)] + + records = await asyncio.wait_for(read_everything(), 60) + # Both writers land on one topic, each under its own identity. The order + # between them is whatever the store took first. + assert sorted( + ((r.producer_id, r.kind, r.value) for r in records), key=str + ) == sorted( + [ + ("tool", RecordKind.DATA, {"from": "producer"}), + ("", RecordKind.DATA, {"from": "workflow"}), + ("", RecordKind.FINISH, None), + ], + key=str, + ) + + +@workflow.defn +class SignalWokenPublish: + """Publishes in the task a signal wakes, then completes in that same task.""" + + def __init__(self) -> None: + self._closed = False + + @workflow.signal + def close(self) -> None: + self._closed = True + + @workflow.run + async def run(self) -> int: + out = workflow.stream_writer("out") + out.publish({"n": 0}) + await workflow.wait_condition(lambda: self._closed) + for n in range(1, 4): + out.publish({"n": n}) + return 4 + + @workflow.query + def probe(self) -> int: + return 1 + + +async def test_query_after_completion_replays_the_final_task( + live_client: Client, provider: RedisStreams +): + # A query against a completed run replays it. The final task's publishes + # were woken by a signal, which the activation applies before the replay + # marker, so the marker's expectations have to be installed before that + # task's code runs or the replay records nothing against a manifest of + # three. + workflow_id = f"streams-redis-replay-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[SignalWokenPublish], + plugins=[provider], + ): + handle = await live_client.start_workflow( + SignalWokenPublish.run, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + stream = provider.get_stream_handle(live_client, workflow_id) + await take(stream.read(topic="out", result_type=dict), 1, timeout=60) + await handle.signal(SignalWokenPublish.close) + assert await handle.result() == 4 + assert await handle.query(SignalWokenPublish.probe) == 1 + values = [r.value async for r in stream.read(topic="out", result_type=dict)] + assert values == [{"n": 0}, {"n": 1}, {"n": 2}, {"n": 3}] diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index 9002ea16a..e4da91255 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -21,6 +21,7 @@ from __future__ import annotations import asyncio +import os import uuid from collections.abc import AsyncIterator, Awaitable, Callable from dataclasses import dataclass @@ -28,8 +29,9 @@ import pytest +from temporalio import workflow from temporalio.api.common.v1 import Payload -from temporalio.client import Client +from temporalio.client import Client, WorkflowHandle from temporalio.common import RawValue from temporalio.converter import DataConverter from temporalio.streams import ( @@ -46,6 +48,8 @@ ) from temporalio.streams._policy import AttemptTracker from temporalio.streams.providers.memory import MemoryStreams +from temporalio.streams.providers.redis import RedisStreams +from tests.helpers import new_worker # Defined once and shared by every case, the way an application shares them # between its workflow, its activities and its backend. @@ -91,9 +95,61 @@ async def _memory_case(_client: Client) -> AsyncIterator[ProviderCase]: provider.reset() +@workflow.defn +class StreamHost: + """Owns a stream and lingers, so outside code has a running workflow to address.""" + + def __init__(self) -> None: + self._released = False + + @workflow.signal + def release(self) -> None: + self._released = True + + @workflow.run + async def run(self) -> None: + await workflow.wait_condition(lambda: self._released) + + +async def _redis_case(client: Client) -> AsyncIterator[ProviderCase]: + # The store is a Redis the test environment does not start; the server + # is the environment's own unless TEMPORAL_ADDRESS names another. + address = os.environ.get("TEMPORAL_ADDRESS") + if address: + client = await Client.connect( + address, namespace=os.environ.get("TEMPORAL_NAMESPACE", "default") + ) + provider = RedisStreams( + url=os.environ.get("TEMPORAL_TEST_REDIS_URL") + or os.environ.get("AI198_REDIS_URL", "redis://127.0.0.1:6379"), + # A prefix per setup, because the store keeps what earlier runs wrote. + key_prefix=f"streams-conformance-{uuid.uuid4().hex}", + ) + # Registered once, on the client: the host's worker inherits it and the + # cases open handles through client.get_stream_handle. + config = client.config() + config["plugins"] = [provider] + client = Client(**config) + hosts: dict[str, WorkflowHandle[Any, Any]] = {} + async with new_worker(client, StreamHost) as worker: + + async def host(workflow_id: str) -> None: + if workflow_id not in hosts: + hosts[workflow_id] = await client.start_workflow( + StreamHost.run, id=workflow_id, task_queue=worker.task_queue + ) + + yield ProviderCase("redis", provider, client, host=host) + for handle in hosts.values(): + await handle.terminate() + await provider.close() + + SETUPS: dict[str, Callable[[Client], AsyncIterator[ProviderCase]]] = { "memory": _memory_case } +if os.environ.get("STREAMS_LIVE") == "redis": + SETUPS["redis"] = _redis_case _CAPABILITIES = { "reports_positions": lambda case: case.reports_positions, From e578e679c0ce80fcdc33f9ac5eee6bf49c2fd3b8 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 25 Sep 2026 08:15:51 -0700 Subject: [PATCH 72/96] Seeded a Redis workflow reader from a cursor. The runtime and the manager already take a start cursor and the marker header records it; only subscribe() had no way to pass one. The provider now seeds the subscription from a redis:in: cursor, and refuses an outside cursor because the two streams number entries apart. --- .../contrib/external_workflow_streams/_api.py | 19 ++++- temporalio/streams/providers/redis.py | 43 +++++++--- .../external_workflow_streams/test_api.py | 18 +++++ tests/streams/test_redis_provider.py | 20 ++++- tests/streams/test_redis_replay.py | 81 ++++++++++++++++--- 5 files changed, 160 insertions(+), 21 deletions(-) diff --git a/temporalio/contrib/external_workflow_streams/_api.py b/temporalio/contrib/external_workflow_streams/_api.py index 0a373b10b..1da059d80 100644 --- a/temporalio/contrib/external_workflow_streams/_api.py +++ b/temporalio/contrib/external_workflow_streams/_api.py @@ -35,6 +35,7 @@ classify_read_failure, ) from temporalio.contrib.external_workflow_streams._record import ( + Cursor, Offset, StreamRecord, ) @@ -95,6 +96,7 @@ def register( wait_id: int, stream_key: StreamKey, idle_timeout: timedelta, + start_cursor: Cursor | None = None, ) -> None: """Registers a wait with the Worker's subscription manager. @@ -267,7 +269,9 @@ class ExternalStreamTopic(Generic[AnyType]): value_type: type[AnyType] | None options: ExternalStreamOptions - def subscribe(self) -> ExternalStreamSubscription[AnyType]: + def subscribe( + self, *, start_cursor: Cursor | None = None + ) -> ExternalStreamSubscription[AnyType]: """Starts a new subscription and returns its async iterator. Each call is an **independent** subscription with its own ``wait_id``, @@ -278,6 +282,13 @@ def subscribe(self) -> ExternalStreamSubscription[AnyType]: the same hazard class as timers and activities: inserting, removing, or reordering a ``subscribe()`` call renumbers every later wait in the Run and must be gated behind ``workflow.patched()``. + + Args: + start_cursor: The boundary the subscription begins after. ``None`` + resumes where the predecessor Run committed this wait, or at + ``BEGINNING`` on a first execution. A boundary the Workflow + names is recorded in the marker's header like the restored one, + so it must be derived deterministically: replay names it again. """ state = _run_state() if state.runtime is None: @@ -289,6 +300,11 @@ def subscribe(self) -> ExternalStreamSubscription[AnyType]: state.next_wait_id += 1 stream_key = state.runtime.stream_key(self.name) + # Only a named boundary travels; the runtime derives the default itself + # from the predecessor Run's continuation. + start: dict[str, Any] = ( + {} if start_cursor is None else {"start_cursor": start_cursor} + ) state.runtime.register( wait_id=wait_id, stream_key=stream_key, @@ -299,6 +315,7 @@ def subscribe(self) -> ExternalStreamSubscription[AnyType]: # `with_options` was given, so no configured value can ever reach # the reduction and every set parks after one second. idle_timeout=self.options.idle_timeout, + **start, ) # Registering a wait is not blocking on one. The quiescent snapshot is a # request to Core to retain the Workflow Task and, once the idle timer diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 0a345ae98..c6972cc84 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -23,9 +23,11 @@ is ``producer#attempt`` and every record carries a sequence, so a retried batch is dropped with the original position and a new attempt passes. - Cursors are ``redis:-`` on the outside surface and name a position - in the output stream. A workflow-side record carries ``redis:in:-`` - for information only: the transport starts a run's subscription where the - chain left off, so a workflow reader cannot resume from a cursor. + in the output stream. A workflow-side record carries ``redis:in:-``, + a position in the input stream, and only that form seeds a workflow reader: + the two streams number their entries independently, so an outside cursor + cannot stand in for one. A reader opened without a cursor starts where the + chain's predecessor run committed, which is the transport's own rule. - Streams are keyed by the chain's first run, so a handle follows continue-as- new by construction and ``run_id`` only decides whose close ends a read. - A task's publishes are staged as one batch. The transport's own per-task @@ -84,7 +86,6 @@ StreamError, StreamNotFoundError, StreamProducerError, - StreamUnsupportedError, ) from temporalio.streams._provider import ReadSource, WriteSink from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord @@ -141,6 +142,26 @@ def _outside_position(after: Cursor) -> Offset | None: return Offset(token) +def _workflow_position(after: Cursor) -> Offset | None: + """The input-stream offset a workflow reader's cursor names, or ``None`` for BEGINNING.""" + token = cursor_position(after, provider=_PROVIDER) + if token is None: + return None + if token.startswith(_INPUT_PREFIX): + entry = token[len(_INPUT_PREFIX) :] + if _REDIS_ID.fullmatch(entry): + return Offset(entry) + elif _REDIS_ID.fullmatch(token): + raise StreamCursorError( + f"cursor {after.token!r} names a position in the topic's output stream; " + "a workflow reader follows the input stream, whose entry ids differ, so " + "pass a cursor a workflow reader returned" + ) + raise StreamCursorError( + f"cursor {after.token!r} does not name a Redis stream position" + ) + + def _drive(coroutine: Coroutine[Any, Any, None]) -> None: """Run a transport publish to completion without yielding to the loop. @@ -209,12 +230,14 @@ def __init__(self, idle_timeout: timedelta) -> None: def open_reader(self, topic: str, *, after: Cursor) -> ReadSource: _require_topic(topic) - if cursor_position(after, provider=_PROVIDER) is not None: - raise StreamUnsupportedError( - "the redis provider starts a run's subscription where the chain " - "left off; it cannot resume a workflow reader from a cursor" - ) - return _RedisReadSource(self._input.topic(topic, type=bytes).subscribe()) + position = _workflow_position(after) + # Without a position the transport resumes where the chain's + # predecessor run committed; with one, that is where the wait starts + # and what the marker's header records. + start = None if position is None else AFTER(position) + return _RedisReadSource( + self._input.topic(topic, type=bytes).subscribe(start_cursor=start) + ) def open_writer(self, topic: str) -> WriteSink: _require_topic(topic) diff --git a/tests/contrib/external_workflow_streams/test_api.py b/tests/contrib/external_workflow_streams/test_api.py index 236d0b614..18095c310 100644 --- a/tests/contrib/external_workflow_streams/test_api.py +++ b/tests/contrib/external_workflow_streams/test_api.py @@ -30,6 +30,8 @@ ) from temporalio.contrib.external_workflow_streams._manager import PreparedRecord from temporalio.contrib.external_workflow_streams._record import ( + AFTER, + Cursor, Offset, RecordKind, StreamRecord, @@ -48,6 +50,7 @@ def __init__(self) -> None: #: `wait_id -> configured idle timeout`, so a test can see that the #: value `with_options` was given actually reached the Worker. self.idle_timeouts: dict[int, timedelta] = {} + self.start_cursors: dict[int, Cursor | None] = {} self.buffers: dict[int, list[StreamRecord]] = {} self.deliveries: list[tuple[int, StreamRecord]] = [] self.consumed: list[tuple[int, StreamRecord]] = [] @@ -72,9 +75,11 @@ def register( wait_id: int, stream_key: StreamKey, idle_timeout: timedelta, + start_cursor: Cursor | None = None, ) -> None: self.registrations.append((wait_id, stream_key)) self.idle_timeouts[wait_id] = idle_timeout + self.start_cursors[wait_id] = start_cursor def drain(self, wait_id: int, max_records: int | None = None) -> list[StreamRecord]: buffered = self.buffers.get(wait_id, []) @@ -192,6 +197,19 @@ def test_topics_inherit_their_options( assert subscription.idle_timeout == timedelta(seconds=3) +def test_a_subscription_may_name_the_boundary_it_starts_after( + runtime: FakeRuntime, +) -> None: + """A named boundary reaches the runtime; an unnamed one leaves the default to it.""" + boundary = AFTER(Offset("1700000000000-3")) + + seeded = external_stream.topic("tokens").subscribe(start_cursor=boundary) + default = external_stream.topic("tokens").subscribe() + + assert runtime.start_cursors[seeded.wait_id] == boundary + assert runtime.start_cursors[default.wait_id] is None + + # --- topics ------------------------------------------------------------------- diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py index 994ef5309..d4e215882 100644 --- a/tests/streams/test_redis_provider.py +++ b/tests/streams/test_redis_provider.py @@ -7,7 +7,11 @@ import pytest from temporalio.streams import BEGINNING, Cursor, StreamCursorError, StreamError -from temporalio.streams.providers.redis import _drive, _outside_position +from temporalio.streams.providers.redis import ( + _drive, + _outside_position, + _workflow_position, +) def test_outside_cursors_name_output_positions(): @@ -43,3 +47,17 @@ async def run() -> None: _drive(publish()) asyncio.run(run()) + + +def test_workflow_cursors_name_input_positions(): + assert _workflow_position(BEGINNING) is None + position = _workflow_position(Cursor("redis:in:1700000000000-3")) + assert position is not None and position.token == "1700000000000-3" + # An outside cursor names the output stream, whose entry ids are not the + # input stream's, so it cannot seed a workflow reader. + with pytest.raises(StreamCursorError, match="output stream"): + _workflow_position(Cursor("redis:1700000000000-3")) + with pytest.raises(StreamCursorError): + _workflow_position(Cursor("memory:3")) + with pytest.raises(StreamCursorError): + _workflow_position(Cursor("redis:in:not-an-id")) diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 4ee50e68b..4871e1cfa 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -3,10 +3,10 @@ The conformance suite covers the outside surface when ``STREAMS_LIVE=redis``. This module runs the interface loop inside a workflow over the staged commit, lets a read end with the workflow, shares a topic between an outside producer -and the workflow, and queries a completed run, which replays it. All need a -dev server (``TEMPORAL_ADDRESS``) and a Redis (``TEMPORAL_TEST_REDIS_URL`` or -``AI198_REDIS_URL``). The worker keeps a warm cache because the transport -holds the task open between records. +and the workflow, queries a completed run, which replays it, and seeds a +workflow reader from a cursor. All need a dev server (``TEMPORAL_ADDRESS``) +and a Redis (``TEMPORAL_TEST_REDIS_URL`` or ``AI198_REDIS_URL``). The worker +keeps a warm cache because the transport holds the task open between records. """ from __future__ import annotations @@ -21,9 +21,9 @@ from temporalio import workflow from temporalio.client import Client -from temporalio.streams import RecordKind +from temporalio.streams import BEGINNING, Cursor, RecordKind from temporalio.streams.providers.redis import RedisStreams -from temporalio.worker import Worker +from temporalio.worker import Replayer, Worker from tests.streams.test_streams_conformance import take from tests.streams.test_streams_workflow import ( DECISIONS, @@ -100,9 +100,7 @@ async def test_interface_loop_over_redis(live_client: Client, provider: RedisStr # The read ends by itself once the workflow is closed and every # promoted record has been handed over. async def read_everything() -> list[Any]: - return [ - r.value async for r in stream.read(topic=DECISIONS) - ] + return [r.value async for r in stream.read(topic=DECISIONS)] assert await asyncio.wait_for(read_everything(), 60) == [ {"decided": 1}, @@ -205,3 +203,68 @@ async def test_query_after_completion_replays_the_final_task( assert await handle.query(SignalWokenPublish.probe) == 1 values = [r.value async for r in stream.read(topic="out", result_type=dict)] assert values == [{"n": 0}, {"n": 1}, {"n": 2}, {"n": 3}] + + +@workflow.defn +class ResumeAfter: + """Reads ``inputs``; the first run hands its first record's cursor to the next.""" + + def __init__(self) -> None: + self._seen: list[int] = [] + + @workflow.query + def seen(self) -> list[int]: + return self._seen + + @workflow.run + async def run(self, after: str | None) -> list[int]: + reader = workflow.stream_reader( + INPUTS, after=Cursor(after) if after else BEGINNING + ) + first: str | None = None + async for record in reader: + if record.kind is RecordKind.FINISH: + break + assert record.value is not None + self._seen.append(record.value["n"]) + first = first or record.cursor.token + if after is None and len(self._seen) == 2: + workflow.continue_as_new(first) + return self._seen + + +async def test_a_workflow_reader_started_from_a_cursor_skips_the_earlier_records( + live_client: Client, provider: RedisStreams +): + # The first run reads two records and continues as new with the cursor of + # the first. Without the cursor the successor would resume after the + # second, which is where the chain left off; with it, it reads the second + # again and then the rest. + workflow_id = f"streams-redis-resume-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[ResumeAfter], + plugins=[provider], + max_cached_workflows=100, + ): + handle = await live_client.start_workflow( + ResumeAfter.run, None, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + stream = provider.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="model", attempt=1) + await producer.append({"n": 1}, {"n": 2}, {"n": 3}) + await producer.finish() + assert await handle.result() == [2, 3] + # The completed run is evicted, so the query replays it cold with the + # seeded position and the recorded ranges. + assert await handle.query(ResumeAfter.seen) == [2, 3] + + # Both runs replay offline against the store, the seeded one included. + replayer = Replayer(workflows=[ResumeAfter], plugins=[provider]) + await replayer.replay_workflow( + await live_client.get_workflow_handle( + workflow_id, run_id=handle.first_execution_run_id + ).fetch_history() + ) + await replayer.replay_workflow(await handle.fetch_history()) From 84c77d4faa185bc4d0e3d67170f1856b51bcaaae Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 25 Sep 2026 08:15:51 -0700 Subject: [PATCH 73/96] Trimmed the Redis streams by retention on every append. RedisStreams(retention=, max_len=) trims a topic's input and output keys behind every append the provider makes, exactly, because the approximate trim never fires on a stream shorter than a macro node. A backend that can tell a recorded range is gone now reports the loss itself rather than having it filed under a transient storage failure. --- .../external_workflow_streams/_replay.py | 4 +- temporalio/streams/providers/redis.py | 167 ++++++++++++++++- tests/streams/test_redis_provider.py | 13 ++ tests/streams/test_redis_replay.py | 169 +++++++++++++++++- 4 files changed, 339 insertions(+), 14 deletions(-) diff --git a/temporalio/contrib/external_workflow_streams/_replay.py b/temporalio/contrib/external_workflow_streams/_replay.py index f136c9cbb..ac293c172 100644 --- a/temporalio/contrib/external_workflow_streams/_replay.py +++ b/temporalio/contrib/external_workflow_streams/_replay.py @@ -252,7 +252,9 @@ async def _read_range( """ try: return await backend.read_range(key, run.first_offset, run.last_offset) - except StreamStorageError: + except (StreamStorageError, StreamIntegrityError): + # A backend that can tell the range is gone reports the loss itself; + # wrapping it would file a permanent loss under a transient failure. raise except Exception as err: # Not integrity loss: nothing has been shown to be missing, only diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index c6972cc84..f67c11b6e 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -34,6 +34,11 @@ batch limits are lifted for this provider, because a synchronous publish cannot wait for the worker to stage a full batch; a batch it cannot stage fails the task. +- Retention is trimming, with no consumer floor. When ``retention`` or + ``max_len`` is set, every append the provider makes trims the key it wrote, + whatever any reader has reached. A replay that reaches a recorded range the + trim removed fails its Workflow Task, an outside cursor below the trim is + refused, and a fully trimmed topic reads as empty. """ from __future__ import annotations @@ -42,7 +47,7 @@ import logging import re import time -from collections.abc import AsyncGenerator, Coroutine +from collections.abc import AsyncGenerator, Coroutine, Sequence from datetime import timedelta from typing import Any, Generic, TypeVar @@ -74,11 +79,21 @@ from temporalio.contrib.external_workflow_streams import ( StreamError as TransportStreamError, ) -from temporalio.contrib.external_workflow_streams._backend import DEFAULT_WATCH_BLOCK +from temporalio.contrib.external_workflow_streams._backend import ( + DEFAULT_WATCH_BLOCK, + StreamKey, +) from temporalio.contrib.external_workflow_streams._codec import StreamPayloadCodec +from temporalio.contrib.external_workflow_streams._errors import StreamIntegrityError +from temporalio.contrib.external_workflow_streams._output_backend import ( + OutputStage, + OutputStageManifest, + StagedOutputRecord, +) from temporalio.contrib.external_workflow_streams._output_client import ( _reconcile_output_stage, ) +from temporalio.contrib.external_workflow_streams._redis import RedisStreamBackend from temporalio.converter import WorkflowSerializationContext from temporalio.service import RPCError, RPCStatusCode from temporalio.streams._errors import ( @@ -162,6 +177,101 @@ def _workflow_position(after: Cursor) -> Offset | None: ) +def _entry_id(token: str | bytes) -> tuple[int, int]: + """A Redis entry id as the ``(ms, seq)`` pair it orders by.""" + text = token.decode() if isinstance(token, bytes) else token + ms, _, seq = text.partition("-") + return int(ms), int(seq or 0) + + +class _RetainingBackend(RedisStreamBackend): + """The transport's Redis backend, trimming the key behind every append it makes. + + Trims are exact rather than approximate: Redis's approximate trim drops + whole macro nodes only, so a stream shorter than one node, a hundred + entries by default, would never trim and the window would not mean what + it says. Only the streams are trimmed; the idempotency and stage hashes + beside them keep one entry per record and stage. + """ + + def __init__( + self, + *, + client: Any, + key_prefix: str, + retention: timedelta | None, + max_len: int | None, + ) -> None: + super().__init__(client=client, key_prefix=key_prefix) + self._retention = retention + self._max_len = max_len + + def describe_window(self) -> str: + """The configured window, for messages.""" + parts = [] + if self._retention is not None: + parts.append(f"retention={self._retention}") + if self._max_len is not None: + parts.append(f"max_len={self._max_len}") + return ", ".join(parts) or "no retention" + + async def append(self, key: StreamKey, record: Any) -> Any: + placed = await super().append(key, record) + await self._trim(key) + return placed + + async def stage_output( + self, manifest: OutputStageManifest, records: Sequence[StagedOutputRecord] + ) -> OutputStage: + stage = await super().stage_output(manifest, records) + await self._trim(manifest.stream_key) + return stage + + async def read_range(self, key: StreamKey, first: Offset, last: Offset) -> Any: + # The replay read. Said here, where the trim is known, rather than left + # to the range checks, which can only report the record as missing. + if not await self.retains(key, first): + raise StreamIntegrityError( + f"the recorded range [{first}, {last}] on topic " + f"{key.stream_name!r} is past the redis provider's retention " + f"({self.describe_window()}): the records were trimmed, so this " + "run cannot be replayed" + ) + return await super().read_range(key, first, last) + + async def retains(self, key: StreamKey, offset: Offset) -> bool: + """Whether the record at ``offset`` survived trimming. + + A record at or after the first retained entry is there. On an emptied + stream the last id Redis generated says whether the record ever was. + """ + from redis.exceptions import ResponseError + + try: + info = await self._client.xinfo_stream(self.stream_key(key)) + except ResponseError as error: + if "no such key" in str(error).lower(): + return True + raise + wanted = _entry_id(offset.token) + first = info.get("first-entry") + if first: + return _entry_id(first[0]) <= wanted + return wanted > _entry_id(info["last-generated-id"]) + + async def _trim(self, key: StreamKey) -> None: + name = self.stream_key(key) + if self._retention is not None: + # The worker's clock names the floor, so a skewed worker shifts + # the window by its skew. + floor = int((time.time() - self._retention.total_seconds()) * 1000) + await self._client.xtrim( + name, minid=f"{max(floor, 0)}-0", approximate=False + ) + if self._max_len is not None: + await self._client.xtrim(name, maxlen=self._max_len, approximate=False) + + def _drive(coroutine: Coroutine[Any, Any, None]) -> None: """Run a transport publish to completion without yielding to the loop. @@ -504,6 +614,17 @@ async def _read( backend = self._streams._require_backend() chain = await _chain(self._client, self._workflow_id) key = chain.stream_key(topic, direction=StreamDirection.OUTPUT) + if ( + position is not None + and isinstance(backend, _RetainingBackend) + and not await backend.retains(key, position) + ): + # Refused rather than resumed from the first retained record, + # which would skip whatever the trim took in between. + raise StreamCursorError( + f"cursor {after.token!r} names a record on {topic!r} that the " + f"provider's retention has trimmed ({backend.describe_window()})" + ) cursor = TRANSPORT_BEGINNING if position is None else AFTER(position) closed = False while True: @@ -616,6 +737,8 @@ def __init__( idle_timeout: timedelta = timedelta(seconds=1), backend: Any | None = None, poll_interval: timedelta = timedelta(milliseconds=500), + retention: timedelta | None = None, + max_len: int | None = None, ) -> None: """Create the provider. @@ -625,25 +748,48 @@ def __init__( deployments. idle_timeout: How long a workflow reader with nothing to read holds its Workflow Task open before the worker parks it. - backend: A transport backend the caller constructed and owns. + backend: A transport backend the caller constructed and owns. It + is trimmed by its owner, so it takes neither ``retention`` nor + ``max_len``. poll_interval: How long an outside reader that is caught up waits for a record before asking whether the workflow closed. + retention: Trim records older than this from a topic's input and + output keys on every append the provider makes to them. This + is retention without a consumer floor: nothing holds a record + for a reader that has not reached it. A workflow whose replay + reaches a recorded range past the window fails its Workflow + Task with the transport's ``StreamIntegrityError`` until the + window is raised, an outside ``read(after=)`` below the window + raises ``StreamCursorError``, and a live reader that falls + behind the window misses records. The floor the server-side + provider keeps would need a consumer registry in Redis. + max_len: Keep at most this many entries per key, trimmed on the + same appends and with the same consequences. It must exceed + the largest batch a task publishes, or a stage is trimmed + before its commit. """ + if retention is not None and retention <= timedelta(0): + raise ValueError("retention must be positive") + if max_len is not None and max_len < 1: + raise ValueError("max_len must be positive") + if backend is not None and (retention is not None or max_len is not None): + raise ValueError( + "retention and max_len trim the backend this provider opens; a " + "backend handed in is trimmed by its owner" + ) self._url = url self._key_prefix = key_prefix self._idle_timeout = idle_timeout self._backend = backend self._owned_client: Any = None self._poll = poll_interval + self._retention = retention + self._max_len = max_len def _require_backend(self) -> Any: if self._backend is None: import redis.asyncio - from temporalio.contrib.external_workflow_streams._redis import ( - RedisStreamBackend, - ) - # A dead peer would otherwise hold a blocking read open forever. # Several block periods, so a healthy socket that is merely idle # inside one read window is never abandoned. @@ -652,8 +798,11 @@ def _require_backend(self) -> Any: decode_responses=False, socket_timeout=DEFAULT_WATCH_BLOCK.total_seconds() * 6, ) - self._backend = RedisStreamBackend( - client=self._owned_client, key_prefix=self._key_prefix + self._backend = _RetainingBackend( + client=self._owned_client, + key_prefix=self._key_prefix, + retention=self._retention, + max_len=self._max_len, ) return self._backend diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py index d4e215882..ac4919c81 100644 --- a/tests/streams/test_redis_provider.py +++ b/tests/streams/test_redis_provider.py @@ -3,11 +3,13 @@ from __future__ import annotations import asyncio +from datetime import timedelta import pytest from temporalio.streams import BEGINNING, Cursor, StreamCursorError, StreamError from temporalio.streams.providers.redis import ( + RedisStreams, _drive, _outside_position, _workflow_position, @@ -61,3 +63,14 @@ def test_workflow_cursors_name_input_positions(): _workflow_position(Cursor("memory:3")) with pytest.raises(StreamCursorError): _workflow_position(Cursor("redis:in:not-an-id")) + + +def test_retention_options_are_checked_at_construction(): + with pytest.raises(ValueError, match="retention"): + RedisStreams(retention=timedelta(0)) + with pytest.raises(ValueError, match="max_len"): + RedisStreams(max_len=0) + # A backend the caller owns is the caller's to trim. + with pytest.raises(ValueError, match="trimmed by its owner"): + RedisStreams(backend=object(), max_len=10) + RedisStreams(retention=timedelta(hours=1), max_len=10) diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 4871e1cfa..167b0e310 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -3,7 +3,8 @@ The conformance suite covers the outside surface when ``STREAMS_LIVE=redis``. This module runs the interface loop inside a workflow over the staged commit, lets a read end with the workflow, shares a topic between an outside producer -and the workflow, queries a completed run, which replays it, and seeds a +and the workflow, queries a completed run, which replays it, trims by +retention and shows what a replay and a read past the trim do, and seeds a workflow reader from a cursor. All need a dev server (``TEMPORAL_ADDRESS``) and a Redis (``TEMPORAL_TEST_REDIS_URL`` or ``AI198_REDIS_URL``). The worker keeps a warm cache because the transport holds the task open between records. @@ -15,16 +16,18 @@ import os import uuid from collections.abc import AsyncIterator +from datetime import timedelta from typing import Any import pytest from temporalio import workflow from temporalio.client import Client -from temporalio.streams import BEGINNING, Cursor, RecordKind -from temporalio.streams.providers.redis import RedisStreams +from temporalio.contrib.external_workflow_streams import StreamDirection +from temporalio.streams import BEGINNING, Cursor, RecordKind, StreamCursorError +from temporalio.streams.providers.redis import RedisStreams, _chain from temporalio.worker import Replayer, Worker -from tests.streams.test_streams_conformance import take +from tests.streams.test_streams_conformance import StreamHost, take from tests.streams.test_streams_workflow import ( DECISIONS, INPUTS, @@ -205,6 +208,164 @@ async def test_query_after_completion_replays_the_final_task( assert values == [{"n": 0}, {"n": 1}, {"n": 2}, {"n": 3}] +async def _key_lengths( + streams: RedisStreams, client: Client, workflow_id: str, topic: str +) -> tuple[int, int]: + """How many entries the topic's input and output keys hold.""" + import redis.asyncio + + backend = streams._require_backend() + chain = await _chain(client, workflow_id) + store = redis.asyncio.from_url(redis_url()) + try: + return ( + await store.xlen( + backend.stream_key( + chain.stream_key(topic, direction=StreamDirection.INPUT) + ) + ), + await store.xlen( + backend.stream_key( + chain.stream_key(topic, direction=StreamDirection.OUTPUT) + ) + ), + ) + finally: + await store.aclose() + + +async def _trim_everything( + streams: RedisStreams, client: Client, workflow_id: str, topic: str +) -> None: + """What retention elsewhere, or an operator, does to a topic's output key.""" + import redis.asyncio + + backend = streams._require_backend() + chain = await _chain(client, workflow_id) + store = redis.asyncio.from_url(redis_url()) + try: + await store.xtrim( + backend.stream_key( + chain.stream_key(topic, direction=StreamDirection.OUTPUT) + ), + maxlen=0, + approximate=False, + ) + finally: + await store.aclose() + + +async def test_a_replay_past_the_retention_window_fails_loudly(live_client: Client): + # Six entries per key: the loop's four input records stay while it runs, + # and six more appends afterwards push them out. + streams = RedisStreams( + url=redis_url(), key_prefix=f"streams-redis-{uuid.uuid4().hex}", max_len=6 + ) + workflow_id = f"streams-redis-retention-{uuid.uuid4().hex}" + try: + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[ContractLoop], + plugins=[streams], + max_cached_workflows=100, + ): + handle = await live_client.start_workflow( + ContractLoop.run, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + stream = streams.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="model", attempt=1) + await producer.append({"n": 1}, {"n": 2}, {"n": 3}) + await producer.finish() + assert len(await handle.result()) == 4 + before = await take(stream.read(topic=INPUTS), 4, 60) + history = await handle.fetch_history() + assert await _key_lengths(streams, live_client, workflow_id, INPUTS.name) == ( + 4, + 4, + ) + + # Inside the window the recorded ranges read back and the replay passes. + await Replayer(workflows=[ContractLoop], plugins=[streams]).replay_workflow( + history + ) + + late = stream.producer(topic=INPUTS, producer_id="late", attempt=1) + cursors = [await late.append({"n": n}) for n in range(10, 16)] + assert await _key_lengths(streams, live_client, workflow_id, INPUTS.name) == ( + 6, + 6, + ) + + # The recorded input ranges are gone, and the replay says so rather + # than delivering fewer records. + with pytest.raises(Exception) as failure: + await Replayer(workflows=[ContractLoop], plugins=[streams]).replay_workflow( + history + ) + # The task fails under the transport's integrity row: its type, the + # external storage cause, and a message that names the window. + message = str(failure.value) + assert "StreamIntegrityError" in message and "ExternalStorageFailure" in message + assert "past the redis provider's retention (max_len=6)" in message + + # An outside cursor below the trim is refused, not resumed from the + # first retained record. + with pytest.raises(StreamCursorError, match="retention has trimmed"): + await take(stream.read(topic=INPUTS, after=before[0].cursor), 1, 10) + + # Inside the window a read still works and lands where append said. + records = [r async for r in stream.read(topic=INPUTS, after=cursors[1])] + assert [r.value for r in records] == [{"n": n} for n in range(12, 16)] + assert [r.cursor for r in records] == cursors[2:] + finally: + await streams.close() + + +async def test_retention_by_age_trims_older_entries(live_client: Client): + streams = RedisStreams( + url=redis_url(), + key_prefix=f"streams-redis-{uuid.uuid4().hex}", + retention=timedelta(milliseconds=300), + ) + workflow_id = f"streams-redis-age-{uuid.uuid4().hex}" + try: + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[StreamHost], + plugins=[streams], + ): + handle = await live_client.start_workflow( + StreamHost.run, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + stream = streams.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="model", attempt=1) + first = await producer.append({"n": 1}) + await producer.append({"n": 2}) + await asyncio.sleep(0.6) + # The append that crosses the window is what trims the two before it. + third = await producer.append({"n": 3}) + assert await _key_lengths( + streams, live_client, workflow_id, INPUTS.name + ) == ( + 1, + 1, + ) + assert await stream.latest(topic=INPUTS) == third + with pytest.raises(StreamCursorError, match="retention has trimmed"): + await take(stream.read(topic=INPUTS, after=first), 1, 10) + + # A fully trimmed topic answers the way an empty one does. + await _trim_everything(streams, live_client, workflow_id, INPUTS.name) + assert await stream.latest(topic=INPUTS) == BEGINNING + await handle.signal(StreamHost.release) + await handle.result() + assert [r async for r in stream.read(topic=INPUTS)] == [] + finally: + await streams.close() + + @workflow.defn class ResumeAfter: """Reads ``inputs``; the first run hands its first record's cursor to the next.""" From bb2d7ca59e89aeedb3513bba950c8cd0bae64192 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 25 Sep 2026 14:57:45 -0700 Subject: [PATCH 74/96] Repaired the Redis provider's writes, refusals and error family. A producer's record now lands on both of a topic's keys or on neither, a batch the retention window cannot hold is refused where it is staged, and a run that continued as new still counts as a consumer. The transport's stage and integrity types no longer reach the public surface. --- temporalio/streams/providers/redis.py | 266 +++++++++++++++++--- tests/streams/test_redis_replay.py | 340 +++++++++++++++++++++++++- 2 files changed, 576 insertions(+), 30 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index f67c11b6e..69ac99a03 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -15,7 +15,10 @@ output stream, where the workflow's own promoted batches land, so an outside reader sees the producer's records and the workflow's in one order and a workflow reading the topic misses nothing however early the producer - started. The cost is one more append per record and one wake per batch. + started. Both keys are written by one script, so a record is on both or on + neither: split across two calls, a crash between them leaves a record the + workflow consumes and no outside reader can ever see. The cost is one more + append per record and one wake per batch. - A record rides as the transport's payload: the serialized ``StreamRecord`` proto as a ``binary/plain`` value, which the worker's codec encodes and decodes like any other payload. @@ -49,7 +52,7 @@ import time from collections.abc import AsyncGenerator, Coroutine, Sequence from datetime import timedelta -from typing import Any, Generic, TypeVar +from typing import Any, Final, Generic, TypeVar from google.protobuf.message import DecodeError @@ -79,6 +82,9 @@ from temporalio.contrib.external_workflow_streams import ( StreamError as TransportStreamError, ) +from temporalio.contrib.external_workflow_streams import ( + StreamRecord as TransportRecord, +) from temporalio.contrib.external_workflow_streams._backend import ( DEFAULT_WATCH_BLOCK, StreamKey, @@ -87,13 +93,20 @@ from temporalio.contrib.external_workflow_streams._errors import StreamIntegrityError from temporalio.contrib.external_workflow_streams._output_backend import ( OutputStage, + OutputStageConflictError, OutputStageManifest, + OutputStageNotFoundError, + OutputStageResolutionError, StagedOutputRecord, ) from temporalio.contrib.external_workflow_streams._output_client import ( _reconcile_output_stage, ) -from temporalio.contrib.external_workflow_streams._redis import RedisStreamBackend +from temporalio.contrib.external_workflow_streams._redis import ( + RedisStreamBackend, + _content_hash, + _text, +) from temporalio.converter import WorkflowSerializationContext from temporalio.service import RPCError, RPCStatusCode from temporalio.streams._errors import ( @@ -184,6 +197,115 @@ def _entry_id(token: str | bytes) -> tuple[int, int]: return int(ms), int(seq or 0) +#: Append one record to a topic's two keys, or to neither. +#: +#: The provider's own write, not the transport's: the transport appends one key at a +#: time, and the two calls that would take are not one failure. A record on the input +#: key alone is one the workflow consumes and no outside reader can ever see, and a +#: producer that comes back with a fresh sequence never heals it. +#: +#: Both idempotency hashes are read before either stream is touched, so a key already +#: used with different bytes refuses the pair rather than half-writing it. Either half +#: already present is reused, which is what settles a pair some earlier call left +#: half-written. The retention trims ride along rather than costing their own round +#: trips, and they are exact for the reason the backend's own trims are. +_PAIRED_APPEND_LUA: Final = """ +local minid = ARGV[1] +local maxlen = ARGV[2] +local function placed(idem, key, digest) + local existing = redis.call('HGET', idem, key) + if not existing then + return nil + end + local sep = string.find(existing, '|') + if string.sub(existing, sep + 1) ~= digest then + return 'conflict' + end + return string.sub(existing, 1, sep - 1) +end +local input = placed(KEYS[2], ARGV[3], ARGV[4]) +local output = placed(KEYS[4], ARGV[3], ARGV[4]) +if input == 'conflict' or output == 'conflict' then + return {'conflict', '', ''} +end +local fields = {unpack(ARGV, 5)} +if not input then + input = redis.call('XADD', KEYS[1], '*', unpack(fields)) + redis.call('HSET', KEYS[2], ARGV[3], input .. '|' .. ARGV[4]) +end +if not output then + output = redis.call('XADD', KEYS[3], '*', unpack(fields)) + redis.call('HSET', KEYS[4], ARGV[3], output .. '|' .. ARGV[4]) +end +if minid ~= '' then + redis.call('XTRIM', KEYS[1], 'MINID', minid) + redis.call('XTRIM', KEYS[3], 'MINID', minid) +end +if maxlen ~= '' then + redis.call('XTRIM', KEYS[1], 'MAXLEN', maxlen) + redis.call('XTRIM', KEYS[3], 'MAXLEN', maxlen) +end +return {'ok', input, output} +""" + + +class _PairedAppend: + """One logical record on a topic's input and output keys, in one Redis call.""" + + def __init__(self, backend: RedisStreamBackend) -> None: + """Bind to ``backend``'s client and key layout.""" + self._backend = backend + self._script = backend._client.register_script(_PAIRED_APPEND_LUA) + + async def write( + self, + *, + input_key: StreamKey, + output_key: StreamKey, + record: TransportRecord, + ) -> Offset: + """Append ``record`` to both keys and return where it landed on the output key. + + A repeat of the same ``(session, sequence)`` with the same bytes returns the + original positions and writes nothing, so a call whose answer was lost is + settled by making it again. + """ + args: list[Any] = [ + self._minid().encode(), + self._maxlen().encode(), + str(record.idempotency_key).encode(), + _content_hash(record).encode(), + ] + for name, value in sorted(record.to_fields().items()): + args.append(name.encode()) + args.append(value) + outcome, _, output = await self._script( + keys=[ + self._backend.stream_key(input_key), + self._backend._idempotency_key(input_key), + self._backend.stream_key(output_key), + self._backend._idempotency_key(output_key), + ], + args=args, + ) + if _text(outcome) == "conflict": + raise AppendConflictError(record.idempotency_key) + return Offset(_text(output)) + + def _minid(self) -> str: + retention = getattr(self._backend, "_retention", None) + if retention is None: + return "" + # The worker's clock names the floor, so a skewed worker shifts the window by + # its skew, the same as the backend's own trim. + floor = int((time.time() - retention.total_seconds()) * 1000) + return f"{max(floor, 0)}-0" + + def _maxlen(self) -> str: + max_len = getattr(self._backend, "_max_len", None) + return "" if max_len is None else str(max_len) + + class _RetainingBackend(RedisStreamBackend): """The transport's Redis backend, trimming the key behind every append it makes. @@ -223,6 +345,18 @@ async def append(self, key: StreamKey, record: Any) -> Any: async def stage_output( self, manifest: OutputStageManifest, records: Sequence[StagedOutputRecord] ) -> OutputStage: + # A stage is invisible until its task commits, and the trim has no consumer + # floor to hold it: a window at or below the batch takes entries out of the + # stage that was just written, and the commit then fails on a missing record + # for as long as the task retries. Flooring the trim instead would need the + # floor this provider deliberately does not keep, and would not hold anyway, + # because the trim that removes the stage is not the one that staged it. + if self._max_len is not None and manifest.record_count >= self._max_len: + raise ValueError( + f"this task publishes {manifest.record_count} records and max_len is " + f"{self._max_len}: the window has to exceed the largest batch a task " + "publishes, or the batch is trimmed before it commits" + ) stage = await super().stage_output(manifest, records) await self._trim(manifest.stream_key) return stage @@ -245,14 +379,11 @@ async def retains(self, key: StreamKey, offset: Offset) -> bool: A record at or after the first retained entry is there. On an emptied stream the last id Redis generated says whether the record ever was. """ - from redis.exceptions import ResponseError - - try: - info = await self._client.xinfo_stream(self.stream_key(key)) - except ResponseError as error: - if "no such key" in str(error).lower(): - return True - raise + name = self.stream_key(key) + if not await self._client.exists(name): + # Nothing was ever written under this key, so nothing was trimmed from it. + return True + info = await self._client.xinfo_stream(name) wanted = _entry_id(offset.token) first = info.get("first-entry") if first: @@ -376,10 +507,24 @@ async def _chain(client: Client, workflow_id: str) -> WorkflowChainKey: ) +#: A chain still has a consumer while a run is in either of these: a run that +#: continued as new hands the stream to its successor rather than ending it. +_STILL_CONSUMING: Final = ( + WorkflowExecutionStatus.RUNNING, + WorkflowExecutionStatus.CONTINUED_AS_NEW, +) + + def _storage_error(error: Exception, what: str) -> StreamError: return StreamError(f"{what}: {error}") +def _integrity_error(error: Exception, what: str) -> StreamError: + # Named as a loss rather than a transient read failure, because no retry brings + # a trimmed record back and the caller's next move is different. + return StreamNotFoundError(f"{what}: {error}") + + class RedisProducer(Generic[T]): """Appends to a topic from outside workflow code. @@ -410,6 +555,8 @@ def __init__( self._last = BEGINNING self._input: Any = None self._output: Any = None + self._pair: _PairedAppend | None = None + self._codec: StreamPayloadCodec[bytes] | None = None @property def producer_id(self) -> str: @@ -501,16 +648,36 @@ async def _connect(self) -> None: ) from error self._output = output.topic(self._topic, type=bytes) self._input = input_.topic(self._topic, type=bytes) + self._pair = _PairedAppend(backend) + self._codec = StreamPayloadCodec( + self._client.data_converter.with_context( + WorkflowSerializationContext( + namespace=chain.namespace, workflow_id=chain.workflow_id + ) + ), + bytes, + ) async def _write(self, records: list[WireRecord]) -> Cursor: await self._connect() + assert self._pair is not None and self._codec is not None last: Offset | None = None try: - for record in records: - frame = record.SerializeToString() - await self._input.publish(frame, wake=False) - placed = await self._output.publish(frame) - last = placed.offset + for index, record in enumerate(records): + # Built here rather than handed to the transport's publish, which + # appends one key per call. The identity is the same one the transport + # would derive, so a record either side already holds is reused. + staged = TransportRecord( + kind=TransportRecordKind.DATA, + payload=await self._codec.encode(record.SerializeToString()), + producer_session_id=self._session, + sequence=self._sequence + index, + ) + last = await self._pair.write( + input_key=self._input.stream_key, + output_key=self._output.stream_key, + record=staged, + ) await self._wake() except AppendConflictError as error: raise StreamProducerError( @@ -544,7 +711,8 @@ async def _consumer_has_gone(self) -> bool: Asked for a few seconds rather than once: the server refuses the wake while the execution is closing, and at that moment its status is - still the running one. + still the running one. A run that continued as new has not gone: the + streams are keyed by the chain, so its successor is the consumer now. """ handle = self._client.get_workflow_handle(self._workflow_id) deadline = time.monotonic() + 5 @@ -555,7 +723,7 @@ async def _consumer_has_gone(self) -> bool: if error.status == RPCStatusCode.NOT_FOUND: return True raise - if status is not None and status != WorkflowExecutionStatus.RUNNING: + if status is not None and status not in _STILL_CONSUMING: return True if time.monotonic() >= deadline: return False @@ -594,7 +762,19 @@ def read( after: Cursor = BEGINNING, result_type: type | None = None, ) -> AsyncGenerator[StreamRecord[Any], None]: - """Yield records on ``topic`` after ``after`` until the chain, or the pinned run, closes.""" + """Yield records on ``topic`` after ``after`` until the chain, or the pinned run, closes. + + Two refusals and they do not land together. A cursor another provider minted, + or one naming the workflow's own input log, is refused by this call: reading + the token needs nothing from the store. A well-formed cursor the retention has + trimmed is refused on the first step of the generator, because answering that + needs a round trip and this call is not a coroutine. Neither yields a record + first. + + Raises: + StreamCursorError: The cursor is another provider's, or names the + workflow's input log. + """ topic, result_type = resolve_topic(topic, result_type) # Parsed here so a foreign cursor fails this call, not the first # iteration of the generator. @@ -635,6 +815,11 @@ async def _read( max_records=_READ_BATCH, block=self._streams._poll, ) + except StreamIntegrityError as error: + # Integrity loss is permanent, and the taxonomy the transport builds + # on it is the difference between a retry that clears and one that + # never will. Filed as a store read, an operator retries forever. + raise _integrity_error(error, "the store lost records") from error except TransportStreamError as error: raise _storage_error(error, "the store could not be read") from error for placed in result.records: @@ -653,12 +838,31 @@ async def _read( # A staged batch whose task History has not settled yet is a # barrier: nothing past it is read until History says whether # the task was accepted. - resolved = await _reconcile_output_stage( - backend=backend, - client=self._client, - workflow_id=self._workflow_id, - manifest=result.pending.manifest, - ) + try: + resolved = await _reconcile_output_stage( + backend=backend, + client=self._client, + workflow_id=self._workflow_id, + manifest=result.pending.manifest, + ) + except StreamIntegrityError as error: + raise _integrity_error( + error, "a staged batch lost records" + ) from error + except ( + OutputStageConflictError, + OutputStageNotFoundError, + OutputStageResolutionError, + ) as error: + # The transport's own stage vocabulary does not cross the public + # surface: to a reader this is a batch that cannot be settled. + raise _storage_error( + error, "a staged batch could not be settled" + ) from error + except TransportStreamError as error: + raise _storage_error( + error, "a staged batch could not be settled" + ) from error if not resolved: await asyncio.sleep(self._streams._poll.total_seconds()) continue @@ -685,12 +889,16 @@ async def _closed(self) -> bool: return False # The streams are keyed by the chain, so a run that continued as new # is not the end unless the handle was pinned to it. - return not ( - self._run_id is None and status == WorkflowExecutionStatus.CONTINUED_AS_NEW - ) + return not (self._run_id is None and status in _STILL_CONSUMING) async def latest(self, *, topic: str | StreamTopic[Any]) -> Cursor: - """The cursor of the newest committed record on ``topic``, for following from now.""" + """The cursor of the newest committed record on ``topic``, for following from now. + + ``BEGINNING`` when the topic holds no committed record, which a topic whose + records retention has all trimmed answers too: the two are the same state to + a reader, and a read from it starts at the first record retained after it + rather than at the tail the caller asked to follow from. + """ topic, _ = resolve_topic(topic) backend = self._streams._require_backend() chain = await _chain(self._client, self._workflow_id) diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 167b0e310..a2b716756 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -23,8 +23,25 @@ from temporalio import workflow from temporalio.client import Client +from temporalio.contrib.external_workflow_streams import ( + RecordKind as TransportRecordKind, +) from temporalio.contrib.external_workflow_streams import StreamDirection -from temporalio.streams import BEGINNING, Cursor, RecordKind, StreamCursorError +from temporalio.contrib.external_workflow_streams._codec import StreamPayloadCodec +from temporalio.contrib.external_workflow_streams._output_backend import ( + OutputStageManifest, + OutputStageStatus, + StagedOutputRecord, +) +from temporalio.converter import WorkflowSerializationContext +from temporalio.streams import ( + BEGINNING, + Cursor, + RecordKind, + StreamCursorError, + StreamProducerError, +) +from temporalio.streams._wire import to_wire from temporalio.streams.providers.redis import RedisStreams, _chain from temporalio.worker import Replayer, Worker from tests.streams.test_streams_conformance import StreamHost, take @@ -429,3 +446,324 @@ async def test_a_workflow_reader_started_from_a_cursor_skips_the_earlier_records ).fetch_history() ) await replayer.replay_workflow(await handle.fetch_history()) + + +async def _stage_one( + streams: RedisStreams, + client: Client, + workflow_id: str, + run_id: str, + topic: str, + values: list[Any], +) -> tuple[Any, Any]: + """Stage ``values`` on ``topic``'s output key without committing them. + + Staged the way the worker stages a task's publishes, so the records read back as + records rather than as bytes the reader has to skip. + """ + backend = streams._require_backend() + chain = await _chain(client, workflow_id) + key = chain.stream_key(topic, direction=StreamDirection.OUTPUT) + codec: StreamPayloadCodec[bytes] = StreamPayloadCodec( + client.data_converter.with_context( + WorkflowSerializationContext( + namespace=client.namespace, workflow_id=workflow_id + ) + ), + bytes, + ) + records = [] + for index, value in enumerate(values): + wire = to_wire( + client.data_converter.payload_converter, + topic=topic, + kind=RecordKind.DATA, + value=value, + ) + records.append( + StagedOutputRecord( + kind=TransportRecordKind.DATA, + payload=await codec.encode(wire.SerializeToString()), + publish_index=index, + ) + ) + manifest = OutputStageManifest( + stream_key=key, + provider_id=backend.provider_id, + provider_format_version=backend.provider_format_version, + stage_token=uuid.uuid4().hex, + run_id=run_id, + history_floor_event_id=3, + sub_batch_id=0, + fingerprint_version=1, + fingerprint=b"\x01" * 32, + record_count=len(records), + logical_byte_count=sum(len(r.payload) for r in records), + ) + await backend.stage_output(manifest, records) + return backend, manifest + + +async def test_a_pending_stage_is_settled_by_the_reader_rather_than_wedging_it( + live_client: Client, provider: RedisStreams +): + # The commit protocol's own failure modes, which nothing else here reaches: a + # stage whose task never landed in History is a barrier until the reader + # reconciles it, the reconcile aborts it, and the records it held are never + # handed over while everything behind it flows. + workflow_id = f"streams-redis-stage-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[StreamHost], + plugins=[provider], + ) as worker: + handle = await live_client.start_workflow( + StreamHost.run, id=workflow_id, task_queue=worker.task_queue + ) + stream = provider.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="model", attempt=1) + await producer.append({"n": 1}) + + backend, manifest = await _stage_one( + provider, + live_client, + workflow_id, + handle.first_execution_run_id or "", + INPUTS.name, + [{"n": 10}, {"n": 11}], + ) + assert ( + await backend.output_stage(manifest) + ).status is OutputStageStatus.PENDING + + # Behind the stage so the read has to get past it to reach this. + await producer.append({"n": 2}) + + assert [r.value for r in await take(stream.read(topic=INPUTS), 2, 30)] == [ + {"n": 1}, + {"n": 2}, + ] + # The task never reached History, so the reader settled the stage as + # aborted and its records are gone rather than pending forever. + assert ( + await backend.output_stage(manifest) + ).status is OutputStageStatus.ABORTED + + await handle.signal(StreamHost.release) + await handle.result() + + +async def test_an_aborted_stage_yields_nothing_and_stops_being_a_barrier( + live_client: Client, provider: RedisStreams +): + workflow_id = f"streams-redis-abort-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[StreamHost], + plugins=[provider], + ) as worker: + handle = await live_client.start_workflow( + StreamHost.run, id=workflow_id, task_queue=worker.task_queue + ) + stream = provider.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="model", attempt=1) + await producer.append({"n": 1}) + backend, manifest = await _stage_one( + provider, + live_client, + workflow_id, + handle.first_execution_run_id or "", + INPUTS.name, + [{"n": 10}], + ) + await backend.abort_output(manifest) + await producer.append({"n": 2}) + + await handle.signal(StreamHost.release) + await handle.result() + assert [r.value async for r in stream.read(topic=INPUTS)] == [ + {"n": 1}, + {"n": 2}, + ] + + +async def test_a_committed_stage_is_released_in_the_order_it_was_staged( + live_client: Client, provider: RedisStreams +): + workflow_id = f"streams-redis-commit-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[StreamHost], + plugins=[provider], + ) as worker: + handle = await live_client.start_workflow( + StreamHost.run, id=workflow_id, task_queue=worker.task_queue + ) + stream = provider.get_stream_handle(live_client, workflow_id) + backend, manifest = await _stage_one( + provider, + live_client, + workflow_id, + handle.first_execution_run_id or "", + INPUTS.name, + [{"n": 10}, {"n": 11}], + ) + await backend.commit_output(manifest) + + assert await stream.latest(topic=INPUTS) != BEGINNING + await handle.signal(StreamHost.release) + await handle.result() + assert [r.value async for r in stream.read(topic=INPUTS)] == [ + {"n": 10}, + {"n": 11}, + ] + + +async def test_a_batch_the_window_cannot_hold_is_refused_at_the_stage( + live_client: Client, +): + # max_len at or below a task's batch trims the stage before its commit, and + # the commit then fails on a missing record for as long as the task retries. + streams = RedisStreams( + url=redis_url(), key_prefix=f"streams-redis-{uuid.uuid4().hex}", max_len=2 + ) + workflow_id = f"streams-redis-window-{uuid.uuid4().hex}" + try: + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[StreamHost], + plugins=[streams], + ) as worker: + handle = await live_client.start_workflow( + StreamHost.run, id=workflow_id, task_queue=worker.task_queue + ) + run_id = handle.first_execution_run_id or "" + with pytest.raises(ValueError, match="has to exceed the largest batch"): + await _stage_one( + streams, + live_client, + workflow_id, + run_id, + INPUTS.name, + [{"n": 1}, {"n": 2}], + ) + # One below the window still stages. + await _stage_one( + streams, live_client, workflow_id, run_id, INPUTS.name, [{"n": 1}] + ) + await handle.signal(StreamHost.release) + await handle.result() + finally: + await streams.close() + + +async def test_a_producer_record_lands_on_both_keys_or_on_neither( + live_client: Client, provider: RedisStreams +): + # The workflow reads the input key and outside readers read the output key. + # Written one at a time, a crash between them leaves a record the workflow + # acts on that no outside reader can ever see. + workflow_id = f"streams-redis-pair-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[StreamHost], + plugins=[provider], + ) as worker: + handle = await live_client.start_workflow( + StreamHost.run, id=workflow_id, task_queue=worker.task_queue + ) + producer = provider.get_stream_handle(live_client, workflow_id).producer( + topic=INPUTS, producer_id="model", attempt=1 + ) + await producer.append({"n": 1}, {"n": 2}, {"n": 3}) + assert await _key_lengths(provider, live_client, workflow_id, INPUTS.name) == ( + 3, + 3, + ) + + # A producer that comes back with a fresh sequence re-appends the same + # identities, which the script reuses rather than doubling. + again = provider.get_stream_handle(live_client, workflow_id).producer( + topic=INPUTS, producer_id="model", attempt=1 + ) + await again.append({"n": 1}, {"n": 2}, {"n": 3}) + assert await _key_lengths(provider, live_client, workflow_id, INPUTS.name) == ( + 3, + 3, + ) + + await handle.signal(StreamHost.release) + await handle.result() + + +async def test_a_repeat_under_one_identity_with_other_bytes_writes_neither_key( + live_client: Client, provider: RedisStreams +): + workflow_id = f"streams-redis-conflict-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[StreamHost], + plugins=[provider], + ) as worker: + handle = await live_client.start_workflow( + StreamHost.run, id=workflow_id, task_queue=worker.task_queue + ) + stream = provider.get_stream_handle(live_client, workflow_id) + await stream.producer(topic=INPUTS, producer_id="model", attempt=1).append( + {"n": 1} + ) + other = stream.producer(topic=INPUTS, producer_id="model", attempt=1) + with pytest.raises(StreamProducerError, match="sequence 0"): + await other.append({"n": 99}) + # The refusal wrote nothing on either key. + assert await _key_lengths(provider, live_client, workflow_id, INPUTS.name) == ( + 1, + 1, + ) + await handle.signal(StreamHost.release) + await handle.result() + + +async def test_a_refused_output_half_leaves_the_input_key_untouched( + live_client: Client, provider: RedisStreams +): + # The two keys are one write. Written one at a time, the input half lands + # before the output half is refused, and the workflow then consumes a record + # no outside reader can ever see. + workflow_id = f"streams-redis-atomic-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[StreamHost], + plugins=[provider], + ) as worker: + handle = await live_client.start_workflow( + StreamHost.run, id=workflow_id, task_queue=worker.task_queue + ) + backend = provider._require_backend() + chain = await _chain(live_client, workflow_id) + output_key = chain.stream_key(INPUTS.name, direction=StreamDirection.OUTPUT) + # Claim the output half's first identity with other bytes, which is what a + # refusal of that half looks like from the producer's side. + await backend._client.hset( + backend._idempotency_key(output_key), "model#1/0", "1-0|" + "0" * 64 + ) + + producer = provider.get_stream_handle(live_client, workflow_id).producer( + topic=INPUTS, producer_id="model", attempt=1 + ) + with pytest.raises(StreamProducerError, match="sequence 0"): + await producer.append({"n": 1}) + + assert await _key_lengths(provider, live_client, workflow_id, INPUTS.name) == ( + 0, + 0, + ) + await handle.signal(StreamHost.release) + await handle.result() From 17a308e634d4146321ef2e0eea94d6b54bab9f2b Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 25 Sep 2026 14:58:10 -0700 Subject: [PATCH 75/96] Ran the Redis stream tests in CI against a service container. Every case that proves retention, cursor ownership and the staged commit was behind a gate no job set, so the provider had no automated evidence at all. --- .github/workflows/ci.yml | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a53035a7d..f55413c2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,56 @@ jobs: npx doctoc README.md [[ -z $(git status --porcelain README.md) ]] || (git diff README.md; echo "README changed"; exit 1) + # The client-side (Redis) stream provider's own evidence. Its tests need a store + # this repo does not otherwise stand up, so without this job nothing that proves + # retention, cursor ownership, the staged commit or the paired producer write ever + # runs anywhere but a developer's machine. + streams-redis: + timeout-minutes: 30 + runs-on: ubuntu-latest + services: + redis: + image: redis:8-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: temporalio/bridge -> target + key: streams-redis-${{ env.pythonLocation }} + - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3 + with: + version: "23.x" + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8 + - run: uv tool install poethepoet + - run: uv sync --all-extras + - run: poe build-develop + # The dev server comes from the test environment, the store from the service + # above. Run serially: the cases measure real timing and share one Redis. + - run: uv run pytest tests/streams -p no:randomly -s + timeout-minutes: 20 + env: + STREAMS_LIVE: redis + TEMPORAL_TEST_REDIS_URL: redis://127.0.0.1:6379 + AI198_REDIS_URL: redis://127.0.0.1:6379 + # Also without the store, so the gate itself keeps working and the memory + # provider's conformance run stays honest. + - run: uv run pytest tests/streams -p no:randomly -s + timeout-minutes: 10 + # Verify the optional FIPS build: the Rust core must link aws-lc-fips-sys # (aws-lc-rs FIPS mode) and must NOT link `ring` (the cargo-tree guard, ported # from sdk-ruby PR #466's `fips_tree` guard); then run the test suite against the From 5b9fbda2835889820ba0fed4f25b03190cbd72b1 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Tue, 29 Sep 2026 17:07:53 -0700 Subject: [PATCH 76/96] Held an activity's own streams on the Redis provider. An activity's streams are one Redis stream per topic under an owner component the chain keys can never render, written by a single-key form of the paired script so retries dedupe and a new attempt reads as SUPERSEDED. A read ends when the owner is terminal: a standalone activity is described directly, and a workflow's activity through its workflow, whose close ends the read, as does the activity leaving the pending set once its stream exists. --- temporalio/streams/providers/redis.py | 495 ++++++++++++++++++++++---- 1 file changed, 417 insertions(+), 78 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 69ac99a03..09232ed70 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -31,17 +31,35 @@ the two streams number their entries independently, so an outside cursor cannot stand in for one. A reader opened without a cursor starts where the chain's predecessor run committed, which is the transport's own rule. -- Streams are keyed by the chain's first run, so a handle follows continue-as- - new by construction and ``run_id`` only decides whose close ends a read. +- A workflow's streams are keyed by the chain's first run, so a handle + follows continue-as-new by construction and ``run_id`` only decides whose + close ends a read. +- An activity's own streams are one Redis stream per topic, keyed by the + namespace, the workflow id, empty for a standalone activity, and the + activity id. The owner is one key component joined with ``/``, a character + the chain keys percent-encode out of every id, so no chain key and no key + derived from one can name an activity's stream. There is no input stream, + because no workflow reads these, and no staging, because an activity's + append is visible as soon as the store accepts it. The key holds the + activity id and not its run, so a retry writes to the same stream, which a + reader sees as ``SUPERSEDED``, and an id started again continues it. A + read ends when the owner is terminal and the retained tail is delivered: a + standalone activity is described directly, and a workflow's activity + through its workflow, whose close ends the read, as does the activity + leaving the pending set once its stream exists. An activity that never + wrote has no stream, so a read on it waits for the workflow. Nothing gates + an append after that, so a late attempt still lands, and a read that has + ended does not see it. - A task's publishes are staged as one batch. The transport's own per-task batch limits are lifted for this provider, because a synchronous publish cannot wait for the worker to stage a full batch; a batch it cannot stage fails the task. - Retention is trimming, with no consumer floor. When ``retention`` or ``max_len`` is set, every append the provider makes trims the key it wrote, - whatever any reader has reached. A replay that reaches a recorded range the - trim removed fails its Workflow Task, an outside cursor below the trim is - refused, and a fully trimmed topic reads as empty. + an activity's stream included, whatever any reader has reached. A replay + that reaches a recorded range the trim removed fails its Workflow Task, an + outside cursor below the trim is refused, and a fully trimmed topic reads + as empty. """ from __future__ import annotations @@ -51,13 +69,15 @@ import re import time from collections.abc import AsyncGenerator, Coroutine, Sequence +from dataclasses import dataclass from datetime import timedelta from typing import Any, Final, Generic, TypeVar +from urllib.parse import quote from google.protobuf.message import DecodeError from temporalio import workflow -from temporalio.client import Client, WorkflowExecutionStatus +from temporalio.client import ActivityExecutionStatus, Client, WorkflowExecutionStatus from temporalio.contrib.external_workflow_streams import ( AFTER, AppendConflictError, @@ -103,11 +123,17 @@ _reconcile_output_stage, ) from temporalio.contrib.external_workflow_streams._redis import ( + _BEGINNING_SENTINEL, RedisStreamBackend, _content_hash, _text, + _to_record, +) +from temporalio.converter import ( + ActivitySerializationContext, + SerializationContext, + WorkflowSerializationContext, ) -from temporalio.converter import WorkflowSerializationContext from temporalio.service import RPCError, RPCStatusCode from temporalio.streams._errors import ( StreamCursorError, @@ -249,6 +275,35 @@ def _entry_id(token: str | bytes) -> tuple[int, int]: """ +def _trim_floor(backend: Any) -> str: + retention = getattr(backend, "_retention", None) + if retention is None: + return "" + # The worker's clock names the floor, so a skewed worker shifts the window by + # its skew, the same as the backend's own trim. + floor = int((time.time() - retention.total_seconds()) * 1000) + return f"{max(floor, 0)}-0" + + +def _trim_maxlen(backend: Any) -> str: + max_len = getattr(backend, "_max_len", None) + return "" if max_len is None else str(max_len) + + +def _append_args(backend: Any, record: TransportRecord) -> list[Any]: + """What both append scripts take: the trims, the record's identity, its fields.""" + args: list[Any] = [ + _trim_floor(backend).encode(), + _trim_maxlen(backend).encode(), + str(record.idempotency_key).encode(), + _content_hash(record).encode(), + ] + for name, value in sorted(record.to_fields().items()): + args.append(name.encode()) + args.append(value) + return args + + class _PairedAppend: """One logical record on a topic's input and output keys, in one Redis call.""" @@ -270,15 +325,6 @@ async def write( original positions and writes nothing, so a call whose answer was lost is settled by making it again. """ - args: list[Any] = [ - self._minid().encode(), - self._maxlen().encode(), - str(record.idempotency_key).encode(), - _content_hash(record).encode(), - ] - for name, value in sorted(record.to_fields().items()): - args.append(name.encode()) - args.append(value) outcome, _, output = await self._script( keys=[ self._backend.stream_key(input_key), @@ -286,24 +332,136 @@ async def write( self._backend.stream_key(output_key), self._backend._idempotency_key(output_key), ], - args=args, + args=_append_args(self._backend, record), ) if _text(outcome) == "conflict": raise AppendConflictError(record.idempotency_key) return Offset(_text(output)) - def _minid(self) -> str: - retention = getattr(self._backend, "_retention", None) - if retention is None: - return "" - # The worker's clock names the floor, so a skewed worker shifts the window by - # its skew, the same as the backend's own trim. - floor = int((time.time() - retention.total_seconds()) * 1000) - return f"{max(floor, 0)}-0" - def _maxlen(self) -> str: - max_len = getattr(self._backend, "_max_len", None) - return "" if max_len is None else str(max_len) +#: Append one record to an activity's stream, or answer where it already is. +#: +#: The single-key form of the paired write: an activity's stream has no input +#: half and no stage, so one stream and its idempotency hash are the whole +#: write, and the retention trims ride along as they do on the pair. +_OWNED_APPEND_LUA: Final = """ +local minid = ARGV[1] +local maxlen = ARGV[2] +local existing = redis.call('HGET', KEYS[2], ARGV[3]) +if existing then + local sep = string.find(existing, '|') + if string.sub(existing, sep + 1) ~= ARGV[4] then + return {'conflict', ''} + end + return {'ok', string.sub(existing, 1, sep - 1)} +end +local id = redis.call('XADD', KEYS[1], '*', unpack(ARGV, 5)) +redis.call('HSET', KEYS[2], ARGV[3], id .. '|' .. ARGV[4]) +if minid ~= '' then + redis.call('XTRIM', KEYS[1], 'MINID', minid) +end +if maxlen ~= '' then + redis.call('XTRIM', KEYS[1], 'MAXLEN', maxlen) +end +return {'ok', id} +""" + + +class _OwnedAppend: + """One record on an activity's stream, in one Redis call.""" + + def __init__(self, backend: RedisStreamBackend) -> None: + """Bind to ``backend``'s client.""" + self._backend = backend + self._script = backend._client.register_script(_OWNED_APPEND_LUA) + + async def write(self, *, name: str, record: TransportRecord) -> Offset: + """Append ``record`` to the stream ``name`` and return where it landed. + + A repeat of the same ``(session, sequence)`` with the same bytes returns + the original position and writes nothing. + """ + outcome, placed = await self._script( + keys=[name, f"{name}:idem"], args=_append_args(self._backend, record) + ) + if _text(outcome) == "conflict": + raise AppendConflictError(record.idempotency_key) + return Offset(_text(placed)) + + +def _prefix(backend: Any) -> str: + """The key prefix ``backend`` writes under, for keys the provider derives itself.""" + return backend._key_prefix + + +@dataclass(frozen=True) +class _ActivityOwner: + """The activity whose streams a handle addresses, and where Redis keeps them. + + ``workflow_id`` is ``None`` for a standalone activity. ``run_id`` pins the + workflow's run for a workflow's activity and the activity's own run for a + standalone one: it decides whose close ends a read and never which key is + read, because an activity's streams belong to the activity id. + """ + + namespace: str + workflow_id: str | None + activity_id: str + run_id: str | None + + def key(self, key_prefix: str, topic: str) -> str: + """The Redis stream holding ``topic`` of this activity's streams.""" + # The chain keys percent-encode every id, so none of their components + # holds a "/", and a component built around one can never equal a + # chain key or a key derived from one. + namespace = quote(self.namespace, safe="") + owner = f"activity/{quote(self.workflow_id or '', safe='')}/" + owner += quote(self.activity_id, safe="") + return f"{key_prefix}:{namespace}:{owner}:{quote(topic, safe='')}" + + def context(self) -> SerializationContext: + """What the owner's payloads are coded under. + + A workflow's activity writes the workflow's data, as it does on the + workflow's own topics; a standalone activity has no workflow, so its + own identity is the context. + """ + if self.workflow_id is not None: + return WorkflowSerializationContext( + namespace=self.namespace, workflow_id=self.workflow_id + ) + return ActivitySerializationContext( + namespace=self.namespace, + activity_id=self.activity_id, + activity_type=None, + activity_task_queue=None, + workflow_id=None, + workflow_type=None, + is_local=False, + ) + + def __str__(self) -> str: + """The owner, for messages.""" + if self.workflow_id is None: + return f"activity {self.activity_id!r}" + return f"activity {self.activity_id!r} of workflow {self.workflow_id!r}" + + +async def _retained(client: Any, name: str, offset: Offset) -> bool: + """Whether the record at ``offset`` on the stream ``name`` survived trimming. + + A record at or after the first retained entry is there. On an emptied + stream the last id Redis generated says whether the record ever was. + """ + if not await client.exists(name): + # Nothing was ever written under this key, so nothing was trimmed from it. + return True + info = await client.xinfo_stream(name) + wanted = _entry_id(offset.token) + first = info.get("first-entry") + if first: + return _entry_id(first[0]) <= wanted + return wanted > _entry_id(info["last-generated-id"]) class _RetainingBackend(RedisStreamBackend): @@ -374,21 +532,8 @@ async def read_range(self, key: StreamKey, first: Offset, last: Offset) -> Any: return await super().read_range(key, first, last) async def retains(self, key: StreamKey, offset: Offset) -> bool: - """Whether the record at ``offset`` survived trimming. - - A record at or after the first retained entry is there. On an emptied - stream the last id Redis generated says whether the record ever was. - """ - name = self.stream_key(key) - if not await self._client.exists(name): - # Nothing was ever written under this key, so nothing was trimmed from it. - return True - info = await self._client.xinfo_stream(name) - wanted = _entry_id(offset.token) - first = info.get("first-entry") - if first: - return _entry_id(first[0]) <= wanted - return wanted > _entry_id(info["last-generated-id"]) + """Whether the record at ``offset`` survived trimming.""" + return await _retained(self._client, self.stream_key(key), offset) async def _trim(self, key: StreamKey) -> None: name = self.stream_key(key) @@ -538,15 +683,17 @@ def __init__( self, streams: RedisStreams, client: Client, - workflow_id: str, + workflow_id: str | None, topic: str, producer_id: str, attempt: int, + owner: _ActivityOwner | None = None, ) -> None: - """Bind this producer to ``topic`` of ``workflow_id``'s stream.""" + """Bind this producer to ``topic`` of ``workflow_id``'s chain, or of ``owner``'s streams.""" self._streams = streams self._client = client self._workflow_id = workflow_id + self._owner = owner self._topic = topic self._producer_id = producer_id self._attempt = attempt @@ -556,6 +703,8 @@ def __init__( self._input: Any = None self._output: Any = None self._pair: _PairedAppend | None = None + self._owned: _OwnedAppend | None = None + self._name: str | None = None self._codec: StreamPayloadCodec[bytes] | None = None @property @@ -624,9 +773,19 @@ async def finish(self) -> None: ) async def _connect(self) -> None: - if self._output is not None: + if self._codec is not None: return backend = self._streams._require_backend() + if self._owner is not None: + # No transport producer to bind and no one to wake: an activity's + # stream has no workflow reader and no chain to check the key against. + self._name = self._owner.key(_prefix(backend), self._topic) + self._owned = _OwnedAppend(backend) + self._codec = StreamPayloadCodec( + self._client.data_converter.with_context(self._owner.context()), bytes + ) + return + assert self._workflow_id is not None chain = await _chain(self._client, self._workflow_id) try: output = await ExternalOutputStreamProducer.connect( @@ -660,25 +819,8 @@ async def _connect(self) -> None: async def _write(self, records: list[WireRecord]) -> Cursor: await self._connect() - assert self._pair is not None and self._codec is not None - last: Offset | None = None try: - for index, record in enumerate(records): - # Built here rather than handed to the transport's publish, which - # appends one key per call. The identity is the same one the transport - # would derive, so a record either side already holds is reused. - staged = TransportRecord( - kind=TransportRecordKind.DATA, - payload=await self._codec.encode(record.SerializeToString()), - producer_session_id=self._session, - sequence=self._sequence + index, - ) - last = await self._pair.write( - input_key=self._input.stream_key, - output_key=self._output.stream_key, - record=staged, - ) - await self._wake() + last = await self._place(records) except AppendConflictError as error: raise StreamProducerError( f"producer {self._session!r} already wrote a different record at " @@ -693,6 +835,34 @@ async def _write(self, records: list[WireRecord]) -> Cursor: self._last = mint_cursor(_PROVIDER, last.token) return self._last + async def _place(self, records: list[WireRecord]) -> Offset | None: + """Append ``records`` where the owner keeps them and return the last position.""" + assert self._codec is not None + last: Offset | None = None + for index, record in enumerate(records): + # Built here rather than handed to the transport's publish, which + # appends one key per call. The identity is the same one the transport + # would derive, so a record either side already holds is reused. + staged = TransportRecord( + kind=TransportRecordKind.DATA, + payload=await self._codec.encode(record.SerializeToString()), + producer_session_id=self._session, + sequence=self._sequence + index, + ) + if self._owned is not None: + assert self._name is not None + last = await self._owned.write(name=self._name, record=staged) + continue + assert self._pair is not None + last = await self._pair.write( + input_key=self._input.stream_key, + output_key=self._output.stream_key, + record=staged, + ) + if self._owned is None: + await self._wake() + return last + async def _wake(self) -> None: try: await self._input.wake() @@ -714,6 +884,7 @@ async def _consumer_has_gone(self) -> bool: still the running one. A run that continued as new has not gone: the streams are keyed by the chain, so its successor is the consumer now. """ + assert self._workflow_id is not None handle = self._client.get_workflow_handle(self._workflow_id) deadline = time.monotonic() + 5 while True: @@ -731,28 +902,44 @@ async def _consumer_has_gone(self) -> bool: class RedisStreamHandle: - """One workflow's topics from outside, over the transport's output streams.""" + """One owner's topics from outside. + + The owner is ``workflow_id``'s workflow, read over the transport's output + streams, or with ``activity_id`` an activity, read from the stream the + provider keeps for it: a standalone activity without ``workflow_id``, or + an activity that workflow scheduled. + """ def __init__( self, streams: RedisStreams, client: Client, - workflow_id: str, + workflow_id: str | None, run_id: str | None, + activity_id: str | None = None, ) -> None: - """Address ``workflow_id``'s topics; ``run_id`` decides whose close ends a read.""" + """Address the owner's topics; ``run_id`` decides whose close ends a read.""" self._streams = streams self._client = client self._workflow_id = workflow_id self._run_id = run_id + self._owner: _ActivityOwner | None = None + if activity_id is not None: + self._owner = _ActivityOwner( + client.namespace, workflow_id, activity_id, run_id + ) + context = self._owner.context() + else: + if workflow_id is None: + raise ValueError( + "a stream handle needs a workflow_id or an activity_id" + ) + context = WorkflowSerializationContext( + namespace=client.namespace, workflow_id=workflow_id + ) self._converter = client.data_converter.payload_converter self._codec: StreamPayloadCodec[bytes] = StreamPayloadCodec( - client.data_converter.with_context( - WorkflowSerializationContext( - namespace=client.namespace, workflow_id=workflow_id - ) - ), - bytes, + client.data_converter.with_context(context), bytes ) def read( @@ -762,25 +949,140 @@ def read( after: Cursor = BEGINNING, result_type: type | None = None, ) -> AsyncGenerator[StreamRecord[Any], None]: - """Yield records on ``topic`` after ``after`` until the chain, or the pinned run, closes. + """Yield records on ``topic`` after ``after`` until the owner closes. + + For a workflow that is the chain, or the pinned run; for an activity it is + the activity reaching a terminal status, learned as the module docstring + says. Two refusals and they do not land together. A cursor another provider minted, - or one naming the workflow's own input log, is refused by this call: reading - the token needs nothing from the store. A well-formed cursor the retention has + or one naming a workflow's input log, is refused by this call: reading the + token needs nothing from the store. A well-formed cursor the retention has trimmed is refused on the first step of the generator, because answering that needs a round trip and this call is not a coroutine. Neither yields a record first. Raises: - StreamCursorError: The cursor is another provider's, or names the + StreamCursorError: The cursor is another provider's, or names a workflow's input log. """ topic, result_type = resolve_topic(topic, result_type) # Parsed here so a foreign cursor fails this call, not the first # iteration of the generator. position = _outside_position(after) + if self._owner is not None: + return self._read_owned(self._owner, topic, position, after, result_type) return self._read(topic, position, after, result_type) + async def _read_owned( + self, + owner: _ActivityOwner, + topic: str, + position: Offset | None, + after: Cursor, + result_type: type | None, + ) -> AsyncGenerator[StreamRecord[Any], None]: + decoder = RecordDecoder( + self._converter, result_type, after=after, warn=logger.warning + ) + backend = self._streams._require_backend() + await self._require_owner(owner) + store = backend._client + name = owner.key(_prefix(backend), topic) + if ( + position is not None + and isinstance(backend, _RetainingBackend) + and not await _retained(store, name, position) + ): + # Refused rather than resumed from the first retained record, + # which would skip whatever the trim took in between. + raise StreamCursorError( + f"cursor {after.token!r} names a record on {topic!r} that the " + f"provider's retention has trimmed ({backend.describe_window()})" + ) + start = _BEGINNING_SENTINEL if position is None else position.token + # XREAD BLOCK 0 waits forever, which is not what a zero poll asks for. + block = int(self._streams._poll.total_seconds() * 1000) or None + closed = False + while True: + found: Any = await store.xread( + {name: start}, count=_READ_BATCH, block=block + ) + entries = found[0][1] if found else [] + for entry_id, fields in entries: + placed = _to_record(entry_id, fields) + assert placed.offset is not None + start = placed.offset.token + if placed.kind is not TransportRecordKind.DATA: + continue + minted = mint_cursor(_PROVIDER, placed.offset.token) + wire = _parse( + minted, await self._codec.decode(placed.payload), logger.warning + ) + if wire is None: + continue + for record in decoder.decode(minted, wire): + yield record + if entries: + continue + if closed: + return + # One more pass after learning the owner is terminal, so a record + # that landed between the read and the describe is not lost. + closed = await self._owner_closed(owner, store, name) + + async def _require_owner(self, owner: _ActivityOwner) -> None: + """Raise ``StreamNotFoundError`` when the server does not know ``owner``.""" + try: + if owner.workflow_id is None: + await self._client.get_activity_handle( + owner.activity_id, run_id=owner.run_id + ).describe() + else: + await self._client.get_workflow_handle( + owner.workflow_id, run_id=owner.run_id + ).describe() + except RPCError as error: + if error.status == RPCStatusCode.NOT_FOUND: + raise StreamNotFoundError(f"{owner} was not found") from error + raise + + async def _owner_closed(self, owner: _ActivityOwner, store: Any, name: str) -> bool: + """Whether the owning activity is terminal, as far as this store can tell. + + A standalone activity says so itself. The server does not describe a + workflow's activity, so its workflow is asked: the workflow closing ends + the read, and so does the activity leaving the pending set once its + stream exists. Before the first write there is no stream, so an + activity that never wrote is read until its workflow closes. + """ + try: + if owner.workflow_id is None: + described = await self._client.get_activity_handle( + owner.activity_id, run_id=owner.run_id + ).describe() + return described.status != ActivityExecutionStatus.RUNNING + description = await self._client.get_workflow_handle( + owner.workflow_id, run_id=owner.run_id + ).describe() + except RPCError as error: + if error.status == RPCStatusCode.NOT_FOUND: + # The owner's History is gone, so there is nothing left to follow. + return True + raise + status = description.status + if status is not None and status != WorkflowExecutionStatus.RUNNING: + # An activity's streams are not the chain's: a run that continued + # as new took its activities with it. + return True + pending = any( + info.activity_id == owner.activity_id + for info in description.raw_description.pending_activities + ) + if pending: + return False + return bool(await store.exists(name)) + async def _read( self, topic: str, @@ -792,6 +1094,7 @@ async def _read( self._converter, result_type, after=after, warn=logger.warning ) backend = self._streams._require_backend() + assert self._workflow_id is not None chain = await _chain(self._client, self._workflow_id) key = chain.stream_key(topic, direction=StreamDirection.OUTPUT) if ( @@ -875,6 +1178,7 @@ async def _read( closed = await self._closed() async def _closed(self) -> bool: + assert self._workflow_id is not None handle = self._client.get_workflow_handle( self._workflow_id, run_id=self._run_id ) @@ -901,6 +1205,14 @@ async def latest(self, *, topic: str | StreamTopic[Any]) -> Cursor: """ topic, _ = resolve_topic(topic) backend = self._streams._require_backend() + if self._owner is not None: + newest: Any = await backend._client.xrevrange( + self._owner.key(_prefix(backend), topic), "+", "-", count=1 + ) + if not newest: + return BEGINNING + return mint_cursor(_PROVIDER, _text(newest[0][0])) + assert self._workflow_id is not None chain = await _chain(self._client, self._workflow_id) try: tail = await backend.output_tail( @@ -924,7 +1236,13 @@ def producer( topic, _ = resolve_topic(topic) producer_id, attempt = producer_identity(producer_id, attempt) return RedisProducer( - self._streams, self._client, self._workflow_id, topic, producer_id, attempt + self._streams, + self._client, + self._workflow_id, + topic, + producer_id, + attempt, + owner=self._owner, ) @@ -1036,6 +1354,27 @@ def get_stream_handle( """A handle on ``workflow_id``'s topics; it follows the chain by construction.""" return RedisStreamHandle(self, client, workflow_id, run_id) + def get_activity_stream_handle( + self, + client: Client, + activity_id: str, + *, + workflow_id: str | None = None, + run_id: str | None = None, + ) -> RedisStreamHandle: + """A handle on the topics ``activity_id`` owns, apart from any workflow's. + + Without ``workflow_id`` the activity is a standalone one and ``run_id`` + pins its run; with one it is that workflow's activity and ``run_id`` + pins the workflow's run. The streams are keyed by the activity id and + not its run, so a retry writes to the same ones and a reader sees the + attempt change as ``SUPERSEDED``, and an id started again continues + them. A read ends when the activity is terminal and the retained tail + is delivered; the store has no gate on an append after that, so a late + attempt still lands, and a read that has ended does not see it. + """ + return RedisStreamHandle(self, client, workflow_id, run_id, activity_id) + async def close(self) -> None: """Release the Redis connections this provider opened.""" client, self._owned_client = self._owned_client, None From aa6733971c8c4c6eb17d2e1a06a0520c3371eac2 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Tue, 29 Sep 2026 17:07:53 -0700 Subject: [PATCH 77/96] Tested activity-owned streams on the Redis provider. The shared activity cases run on Redis behind the STREAMS_LIVE gate, beside the key scheme and what only this store decides: how a read on a workflow's activity ends, the wait for one that never wrote, retention on its stream and the append nothing gates once the owner is terminal. --- tests/streams/test_activity_streams.py | 25 +++ tests/streams/test_redis_activity_streams.py | 215 +++++++++++++++++++ tests/streams/test_redis_provider.py | 48 +++++ 3 files changed, 288 insertions(+) create mode 100644 tests/streams/test_redis_activity_streams.py diff --git a/tests/streams/test_activity_streams.py b/tests/streams/test_activity_streams.py index 8aa4a653a..3695b1416 100644 --- a/tests/streams/test_activity_streams.py +++ b/tests/streams/test_activity_streams.py @@ -16,6 +16,7 @@ from __future__ import annotations import asyncio +import os import uuid from collections.abc import AsyncIterator, Callable from dataclasses import dataclass @@ -34,6 +35,7 @@ topic, ) from temporalio.streams.providers.memory import MemoryStreams +from temporalio.streams.providers.redis import RedisStreams from temporalio.testing import WorkflowEnvironment from tests.helpers import new_worker @@ -57,9 +59,32 @@ async def _memory_setup(client: Client) -> AsyncIterator[ActivitySetup]: provider.reset() +async def _redis_setup(client: Client) -> AsyncIterator[ActivitySetup]: + # The store is a Redis the test environment does not start; the server + # is the environment's own unless TEMPORAL_ADDRESS names another. + address = os.environ.get("TEMPORAL_ADDRESS") + if address: + client = await Client.connect( + address, namespace=os.environ.get("TEMPORAL_NAMESPACE", "default") + ) + provider = RedisStreams( + url=os.environ.get("TEMPORAL_TEST_REDIS_URL") + or os.environ.get("AI198_REDIS_URL", "redis://127.0.0.1:6379"), + # A prefix per setup, because the store keeps what earlier runs wrote. + key_prefix=f"streams-activity-{uuid.uuid4().hex}", + poll_interval=timedelta(milliseconds=100), + ) + config = client.config() + config["plugins"] = [provider] + yield ActivitySetup("redis", provider, Client(**config)) + await provider.close() + + SETUPS: dict[str, Callable[[Client], AsyncIterator[ActivitySetup]]] = { "memory": _memory_setup } +if os.environ.get("STREAMS_LIVE") == "redis": + SETUPS["redis"] = _redis_setup @pytest.fixture(params=sorted(SETUPS)) diff --git a/tests/streams/test_redis_activity_streams.py b/tests/streams/test_redis_activity_streams.py new file mode 100644 index 000000000..ec60a4a7f --- /dev/null +++ b/tests/streams/test_redis_activity_streams.py @@ -0,0 +1,215 @@ +"""Live checks for what the Redis provider decides about an activity's streams. + +``test_activity_streams`` runs the shared cases on this provider behind +``STREAMS_LIVE=redis``. This module covers what only this store does: how a +read learns that a workflow's activity is terminal without the server saying +so, that an activity that never wrote is read until its workflow closes, +that retention trims an activity's stream like any other, and that nothing +gates an append once the owner is terminal. All need a dev server +(``TEMPORAL_ADDRESS`` or the test environment's own) and a Redis +(``TEMPORAL_TEST_REDIS_URL`` or ``AI198_REDIS_URL``). +""" + +from __future__ import annotations + +import asyncio +import os +import uuid +from collections.abc import AsyncIterator +from datetime import timedelta + +import pytest + +from temporalio import activity, workflow +from temporalio.client import Client +from temporalio.streams import BEGINNING, RecordKind, StreamCursorError +from temporalio.streams.providers.redis import RedisStreams +from temporalio.testing import WorkflowEnvironment +from tests.helpers import new_worker +from tests.streams.test_activity_streams import ( + TOKENS, + read_all, + summary, + write_by_default, + write_to_own_streams, +) +from tests.streams.test_streams_conformance import StreamHost, take + +pytestmark = pytest.mark.skipif( + os.environ.get("STREAMS_LIVE") != "redis", + reason="needs a live server and redis; run with STREAMS_LIVE=redis", +) + + +def redis_url() -> str: + return os.environ.get("TEMPORAL_TEST_REDIS_URL") or os.environ.get( + "AI198_REDIS_URL", "redis://127.0.0.1:6379" + ) + + +@pytest.fixture +async def live(client: Client, env: WorkflowEnvironment) -> AsyncIterator[Client]: + """A client with a fresh provider registered, on a server with standalone activities.""" + if env.supports_time_skipping: + pytest.skip("the time-skipping test server has no standalone activities") + address = os.environ.get("TEMPORAL_ADDRESS") + if address: + client = await Client.connect( + address, namespace=os.environ.get("TEMPORAL_NAMESPACE", "default") + ) + provider = RedisStreams( + url=redis_url(), + # A prefix per case, because the store keeps what earlier cases wrote. + key_prefix=f"streams-redis-activity-{uuid.uuid4().hex}", + poll_interval=timedelta(milliseconds=100), + ) + config = client.config() + config["plugins"] = [provider] + try: + yield Client(**config) + finally: + await provider.close() + + +@activity.defn +async def write_nothing(label: str) -> str: + return label + + +@workflow.defn +class RunsOneActivityThenWaits: + """Runs one activity by name, then stays open until released.""" + + def __init__(self) -> None: + self._released = False + + @workflow.signal + def release(self) -> None: + self._released = True + + @workflow.run + async def run(self, name: str, label: str) -> str: + result = await workflow.execute_activity( + name, + label, + activity_id="streamer", + start_to_close_timeout=timedelta(seconds=30), + ) + await workflow.wait_condition(lambda: self._released) + return result + + +async def test_a_read_opened_after_the_activity_finished_ends_by_itself( + live: Client, +): + # The server does not describe a workflow's activity, so the provider ends + # the read once the activity is no longer pending and its stream exists. + workflow_id = f"streams-redis-wfa-{uuid.uuid4().hex}" + async with new_worker( + live, RunsOneActivityThenWaits, activities=[write_to_own_streams] + ) as worker: + handle = await live.start_workflow( + RunsOneActivityThenWaits.run, + args=["write_to_own_streams", "to the activity"], + id=workflow_id, + task_queue=worker.task_queue, + ) + own = live.get_stream_handle(workflow_id, activity_id="streamer") + # The first read follows the activity to its FINISH while it runs. + await take(own.read(topic=TOKENS), 2, 30) + # A read opened afterwards ends on its own, with the workflow still open. + records = await read_all(own.read(topic=TOKENS), timeout=10) + assert summary(records) == [ + (RecordKind.DATA, 1, "to the activity"), + (RecordKind.FINISH, 1, None), + ] + assert (await handle.describe()).status is not None + await handle.signal(RunsOneActivityThenWaits.release) + await handle.result() + + +async def test_an_activity_that_never_wrote_is_read_until_its_workflow_closes( + live: Client, +): + workflow_id = f"streams-redis-wfa-silent-{uuid.uuid4().hex}" + async with new_worker( + live, RunsOneActivityThenWaits, activities=[write_nothing] + ) as worker: + handle = await live.start_workflow( + RunsOneActivityThenWaits.run, + args=["write_nothing", "quiet"], + id=workflow_id, + task_queue=worker.task_queue, + ) + assert await handle.query("__temporal_workflow_metadata") is not None + own = live.get_stream_handle(workflow_id, activity_id="streamer") + reading = asyncio.ensure_future(read_all(own.read(topic=TOKENS), timeout=30)) + # No stream exists for an activity that wrote nothing, so the read has + # nothing to say the activity is over and waits for the workflow. + await asyncio.sleep(1.0) + assert not reading.done() + await handle.signal(RunsOneActivityThenWaits.release) + await handle.result() + assert await reading == [] + + +async def test_retention_trims_an_activity_stream_too(client: Client): + address = os.environ.get("TEMPORAL_ADDRESS") + if address: + client = await Client.connect(address) + provider = RedisStreams( + url=redis_url(), + key_prefix=f"streams-redis-activity-{uuid.uuid4().hex}", + poll_interval=timedelta(milliseconds=100), + max_len=2, + ) + config = client.config() + config["plugins"] = [provider] + live = Client(**config) + workflow_id = f"streams-redis-wfa-trim-{uuid.uuid4().hex}" + try: + async with new_worker(live, StreamHost) as worker: + host = await live.start_workflow( + StreamHost.run, id=workflow_id, task_queue=worker.task_queue + ) + stream = live.get_stream_handle(workflow_id, activity_id="tool") + producer = stream.producer(topic=TOKENS, producer_id="tool", attempt=1) + first = await producer.append({"token": "one"}) + assert first is not None + await producer.append({"token": "two"}, {"token": "three"}) + # The window holds two entries, so the first record is gone. + records = await take(stream.read(topic=TOKENS), 2, 10) + assert [r.value["token"] for r in records] == ["two", "three"] + with pytest.raises(StreamCursorError, match="retention has trimmed"): + await take(stream.read(topic=TOKENS, after=first), 1, 10) + await host.terminate() + finally: + await provider.close() + + +async def test_an_append_after_the_owner_is_terminal_still_lands(live: Client): + # The store has no gate the server would have: a late attempt writes, and + # a reader that already ended is not told. + activity_id = f"streams-redis-saa-late-{uuid.uuid4().hex}" + async with new_worker(live, activities=[write_by_default]) as worker: + handle = await live.start_activity( + write_by_default, + "standalone", + id=activity_id, + task_queue=worker.task_queue, + start_to_close_timeout=timedelta(seconds=30), + ) + assert await handle.result() == activity_id + stream = live.get_stream_handle(activity_id=activity_id) + before = await stream.latest(topic=TOKENS) + assert before != BEGINNING + late = stream.producer(topic=TOKENS, producer_id=activity_id, attempt=2) + after = await late.append({"token": "late"}) + assert after != before + records = await read_all(stream.read(topic=TOKENS), timeout=10) + assert summary(records) == [ + (RecordKind.DATA, 1, "standalone"), + (RecordKind.FINISH, 1, None), + (RecordKind.SUPERSEDED, 2, None), + (RecordKind.DATA, 2, "late"), + ] diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py index ac4919c81..6066cce8b 100644 --- a/tests/streams/test_redis_provider.py +++ b/tests/streams/test_redis_provider.py @@ -7,15 +7,63 @@ import pytest +from temporalio.contrib.external_workflow_streams import StreamDirection +from temporalio.contrib.external_workflow_streams._backend import StreamKey +from temporalio.contrib.external_workflow_streams._redis import RedisStreamBackend from temporalio.streams import BEGINNING, Cursor, StreamCursorError, StreamError from temporalio.streams.providers.redis import ( RedisStreams, + _ActivityOwner, _drive, _outside_position, _workflow_position, ) +class _NoRedis: + """Enough of a Redis client to construct a backend and render its keys.""" + + def register_script(self, _script: str) -> None: + return None + + +def test_activity_keys_encode_their_ids_and_never_meet_chain_keys(): + assert ( + _ActivityOwner("ns", "wf", "act", None).key("p", "t") + == "p:ns:activity/wf/act:t" + ) + assert ( + _ActivityOwner("ns", None, "act", None).key("p", "t") == "p:ns:activity//act:t" + ) + # An id holding a separator is encoded, so it cannot move a boundary. + assert ( + _ActivityOwner("n:s", "w/f", "a:c", None).key("p", "t/u") + == "p:n%3As:activity/w%2Ff/a%3Ac:t%2Fu" + ) + # A chain key percent-encodes every id, so none of its components holds a + # "/" however the ids are chosen, and the owner component here always does. + backend = RedisStreamBackend(client=_NoRedis(), key_prefix="p") + forged = StreamKey( + namespace="ns", + workflow_id="activity/wf/act", + first_execution_run_id="t", + stream_name="t", + direction=StreamDirection.OUTPUT, + ) + assert "/" not in backend.stream_key(forged) + assert backend.stream_key(forged) != _ActivityOwner("ns", "wf", "act", None).key( + "p", "t" + ) + + +def test_an_activity_owner_names_itself_for_messages(): + assert str(_ActivityOwner("ns", None, "act", None)) == "activity 'act'" + assert ( + str(_ActivityOwner("ns", "wf", "act", "run")) + == "activity 'act' of workflow 'wf'" + ) + + def test_outside_cursors_name_output_positions(): assert _outside_position(BEGINNING) is None position = _outside_position(Cursor("redis:1700000000000-3")) From 2c19822627a96fb65ca31eaa6c156b3d67286b70 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 30 Sep 2026 00:40:16 -0700 Subject: [PATCH 78/96] Sent a refused wake again along the chain instead of failing the sender. A consumer that reads the sender's FINISH straight from the store and continues as new on it closes before the wake Signal for that record lands, and the server refuses a Signal to a closing run. The streams are keyed by the chain, so the records are already where the successor reads them; only the wake has to follow, and it is given up only once the chain is terminal. --- temporalio/streams/providers/redis.py | 79 +++++++++------ tests/streams/test_redis_provider.py | 137 +++++++++++++++++++++++++- tests/streams/test_redis_replay.py | 71 +++++++++++++ 3 files changed, 255 insertions(+), 32 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 09232ed70..06a2f7c12 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -18,7 +18,12 @@ started. Both keys are written by one script, so a record is on both or on neither: split across two calls, a crash between them leaves a record the workflow consumes and no outside reader can ever see. The cost is one more - append per record and one wake per batch. + append per record and one wake per batch. A wake the server refuses because + the consuming run is closing is sent again after a short wait: a run that + continues as new hands the stream to its successor, the records are already + where the successor reads them, and only the wake has to follow. The + retries stop when the chain's current run takes the wake or the chain + proves terminal. - A record rides as the transport's payload: the serialized ``StreamRecord`` proto as a ``binary/plain`` value, which the worker's codec encodes and decodes like any other payload. @@ -164,6 +169,12 @@ _REDIS_ID = re.compile(r"\d+-\d+") _READ_BATCH = 256 +#: How long a wake the server refused is sent again before it is given up, and +#: the pause between attempts. The pause is there because in the instant between +#: two runs of a chain the successor is not yet the run a Signal resolves to. +_WAKE_RETRY_WINDOW: Final = timedelta(seconds=5) +_WAKE_RETRY_BACKOFF: Final = timedelta(milliseconds=200) + # The transport's per-task batch limits are a backpressure point that awaits # inside the workflow, and a synchronous publish has nowhere to await. Lifted # to where they cannot fire, so a task's publishes stage as one batch. @@ -864,41 +875,49 @@ async def _place(self, records: list[WireRecord]) -> Offset | None: return last async def _wake(self) -> None: - try: - await self._input.wake() - except WakeNotAcknowledgedError: - # The records are appended; what failed is telling a consumer that - # is no longer there to be told. A terminal record most often races - # the consumer acting on it, so an absent consumer is the ordinary - # ending rather than an error. - if not await self._consumer_has_gone(): - raise - except TransportStreamError as error: - raise _storage_error(error, "the wake could not be sent") from error + """Wake the consuming workflow, following the chain past a closing run. + + The records are appended before this is called; what can fail is + telling the consumer. The server refuses a Signal while the run it + resolves to is closing, and a consumer that reads a terminal record + straight from the store and continues as new on it closes in exactly + that way, ahead of the wake for that record. The streams are keyed by + the chain, so the records are already where the successor reads them + and only the wake has to follow: it is sent again, addressed to the + workflow id as every wake is, until the chain's current run takes it. + A chain that has ended instead is the ordinary ending of a terminal + record racing the consumer acting on it, not an error. + """ + deadline = time.monotonic() + _WAKE_RETRY_WINDOW.total_seconds() + while True: + try: + await self._input.wake() + return + except WakeNotAcknowledgedError: + if await self._chain_is_terminal(): + return + if time.monotonic() >= deadline: + raise + except TransportStreamError as error: + raise _storage_error(error, "the wake could not be sent") from error + await asyncio.sleep(_WAKE_RETRY_BACKOFF.total_seconds()) - async def _consumer_has_gone(self) -> bool: - """Whether the consuming execution is closing or closed. + async def _chain_is_terminal(self) -> bool: + """Whether the chain has ended for good, rather than handing over. - Asked for a few seconds rather than once: the server refuses the wake - while the execution is closing, and at that moment its status is - still the running one. A run that continued as new has not gone: the - streams are keyed by the chain, so its successor is the consumer now. + A run that continued as new is not the end: its successor is the + consumer now, and a closing run still describes as running. A chain + whose History is gone has ended. """ assert self._workflow_id is not None handle = self._client.get_workflow_handle(self._workflow_id) - deadline = time.monotonic() + 5 - while True: - try: - status = (await handle.describe()).status - except RPCError as error: - if error.status == RPCStatusCode.NOT_FOUND: - return True - raise - if status is not None and status not in _STILL_CONSUMING: + try: + status = (await handle.describe()).status + except RPCError as error: + if error.status == RPCStatusCode.NOT_FOUND: return True - if time.monotonic() >= deadline: - return False - await asyncio.sleep(0.1) + raise + return status is not None and status not in _STILL_CONSUMING class RedisStreamHandle: diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py index 6066cce8b..54ef6f46c 100644 --- a/tests/streams/test_redis_provider.py +++ b/tests/streams/test_redis_provider.py @@ -1,17 +1,30 @@ -"""What the Redis provider decides without a store: cursors and the sync publish.""" +"""What the Redis provider decides without a store: cursors, the sync publish, the wake.""" from __future__ import annotations import asyncio from datetime import timedelta +from types import SimpleNamespace +from typing import Any, cast import pytest -from temporalio.contrib.external_workflow_streams import StreamDirection +from temporalio.client import WorkflowExecutionStatus +from temporalio.contrib.external_workflow_streams import ( + StreamDirection, + WakeNotAcknowledgedError, +) +from temporalio.contrib.external_workflow_streams import ( + StreamError as TransportStreamError, +) from temporalio.contrib.external_workflow_streams._backend import StreamKey from temporalio.contrib.external_workflow_streams._redis import RedisStreamBackend +from temporalio.converter import DataConverter +from temporalio.service import RPCError, RPCStatusCode from temporalio.streams import BEGINNING, Cursor, StreamCursorError, StreamError +from temporalio.streams.providers import redis as redis_provider from temporalio.streams.providers.redis import ( + RedisProducer, RedisStreams, _ActivityOwner, _drive, @@ -20,6 +33,126 @@ ) +class _ChainClient: + """A client whose describes of the chain answer from a script of statuses. + + The last entry repeats, so a chain that stays running keeps saying so. + """ + + def __init__(self, *answers: WorkflowExecutionStatus | Exception) -> None: + self.data_converter = DataConverter.default + self.namespace = "default" + self._answers = list(answers) + self.describes = 0 + + def get_workflow_handle(self, _workflow_id: str, **_: Any) -> Any: + return self + + async def describe(self) -> Any: + self.describes += 1 + answer = self._answers.pop(0) if len(self._answers) > 1 else self._answers[0] + if isinstance(answer, Exception): + raise answer + return SimpleNamespace(status=answer) + + +class _RefusingInput: + """The transport's input topic, refusing the first ``refusals`` wakes.""" + + def __init__(self, refusals: int, error: Exception | None = None) -> None: + self._refusals = refusals + self._error = error + self.calls = 0 + + async def wake(self) -> list[str]: + self.calls += 1 + if self.calls <= self._refusals: + raise self._error or WakeNotAcknowledgedError( + "workflow operation can not be applied because workflow is closing", + pending=[], + ) + return ["sent"] + + +def _waking_producer(client: _ChainClient, wakes: _RefusingInput) -> RedisProducer[Any]: + producer: RedisProducer[Any] = RedisProducer( + RedisStreams(), cast(Any, client), "wf", "inputs", "console", 1 + ) + producer._input = wakes + return producer + + +@pytest.fixture +def quick_wake_retries(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + redis_provider, "_WAKE_RETRY_BACKOFF", timedelta(milliseconds=10) + ) + monkeypatch.setattr( + redis_provider, "_WAKE_RETRY_WINDOW", timedelta(milliseconds=300) + ) + + +@pytest.mark.usefixtures("quick_wake_retries") +async def test_a_wake_refused_by_a_closing_run_is_sent_again_to_its_successor(): + # The chain describes as running: the run that refused the wake handed over + # to a successor, which is where the records already are. + client = _ChainClient(WorkflowExecutionStatus.RUNNING) + wakes = _RefusingInput(refusals=1) + await _waking_producer(client, wakes)._wake() + assert (wakes.calls, client.describes) == (2, 1) + + +@pytest.mark.usefixtures("quick_wake_retries") +async def test_a_refused_wake_waits_for_the_successor_to_become_current(): + # In the instant between two runs the chain still describes as the + # predecessor that continued as new, and the next Signal is refused too. + client = _ChainClient( + WorkflowExecutionStatus.CONTINUED_AS_NEW, WorkflowExecutionStatus.RUNNING + ) + wakes = _RefusingInput(refusals=2) + await _waking_producer(client, wakes)._wake() + assert (wakes.calls, client.describes) == (3, 2) + + +@pytest.mark.usefixtures("quick_wake_retries") +@pytest.mark.parametrize( + "ending", + [ + WorkflowExecutionStatus.COMPLETED, + WorkflowExecutionStatus.FAILED, + WorkflowExecutionStatus.CANCELED, + WorkflowExecutionStatus.TERMINATED, + WorkflowExecutionStatus.TIMED_OUT, + RPCError("gone", RPCStatusCode.NOT_FOUND, b""), + ], + ids=lambda ending: getattr(ending, "name", "not found"), +) +async def test_a_wake_refused_by_a_finished_chain_is_the_ordinary_ending( + ending: WorkflowExecutionStatus | Exception, +): + client = _ChainClient(ending) + wakes = _RefusingInput(refusals=99) + await _waking_producer(client, wakes)._wake() + assert wakes.calls == 1 + + +@pytest.mark.usefixtures("quick_wake_retries") +async def test_a_wake_refused_for_the_whole_window_is_raised(): + client = _ChainClient(WorkflowExecutionStatus.RUNNING) + wakes = _RefusingInput(refusals=99) + with pytest.raises(WakeNotAcknowledgedError, match="closing"): + await _waking_producer(client, wakes)._wake() + assert wakes.calls > 1 + + +async def test_a_wake_the_store_could_not_send_is_a_storage_error(): + client = _ChainClient(WorkflowExecutionStatus.RUNNING) + wakes = _RefusingInput(refusals=1, error=TransportStreamError("no connection")) + with pytest.raises(StreamError, match="could not be sent"): + await _waking_producer(client, wakes)._wake() + assert (wakes.calls, client.describes) == (1, 0) + + class _NoRedis: """Enough of a Redis client to construct a backend and render its keys.""" diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index a2b716756..1e7ddb663 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -448,6 +448,77 @@ async def test_a_workflow_reader_started_from_a_cursor_skips_the_earlier_records await replayer.replay_workflow(await handle.fetch_history()) +@workflow.defn +class BatchConsumer: + """Applies one batch of ``inputs`` per run, handing over at the sender's FINISH.""" + + @workflow.run + async def run(self, applied: list[str]) -> list[str]: + stop = False + async for record in workflow.stream_reader(INPUTS): + if record.kind is RecordKind.FINISH: + break + if record.kind is not RecordKind.DATA: + continue + assert record.value is not None + if record.value["op"] == "stop": + stop = True + continue + applied.append(record.value["op"]) + if stop: + return applied + workflow.continue_as_new(applied) + + +async def _current_open_run( + client: Client, workflow_id: str, previous: str | None +) -> str: + """Wait until the chain's newest run is a new one and still open.""" + while True: + description = await client.get_workflow_handle(workflow_id).describe() + assert description.run_id is not None + if description.run_id != previous and description.close_time is None: + return description.run_id + await asyncio.sleep(0.1) + + +async def test_a_wake_refused_by_a_run_continuing_as_new_reaches_its_successor( + live_client: Client, provider: RedisStreams +): + # The consumer reads the sender's FINISH straight from the store while it + # holds its task open and continues as new on it, so the wake Signal for + # that record resolves to a run that is closing and the server refuses it. + # The records are keyed by the chain and already where the successor reads + # them; the wake has to follow, and finish() must not fail the sender. + workflow_id = f"streams-redis-batches-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[BatchConsumer], + plugins=[provider], + max_cached_workflows=100, + ): + handle = await live_client.start_workflow( + BatchConsumer.run, [], id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + stream = provider.get_stream_handle(live_client, workflow_id) + run_id: str | None = None + for number, batch in enumerate([["a", "b"], ["c"], ["d", "stop"]], start=1): + run_id = await asyncio.wait_for( + _current_open_run(live_client, workflow_id, run_id), 30 + ) + # A sender per batch: the stream spans the chain, so one identity + # numbering its records from the start again would collide with + # the batch before. + sender = stream.producer( + topic=INPUTS, producer_id=f"console-{number}", attempt=1 + ) + for op in batch: + await sender.append({"op": op}) + await sender.finish() + assert await asyncio.wait_for(handle.result(), 30) == ["a", "b", "c", "d"] + + async def _stage_one( streams: RedisStreams, client: Client, From bf99f744b8075b8e8dd99c1e2ab268182887bf0d Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 30 Sep 2026 02:12:21 -0700 Subject: [PATCH 79/96] Trimmed Redis stream keys to seven days unless told otherwise. No trimming was the default, so unbounded growth was the default failure mode of a store with no consumer floor. An age rather than a count, because a count cap refuses a task whose batch does not fit under it; `retention=None` opts out. --- temporalio/streams/providers/redis.py | 52 ++++++++++++++++++--------- tests/streams/test_redis_provider.py | 26 +++++++++++++- tests/streams/test_redis_replay.py | 3 +- 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 06a2f7c12..1ada92889 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -59,12 +59,16 @@ batch limits are lifted for this provider, because a synchronous publish cannot wait for the worker to stage a full batch; a batch it cannot stage fails the task. -- Retention is trimming, with no consumer floor. When ``retention`` or - ``max_len`` is set, every append the provider makes trims the key it wrote, - an activity's stream included, whatever any reader has reached. A replay - that reaches a recorded range the trim removed fails its Workflow Task, an - outside cursor below the trim is refused, and a fully trimmed topic reads - as empty. +- Retention is trimming, with no consumer floor. By default a record older + than :data:`DEFAULT_RETENTION`, seven days, is trimmed by the next append + the provider makes to its key, an activity's stream included, whatever any + reader has reached; ``retention=None`` turns the age trim off, and + ``max_len`` adds a count cap that is off by default. A replay that reaches + a recorded range the trim removed fails its Workflow Task, an outside + cursor below the trim is refused, and a fully trimmed topic reads as + empty. A run that has to replay cold after seven days of consuming fails, + so a long-lived consumer continues as new inside the window, or is + configured with a longer one. """ from __future__ import annotations @@ -160,7 +164,7 @@ from temporalio.streams.providers import ProviderPlugin from temporalio.worker import ReplayerConfig, WorkerConfig -__all__ = ["RedisProducer", "RedisStreamHandle", "RedisStreams"] +__all__ = ["DEFAULT_RETENTION", "RedisProducer", "RedisStreamHandle", "RedisStreams"] T = TypeVar("T") @@ -169,6 +173,15 @@ _REDIS_ID = re.compile(r"\d+-\d+") _READ_BATCH = 256 +#: How long a record is kept when the constructor is not told otherwise. +#: +#: An age rather than a count, because the count a topic can afford depends on +#: its record size and the age does not, and because a count cap refuses a task +#: whose batch does not fit under it. Seven days is long enough to replay a +#: consumer that was evicted over a weekend and short enough that a chain +#: nobody reads any more does not keep its records for good. +DEFAULT_RETENTION: Final = timedelta(days=7) + #: How long a wake the server refused is sent again before it is given up, and #: the pause between attempts. The pause is there because in the instant between #: two runs of a chain the successor is not yet the run a Signal resolves to. @@ -1282,7 +1295,7 @@ def __init__( idle_timeout: timedelta = timedelta(seconds=1), backend: Any | None = None, poll_interval: timedelta = timedelta(milliseconds=500), - retention: timedelta | None = None, + retention: timedelta | None = DEFAULT_RETENTION, max_len: int | None = None, ) -> None: """Create the provider. @@ -1294,13 +1307,17 @@ def __init__( idle_timeout: How long a workflow reader with nothing to read holds its Workflow Task open before the worker parks it. backend: A transport backend the caller constructed and owns. It - is trimmed by its owner, so it takes neither ``retention`` nor - ``max_len``. + is trimmed by its owner, so it takes no ``max_len`` and no + ``retention`` other than the default, which does not apply + to it. poll_interval: How long an outside reader that is caught up waits for a record before asking whether the workflow closed. retention: Trim records older than this from a topic's input and - output keys on every append the provider makes to them. This - is retention without a consumer floor: nothing holds a record + output keys on every append the provider makes to them. + :data:`DEFAULT_RETENTION`, seven days, unless the caller says + otherwise; ``None`` keeps every record until ``max_len`` + trims it, or for good when that is unset too. This is + retention without a consumer floor: nothing holds a record for a reader that has not reached it. A workflow whose replay reaches a recorded range past the window fails its Workflow Task with the transport's ``StreamIntegrityError`` until the @@ -1309,15 +1326,18 @@ def __init__( behind the window misses records. The floor the server-side provider keeps would need a consumer registry in Redis. max_len: Keep at most this many entries per key, trimmed on the - same appends and with the same consequences. It must exceed - the largest batch a task publishes, or a stage is trimmed - before its commit. + same appends and with the same consequences. Off unless set. + It must exceed the largest batch a task publishes, or a stage + is trimmed before its commit; a batch at or above it is + refused where it is staged. """ if retention is not None and retention <= timedelta(0): raise ValueError("retention must be positive") if max_len is not None and max_len < 1: raise ValueError("max_len must be positive") - if backend is not None and (retention is not None or max_len is not None): + if backend is not None and ( + retention not in (None, DEFAULT_RETENTION) or max_len is not None + ): raise ValueError( "retention and max_len trim the backend this provider opens; a " "backend handed in is trimmed by its owner" diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py index 54ef6f46c..36dc56fb8 100644 --- a/tests/streams/test_redis_provider.py +++ b/tests/streams/test_redis_provider.py @@ -24,6 +24,7 @@ from temporalio.streams import BEGINNING, Cursor, StreamCursorError, StreamError from temporalio.streams.providers import redis as redis_provider from temporalio.streams.providers.redis import ( + DEFAULT_RETENTION, RedisProducer, RedisStreams, _ActivityOwner, @@ -251,7 +252,30 @@ def test_retention_options_are_checked_at_construction(): RedisStreams(retention=timedelta(0)) with pytest.raises(ValueError, match="max_len"): RedisStreams(max_len=0) - # A backend the caller owns is the caller's to trim. + # A backend the caller owns is the caller's to trim; the default window + # is not a request to trim it. with pytest.raises(ValueError, match="trimmed by its owner"): RedisStreams(backend=object(), max_len=10) + with pytest.raises(ValueError, match="trimmed by its owner"): + RedisStreams(backend=object(), retention=timedelta(hours=1)) + RedisStreams(backend=object()) RedisStreams(retention=timedelta(hours=1), max_len=10) + + +async def test_the_default_window_is_an_age_and_can_be_turned_off(): + # Nothing is trimmed on a topic nobody appends to, so the default has to + # be a window that every append applies; a count cap would refuse a task + # whose batch does not fit under it, so that one stays off. + provider = RedisStreams() + try: + backend = provider._require_backend() + assert backend._retention == DEFAULT_RETENTION == timedelta(days=7) + assert backend._max_len is None + assert backend.describe_window() == f"retention={DEFAULT_RETENTION}" + finally: + await provider.close() + unbounded = RedisStreams(retention=None) + try: + assert unbounded._require_backend().describe_window() == "no retention" + finally: + await unbounded.close() diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 1e7ddb663..6a9cea2b6 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -324,7 +324,8 @@ async def test_a_replay_past_the_retention_window_fails_loudly(live_client: Clie # external storage cause, and a message that names the window. message = str(failure.value) assert "StreamIntegrityError" in message and "ExternalStorageFailure" in message - assert "past the redis provider's retention (max_len=6)" in message + assert "past the redis provider's retention (" in message + assert "max_len=6)" in message # An outside cursor below the trim is refused, not resumed from the # first retained record. From b51c3e378a7c0598967753d2b6bf8725c9941bde Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 30 Sep 2026 02:16:14 -0700 Subject: [PATCH 80/96] Keyed an activity's Redis streams by the run its execution is in. The key held the activity id alone, so an id started again continued the earlier stream. The workflow's run for a workflow's activity and the activity's own run for a standalone one make each execution's streams its own, as on the server-side provider; a handle opened without a run describes the owner first. --- temporalio/streams/providers/redis.py | 119 ++++++++++++------- tests/streams/test_redis_activity_streams.py | 83 ++++++++++++- tests/streams/test_redis_provider.py | 19 ++- 3 files changed, 165 insertions(+), 56 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 1ada92889..082a93c7d 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -40,21 +40,25 @@ follows continue-as-new by construction and ``run_id`` only decides whose close ends a read. - An activity's own streams are one Redis stream per topic, keyed by the - namespace, the workflow id, empty for a standalone activity, and the - activity id. The owner is one key component joined with ``/``, a character - the chain keys percent-encode out of every id, so no chain key and no key - derived from one can name an activity's stream. There is no input stream, - because no workflow reads these, and no staging, because an activity's - append is visible as soon as the store accepts it. The key holds the - activity id and not its run, so a retry writes to the same stream, which a - reader sees as ``SUPERSEDED``, and an id started again continues it. A - read ends when the owner is terminal and the retained tail is delivered: a - standalone activity is described directly, and a workflow's activity - through its workflow, whose close ends the read, as does the activity - leaving the pending set once its stream exists. An activity that never - wrote has no stream, so a read on it waits for the workflow. Nothing gates - an append after that, so a late attempt still lands, and a read that has - ended does not see it. + namespace, the workflow id, empty for a standalone activity, the run the + activity execution belongs to, and the activity id. The run is the + workflow's for a workflow's activity and the activity's own for a + standalone one, so a retry writes to the same stream, which a reader sees + as ``SUPERSEDED``, and an id started again, in a new run, starts a new + one, as it does on the server-side provider. Inside the activity the run + is in its info; a handle opened outside without one describes the owner + and takes its current run. The owner is one key component joined with + ``/``, a character the chain keys percent-encode out of every id, so no + chain key and no key derived from one can name an activity's stream. + There is no input stream, because no workflow reads these, and no + staging, because an activity's append is visible as soon as the store + accepts it. A read ends when the owner is terminal and the retained tail + is delivered: a standalone activity is described directly, and a + workflow's activity through its workflow, whose close ends the read, as + does the activity leaving the pending set once its stream exists. An + activity that never wrote has no stream, so a read on it waits for the + workflow. Nothing gates an append after that, so a late attempt still + lands, and a read that has ended does not see it. - A task's publishes are staged as one batch. The transport's own per-task batch limits are lifted for this provider, because a synchronous publish cannot wait for the worker to stage a full batch; a batch it cannot stage @@ -78,7 +82,7 @@ import re import time from collections.abc import AsyncGenerator, Coroutine, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import timedelta from typing import Any, Final, Generic, TypeVar from urllib.parse import quote @@ -422,10 +426,13 @@ def _prefix(backend: Any) -> str: class _ActivityOwner: """The activity whose streams a handle addresses, and where Redis keeps them. - ``workflow_id`` is ``None`` for a standalone activity. ``run_id`` pins the + ``workflow_id`` is ``None`` for a standalone activity. ``run_id`` is the workflow's run for a workflow's activity and the activity's own run for a - standalone one: it decides whose close ends a read and never which key is - read, because an activity's streams belong to the activity id. + standalone one. It is part of the key, so an activity execution's streams + are its own and an id started again in a new run starts new ones, and it + decides whose close ends a read. ``None`` means the run is not known yet: + a handle opened outside without one asks the server for the current run + before it touches a key. """ namespace: str @@ -435,12 +442,14 @@ class _ActivityOwner: def key(self, key_prefix: str, topic: str) -> str: """The Redis stream holding ``topic`` of this activity's streams.""" + if self.run_id is None: + raise RuntimeError(f"the run of {self} was not resolved before its key") # The chain keys percent-encode every id, so none of their components # holds a "/", and a component built around one can never equal a # chain key or a key derived from one. namespace = quote(self.namespace, safe="") owner = f"activity/{quote(self.workflow_id or '', safe='')}/" - owner += quote(self.activity_id, safe="") + owner += f"{quote(self.run_id, safe='')}/{quote(self.activity_id, safe='')}" return f"{key_prefix}:{namespace}:{owner}:{quote(topic, safe='')}" def context(self) -> SerializationContext: @@ -471,6 +480,34 @@ def __str__(self) -> str: return f"activity {self.activity_id!r} of workflow {self.workflow_id!r}" +async def _resolve_owner(client: Client, owner: _ActivityOwner) -> _ActivityOwner: + """``owner`` with its run known, or ``StreamNotFoundError`` when the server does not know it. + + One describe: it says whether the owner exists and, for a handle opened + without a run, which run is current. A standalone activity is described + itself; a workflow's activity is described through its workflow, because + the server does not describe it on its own. + """ + try: + if owner.workflow_id is None: + described = await client.get_activity_handle( + owner.activity_id, run_id=owner.run_id + ).describe() + run_id = owner.run_id or described.activity_run_id + else: + description = await client.get_workflow_handle( + owner.workflow_id, run_id=owner.run_id + ).describe() + run_id = owner.run_id or description.run_id + except RPCError as error: + if error.status == RPCStatusCode.NOT_FOUND: + raise StreamNotFoundError(f"{owner} was not found") from error + raise + if not run_id: + raise StreamNotFoundError(f"the server reports no run for {owner}") + return replace(owner, run_id=run_id) + + async def _retained(client: Any, name: str, offset: Offset) -> bool: """Whether the record at ``offset`` on the stream ``name`` survived trimming. @@ -803,6 +840,9 @@ async def _connect(self) -> None: if self._owner is not None: # No transport producer to bind and no one to wake: an activity's # stream has no workflow reader and no chain to check the key against. + # Inside the activity the run is known and nothing is described. + if self._owner.run_id is None: + self._owner = await _resolve_owner(self._client, self._owner) self._name = self._owner.key(_prefix(backend), self._topic) self._owned = _OwnedAppend(backend) self._codec = StreamPayloadCodec( @@ -1018,7 +1058,9 @@ async def _read_owned( self._converter, result_type, after=after, warn=logger.warning ) backend = self._streams._require_backend() - await self._require_owner(owner) + # Described on every read, so a handle without a run reads the run that + # is current when the read starts, and ends with it. + owner = await _resolve_owner(self._client, owner) store = backend._client name = owner.key(_prefix(backend), topic) if ( @@ -1063,22 +1105,6 @@ async def _read_owned( # that landed between the read and the describe is not lost. closed = await self._owner_closed(owner, store, name) - async def _require_owner(self, owner: _ActivityOwner) -> None: - """Raise ``StreamNotFoundError`` when the server does not know ``owner``.""" - try: - if owner.workflow_id is None: - await self._client.get_activity_handle( - owner.activity_id, run_id=owner.run_id - ).describe() - else: - await self._client.get_workflow_handle( - owner.workflow_id, run_id=owner.run_id - ).describe() - except RPCError as error: - if error.status == RPCStatusCode.NOT_FOUND: - raise StreamNotFoundError(f"{owner} was not found") from error - raise - async def _owner_closed(self, owner: _ActivityOwner, store: Any, name: str) -> bool: """Whether the owning activity is terminal, as far as this store can tell. @@ -1238,8 +1264,9 @@ async def latest(self, *, topic: str | StreamTopic[Any]) -> Cursor: topic, _ = resolve_topic(topic) backend = self._streams._require_backend() if self._owner is not None: + owner = await _resolve_owner(self._client, self._owner) newest: Any = await backend._client.xrevrange( - self._owner.key(_prefix(backend), topic), "+", "-", count=1 + owner.key(_prefix(backend), topic), "+", "-", count=1 ) if not newest: return BEGINNING @@ -1404,13 +1431,15 @@ def get_activity_stream_handle( """A handle on the topics ``activity_id`` owns, apart from any workflow's. Without ``workflow_id`` the activity is a standalone one and ``run_id`` - pins its run; with one it is that workflow's activity and ``run_id`` - pins the workflow's run. The streams are keyed by the activity id and - not its run, so a retry writes to the same ones and a reader sees the - attempt change as ``SUPERSEDED``, and an id started again continues - them. A read ends when the activity is terminal and the retained tail - is delivered; the store has no gate on an append after that, so a late - attempt still lands, and a read that has ended does not see it. + names its run; with one it is that workflow's activity and ``run_id`` + names the workflow's run. The streams are keyed by that run, so a + retry writes to the same ones and a reader sees the attempt change as + ``SUPERSEDED``, while an id started again in a new run starts new + ones. Without ``run_id`` each read, ``latest()`` and the first append + of a producer describe the owner and take the run current at that + moment. A read ends when the activity is terminal and the retained + tail is delivered; the store has no gate on an append after that, so + a late attempt still lands, and a read that has ended does not see it. """ return RedisStreamHandle(self, client, workflow_id, run_id, activity_id) diff --git a/tests/streams/test_redis_activity_streams.py b/tests/streams/test_redis_activity_streams.py index ec60a4a7f..9208fb54f 100644 --- a/tests/streams/test_redis_activity_streams.py +++ b/tests/streams/test_redis_activity_streams.py @@ -4,10 +4,12 @@ ``STREAMS_LIVE=redis``. This module covers what only this store does: how a read learns that a workflow's activity is terminal without the server saying so, that an activity that never wrote is read until its workflow closes, -that retention trims an activity's stream like any other, and that nothing -gates an append once the owner is terminal. All need a dev server -(``TEMPORAL_ADDRESS`` or the test environment's own) and a Redis -(``TEMPORAL_TEST_REDIS_URL`` or ``AI198_REDIS_URL``). +that retention trims an activity's stream like any other, that nothing +gates an append once the owner is terminal, and that an activity's streams +belong to the run its execution is in, so an id started again in a new run +starts new ones. All need a dev server (``TEMPORAL_ADDRESS`` or the test +environment's own) and a Redis (``TEMPORAL_TEST_REDIS_URL`` or +``AI198_REDIS_URL``). """ from __future__ import annotations @@ -28,6 +30,7 @@ from tests.helpers import new_worker from tests.streams.test_activity_streams import ( TOKENS, + RunsOneActivity, read_all, summary, write_by_default, @@ -189,7 +192,8 @@ async def test_retention_trims_an_activity_stream_too(client: Client): async def test_an_append_after_the_owner_is_terminal_still_lands(live: Client): # The store has no gate the server would have: a late attempt writes, and - # a reader that already ended is not told. + # a reader that already ended is not told. The handle names no run, so it + # describes the finished activity and lands on that run's stream. activity_id = f"streams-redis-saa-late-{uuid.uuid4().hex}" async with new_worker(live, activities=[write_by_default]) as worker: handle = await live.start_activity( @@ -213,3 +217,72 @@ async def test_an_append_after_the_owner_is_terminal_still_lands(live: Client): (RecordKind.SUPERSEDED, 2, None), (RecordKind.DATA, 2, "late"), ] + # Pinned to the run, the same stream reads the same way. + pinned = live.get_stream_handle(activity_id=activity_id, run_id=handle.run_id) + assert summary(await read_all(pinned.read(topic=TOKENS), timeout=10)) == summary( + records + ) + + +async def test_a_standalone_activity_id_started_again_starts_a_new_stream( + live: Client, +): + # The stream belongs to the activity execution, which is its run, not to + # the id: the second execution under the same id writes to a new stream, + # a handle without a run reads the current one, and a run pins the other. + activity_id = f"streams-redis-saa-again-{uuid.uuid4().hex}" + async with new_worker(live, activities=[write_by_default]) as worker: + runs = [] + for label in ("first", "second"): + handle = await live.start_activity( + write_by_default, + label, + id=activity_id, + task_queue=worker.task_queue, + start_to_close_timeout=timedelta(seconds=30), + ) + assert await handle.result() == activity_id + runs.append(handle.run_id) + assert runs[0] != runs[1] + current = live.get_stream_handle(activity_id=activity_id) + assert summary(await read_all(current.read(topic=TOKENS), timeout=10)) == [ + (RecordKind.DATA, 1, "second"), + (RecordKind.FINISH, 1, None), + ] + earlier = live.get_stream_handle(activity_id=activity_id, run_id=runs[0]) + assert summary(await read_all(earlier.read(topic=TOKENS), timeout=10)) == [ + (RecordKind.DATA, 1, "first"), + (RecordKind.FINISH, 1, None), + ] + + +async def test_a_workflow_activity_in_a_new_run_starts_a_new_stream(live: Client): + # The same workflow id run again schedules the same activity id; keyed by + # the workflow's run, the two executions keep their streams apart. + workflow_id = f"streams-redis-wfa-again-{uuid.uuid4().hex}" + async with new_worker( + live, RunsOneActivity, activities=[write_to_own_streams] + ) as worker: + runs = [] + for label in ("first", "second"): + handle = await live.start_workflow( + RunsOneActivity.run, + args=["write_to_own_streams", label], + id=workflow_id, + task_queue=worker.task_queue, + ) + await handle.result() + runs.append(handle.result_run_id) + assert runs[0] != runs[1] + current = live.get_stream_handle(workflow_id, activity_id="streamer") + assert summary(await read_all(current.read(topic=TOKENS), timeout=10)) == [ + (RecordKind.DATA, 1, "second"), + (RecordKind.FINISH, 1, None), + ] + earlier = live.get_stream_handle( + workflow_id, activity_id="streamer", run_id=runs[0] + ) + assert summary(await read_all(earlier.read(topic=TOKENS), timeout=10)) == [ + (RecordKind.DATA, 1, "first"), + (RecordKind.FINISH, 1, None), + ] diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py index 36dc56fb8..dcb51adff 100644 --- a/tests/streams/test_redis_provider.py +++ b/tests/streams/test_redis_provider.py @@ -162,18 +162,25 @@ def register_script(self, _script: str) -> None: def test_activity_keys_encode_their_ids_and_never_meet_chain_keys(): + # The run is part of the key: a workflow's activity is keyed by the + # workflow's run and a standalone one by its own, so an id started again + # in a new run starts a new stream. assert ( - _ActivityOwner("ns", "wf", "act", None).key("p", "t") - == "p:ns:activity/wf/act:t" + _ActivityOwner("ns", "wf", "act", "run").key("p", "t") + == "p:ns:activity/wf/run/act:t" ) assert ( - _ActivityOwner("ns", None, "act", None).key("p", "t") == "p:ns:activity//act:t" + _ActivityOwner("ns", None, "act", "run").key("p", "t") + == "p:ns:activity//run/act:t" ) # An id holding a separator is encoded, so it cannot move a boundary. assert ( - _ActivityOwner("n:s", "w/f", "a:c", None).key("p", "t/u") - == "p:n%3As:activity/w%2Ff/a%3Ac:t%2Fu" + _ActivityOwner("n:s", "w/f", "a:c", "r/1").key("p", "t/u") + == "p:n%3As:activity/w%2Ff/r%2F1/a%3Ac:t%2Fu" ) + # A key needs the run; a handle opened without one resolves it first. + with pytest.raises(RuntimeError, match="not resolved"): + _ActivityOwner("ns", "wf", "act", None).key("p", "t") # A chain key percent-encodes every id, so none of its components holds a # "/" however the ids are chosen, and the owner component here always does. backend = RedisStreamBackend(client=_NoRedis(), key_prefix="p") @@ -185,7 +192,7 @@ def test_activity_keys_encode_their_ids_and_never_meet_chain_keys(): direction=StreamDirection.OUTPUT, ) assert "/" not in backend.stream_key(forged) - assert backend.stream_key(forged) != _ActivityOwner("ns", "wf", "act", None).key( + assert backend.stream_key(forged) != _ActivityOwner("ns", "wf", "act", "t").key( "p", "t" ) From 84e268c6e3beab665fe94d3b7e256212132d4a80 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 30 Sep 2026 02:39:25 -0700 Subject: [PATCH 81/96] Kept one Redis log per topic instead of an input and an output key. The two-key split gave the workflow and outside readers different views of one topic. The provider's backend renders both transport directions onto the input key's log, an outside producer appends once, the workflow's staged batches are promoted into the same log, and the workflow reader drops its own records by producer identity. Cursors name entries of the one log on both sides. --- temporalio/streams/providers/redis.py | 418 ++++++++++++-------------- tests/streams/test_redis_provider.py | 56 ++-- tests/streams/test_redis_replay.py | 170 +++++++---- 3 files changed, 326 insertions(+), 318 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 082a93c7d..9e1b47853 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -8,34 +8,33 @@ The mapping, in one place: -- The transport keeps two Redis streams per topic, one the workflow reads and - one outside readers read, because the direction is part of every key it - derives. An outside producer therefore appends each record to both: the - input stream, which wakes a workflow subscribed to the topic, and the - output stream, where the workflow's own promoted batches land, so an - outside reader sees the producer's records and the workflow's in one order - and a workflow reading the topic misses nothing however early the producer - started. Both keys are written by one script, so a record is on both or on - neither: split across two calls, a crash between them leaves a record the - workflow consumes and no outside reader can ever see. The cost is one more - append per record and one wake per batch. A wake the server refuses because - the consuming run is closing is sent again after a short wait: a run that - continues as new hands the stream to its successor, the records are already - where the successor reads them, and only the wake has to follow. The - retries stop when the chain's current run takes the wake or the chain - proves terminal. +- One Redis stream per topic, the topic's log, read by the workflow and by + outside readers alike. The transport derives an input key and an output key + for a topic, because the direction is part of every key it renders; this + provider's backend renders both onto the log. An outside producer appends a + record once, and it is where the workflow's subscription reads and where an + outside read follows; the workflow's staged batches land in the same log + when its task completes and are promoted there, so an outside reader sees + the producer's records and the workflow's in one order. The workflow does + not read its own records: the backend drops the entries the workflow staged + from every read it serves the transport, the live read and the replay read + alike, so a recorded range and its replay agree. The wake path is the + transport's own: a producer appends, then wakes the subscribed run through + the input key. A wake the server refuses because the consuming run is + closing is sent again after a short wait: a run that continues as new hands + the log to its successor, the records are already where the successor + reads them, and only the wake has to follow. The retries stop when the + chain's current run takes the wake or the chain proves terminal. - A record rides as the transport's payload: the serialized ``StreamRecord`` proto as a ``binary/plain`` value, which the worker's codec encodes and decodes like any other payload. - Producer identity dedupes through the transport's idempotency: the session is ``producer#attempt`` and every record carries a sequence, so a retried batch is dropped with the original position and a new attempt passes. -- Cursors are ``redis:-`` on the outside surface and name a position - in the output stream. A workflow-side record carries ``redis:in:-``, - a position in the input stream, and only that form seeds a workflow reader: - the two streams number their entries independently, so an outside cursor - cannot stand in for one. A reader opened without a cursor starts where the - chain's predecessor run committed, which is the transport's own rule. +- Cursors are ``redis:-`` and name an entry of the topic's log, so a + cursor a workflow reader returned seeds an outside read and the other way + round. A reader opened without a cursor starts where the chain's + predecessor run committed, which is the transport's own rule. - A workflow's streams are keyed by the chain's first run, so a handle follows continue-as-new by construction and ``run_id`` only decides whose close ends a read. @@ -73,6 +72,18 @@ empty. A run that has to replay cold after seven days of consuming fails, so a long-lived consumer continues as new inside the window, or is configured with a longer one. + +Keys written by the earlier layout. Before the log, a topic was two keys: the +transport's input key, ``::::`` +with every id percent-encoded, and its output key, the same with an ``output`` +component before the topic. The log is the input key, so the records outside +producers wrote, the ranges a workflow recorded and the cursors its readers +returned, ``redis:in:-``, read as they did. The output key is not read +any more: batches a workflow promoted under the old layout are not delivered to +an outside reader, and an outside cursor minted then names an entry of that +key, so a reader holding one starts over from ``BEGINNING`` or positions itself +with ``latest()``. Nothing moves or deletes an old output key; it ages out under +the operator's own retention. """ from __future__ import annotations @@ -96,10 +107,8 @@ AppendConflictError, AppendNotAcknowledgedError, ChainKeyMismatchError, - ExternalOutputStreamProducer, ExternalStreamProducer, Offset, - OutputAppendNotAcknowledgedError, StreamDirection, WakeNotAcknowledgedError, WorkflowChainKey, @@ -137,6 +146,7 @@ ) from temporalio.contrib.external_workflow_streams._redis import ( _BEGINNING_SENTINEL, + _OUTPUT_STAGE_FIELD, RedisStreamBackend, _content_hash, _text, @@ -173,9 +183,12 @@ T = TypeVar("T") _PROVIDER = "redis" -_INPUT_PREFIX = "in:" +#: What a workflow reader's cursor carried when a topic was two keys. It named +#: an entry of the input key, which is the log now, so the form is still read. +_LEGACY_INPUT_PREFIX = "in:" _REDIS_ID = re.compile(r"\d+-\d+") _READ_BATCH = 256 +_STAGE_FIELD: Final = _OUTPUT_STAGE_FIELD.encode() #: How long a record is kept when the constructor is not told otherwise. #: @@ -207,16 +220,18 @@ def _require_topic(topic: str) -> None: raise ValueError("topic must not be empty") -def _outside_position(after: Cursor) -> Offset | None: - """The output-stream offset an outside cursor names, or ``None`` for BEGINNING.""" +def _position(after: Cursor) -> Offset | None: + """The log entry a cursor names, or ``None`` for BEGINNING. + + Raises: + StreamCursorError: The cursor is another provider's, or does not name + a Redis entry id. + """ token = cursor_position(after, provider=_PROVIDER) if token is None: return None - if token.startswith(_INPUT_PREFIX): - raise StreamCursorError( - f"cursor {after.token!r} names a position in the workflow's input log, " - "which only the workflow reads" - ) + if token.startswith(_LEGACY_INPUT_PREFIX): + token = token[len(_LEGACY_INPUT_PREFIX) :] if not _REDIS_ID.fullmatch(token): raise StreamCursorError( f"cursor {after.token!r} does not name a Redis stream position" @@ -224,26 +239,6 @@ def _outside_position(after: Cursor) -> Offset | None: return Offset(token) -def _workflow_position(after: Cursor) -> Offset | None: - """The input-stream offset a workflow reader's cursor names, or ``None`` for BEGINNING.""" - token = cursor_position(after, provider=_PROVIDER) - if token is None: - return None - if token.startswith(_INPUT_PREFIX): - entry = token[len(_INPUT_PREFIX) :] - if _REDIS_ID.fullmatch(entry): - return Offset(entry) - elif _REDIS_ID.fullmatch(token): - raise StreamCursorError( - f"cursor {after.token!r} names a position in the topic's output stream; " - "a workflow reader follows the input stream, whose entry ids differ, so " - "pass a cursor a workflow reader returned" - ) - raise StreamCursorError( - f"cursor {after.token!r} does not name a Redis stream position" - ) - - def _entry_id(token: str | bytes) -> tuple[int, int]: """A Redis entry id as the ``(ms, seq)`` pair it orders by.""" text = token.decode() if isinstance(token, bytes) else token @@ -251,58 +246,6 @@ def _entry_id(token: str | bytes) -> tuple[int, int]: return int(ms), int(seq or 0) -#: Append one record to a topic's two keys, or to neither. -#: -#: The provider's own write, not the transport's: the transport appends one key at a -#: time, and the two calls that would take are not one failure. A record on the input -#: key alone is one the workflow consumes and no outside reader can ever see, and a -#: producer that comes back with a fresh sequence never heals it. -#: -#: Both idempotency hashes are read before either stream is touched, so a key already -#: used with different bytes refuses the pair rather than half-writing it. Either half -#: already present is reused, which is what settles a pair some earlier call left -#: half-written. The retention trims ride along rather than costing their own round -#: trips, and they are exact for the reason the backend's own trims are. -_PAIRED_APPEND_LUA: Final = """ -local minid = ARGV[1] -local maxlen = ARGV[2] -local function placed(idem, key, digest) - local existing = redis.call('HGET', idem, key) - if not existing then - return nil - end - local sep = string.find(existing, '|') - if string.sub(existing, sep + 1) ~= digest then - return 'conflict' - end - return string.sub(existing, 1, sep - 1) -end -local input = placed(KEYS[2], ARGV[3], ARGV[4]) -local output = placed(KEYS[4], ARGV[3], ARGV[4]) -if input == 'conflict' or output == 'conflict' then - return {'conflict', '', ''} -end -local fields = {unpack(ARGV, 5)} -if not input then - input = redis.call('XADD', KEYS[1], '*', unpack(fields)) - redis.call('HSET', KEYS[2], ARGV[3], input .. '|' .. ARGV[4]) -end -if not output then - output = redis.call('XADD', KEYS[3], '*', unpack(fields)) - redis.call('HSET', KEYS[4], ARGV[3], output .. '|' .. ARGV[4]) -end -if minid ~= '' then - redis.call('XTRIM', KEYS[1], 'MINID', minid) - redis.call('XTRIM', KEYS[3], 'MINID', minid) -end -if maxlen ~= '' then - redis.call('XTRIM', KEYS[1], 'MAXLEN', maxlen) - redis.call('XTRIM', KEYS[3], 'MAXLEN', maxlen) -end -return {'ok', input, output} -""" - - def _trim_floor(backend: Any) -> str: retention = getattr(backend, "_retention", None) if retention is None: @@ -319,7 +262,7 @@ def _trim_maxlen(backend: Any) -> str: def _append_args(backend: Any, record: TransportRecord) -> list[Any]: - """What both append scripts take: the trims, the record's identity, its fields.""" + """What the append script takes: the trims, the record's identity, its fields.""" args: list[Any] = [ _trim_floor(backend).encode(), _trim_maxlen(backend).encode(), @@ -332,47 +275,15 @@ def _append_args(backend: Any, record: TransportRecord) -> list[Any]: return args -class _PairedAppend: - """One logical record on a topic's input and output keys, in one Redis call.""" - - def __init__(self, backend: RedisStreamBackend) -> None: - """Bind to ``backend``'s client and key layout.""" - self._backend = backend - self._script = backend._client.register_script(_PAIRED_APPEND_LUA) - - async def write( - self, - *, - input_key: StreamKey, - output_key: StreamKey, - record: TransportRecord, - ) -> Offset: - """Append ``record`` to both keys and return where it landed on the output key. - - A repeat of the same ``(session, sequence)`` with the same bytes returns the - original positions and writes nothing, so a call whose answer was lost is - settled by making it again. - """ - outcome, _, output = await self._script( - keys=[ - self._backend.stream_key(input_key), - self._backend._idempotency_key(input_key), - self._backend.stream_key(output_key), - self._backend._idempotency_key(output_key), - ], - args=_append_args(self._backend, record), - ) - if _text(outcome) == "conflict": - raise AppendConflictError(record.idempotency_key) - return Offset(_text(output)) - - -#: Append one record to an activity's stream, or answer where it already is. +#: Append one record to a log, or answer where it already is. #: -#: The single-key form of the paired write: an activity's stream has no input -#: half and no stage, so one stream and its idempotency hash are the whole -#: write, and the retention trims ride along as they do on the pair. -_OWNED_APPEND_LUA: Final = """ +#: The provider's own write rather than the transport's, so the retention trims +#: ride along instead of costing their own round trips; they are exact for the +#: reason the backend's own trims are. The idempotency hash is read before the +#: log is touched, so an identity already used with different bytes refuses the +#: record rather than writing it, and one used with the same bytes answers with +#: the original position, which is what settles a call whose answer was lost. +_LOG_APPEND_LUA: Final = """ local minid = ARGV[1] local maxlen = ARGV[2] local existing = redis.call('HGET', KEYS[2], ARGV[3]) @@ -395,16 +306,16 @@ async def write( """ -class _OwnedAppend: - """One record on an activity's stream, in one Redis call.""" +class _LogAppend: + """One record on one log, a topic's or an activity's, in one Redis call.""" def __init__(self, backend: RedisStreamBackend) -> None: """Bind to ``backend``'s client.""" self._backend = backend - self._script = backend._client.register_script(_OWNED_APPEND_LUA) + self._script = backend._client.register_script(_LOG_APPEND_LUA) async def write(self, *, name: str, record: TransportRecord) -> Offset: - """Append ``record`` to the stream ``name`` and return where it landed. + """Append ``record`` to the log ``name`` and return where it landed. A repeat of the same ``(session, sequence)`` with the same bytes returns the original position and writes nothing. @@ -525,13 +436,27 @@ async def _retained(client: Any, name: str, offset: Offset) -> bool: return wanted > _entry_id(info["last-generated-id"]) -class _RetainingBackend(RedisStreamBackend): - """The transport's Redis backend, trimming the key behind every append it makes. +def _is_staged(fields: Any) -> bool: + """Whether a log entry is one the workflow staged, rather than a producer's.""" + return _STAGE_FIELD in fields + + +class _TopicLogBackend(RedisStreamBackend): + """The transport's Redis backend with this provider's layout and trims. + + One key per topic: the transport renders a topic's input key and its + output key apart, because the direction is part of every key it derives, + and this backend renders both onto the log, the input key's rendering. + Every read the transport makes through an input key, the live watch and + the replay range alike, drops the entries the workflow staged itself, so + a workflow never reads its own records and a recorded range replays to + what was delivered. Reads through an output key are the outside reader's + and see the whole log, with the stage protocol deciding what is visible. Trims are exact rather than approximate: Redis's approximate trim drops whole macro nodes only, so a stream shorter than one node, a hundred entries by default, would never trim and the window would not mean what - it says. Only the streams are trimmed; the idempotency and stage hashes + it says. Only the logs are trimmed; the idempotency and stage hashes beside them keep one entry per record and stage. """ @@ -547,6 +472,51 @@ def __init__( self._retention = retention self._max_len = max_len + def stream_key(self, key: StreamKey) -> str: + """The topic's log, whichever direction the transport asks for.""" + return super().stream_key(replace(key, direction=StreamDirection.INPUT)) + + async def read_after( + self, + key: StreamKey, + after: Any, + *, + max_records: int, + block: timedelta | None = DEFAULT_WATCH_BLOCK, + ) -> list[TransportRecord]: + """The live read, without the entries the workflow staged itself. + + A batch that held nothing but the workflow's own entries is read past + without blocking again, so the transport's recheck, which asks for one + record, is not answered "nothing" while a producer's record sits behind + a staged batch. + """ + if key.direction is StreamDirection.OUTPUT: + return await super().read_after( + key, after, max_records=max_records, block=block + ) + name = self.stream_key(key) + start = _BEGINNING_SENTINEL if after.is_beginning else after.offset.serialize() + block_ms = None if block is None else int(block.total_seconds() * 1000) + if block_ms is not None and block_ms <= 0: + block_ms = None + while True: + found: Any = await self._client.xread( + {name: start}, count=max_records, block=block_ms + ) + entries = found[0][1] if found else [] + if not entries: + return [] + records = [ + _to_record(entry_id, fields) + for entry_id, fields in entries + if not _is_staged(fields) + ] + if records: + return records + start = _text(entries[-1][0]) + block_ms = None + def describe_window(self) -> str: """The configured window, for messages.""" parts = [] @@ -580,7 +550,9 @@ async def stage_output( await self._trim(manifest.stream_key) return stage - async def read_range(self, key: StreamKey, first: Offset, last: Offset) -> Any: + async def read_range( + self, key: StreamKey, first: Offset, last: Offset + ) -> list[TransportRecord]: # The replay read. Said here, where the trim is known, rather than left # to the range checks, which can only report the record as missing. if not await self.retains(key, first): @@ -590,7 +562,16 @@ async def read_range(self, key: StreamKey, first: Offset, last: Offset) -> Any: f"({self.describe_window()}): the records were trimmed, so this " "run cannot be replayed" ) - return await super().read_range(key, first, last) + entries: Any = await self._client.xrange( + self.stream_key(key), first.serialize(), last.serialize() + ) + # The same entries the live read dropped, so the range holds the count + # the marker recorded. + return [ + _to_record(entry_id, fields) + for entry_id, fields in entries + if key.direction is StreamDirection.OUTPUT or not _is_staged(fields) + ] async def retains(self, key: StreamKey, offset: Offset) -> bool: """Whether the record at ``offset`` survived trimming.""" @@ -638,7 +619,7 @@ def _parse(cursor: Cursor, raw: bytes, warn: Any) -> WireRecord | None: class _RedisReadSource: - """One subscription of the running workflow, over the transport's input stream.""" + """One subscription of the running workflow, over the transport's input key.""" def __init__(self, subscription: Any) -> None: self._subscription = subscription @@ -649,7 +630,7 @@ async def next_batch(self) -> list[tuple[Cursor, WireRecord]]: # One record per batch: the transport reports readiness per record, # and a batch here would invent a boundary replay never observed. offset, body = await self._records.__anext__() - cursor = mint_cursor(_PROVIDER, f"{_INPUT_PREFIX}{offset.token}") + cursor = mint_cursor(_PROVIDER, offset.token) wire = _parse(cursor, body, workflow.logger.warning) if wire is not None: return [(cursor, wire)] @@ -677,7 +658,7 @@ def __init__(self, idle_timeout: timedelta) -> None: def open_reader(self, topic: str, *, after: Cursor) -> ReadSource: _require_topic(topic) - position = _workflow_position(after) + position = _position(after) # Without a position the transport resumes where the chain's # predecessor run committed; with one, that is where the wait starts # and what the marker's header records. @@ -735,9 +716,9 @@ class RedisProducer(Generic[T]): """Appends to a topic from outside workflow code. Every append is visible as soon as the store accepts it. Each record goes - to the topic's input stream, waking a workflow subscribed to it, and to - its output stream, where outside readers and the workflow's own records - meet; the cursor returned names the output position. + to the topic's log, where the workflow's subscription reads it and where + outside readers see it beside the workflow's own records, and a workflow + subscribed to the topic is woken; the cursor returned names the entry. """ def __init__( @@ -762,9 +743,7 @@ def __init__( self._sequence = 0 self._last = BEGINNING self._input: Any = None - self._output: Any = None - self._pair: _PairedAppend | None = None - self._owned: _OwnedAppend | None = None + self._append: _LogAppend | None = None self._name: str | None = None self._codec: StreamPayloadCodec[bytes] | None = None @@ -837,6 +816,7 @@ async def _connect(self) -> None: if self._codec is not None: return backend = self._streams._require_backend() + self._append = _LogAppend(backend) if self._owner is not None: # No transport producer to bind and no one to wake: an activity's # stream has no workflow reader and no chain to check the key against. @@ -844,7 +824,6 @@ async def _connect(self) -> None: if self._owner.run_id is None: self._owner = await _resolve_owner(self._client, self._owner) self._name = self._owner.key(_prefix(backend), self._topic) - self._owned = _OwnedAppend(backend) self._codec = StreamPayloadCodec( self._client.data_converter.with_context(self._owner.context()), bytes ) @@ -852,12 +831,8 @@ async def _connect(self) -> None: assert self._workflow_id is not None chain = await _chain(self._client, self._workflow_id) try: - output = await ExternalOutputStreamProducer.connect( - backend=backend, - workflow=chain, - client=self._client, - session_id=self._session, - ) + # The transport's producer is bound for its wake and its key: the + # append itself is the provider's, so the trims ride along with it. input_ = await ExternalStreamProducer.connect( backend=backend, workflow=chain, @@ -869,9 +844,8 @@ async def _connect(self) -> None: f"workflow {self._workflow_id!r} is not the chain this producer " f"was opened on: {error}" ) from error - self._output = output.topic(self._topic, type=bytes) self._input = input_.topic(self._topic, type=bytes) - self._pair = _PairedAppend(backend) + self._name = backend.stream_key(self._input.stream_key) self._codec = StreamPayloadCodec( self._client.data_converter.with_context( WorkflowSerializationContext( @@ -890,7 +864,7 @@ async def _write(self, records: list[WireRecord]) -> Cursor: f"producer {self._session!r} already wrote a different record at " f"sequence {error.key.sequence}" ) from error - except (AppendNotAcknowledgedError, OutputAppendNotAcknowledgedError) as error: + except AppendNotAcknowledgedError as error: raise _storage_error(error, "an append was not acknowledged") from error except TransportStreamError as error: raise _storage_error(error, "the store refused an append") from error @@ -900,30 +874,22 @@ async def _write(self, records: list[WireRecord]) -> Cursor: return self._last async def _place(self, records: list[WireRecord]) -> Offset | None: - """Append ``records`` where the owner keeps them and return the last position.""" - assert self._codec is not None + """Append ``records`` to the owner's log and return the last position.""" + assert self._codec is not None and self._append is not None + assert self._name is not None last: Offset | None = None for index, record in enumerate(records): - # Built here rather than handed to the transport's publish, which - # appends one key per call. The identity is the same one the transport - # would derive, so a record either side already holds is reused. + # Built here rather than handed to the transport's publish, so the + # trims ride along. The identity is the one the transport would + # derive, so a record the log already holds is reused. staged = TransportRecord( kind=TransportRecordKind.DATA, payload=await self._codec.encode(record.SerializeToString()), producer_session_id=self._session, sequence=self._sequence + index, ) - if self._owned is not None: - assert self._name is not None - last = await self._owned.write(name=self._name, record=staged) - continue - assert self._pair is not None - last = await self._pair.write( - input_key=self._input.stream_key, - output_key=self._output.stream_key, - record=staged, - ) - if self._owned is None: + last = await self._append.write(name=self._name, record=staged) + if self._owner is None: await self._wake() return last @@ -976,8 +942,9 @@ async def _chain_is_terminal(self) -> bool: class RedisStreamHandle: """One owner's topics from outside. - The owner is ``workflow_id``'s workflow, read over the transport's output - streams, or with ``activity_id`` an activity, read from the stream the + The owner is ``workflow_id``'s workflow, whose topic logs are read through + the transport's output read so a staged batch is a barrier until its task + settles, or with ``activity_id`` an activity, read from the stream the provider keeps for it: a standalone activity without ``workflow_id``, or an activity that workflow scheduled. """ @@ -1028,20 +995,20 @@ def read( says. Two refusals and they do not land together. A cursor another provider minted, - or one naming a workflow's input log, is refused by this call: reading the + or one that is not a Redis entry id, is refused by this call: reading the token needs nothing from the store. A well-formed cursor the retention has trimmed is refused on the first step of the generator, because answering that needs a round trip and this call is not a coroutine. Neither yields a record first. Raises: - StreamCursorError: The cursor is another provider's, or names a - workflow's input log. + StreamCursorError: The cursor is another provider's, or does not name + a Redis entry. """ topic, result_type = resolve_topic(topic, result_type) # Parsed here so a foreign cursor fails this call, not the first # iteration of the generator. - position = _outside_position(after) + position = _position(after) if self._owner is not None: return self._read_owned(self._owner, topic, position, after, result_type) return self._read(topic, position, after, result_type) @@ -1065,7 +1032,7 @@ async def _read_owned( name = owner.key(_prefix(backend), topic) if ( position is not None - and isinstance(backend, _RetainingBackend) + and isinstance(backend, _TopicLogBackend) and not await _retained(store, name, position) ): # Refused rather than resumed from the first retained record, @@ -1157,7 +1124,7 @@ async def _read( key = chain.stream_key(topic, direction=StreamDirection.OUTPUT) if ( position is not None - and isinstance(backend, _RetainingBackend) + and isinstance(backend, _TopicLogBackend) and not await backend.retains(key, position) ): # Refused rather than resumed from the first retained record, @@ -1320,7 +1287,7 @@ def __init__( url: str = "redis://127.0.0.1:6379", key_prefix: str = "temporal-streams", idle_timeout: timedelta = timedelta(seconds=1), - backend: Any | None = None, + client: Any | None = None, poll_interval: timedelta = timedelta(milliseconds=500), retention: timedelta | None = DEFAULT_RETENTION, max_len: int | None = None, @@ -1328,19 +1295,18 @@ def __init__( """Create the provider. Args: - url: The Redis to connect to when no ``backend`` is given. + url: The Redis to connect to when no ``client`` is given. key_prefix: Prepended to every key, so one Redis serves several deployments. idle_timeout: How long a workflow reader with nothing to read holds its Workflow Task open before the worker parks it. - backend: A transport backend the caller constructed and owns. It - is trimmed by its owner, so it takes no ``max_len`` and no - ``retention`` other than the default, which does not apply - to it. + client: A ``redis.asyncio.Redis`` the caller opened, with + ``decode_responses=False``, and closes itself; the provider + puts its own key layout and trims on top of it. poll_interval: How long an outside reader that is caught up waits for a record before asking whether the workflow closed. - retention: Trim records older than this from a topic's input and - output keys on every append the provider makes to them. + retention: Trim records older than this from a topic's log on + every append the provider makes to it. :data:`DEFAULT_RETENTION`, seven days, unless the caller says otherwise; ``None`` keeps every record until ``max_len`` trims it, or for good when that is unset too. This is @@ -1362,36 +1328,32 @@ def __init__( raise ValueError("retention must be positive") if max_len is not None and max_len < 1: raise ValueError("max_len must be positive") - if backend is not None and ( - retention not in (None, DEFAULT_RETENTION) or max_len is not None - ): - raise ValueError( - "retention and max_len trim the backend this provider opens; a " - "backend handed in is trimmed by its owner" - ) self._url = url self._key_prefix = key_prefix self._idle_timeout = idle_timeout - self._backend = backend + self._client = client + self._backend: _TopicLogBackend | None = None self._owned_client: Any = None self._poll = poll_interval self._retention = retention self._max_len = max_len - def _require_backend(self) -> Any: + def _require_backend(self) -> _TopicLogBackend: if self._backend is None: - import redis.asyncio - - # A dead peer would otherwise hold a blocking read open forever. - # Several block periods, so a healthy socket that is merely idle - # inside one read window is never abandoned. - self._owned_client = redis.asyncio.from_url( - self._url, - decode_responses=False, - socket_timeout=DEFAULT_WATCH_BLOCK.total_seconds() * 6, - ) - self._backend = _RetainingBackend( - client=self._owned_client, + client = self._client + if client is None: + import redis.asyncio + + # A dead peer would otherwise hold a blocking read open forever. + # Several block periods, so a healthy socket that is merely idle + # inside one read window is never abandoned. + client = self._owned_client = redis.asyncio.from_url( + self._url, + decode_responses=False, + socket_timeout=DEFAULT_WATCH_BLOCK.total_seconds() * 6, + ) + self._backend = _TopicLogBackend( + client=client, key_prefix=self._key_prefix, retention=self._retention, max_len=self._max_len, @@ -1444,8 +1406,8 @@ def get_activity_stream_handle( return RedisStreamHandle(self, client, workflow_id, run_id, activity_id) async def close(self) -> None: - """Release the Redis connections this provider opened.""" + """Release the Redis connection this provider opened; a caller's stays open.""" + self._backend = None client, self._owned_client = self._owned_client, None if client is not None: - self._backend = None await client.aclose() diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py index dcb51adff..93758076e 100644 --- a/tests/streams/test_redis_provider.py +++ b/tests/streams/test_redis_provider.py @@ -29,8 +29,7 @@ RedisStreams, _ActivityOwner, _drive, - _outside_position, - _workflow_position, + _position, ) @@ -205,18 +204,21 @@ def test_an_activity_owner_names_itself_for_messages(): ) -def test_outside_cursors_name_output_positions(): - assert _outside_position(BEGINNING) is None - position = _outside_position(Cursor("redis:1700000000000-3")) +def test_cursors_name_entries_of_the_topics_one_log(): + assert _position(BEGINNING) is None + position = _position(Cursor("redis:1700000000000-3")) assert position is not None and position.token == "1700000000000-3" - # A workflow-side cursor names the input log, which outside code cannot - # read from, and a token another provider minted is refused the same way. + # A workflow reader and an outside reader name the same log, so either + # side's cursor seeds the other. The form the two-key layout minted for + # workflow readers named an entry of the input key, which is the log now. + legacy = _position(Cursor("redis:in:1700000000000-3")) + assert legacy is not None and legacy.token == "1700000000000-3" with pytest.raises(StreamCursorError): - _outside_position(Cursor("redis:in:1700000000000-3")) + _position(Cursor("memory:3")) with pytest.raises(StreamCursorError): - _outside_position(Cursor("memory:3")) + _position(Cursor("redis:not-an-id")) with pytest.raises(StreamCursorError): - _outside_position(Cursor("redis:not-an-id")) + _position(Cursor("redis:in:not-an-id")) def test_a_publish_that_completes_at_once_is_driven_to_the_end(): @@ -240,35 +242,27 @@ async def run() -> None: asyncio.run(run()) -def test_workflow_cursors_name_input_positions(): - assert _workflow_position(BEGINNING) is None - position = _workflow_position(Cursor("redis:in:1700000000000-3")) - assert position is not None and position.token == "1700000000000-3" - # An outside cursor names the output stream, whose entry ids are not the - # input stream's, so it cannot seed a workflow reader. - with pytest.raises(StreamCursorError, match="output stream"): - _workflow_position(Cursor("redis:1700000000000-3")) - with pytest.raises(StreamCursorError): - _workflow_position(Cursor("memory:3")) - with pytest.raises(StreamCursorError): - _workflow_position(Cursor("redis:in:not-an-id")) - - def test_retention_options_are_checked_at_construction(): with pytest.raises(ValueError, match="retention"): RedisStreams(retention=timedelta(0)) with pytest.raises(ValueError, match="max_len"): RedisStreams(max_len=0) - # A backend the caller owns is the caller's to trim; the default window - # is not a request to trim it. - with pytest.raises(ValueError, match="trimmed by its owner"): - RedisStreams(backend=object(), max_len=10) - with pytest.raises(ValueError, match="trimmed by its owner"): - RedisStreams(backend=object(), retention=timedelta(hours=1)) - RedisStreams(backend=object()) RedisStreams(retention=timedelta(hours=1), max_len=10) +async def test_a_client_the_caller_opened_gets_the_providers_layout_and_stays_open(): + # The layout and the trims are the provider's whichever connection it + # runs on, and closing the provider does not close a caller's client. + client = _NoRedis() + provider = RedisStreams(client=client, max_len=10) + backend = provider._require_backend() + assert backend._client is client + assert backend.describe_window() == f"retention={DEFAULT_RETENTION}, max_len=10" + await provider.close() + assert provider._require_backend() is not backend + assert provider._require_backend()._client is client + + async def test_the_default_window_is_an_age_and_can_be_turned_off(): # Nothing is trimmed on a topic nobody appends to, so the default has to # be a window that every append applies; a count cap would refuse a task diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 6a9cea2b6..586cbefb0 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -225,28 +225,29 @@ async def test_query_after_completion_replays_the_final_task( assert values == [{"n": 0}, {"n": 1}, {"n": 2}, {"n": 3}] -async def _key_lengths( +async def _log_length( streams: RedisStreams, client: Client, workflow_id: str, topic: str -) -> tuple[int, int]: - """How many entries the topic's input and output keys hold.""" +) -> int: + """How many entries the topic's log holds. + + Both of the transport's keys for the topic render onto the log, which is + what makes it one; asked through both so a test would notice if they came + apart. + """ import redis.asyncio backend = streams._require_backend() chain = await _chain(client, workflow_id) store = redis.asyncio.from_url(redis_url()) try: - return ( - await store.xlen( - backend.stream_key( - chain.stream_key(topic, direction=StreamDirection.INPUT) - ) - ), - await store.xlen( - backend.stream_key( - chain.stream_key(topic, direction=StreamDirection.OUTPUT) - ) - ), + by_input = backend.stream_key( + chain.stream_key(topic, direction=StreamDirection.INPUT) ) + by_output = backend.stream_key( + chain.stream_key(topic, direction=StreamDirection.OUTPUT) + ) + assert by_input == by_output + return await store.xlen(by_input) finally: await store.aclose() @@ -297,10 +298,7 @@ async def test_a_replay_past_the_retention_window_fails_loudly(live_client: Clie assert len(await handle.result()) == 4 before = await take(stream.read(topic=INPUTS), 4, 60) history = await handle.fetch_history() - assert await _key_lengths(streams, live_client, workflow_id, INPUTS.name) == ( - 4, - 4, - ) + assert await _log_length(streams, live_client, workflow_id, INPUTS.name) == 4 # Inside the window the recorded ranges read back and the replay passes. await Replayer(workflows=[ContractLoop], plugins=[streams]).replay_workflow( @@ -309,10 +307,7 @@ async def test_a_replay_past_the_retention_window_fails_loudly(live_client: Clie late = stream.producer(topic=INPUTS, producer_id="late", attempt=1) cursors = [await late.append({"n": n}) for n in range(10, 16)] - assert await _key_lengths(streams, live_client, workflow_id, INPUTS.name) == ( - 6, - 6, - ) + assert await _log_length(streams, live_client, workflow_id, INPUTS.name) == 6 # The recorded input ranges are gone, and the replay says so rather # than delivering fewer records. @@ -364,11 +359,8 @@ async def test_retention_by_age_trims_older_entries(live_client: Client): await asyncio.sleep(0.6) # The append that crosses the window is what trims the two before it. third = await producer.append({"n": 3}) - assert await _key_lengths( - streams, live_client, workflow_id, INPUTS.name - ) == ( - 1, - 1, + assert ( + await _log_length(streams, live_client, workflow_id, INPUTS.name) == 1 ) assert await stream.latest(topic=INPUTS) == third with pytest.raises(StreamCursorError, match="retention has trimmed"): @@ -733,13 +725,12 @@ async def test_a_batch_the_window_cannot_hold_is_refused_at_the_stage( await streams.close() -async def test_a_producer_record_lands_on_both_keys_or_on_neither( +async def test_a_producer_record_lands_once_however_often_it_is_sent( live_client: Client, provider: RedisStreams ): - # The workflow reads the input key and outside readers read the output key. - # Written one at a time, a crash between them leaves a record the workflow - # acts on that no outside reader can ever see. - workflow_id = f"streams-redis-pair-{uuid.uuid4().hex}" + # One log per topic: an outside record is written once, where the + # workflow's subscription and outside readers both find it. + workflow_id = f"streams-redis-once-{uuid.uuid4().hex}" async with Worker( live_client, task_queue=f"tq-{workflow_id}", @@ -753,10 +744,7 @@ async def test_a_producer_record_lands_on_both_keys_or_on_neither( topic=INPUTS, producer_id="model", attempt=1 ) await producer.append({"n": 1}, {"n": 2}, {"n": 3}) - assert await _key_lengths(provider, live_client, workflow_id, INPUTS.name) == ( - 3, - 3, - ) + assert await _log_length(provider, live_client, workflow_id, INPUTS.name) == 3 # A producer that comes back with a fresh sequence re-appends the same # identities, which the script reuses rather than doubling. @@ -764,16 +752,13 @@ async def test_a_producer_record_lands_on_both_keys_or_on_neither( topic=INPUTS, producer_id="model", attempt=1 ) await again.append({"n": 1}, {"n": 2}, {"n": 3}) - assert await _key_lengths(provider, live_client, workflow_id, INPUTS.name) == ( - 3, - 3, - ) + assert await _log_length(provider, live_client, workflow_id, INPUTS.name) == 3 await handle.signal(StreamHost.release) await handle.result() -async def test_a_repeat_under_one_identity_with_other_bytes_writes_neither_key( +async def test_a_repeat_under_one_identity_with_other_bytes_writes_nothing( live_client: Client, provider: RedisStreams ): workflow_id = f"streams-redis-conflict-{uuid.uuid4().hex}" @@ -793,22 +778,18 @@ async def test_a_repeat_under_one_identity_with_other_bytes_writes_neither_key( other = stream.producer(topic=INPUTS, producer_id="model", attempt=1) with pytest.raises(StreamProducerError, match="sequence 0"): await other.append({"n": 99}) - # The refusal wrote nothing on either key. - assert await _key_lengths(provider, live_client, workflow_id, INPUTS.name) == ( - 1, - 1, - ) + # The refusal wrote nothing. + assert await _log_length(provider, live_client, workflow_id, INPUTS.name) == 1 await handle.signal(StreamHost.release) await handle.result() -async def test_a_refused_output_half_leaves_the_input_key_untouched( +async def test_an_identity_claimed_with_other_bytes_refuses_the_first_append( live_client: Client, provider: RedisStreams ): - # The two keys are one write. Written one at a time, the input half lands - # before the output half is refused, and the workflow then consumes a record - # no outside reader can ever see. - workflow_id = f"streams-redis-atomic-{uuid.uuid4().hex}" + # The idempotency hash is read before the log is touched, so a claim that + # disagrees with the record refuses it without writing. + workflow_id = f"streams-redis-claimed-{uuid.uuid4().hex}" async with Worker( live_client, task_queue=f"tq-{workflow_id}", @@ -820,11 +801,9 @@ async def test_a_refused_output_half_leaves_the_input_key_untouched( ) backend = provider._require_backend() chain = await _chain(live_client, workflow_id) - output_key = chain.stream_key(INPUTS.name, direction=StreamDirection.OUTPUT) - # Claim the output half's first identity with other bytes, which is what a - # refusal of that half looks like from the producer's side. + key = chain.stream_key(INPUTS.name, direction=StreamDirection.OUTPUT) await backend._client.hset( - backend._idempotency_key(output_key), "model#1/0", "1-0|" + "0" * 64 + backend._idempotency_key(key), "model#1/0", "1-0|" + "0" * 64 ) producer = provider.get_stream_handle(live_client, workflow_id).producer( @@ -833,9 +812,82 @@ async def test_a_refused_output_half_leaves_the_input_key_untouched( with pytest.raises(StreamProducerError, match="sequence 0"): await producer.append({"n": 1}) - assert await _key_lengths(provider, live_client, workflow_id, INPUTS.name) == ( - 0, - 0, - ) + assert await _log_length(provider, live_client, workflow_id, INPUTS.name) == 0 await handle.signal(StreamHost.release) await handle.result() + + +@workflow.defn +class EchoOnOneTopic: + """Reads ``inputs`` and answers each value on the same topic. + + Its own records land in the log it reads, so what it returns says whether + it read them back. + """ + + @workflow.run + async def run(self) -> list[Any]: + seen: list[Any] = [] + out = workflow.stream_writer(INPUTS) + async for record in workflow.stream_reader(INPUTS): + if record.kind is RecordKind.FINISH: + break + if record.kind is not RecordKind.DATA: + continue + seen.append(record.value) + out.publish({"echo": record.value}) + out.finish() + return seen + + @workflow.query + def probe(self) -> int: + return 1 + + +async def test_a_workflow_does_not_read_its_own_records_from_the_shared_log( + live_client: Client, provider: RedisStreams +): + workflow_id = f"streams-redis-echo-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[EchoOnOneTopic], + plugins=[provider], + ) as worker: + handle = await live_client.start_workflow( + EchoOnOneTopic.run, id=workflow_id, task_queue=worker.task_queue + ) + stream = provider.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="model", attempt=1) + await producer.append({"n": 1}, {"n": 2}) + # The echoes are promoted into the same log the producer wrote to. + first_four = await take(stream.read(topic=INPUTS), 4, 60) + assert sorted( + ((r.producer_id, r.value) for r in first_four), key=str + ) == sorted( + [ + ("model", {"n": 1}), + ("model", {"n": 2}), + ("", {"echo": {"n": 1}}), + ("", {"echo": {"n": 2}}), + ], + key=str, + ) + await producer.finish() + # The workflow saw the producer's records and none of its echoes. + assert await asyncio.wait_for(handle.result(), 60) == [{"n": 1}, {"n": 2}] + assert await _log_length(provider, live_client, workflow_id, INPUTS.name) == 6 + + # A cold query replays the run against the log with its own entries + # in every recorded range; the read filters them the way the live one did. + assert await handle.query(EchoOnOneTopic.probe) == 1 + everything = [r async for r in stream.read(topic=INPUTS)] + assert [(r.producer_id, r.kind) for r in everything if r.producer_id == ""] == [ + ("", RecordKind.DATA), + ("", RecordKind.DATA), + ("", RecordKind.FINISH), + ] + + await Replayer(workflows=[EchoOnOneTopic], plugins=[provider]).replay_workflow( + await handle.fetch_history() + ) From 1acece8bdb6e88745f159a60e3f0914544c8c9f8 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 30 Sep 2026 02:59:14 -0700 Subject: [PATCH 82/96] Carried a Redis consumer through a reset and dropped a wake a closing run refuses. A reset run inherits the ranges the base run recorded and re-reads them from the log; the live cases show that, and that the reset-point task is run again from the log. The reset also surfaced a race: a wake buffered under a task that closes the run is refused as closing until the next task settles, so the producer failed although the record was in the log and the run's next park rechecks it. --- temporalio/streams/providers/redis.py | 44 ++++++- tests/streams/test_redis_provider.py | 17 ++- tests/streams/test_redis_replay.py | 163 ++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 4 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 9e1b47853..2461fa9f8 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -24,7 +24,20 @@ closing is sent again after a short wait: a run that continues as new hands the log to its successor, the records are already where the successor reads them, and only the wake has to follow. The retries stop when the - chain's current run takes the wake or the chain proves terminal. + chain's current run takes the wake or the chain proves terminal. A run + still refusing when the window passes is inside a Workflow Task that tried + to close it while the wake sat buffered; the wake is dropped, because the + record is in the log and a run that does not close after all rechecks the + log at its next park. +- A reset run inherits the base run's History up to the reset point, and + with it the ranges the base run's task completions recorded. It re-reads + them from the log and replays them against the inherited markers, so the + batches those tasks published are not published again, and live reading + continues from the last inherited boundary. The reset-point task itself is + run again, and what the base run consumed after the reset point is still + in the log, so the reset run reads it again and publishes again: an + outside reader sees that task's batch twice, once from each run. Nothing + in the log marks the reset. - A record rides as the transport's payload: the serialized ``StreamRecord`` proto as a ``binary/plain`` value, which the worker's codec encodes and decodes like any other payload. @@ -701,6 +714,16 @@ async def _chain(client: Client, workflow_id: str) -> WorkflowChainKey: WorkflowExecutionStatus.CONTINUED_AS_NEW, ) +#: What the server says when a Signal reaches a run whose Workflow Task is +#: closing it: on a continue-as-new handover for an instant, and after a task +#: that tried to close while the Signal sat buffered, until the next one settles. +_CLOSING_REFUSAL: Final = "workflow is closing" + + +def _refused_as_closing(error: WakeNotAcknowledgedError) -> bool: + """Whether the server refused a wake because the run is closing.""" + return _CLOSING_REFUSAL in str(error) + def _storage_error(error: Exception, what: str) -> StreamError: return StreamError(f"{what}: {error}") @@ -906,16 +929,33 @@ async def _wake(self) -> None: workflow id as every wake is, until the chain's current run takes it. A chain that has ended instead is the ordinary ending of a terminal record racing the consumer acting on it, not an error. + + A run that still refuses when the window passes is inside a Workflow + Task that tried to close it while this wake sat buffered, which the + server answers by failing that task and holding the run closed to + Signals until the next one settles. The wake is dropped then: if the + run closes, nothing is owed; if it does not, its next park rechecks + the log and finds the record, the transport's own rule for a record + appended before a park. Any other refusal that outlasts the window is + raised. """ deadline = time.monotonic() + _WAKE_RETRY_WINDOW.total_seconds() while True: try: await self._input.wake() return - except WakeNotAcknowledgedError: + except WakeNotAcknowledgedError as error: if await self._chain_is_terminal(): return if time.monotonic() >= deadline: + if _refused_as_closing(error): + logger.info( + "dropping the wake for %r on topic %r: the run is closing a " + "Workflow Task, and its next park rechecks the log", + self._workflow_id, + self._topic, + ) + return raise except TransportStreamError as error: raise _storage_error(error, "the wake could not be sent") from error diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py index 93758076e..680dc6e8d 100644 --- a/tests/streams/test_redis_provider.py +++ b/tests/streams/test_redis_provider.py @@ -137,10 +137,23 @@ async def test_a_wake_refused_by_a_finished_chain_is_the_ordinary_ending( @pytest.mark.usefixtures("quick_wake_retries") -async def test_a_wake_refused_for_the_whole_window_is_raised(): +async def test_a_wake_refused_as_closing_for_the_whole_window_is_dropped(): + # The run is inside a Workflow Task that tried to close it while the wake + # sat buffered. The record is in the log, so if the run stays open its + # next park rechecks the log and finds it; the producer is not failed. client = _ChainClient(WorkflowExecutionStatus.RUNNING) wakes = _RefusingInput(refusals=99) - with pytest.raises(WakeNotAcknowledgedError, match="closing"): + await _waking_producer(client, wakes)._wake() + assert wakes.calls > 1 + + +@pytest.mark.usefixtures("quick_wake_retries") +async def test_a_wake_refused_for_another_reason_for_the_whole_window_is_raised(): + client = _ChainClient(WorkflowExecutionStatus.RUNNING) + wakes = _RefusingInput( + refusals=99, error=WakeNotAcknowledgedError("signal rate limited", pending=[]) + ) + with pytest.raises(WakeNotAcknowledgedError, match="rate limited"): await _waking_producer(client, wakes)._wake() assert wakes.calls > 1 diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 586cbefb0..bac0ef3b7 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -817,6 +817,169 @@ async def test_an_identity_claimed_with_other_bytes_refuses_the_first_append( await handle.result() +@workflow.defn +class NudgedLoop: + """The contract loop with a signal that does nothing but complete a task.""" + + def __init__(self) -> None: + self._nudges = 0 + + @workflow.signal + def nudge(self) -> None: + self._nudges += 1 + + @workflow.run + async def run(self) -> list[int]: + decisions = workflow.stream_writer(DECISIONS) + seen: list[int] = [] + async for record in workflow.stream_reader(INPUTS): + if record.kind is RecordKind.FINISH: + break + if record.kind is not RecordKind.DATA: + continue + assert record.value is not None + decisions.publish({"decided": record.value["n"]}) + seen.append(record.value["n"]) + decisions.finish() + return seen + + +async def _completed_tasks(client: Client, workflow_id: str, run_id: str) -> list[int]: + return [ + event.event_id + async for event in client.get_workflow_handle( + workflow_id, run_id=run_id + ).fetch_history_events() + if event.HasField("workflow_task_completed_event_attributes") + ] + + +async def _reset_at(client: Client, workflow_id: str, run_id: str, *, task: int) -> str: + """Reset ``run_id`` at its ``task``-th completed Workflow Task; the new run id. + + The server keeps History up to that task's completion and runs the task + again, so the tasks before it are inherited and the task itself is not. + """ + from temporalio.api.common.v1 import WorkflowExecution + from temporalio.api.workflowservice.v1 import ResetWorkflowExecutionRequest + + completed = await _completed_tasks(client, workflow_id, run_id) + response = await client.workflow_service.reset_workflow_execution( + ResetWorkflowExecutionRequest( + namespace=client.namespace, + workflow_execution=WorkflowExecution( + workflow_id=workflow_id, run_id=run_id + ), + reason="streams: reset mid-stream", + workflow_task_finish_event_id=completed[task], + request_id=uuid.uuid4().hex, + ) + ) + return response.run_id + + +async def _consume_two_then_nudge( + live_client: Client, provider: RedisStreams, workflow_id: str, task_queue: str +) -> tuple[str, Any, Any]: + """Two records in two tasks, then a task that consumes nothing; the base run.""" + handle = await live_client.start_workflow( + NudgedLoop.run, id=workflow_id, task_queue=task_queue + ) + base_run = handle.result_run_id + assert base_run is not None + stream = provider.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="model", attempt=1) + await producer.append({"n": 1}) + await take(stream.read(topic=DECISIONS), 1, 60) + await producer.append({"n": 2}) + await take(stream.read(topic=DECISIONS), 2, 60) + before = len(await _completed_tasks(live_client, workflow_id, base_run)) + await handle.signal(NudgedLoop.nudge) + for _ in range(300): + if len(await _completed_tasks(live_client, workflow_id, base_run)) > before: + break + await asyncio.sleep(0.1) + return base_run, stream, producer + + +async def test_a_reset_run_replays_the_inherited_ranges_and_continues( + live_client: Client, provider: RedisStreams +): + # Reset at the completion of the task the nudge woke, which consumed and + # published nothing: both consuming tasks are inherited. Their ranges are + # re-read from the log and replayed against the inherited markers, so + # their decisions are not published again, and reading continues from the + # last inherited boundary. + workflow_id = f"streams-redis-reset-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[NudgedLoop], + plugins=[provider], + max_cached_workflows=100, + ): + base_run, stream, producer = await _consume_two_then_nudge( + live_client, provider, workflow_id, f"tq-{workflow_id}" + ) + reset_run = await _reset_at(live_client, workflow_id, base_run, task=-1) + assert reset_run != base_run + + await producer.append({"n": 3}) + await producer.finish() + continued = live_client.get_workflow_handle(workflow_id, run_id=reset_run) + assert await asyncio.wait_for(continued.result(), 90) == [1, 2, 3] + decisions = [r async for r in stream.read(topic=DECISIONS)] + assert [(r.kind, r.value) for r in decisions] == [ + (RecordKind.DATA, {"decided": 1}), + (RecordKind.DATA, {"decided": 2}), + (RecordKind.DATA, {"decided": 3}), + (RecordKind.FINISH, None), + ] + + # Offline, the reset run's History reads the inherited ranges from the log. + await Replayer(workflows=[NudgedLoop], plugins=[provider]).replay_workflow( + await continued.fetch_history() + ) + + +async def test_a_reset_point_task_is_run_again_from_the_log( + live_client: Client, provider: RedisStreams +): + # Reset at the second consuming task's completion: the first task is + # inherited and the second is run again. The record it consumed is still + # in the log, so the reset run reads it again and publishes again, and an + # outside reader sees that decision from both runs. + workflow_id = f"streams-redis-reset-rerun-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[NudgedLoop], + plugins=[provider], + max_cached_workflows=100, + ): + base_run, stream, producer = await _consume_two_then_nudge( + live_client, provider, workflow_id, f"tq-{workflow_id}" + ) + reset_run = await _reset_at(live_client, workflow_id, base_run, task=1) + + await producer.append({"n": 3}) + await producer.finish() + continued = live_client.get_workflow_handle(workflow_id, run_id=reset_run) + assert await asyncio.wait_for(continued.result(), 90) == [1, 2, 3] + decisions = [r.value async for r in stream.read(topic=DECISIONS)] + assert decisions == [ + {"decided": 1}, + {"decided": 2}, + {"decided": 2}, + {"decided": 3}, + None, + ] + + await Replayer(workflows=[NudgedLoop], plugins=[provider]).replay_workflow( + await continued.fetch_history() + ) + + @workflow.defn class EchoOnOneTopic: """Reads ``inputs`` and answers each value on the same topic. From 10d56a738771c254cedd64df01b8e5f90d3f8b6d Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 30 Sep 2026 03:38:34 -0700 Subject: [PATCH 83/96] Let a Redis reader start at the tail or at the newest N records. A workflow reader took the beginning or a cursor only. The tail is where the log is when the worker looks, which the workflow thread cannot see, so the transport records the request, the worker resolves it against the log after the task that opened the subscription and writes the entry into the marker beside it, and replay reads it from History. Outside, the tail is resolved on the generator's first step, as the retention check already is. --- .../external_workflow_streams/__init__.py | 2 + .../contrib/external_workflow_streams/_api.py | 14 +- .../external_workflow_streams/_backend.py | 17 ++ .../external_workflow_streams/_record.py | 22 ++ .../external_workflow_streams/_runtime.py | 95 +++++++- temporalio/streams/providers/redis.py | 226 ++++++++++++++++-- temporalio/worker/_workflow.py | 5 + .../external_workflow_streams/test_api.py | 6 +- .../test_registry.py | 1 + tests/streams/test_redis_replay.py | 105 ++++++++ tests/streams/test_streams_conformance.py | 16 +- 11 files changed, 475 insertions(+), 34 deletions(-) diff --git a/temporalio/contrib/external_workflow_streams/__init__.py b/temporalio/contrib/external_workflow_streams/__init__.py index 80acb17ae..a04387f21 100644 --- a/temporalio/contrib/external_workflow_streams/__init__.py +++ b/temporalio/contrib/external_workflow_streams/__init__.py @@ -100,6 +100,7 @@ Offset, OffsetComparator, RecordKind, + StartAtTail, StreamRecord, ) from temporalio.contrib.external_workflow_streams._wake import WakeRequest @@ -128,6 +129,7 @@ "ExternalStreamProducerTopic", "ExternalStreamSubscription", "ExternalStreamTopic", + "StartAtTail", "IdempotencyKey", "Offset", "OffsetComparator", diff --git a/temporalio/contrib/external_workflow_streams/_api.py b/temporalio/contrib/external_workflow_streams/_api.py index 1da059d80..f43210867 100644 --- a/temporalio/contrib/external_workflow_streams/_api.py +++ b/temporalio/contrib/external_workflow_streams/_api.py @@ -37,6 +37,7 @@ from temporalio.contrib.external_workflow_streams._record import ( Cursor, Offset, + StartAtTail, StreamRecord, ) from temporalio.types import AnyType @@ -97,6 +98,7 @@ def register( stream_key: StreamKey, idle_timeout: timedelta, start_cursor: Cursor | None = None, + start_at_tail: StartAtTail | None = None, ) -> None: """Registers a wait with the Worker's subscription manager. @@ -270,7 +272,10 @@ class ExternalStreamTopic(Generic[AnyType]): options: ExternalStreamOptions def subscribe( - self, *, start_cursor: Cursor | None = None + self, + *, + start_cursor: Cursor | None = None, + start_at_tail: StartAtTail | None = None, ) -> ExternalStreamSubscription[AnyType]: """Starts a new subscription and returns its async iterator. @@ -289,6 +294,11 @@ def subscribe( ``BEGINNING`` on a first execution. A boundary the Workflow names is recorded in the marker's header like the restored one, so it must be derived deterministically: replay names it again. + start_at_tail: Start at the stream's tail instead, or at its newest + ``last`` records. The Worker resolves the boundary against the + store after this Workflow Task and records it with the + subscription, so replay starts where the live run did rather + than asking the store again. Exclusive with ``start_cursor``. """ state = _run_state() if state.runtime is None: @@ -305,6 +315,8 @@ def subscribe( start: dict[str, Any] = ( {} if start_cursor is None else {"start_cursor": start_cursor} ) + if start_at_tail is not None: + start["start_at_tail"] = start_at_tail state.runtime.register( wait_id=wait_id, stream_key=stream_key, diff --git a/temporalio/contrib/external_workflow_streams/_backend.py b/temporalio/contrib/external_workflow_streams/_backend.py index ca7e1ecdd..1925bff06 100644 --- a/temporalio/contrib/external_workflow_streams/_backend.py +++ b/temporalio/contrib/external_workflow_streams/_backend.py @@ -273,6 +273,23 @@ def compare_offsets(self, left: Offset, right: Offset) -> int: inside a validation loop. """ + async def tail_cursor(self, key: StreamKey, *, before_last: int = 0) -> Cursor: + """The boundary the newest ``before_last`` records begin after. + + With ``before_last=0`` it is the boundary after the newest record, so a + read from it sees only what is appended after this call. ``BEGINNING`` + when the stream holds fewer records than asked for. Resolved on the + Worker, never on the Workflow thread, and recorded with the + subscription so replay reads it from the marker instead of asking + again. A provider that cannot answer leaves this as it is, and a + subscription that asks for a tail start fails when the Worker resolves + it. + """ + raise NotImplementedError( + f"{type(self).__name__} cannot resolve a start {before_last} records " + f"before the tail of {key}; subscribe with a cursor instead" + ) + # --- parking (P2b) ------------------------------------------------------ supports_leased_claims: ClassVar[bool] = False diff --git a/temporalio/contrib/external_workflow_streams/_record.py b/temporalio/contrib/external_workflow_streams/_record.py index 549c5c0b3..8b4d96b46 100644 --- a/temporalio/contrib/external_workflow_streams/_record.py +++ b/temporalio/contrib/external_workflow_streams/_record.py @@ -11,7 +11,29 @@ from dataclasses import dataclass, field from typing import Final, Protocol + +@dataclass(frozen=True) +class StartAtTail: + """A subscription start the Worker resolves against the store: the tail. + + ``last`` is how many of the newest records the read begins with; zero + means the boundary after the newest record, so the read sees only what + is appended after it was opened. The Workflow thread cannot ask the store + where the tail is, so the runtime records the request and the Worker + resolves it before the watcher starts, writing the boundary into the + marker beside the subscription as it does a cursor the Workflow named. + """ + + last: int = 0 + + def __post_init__(self) -> None: + """Refuse a negative count.""" + if self.last < 0: + raise ValueError(f"last must not be negative, got {self.last}") + + __all__ = [ + "StartAtTail", "AFTER", "BEGINNING", "Cursor", diff --git a/temporalio/contrib/external_workflow_streams/_runtime.py b/temporalio/contrib/external_workflow_streams/_runtime.py index e57b3aa91..29dbf6ad8 100644 --- a/temporalio/contrib/external_workflow_streams/_runtime.py +++ b/temporalio/contrib/external_workflow_streams/_runtime.py @@ -87,6 +87,7 @@ BEGINNING, Cursor, RecordKind, + StartAtTail, StreamRecord, ) from temporalio.contrib.external_workflow_streams._replay import ReplayPlan @@ -199,6 +200,14 @@ class _SubscriptionState: generation: int = 0 """Increments each time this wait re-enters the blocked state.""" + start_pending: bool = False + """The start is a tail the Worker has yet to resolve against the store. + + Until it does, the cursors hold ``BEGINNING`` as a placeholder, no watcher + runs, and the binding stays out of every header and bindings frame, so the + marker never records the placeholder as where this wait began. + """ + announced: bool = False """Whether the *current* annotation has carried this subscription's binding. @@ -290,6 +299,9 @@ def __init__( #: would give replay whatever the stream holds now (ADR-022). self._continuation = continuation self._subscriptions: dict[int, _SubscriptionState] = {} + #: Tail starts registered in the activation under way, resolved by the + #: Worker once it returns. See :meth:`resolve_pending_starts`. + self._pending_starts: dict[int, StartAtTail] = {} #: Where the *current* annotation begins, per wait. Captured when the #: annotation begins rather than when its header is first needed -- #: lazily reading the delivery cursor would let a record delivered @@ -599,7 +611,7 @@ def _reserve_bytes(self) -> int: late = { wait_id: self._binding(state) for wait_id, state in sorted(self._subscriptions.items()) - if not state.announced + if not state.announced and not state.start_pending } if late and self._accumulator is not None: # Only with an accumulator: without one the header is about to carry @@ -1129,13 +1141,16 @@ def register( stream_key: StreamKey, idle_timeout: timedelta | None = None, start_cursor: Cursor | None = None, + start_at_tail: StartAtTail | None = None, ) -> None: """Registers a wait with the Worker's manager. Non-blocking, no I/O. The start cursor defaults to what the predecessor Run committed for this ``wait_id``, so a chain resumes where it left off without the Workflow code saying anything about it -- and a first execution gets ``BEGINNING`` - from the same path. + from the same path. A ``start_at_tail`` is resolved by the Worker after + this activation and recorded with the subscription; on replay the + recorded boundary is taken from the marker and the store is not asked. Raises: ExternalStreamCapacityError: This subscription set cannot be recorded @@ -1156,7 +1171,20 @@ def register( "external streams are not configured on this Worker; pass " "external_stream_backend=... to the Worker" ) - if start_cursor is None: + if start_at_tail is not None and start_cursor is not None: + raise ValueError( + "a subscription starts at a cursor or at the tail, not at both" + ) + pending = False + if start_at_tail is not None: + recorded = (self._replay_bindings or {}).get(wait_id) + if recorded is not None: + # The live run resolved it and the marker carries the answer. + start_cursor = recorded.start_cursor + else: + pending = True + start_cursor = BEGINNING + elif start_cursor is None: start_cursor = self.restored_start(wait_id, stream_key.stream_name) if self._replay_bindings is not None: # A subscription made while a marker is being replayed -- which is @@ -1174,6 +1202,7 @@ def register( delivery_cursor=start_cursor, consumption_cursor=start_cursor, idle_timeout=idle_timeout or self._default_idle_timeout, + start_pending=pending, ) self._subscriptions[wait_id] = state # A subscription created part-way through an annotation begins at its @@ -1189,16 +1218,57 @@ def register( del self._annotation_start[wait_id] raise self._update_reserve() - self._manager.register( - run_id=self._run_id, - wait_id=wait_id, - stream_key=stream_key, - start_cursor=start_cursor, - ) + if pending: + assert start_at_tail is not None + self._pending_starts[wait_id] = start_at_tail + else: + self._manager.register( + run_id=self._run_id, + wait_id=wait_id, + stream_key=stream_key, + start_cursor=start_cursor, + ) # A registration alone is replay-visible: it is what puts the stream in # the annotation header, without which replay cannot start. self._observed_this_activation = True + async def resolve_pending_starts(self) -> None: + """Resolves every tail start the activation just finished registered. + + On the Worker's loop, between the activation and its completion. The + Workflow thread can do no I/O, and the boundary has to be fixed before + anything is delivered on the wait, so the marker records it beside the + subscription and replay reads it there instead of asking the store, + which by then holds something else. + """ + if not self._pending_starts: + return + backend = self._backend + if backend is None: + raise RuntimeError( + "external streams are not configured on this Worker; pass " + "external_stream_backend=... to the Worker" + ) + pending, self._pending_starts = self._pending_starts, {} + for wait_id, tail in pending.items(): + state = self._subscriptions.get(wait_id) + if state is None or not state.start_pending: + continue + cursor = await backend.tail_cursor(state.stream_key, before_last=tail.last) + state.start_cursor = cursor + state.delivery_cursor = cursor + state.consumption_cursor = cursor + state.start_pending = False + self._annotation_start[wait_id] = cursor + self._check_annotation_capacity(wait_id) + self._update_reserve() + self._manager.register( + run_id=self._run_id, + wait_id=wait_id, + stream_key=state.stream_key, + start_cursor=cursor, + ) + def drain(self, wait_id: int, max_records: int | None = None) -> list[StreamRecord]: """Pops buffered records. Performs no I/O and never blocks. @@ -2001,7 +2071,7 @@ def _announce_late_subscriptions(self) -> None: late = { wait_id: state for wait_id, state in sorted(self._subscriptions.items()) - if not state.announced + if not state.announced and not state.start_pending } if not late: return @@ -2028,11 +2098,13 @@ def _header(self) -> AnnotationHeader: wrong store for all the others. """ for state in self._subscriptions.values(): - state.announced = True + if not state.start_pending: + state.announced = True return AnnotationHeader( streams={ wait_id: self._binding(state) for wait_id, state in sorted(self._subscriptions.items()) + if not state.start_pending }, ) @@ -2047,6 +2119,7 @@ def _header_preview(self) -> AnnotationHeader: streams={ wait_id: self._binding(state) for wait_id, state in sorted(self._subscriptions.items()) + if not state.start_pending }, ) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 2461fa9f8..ba0b92807 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -47,7 +47,13 @@ - Cursors are ``redis:-`` and name an entry of the topic's log, so a cursor a workflow reader returned seeds an outside read and the other way round. A reader opened without a cursor starts where the chain's - predecessor run committed, which is the transport's own rule. + predecessor run committed, which is the transport's own rule. ``END`` and + ``last=N`` are positioned against the log when the read starts: outside, + on the first step of the generator, since the call itself cannot reach the + store; inside a workflow, by the worker right after the Workflow Task that + opened the subscription, which records the entry it resolved in the marker + beside the subscription, so replay and a cold start read it from History + and never ask the log again. - A workflow's streams are keyed by the chain's first run, so a handle follows continue-as-new by construction and ``run_id`` only decides whose close ends a read. @@ -105,7 +111,7 @@ import logging import re import time -from collections.abc import AsyncGenerator, Coroutine, Sequence +from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine, Sequence from dataclasses import dataclass, replace from datetime import timedelta from typing import Any, Final, Generic, TypeVar @@ -122,6 +128,7 @@ ChainKeyMismatchError, ExternalStreamProducer, Offset, + StartAtTail, StreamDirection, WakeNotAcknowledgedError, WorkflowChainKey, @@ -152,6 +159,7 @@ OutputStageManifest, OutputStageNotFoundError, OutputStageResolutionError, + OutputStageStatus, StagedOutputRecord, ) from temporalio.contrib.external_workflow_streams._output_client import ( @@ -176,9 +184,18 @@ StreamError, StreamNotFoundError, StreamProducerError, + StreamUnsupportedError, ) from temporalio.streams._provider import ReadSource, WriteSink -from temporalio.streams._record import BEGINNING, Cursor, RecordKind, StreamRecord +from temporalio.streams._record import ( + BEGINNING, + END, + Cursor, + RecordKind, + StreamRecord, + check_read_start, +) +from temporalio.streams._ref import StreamRef from temporalio.streams._topic import StreamTopic, resolve_topic from temporalio.streams._wire import ( RecordDecoder, @@ -259,6 +276,45 @@ def _entry_id(token: str | bytes) -> tuple[int, int]: return int(ms), int(seq or 0) +#: Given one page of log entries, newest first, the ids of the ones that are +#: not records to the reader asking. +_SkipIn = Callable[[list[Any]], Awaitable[set[str]]] + + +async def _tail_after( + store: Any, name: str, before_last: int, *, skip_in: _SkipIn | None = None +) -> Offset | None: + """The entry the newest ``before_last`` records of the log ``name`` come after. + + ``None`` when the log holds no more than ``before_last`` records, which + means a read from the beginning. With ``before_last=0`` it is the newest + record itself, the boundary a read at ``END`` starts after. Walks the log + from its newest entry, leaving out what ``skip_in`` names. + """ + needed = before_last + 1 + end = "+" + seen = 0 + while True: + page: Any = await store.xrevrange(name, end, "-", count=max(needed - seen, 16)) + if not page: + return None + skipped = await skip_in(page) if skip_in is not None else set() + for entry_id, _fields in page: + token = _text(entry_id) + if token in skipped: + continue + seen += 1 + if seen == needed: + return Offset(token) + ms, seq = _entry_id(page[-1][0]) + if seq: + end = f"{ms}-{seq - 1}" + elif ms: + end = f"{ms - 1}-18446744073709551615" + else: + return None + + def _trim_floor(backend: Any) -> str: retention = getattr(backend, "_retention", None) if retention is None: @@ -586,6 +642,52 @@ async def read_range( if key.direction is StreamDirection.OUTPUT or not _is_staged(fields) ] + async def tail_cursor(self, key: StreamKey, *, before_last: int = 0) -> Any: + """The boundary the newest ``before_last`` records begin after. + + Through an input key the workflow's own staged entries are not + records, as on every read the transport makes; through an output key + only an aborted batch's entries are left out, since a pending one may + still commit. + """ + name = self.stream_key(key) + skip_in = ( + self._staged_in + if key.direction is StreamDirection.INPUT + else self._aborted_in(key) + ) + after = await _tail_after(self._client, name, before_last, skip_in=skip_in) + return TRANSPORT_BEGINNING if after is None else AFTER(after) + + @staticmethod + async def _staged_in(page: list[Any]) -> set[str]: + return {_text(entry_id) for entry_id, fields in page if _is_staged(fields)} + + def _aborted_in(self, key: StreamKey) -> _SkipIn: + async def aborted(page: list[Any]) -> set[str]: + staged = { + _text(entry_id): _text(fields[_STAGE_FIELD]) + for entry_id, fields in page + if _is_staged(fields) + } + if not staged: + return set() + stage_ids = sorted(set(staged.values())) + statuses = dict( + zip( + stage_ids, + await self._client.hmget(self._output_status_key(key), stage_ids), + ) + ) + return { + entry_id + for entry_id, stage_id in staged.items() + if _text(statuses.get(stage_id) or "") + == OutputStageStatus.ABORTED.value + } + + return aborted + async def retains(self, key: StreamKey, offset: Offset) -> bool: """Whether the record at ``offset`` survived trimming.""" return await _retained(self._client, self.stream_key(key), offset) @@ -669,16 +771,23 @@ class _RedisWorkflowProvider: def __init__(self, idle_timeout: timedelta) -> None: self._input = external_stream.with_options(idle_timeout=idle_timeout) - def open_reader(self, topic: str, *, after: Cursor) -> ReadSource: + def open_reader( + self, topic: str, *, after: Cursor, last: int | None = None + ) -> ReadSource: _require_topic(topic) + check_read_start(after, last) + subscribe = self._input.topic(topic, type=bytes).subscribe + if last is not None or after == END: + # The tail is where the log is when the worker looks, which the + # workflow thread cannot see: the transport has the worker resolve + # it after this task and record the entry with the subscription. + return _RedisReadSource(subscribe(start_at_tail=StartAtTail(last or 0))) position = _position(after) # Without a position the transport resumes where the chain's # predecessor run committed; with one, that is where the wait starts # and what the marker's header records. start = None if position is None else AFTER(position) - return _RedisReadSource( - self._input.topic(topic, type=bytes).subscribe(start_cursor=start) - ) + return _RedisReadSource(subscribe(start_cursor=start)) def open_writer(self, topic: str) -> WriteSink: _require_topic(topic) @@ -1026,13 +1135,15 @@ def read( *, topic: str | StreamTopic[Any], after: Cursor = BEGINNING, + last: int | None = None, result_type: type | None = None, ) -> AsyncGenerator[StreamRecord[Any], None]: - """Yield records on ``topic`` after ``after`` until the owner closes. + """Yield records on ``topic`` from where the read starts until the owner closes. For a workflow that is the chain, or the pinned run; for an activity it is the activity reaching a terminal status, learned as the module docstring - says. + says. ``END`` and ``last=`` are positioned against the log on the first + step of the generator, since this call cannot reach the store. Two refusals and they do not land together. A cursor another provider minted, or one that is not a Redis entry id, is refused by this call: reading the @@ -1042,16 +1153,21 @@ def read( first. Raises: + ValueError: ``last`` is not positive or came with a cursor. StreamCursorError: The cursor is another provider's, or does not name a Redis entry. """ + check_read_start(after, last) topic, result_type = resolve_topic(topic, result_type) - # Parsed here so a foreign cursor fails this call, not the first - # iteration of the generator. - position = _position(after) + # A tail start is resolved in the generator; a cursor is parsed here so + # a foreign one fails this call, not the first iteration. + tail = last if last is not None else (0 if after == END else None) + position = None if tail is not None else _position(after) if self._owner is not None: - return self._read_owned(self._owner, topic, position, after, result_type) - return self._read(topic, position, after, result_type) + return self._read_owned( + self._owner, topic, position, after, result_type, tail=tail + ) + return self._read(topic, position, after, result_type, tail=tail) async def _read_owned( self, @@ -1060,16 +1176,25 @@ async def _read_owned( position: Offset | None, after: Cursor, result_type: type | None, + *, + tail: int | None = None, ) -> AsyncGenerator[StreamRecord[Any], None]: - decoder = RecordDecoder( - self._converter, result_type, after=after, warn=logger.warning - ) backend = self._streams._require_backend() # Described on every read, so a handle without a run reads the run that # is current when the read starts, and ends with it. owner = await _resolve_owner(self._client, owner) store = backend._client name = owner.key(_prefix(backend), topic) + if tail is not None: + position = await _tail_after(store, name, tail) + after = ( + BEGINNING + if position is None + else mint_cursor(_PROVIDER, position.token) + ) + decoder = RecordDecoder( + self._converter, result_type, after=after, warn=logger.warning + ) if ( position is not None and isinstance(backend, _TopicLogBackend) @@ -1154,14 +1279,24 @@ async def _read( position: Offset | None, after: Cursor, result_type: type | None, + *, + tail: int | None = None, ) -> AsyncGenerator[StreamRecord[Any], None]: - decoder = RecordDecoder( - self._converter, result_type, after=after, warn=logger.warning - ) backend = self._streams._require_backend() assert self._workflow_id is not None chain = await _chain(self._client, self._workflow_id) key = chain.stream_key(topic, direction=StreamDirection.OUTPUT) + if tail is not None: + resolved = await backend.tail_cursor(key, before_last=tail) + position = None if resolved.is_beginning else resolved.offset + after = ( + BEGINNING + if position is None + else mint_cursor(_PROVIDER, position.token) + ) + decoder = RecordDecoder( + self._converter, result_type, after=after, warn=logger.warning + ) if ( position is not None and isinstance(backend, _TopicLogBackend) @@ -1291,6 +1426,27 @@ async def latest(self, *, topic: str | StreamTopic[Any]) -> Cursor: assert tail.offset is not None return mint_cursor(_PROVIDER, tail.offset.token) + def ref(self, *, topic: str | StreamTopic[Any] | None = None) -> StreamRef: + """A ref to ``topic`` of this owner's stream, pinned as this handle is.""" + if self._owner is not None: + return StreamRef.for_activity( + self._owner.activity_id, + workflow_id=self._owner.workflow_id, + run_id=self._owner.run_id, + topic=topic, + ) + assert self._workflow_id is not None + return StreamRef.for_workflow( + self._workflow_id, run_id=self._run_id, topic=topic + ) + + async def close(self) -> None: + """Refuse: an owned stream ends with its owner, not by a caller.""" + raise ValueError( + "only a standalone stream can be closed; this handle is on an owned " + "stream, which ends when its workflow or activity does" + ) + def producer( self, *, @@ -1445,6 +1601,36 @@ def get_activity_stream_handle( """ return RedisStreamHandle(self, client, workflow_id, run_id, activity_id) + async def create_standalone_stream( + self, + client: Client, + stream_id: str, + *, + retention: timedelta | None = None, + max_records: int | None = None, + max_bytes: int | None = None, + ) -> RedisStreamHandle: + """Refuse: this provider keeps no stream without an owner. + + Raises: + StreamUnsupportedError: Always. + """ + raise StreamUnsupportedError( + "the redis provider does not host standalone streams" + ) + + def get_standalone_stream_handle( + self, client: Client, stream_id: str + ) -> RedisStreamHandle: + """Refuse: this provider keeps no stream without an owner. + + Raises: + StreamUnsupportedError: Always. + """ + raise StreamUnsupportedError( + "the redis provider does not host standalone streams" + ) + async def close(self) -> None: """Release the Redis connection this provider opened; a caller's stays open.""" self._backend = None diff --git a/temporalio/worker/_workflow.py b/temporalio/worker/_workflow.py index d646ebdac..66f511845 100644 --- a/temporalio/worker/_workflow.py +++ b/temporalio/worker/_workflow.py @@ -637,6 +637,11 @@ async def _handle_activation( raise deadlock_exc from None output_runtime = self._external_stream_runtimes.get(act.run_id) + if output_runtime is not None and completion.HasField("successful"): + # A subscription opened at a stream's tail is positioned here, + # off the Workflow thread and before the completion goes out, so + # the marker records the boundary the watcher starts from. + await output_runtime.resolve_pending_starts() if ( output_runtime is not None and completion.HasField("successful") diff --git a/tests/contrib/external_workflow_streams/test_api.py b/tests/contrib/external_workflow_streams/test_api.py index 18095c310..0f969fd2c 100644 --- a/tests/contrib/external_workflow_streams/test_api.py +++ b/tests/contrib/external_workflow_streams/test_api.py @@ -76,10 +76,14 @@ def register( stream_key: StreamKey, idle_timeout: timedelta, start_cursor: Cursor | None = None, + start_at_tail: Any = None, ) -> None: self.registrations.append((wait_id, stream_key)) self.idle_timeouts[wait_id] = idle_timeout - self.start_cursors[wait_id] = start_cursor + # A tail start stands in for the cursor the Worker would resolve. + self.start_cursors[wait_id] = ( + start_cursor if start_at_tail is None else start_at_tail + ) def drain(self, wait_id: int, max_records: int | None = None) -> list[StreamRecord]: buffered = self.buffers.get(wait_id, []) diff --git a/tests/contrib/external_workflow_streams/test_registry.py b/tests/contrib/external_workflow_streams/test_registry.py index c368be89c..d18d658b4 100644 --- a/tests/contrib/external_workflow_streams/test_registry.py +++ b/tests/contrib/external_workflow_streams/test_registry.py @@ -104,6 +104,7 @@ def test_supported_entry_points_are_public() -> None: "PrecedingWriteFailedError", "RecordKind", "RedisStreamBackend", + "StartAtTail", "StreamBackend", "StreamDecodeError", "StreamError", diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index bac0ef3b7..26543701f 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -36,6 +36,7 @@ from temporalio.converter import WorkflowSerializationContext from temporalio.streams import ( BEGINNING, + END, Cursor, RecordKind, StreamCursorError, @@ -1054,3 +1055,107 @@ async def test_a_workflow_does_not_read_its_own_records_from_the_shared_log( await Replayer(workflows=[EchoOnOneTopic], plugins=[provider]).replay_workflow( await handle.fetch_history() ) + + +@workflow.defn +class NewestTwoOnGo: + """Opens ``inputs`` at its newest two records once told to, and returns them.""" + + def __init__(self) -> None: + self._go = False + + @workflow.signal + def go(self) -> None: + self._go = True + + @workflow.run + async def run(self) -> list[Any]: + await workflow.wait_condition(lambda: self._go) + reader = workflow.stream_reader(INPUTS, last=2) + values: list[Any] = [] + async for value in reader.values(): + values.append(value["n"]) + if len(values) == 2: + reader.close() + return values + + +@workflow.defn +class FromNowOnGo: + """Opens ``inputs`` at its end once told to, and returns the first value.""" + + def __init__(self) -> None: + self._go = False + + @workflow.signal + def go(self) -> None: + self._go = True + + @workflow.run + async def run(self) -> Any: + await workflow.wait_condition(lambda: self._go) + reader = workflow.stream_reader(INPUTS, after=END) + async for value in reader.values(): + reader.close() + return value["n"] + return None + + +async def test_a_workflow_reader_starts_at_the_last_n_records_and_replays_there( + live_client: Client, provider: RedisStreams +): + # The worker positions the subscription against the log after the task that + # opened it and records the entry with it; replay takes the entry from + # History, so what lands in the log later does not move the start. The + # producer needs the run to exist, so the reader opens on a signal. + workflow_id = f"streams-redis-last-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[NewestTwoOnGo], + plugins=[provider], + ): + handle = await live_client.start_workflow( + NewestTwoOnGo.run, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + stream = provider.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="tool", attempt=1) + await producer.append({"n": 1}, {"n": 2}, {"n": 3}, {"n": 4}) + await handle.signal(NewestTwoOnGo.go) + assert await asyncio.wait_for(handle.result(), 60) == [3, 4] + history = await handle.fetch_history() + await producer.append({"n": 5}, {"n": 6}) + # Offline, with two more records in the log than the run ever saw. + await Replayer(workflows=[NewestTwoOnGo], plugins=[provider]).replay_workflow( + history + ) + + +async def test_a_workflow_reader_at_end_skips_what_was_there_and_replays( + live_client: Client, provider: RedisStreams +): + workflow_id = f"streams-redis-end-{uuid.uuid4().hex}" + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[FromNowOnGo], + plugins=[provider], + ): + handle = await live_client.start_workflow( + FromNowOnGo.run, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + stream = provider.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="tool", attempt=1) + await producer.append({"n": "old"}) + await handle.signal(FromNowOnGo.go) + result = asyncio.ensure_future(handle.result()) + # The subscription is positioned when the worker gets to it, which the + # test does not observe, so appends keep coming until the run takes one. + for _ in range(150): + await producer.append({"n": "new"}) + done, _ = await asyncio.wait({result}, timeout=0.2) + if done: + break + assert await asyncio.wait_for(result, 60) == "new" + history = await handle.fetch_history() + await Replayer(workflows=[FromNowOnGo], plugins=[provider]).replay_workflow(history) diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index 67ef81fbf..cd42ae0ca 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -274,7 +274,21 @@ async def host(workflow_id: str) -> None: StreamHost.run, id=workflow_id, task_queue=worker.task_queue ) - yield ProviderCase("redis", provider, client, host=host) + yield ProviderCase( + "redis", + provider, + client, + host=host, + # The refusal of a trimmed cursor lands on the first step on this + # provider, where the case wants it at the call; its own live module + # covers the trimmed floor. + truncate=None, + # Matched by encoded bytes for now, so a retry through a codec that + # differs on every call is not recognized; standalone streams are + # not hosted yet. + detects_divergent_retries=False, + hosts_standalone_streams=False, + ) for handle in hosts.values(): await handle.terminate() await provider.close() From 5a22233ab68ea9abff4d8c370e91a9713f0fec77 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 30 Sep 2026 03:49:10 -0700 Subject: [PATCH 84/96] Hosted standalone streams on Redis and matched retries by the plaintext hash. A stream with an id and no owner gets its own key scheme: a hash for the policy and the seal, a log per topic, and an append script that reads the policy, refuses a sealed stream and trims by count, age and a per-topic byte total. Every record now carries the hash of its converted body and the append scripts compare that hash, so a retry through a codec that differs on every call still matches. --- temporalio/streams/providers/redis.py | 475 +++++++++++++++++++--- tests/streams/test_redis_provider.py | 14 + tests/streams/test_streams_conformance.py | 5 - 3 files changed, 435 insertions(+), 59 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index ba0b92807..b40199db0 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -92,6 +92,24 @@ so a long-lived consumer continues as new inside the window, or is configured with a longer one. +- Every record carries the SHA-256 of its converted body under + ``temporal.io/content-hash``, stamped before the payload codec runs, and the + append script compares that hash rather than the stored bytes, so a retry + through a codec that differs on every call is still the same append while a + divergent one is refused. A record without a body is matched by its + plaintext record instead. +- A standalone stream is one more key scheme: ``standalone/`` as the + owner component, a hash under it that holds the policy and the seal, and one + log per topic beside the hash. ``create_stream`` writes the hash once and + refuses a different policy for an id that has one; a handle on an id with no + hash raises ``StreamNotFoundError`` at its first use. Every append applies + the policy to the topic it wrote, ``max_records`` and ``retention`` as on the + other logs and ``max_bytes`` by a byte total the hash keeps per topic, + dropping the oldest entries until the topic fits. ``close()`` sets the seal: + a later append is refused with ``StreamClosedError``, the retained records + stay readable and a read ends once it has delivered them. Nothing in + Temporal knows these keys, so no tooling lists them. + Keys written by the earlier layout. Before the log, a topic was two keys: the transport's input key, ``::::`` with every id percent-encoded, and its output key, the same with an ``output`` @@ -108,6 +126,7 @@ from __future__ import annotations import asyncio +import hashlib import logging import re import time @@ -120,6 +139,7 @@ from google.protobuf.message import DecodeError from temporalio import workflow +from temporalio.api.common.v1 import Payload from temporalio.client import ActivityExecutionStatus, Client, WorkflowExecutionStatus from temporalio.contrib.external_workflow_streams import ( AFTER, @@ -169,7 +189,6 @@ _BEGINNING_SENTINEL, _OUTPUT_STAGE_FIELD, RedisStreamBackend, - _content_hash, _text, _to_record, ) @@ -179,12 +198,13 @@ WorkflowSerializationContext, ) from temporalio.service import RPCError, RPCStatusCode +from temporalio.streams._body import CONTENT_HASH_KEY, content_hash from temporalio.streams._errors import ( + StreamClosedError, StreamCursorError, StreamError, StreamNotFoundError, StreamProducerError, - StreamUnsupportedError, ) from temporalio.streams._provider import ReadSource, WriteSink from temporalio.streams._record import ( @@ -330,28 +350,58 @@ def _trim_maxlen(backend: Any) -> str: return "" if max_len is None else str(max_len) -def _append_args(backend: Any, record: TransportRecord) -> list[Any]: - """What the append script takes: the trims, the record's identity, its fields.""" - args: list[Any] = [ - _trim_floor(backend).encode(), - _trim_maxlen(backend).encode(), - str(record.idempotency_key).encode(), - _content_hash(record).encode(), - ] +def _fields_args(record: TransportRecord) -> list[Any]: + """The record's stored fields, name then value, as the append scripts take them.""" + args: list[Any] = [] for name, value in sorted(record.to_fields().items()): args.append(name.encode()) args.append(value) return args +def _append_args(backend: Any, record: TransportRecord, digest: str) -> list[Any]: + """What the log append script takes: the trims, the identity and digest, the fields. + + ``digest`` is the plaintext hash of what the record carries, taken before + the payload codec ran, so a retry the codec encoded differently is still + recognized as the same append. + """ + return [ + _trim_floor(backend).encode(), + _trim_maxlen(backend).encode(), + str(record.idempotency_key).encode(), + digest.encode(), + *_fields_args(record), + ] + + +def _plaintext_digest(wire: WireRecord) -> str: + """The digest an append is matched by: the body's hash, or the record's without one.""" + if wire.HasField("body"): + return content_hash(wire.body) + return hashlib.sha256(wire.SerializeToString(deterministic=True)).hexdigest() + + +def _stamp_hash(wire: WireRecord) -> None: + """Put the body's plaintext hash on ``wire`` under the shared metadata key.""" + if wire.HasField("body"): + wire.metadata[CONTENT_HASH_KEY].CopyFrom( + Payload( + metadata={"encoding": b"binary/plain"}, + data=content_hash(wire.body).encode(), + ) + ) + + #: Append one record to a log, or answer where it already is. #: #: The provider's own write rather than the transport's, so the retention trims #: ride along instead of costing their own round trips; they are exact for the #: reason the backend's own trims are. The idempotency hash is read before the -#: log is touched, so an identity already used with different bytes refuses the -#: record rather than writing it, and one used with the same bytes answers with -#: the original position, which is what settles a call whose answer was lost. +#: log is touched, so an identity already used with a different plaintext digest +#: refuses the record rather than writing it, and one used with the same digest +#: answers with the original position, which is what settles a call whose answer +#: was lost. _LOG_APPEND_LUA: Final = """ local minid = ARGV[1] local maxlen = ARGV[2] @@ -383,20 +433,176 @@ def __init__(self, backend: RedisStreamBackend) -> None: self._backend = backend self._script = backend._client.register_script(_LOG_APPEND_LUA) - async def write(self, *, name: str, record: TransportRecord) -> Offset: + async def write(self, *, name: str, record: TransportRecord, digest: str) -> Offset: """Append ``record`` to the log ``name`` and return where it landed. - A repeat of the same ``(session, sequence)`` with the same bytes returns - the original position and writes nothing. + A repeat of the same ``(session, sequence)`` with the same ``digest`` + returns the original position and writes nothing. """ outcome, placed = await self._script( - keys=[name, f"{name}:idem"], args=_append_args(self._backend, record) + keys=[name, f"{name}:idem"], + args=_append_args(self._backend, record, digest), ) if _text(outcome) == "conflict": raise AppendConflictError(record.idempotency_key) return Offset(_text(placed)) +@dataclass(frozen=True) +class _StandaloneOwner: + """A stream with an id of its own and no owner, and where Redis keeps it.""" + + namespace: str + stream_id: str + + def meta(self, key_prefix: str) -> str: + """The hash holding the stream's policy, seal and byte totals.""" + namespace = quote(self.namespace, safe="") + return f"{key_prefix}:{namespace}:standalone/{quote(self.stream_id, safe='')}" + + def key(self, key_prefix: str, topic: str) -> str: + """The log holding ``topic`` of this stream; one more component than the hash.""" + return f"{self.meta(key_prefix)}:{quote(topic, safe='')}" + + def __str__(self) -> str: + """The stream, for messages.""" + return f"standalone stream {self.stream_id!r}" + + +def _policy_fields( + retention: timedelta | None, max_records: int | None, max_bytes: int | None +) -> list[bytes]: + """The policy as the hash stores it: milliseconds, counts, and blanks for none.""" + return [ + b"" + if retention is None + else str(int(retention.total_seconds() * 1000)).encode(), + b"" if max_records is None else str(max_records).encode(), + b"" if max_bytes is None else str(max_bytes).encode(), + ] + + +#: Create a standalone stream's hash, or say whether the one there agrees. +_CREATE_STANDALONE_LUA: Final = """ +if redis.call('EXISTS', KEYS[1]) == 1 then + local held = redis.call('HMGET', KEYS[1], 'retention_ms', 'max_records', 'max_bytes') + if held[1] == ARGV[1] and held[2] == ARGV[2] and held[3] == ARGV[3] then + return 'same' + end + return 'conflict' +end +redis.call('HSET', KEYS[1], 'retention_ms', ARGV[1], 'max_records', ARGV[2], + 'max_bytes', ARGV[3], 'sealed', '0') +return 'created' +""" + + +#: Append one record to a standalone stream's topic under the stream's policy. +#: +#: The policy lives in the hash, so the script reads it rather than being told: +#: a sealed stream refuses, a missing one says so, and the trims apply to the +#: topic just written. ``max_bytes`` has no Redis trim of its own, so each entry +#: carries the size of the record it holds, the hash keeps a byte total per +#: topic, and the oldest entries are dropped one at a time until the topic fits. +_STANDALONE_APPEND_LUA: Final = """ +local meta = redis.call('HMGET', KEYS[3], 'sealed', 'retention_ms', 'max_records', + 'max_bytes', ARGV[6]) +if not meta[1] then + return {'missing', ''} +end +if meta[1] == '1' then + return {'closed', ''} +end +local existing = redis.call('HGET', KEYS[2], ARGV[1]) +if existing then + local sep = string.find(existing, '|') + if string.sub(existing, sep + 1) ~= ARGV[2] then + return {'conflict', ''} + end + return {'ok', string.sub(existing, 1, sep - 1)} +end +local id = redis.call('XADD', KEYS[1], '*', unpack(ARGV, 7)) +redis.call('HSET', KEYS[2], ARGV[1], id .. '|' .. ARGV[2]) +local total = tonumber(meta[5] or '0') + tonumber(ARGV[4]) +local floor = nil +if meta[2] ~= '' then + floor = tonumber(ARGV[3]) - tonumber(meta[2]) +end +while true do + local entries = redis.call('XRANGE', KEYS[1], '-', '+', 'COUNT', 1) + if #entries == 0 then break end + local entry = entries[1] + local drop = false + if meta[3] ~= '' and redis.call('XLEN', KEYS[1]) > tonumber(meta[3]) then drop = true end + if meta[4] ~= '' and total > tonumber(meta[4]) then drop = true end + if floor and tonumber(string.match(entry[1], '^(%d+)')) < floor then drop = true end + if not drop then break end + local size = 0 + local fields = entry[2] + for i = 1, #fields, 2 do + if fields[i] == ARGV[5] then size = tonumber(fields[i + 1]) end + end + redis.call('XDEL', KEYS[1], entry[1]) + total = total - size +end +redis.call('HSET', KEYS[3], ARGV[6], total) +return {'ok', id} +""" + + +#: The entry field holding the size a standalone stream's policy counts: the +#: serialized record, as the memory provider measures it, not the stored payload +#: with its envelope. +_SIZE_FIELD: Final = b"__size" + + +class _StandaloneAppend: + """One record on a standalone stream's topic, under its policy, in one call.""" + + def __init__(self, backend: RedisStreamBackend, owner: _StandaloneOwner) -> None: + """Bind to ``backend``'s client and ``owner``'s hash.""" + self._owner = owner + self._meta = owner.meta(_prefix(backend)) + self._script = backend._client.register_script(_STANDALONE_APPEND_LUA) + + async def write( + self, *, name: str, topic: str, record: TransportRecord, digest: str, size: int + ) -> Offset: + """Append ``record`` to the log ``name`` and return where it landed. + + ``size`` is what the stream's ``max_bytes`` counts for this record. + + Raises: + StreamNotFoundError: The stream was never created. + StreamClosedError: The stream is sealed. + AppendConflictError: The identity is held with a different digest. + """ + outcome, placed = await self._script( + keys=[name, f"{name}:idem", self._meta], + args=[ + str(record.idempotency_key).encode(), + digest.encode(), + str(int(time.time() * 1000)).encode(), + str(size).encode(), + _SIZE_FIELD, + f"bytes:{quote(topic, safe='')}".encode(), + *_fields_args(record), + _SIZE_FIELD, + str(size).encode(), + ], + ) + result = _text(outcome) + if result == "missing": + raise StreamNotFoundError(f"{self._owner} was not found") + if result == "closed": + raise StreamClosedError( + f"{self._owner} is closed and takes no more records" + ) + if result == "conflict": + raise AppendConflictError(record.idempotency_key) + return Offset(_text(placed)) + + def _prefix(backend: Any) -> str: """The key prefix ``backend`` writes under, for keys the provider derives itself.""" return backend._key_prefix @@ -505,6 +711,25 @@ async def _retained(client: Any, name: str, offset: Offset) -> bool: return wanted > _entry_id(info["last-generated-id"]) +async def _require_standalone(store: Any, meta: str, owner: _StandaloneOwner) -> None: + """Raise ``StreamNotFoundError`` unless ``owner``'s hash exists.""" + if not await store.exists(meta): + raise StreamNotFoundError(f"{owner} was not found") + + +async def _sealed(store: Any, meta: str) -> bool: + """Whether the standalone stream behind ``meta`` is sealed.""" + return _text(await store.hget(meta, "sealed") or b"0") == "1" + + +async def _newest(store: Any, name: str) -> Cursor: + """The cursor of the newest entry of the log ``name``, or ``BEGINNING``.""" + newest: Any = await store.xrevrange(name, "+", "-", count=1) + if not newest: + return BEGINNING + return mint_cursor(_PROVIDER, _text(newest[0][0])) + + def _is_staged(fields: Any) -> bool: """Whether a log entry is one the workflow staged, rather than a producer's.""" return _STAGE_FIELD in fields @@ -761,7 +986,9 @@ def __init__(self, topic: str) -> None: def publish(self, record: WireRecord) -> None: # Buffered in the transport's per-task batch, staged by the worker when # the task completes and promoted once History proves the task was - # accepted: rule 1 through the transport's own commit. + # accepted: rule 1 through the transport's own commit. The hash is a + # digest over bytes already in hand, so it costs no I/O here. + _stamp_hash(record) _drive(self._topic.publish(record.SerializeToString())) @@ -862,12 +1089,14 @@ def __init__( producer_id: str, attempt: int, owner: _ActivityOwner | None = None, + standalone: _StandaloneOwner | None = None, ) -> None: - """Bind this producer to ``topic`` of ``workflow_id``'s chain, or of ``owner``'s streams.""" + """Bind this producer to ``topic`` of the chain, of ``owner`` or of ``standalone``.""" self._streams = streams self._client = client self._workflow_id = workflow_id self._owner = owner + self._standalone = standalone self._topic = topic self._producer_id = producer_id self._attempt = attempt @@ -875,7 +1104,7 @@ def __init__( self._sequence = 0 self._last = BEGINNING self._input: Any = None - self._append: _LogAppend | None = None + self._append: _LogAppend | _StandaloneAppend | None = None self._name: str | None = None self._codec: StreamPayloadCodec[bytes] | None = None @@ -948,6 +1177,13 @@ async def _connect(self) -> None: if self._codec is not None: return backend = self._streams._require_backend() + if self._standalone is not None: + # No owner to code under and no one to wake: a standalone stream + # is read from outside only, and its hash says whether it exists. + self._name = self._standalone.key(_prefix(backend), self._topic) + self._append = _StandaloneAppend(backend, self._standalone) + self._codec = StreamPayloadCodec(self._client.data_converter, bytes) + return self._append = _LogAppend(backend) if self._owner is not None: # No transport producer to bind and no one to wake: an activity's @@ -1011,6 +1247,10 @@ async def _place(self, records: list[WireRecord]) -> Offset | None: assert self._name is not None last: Offset | None = None for index, record in enumerate(records): + # The digest is taken and stamped before the codec runs, so a retry + # the codec encodes differently still matches its original. + digest = _plaintext_digest(record) + _stamp_hash(record) # Built here rather than handed to the transport's publish, so the # trims ride along. The identity is the one the transport would # derive, so a record the log already holds is reused. @@ -1020,8 +1260,19 @@ async def _place(self, records: list[WireRecord]) -> Offset | None: producer_session_id=self._session, sequence=self._sequence + index, ) - last = await self._append.write(name=self._name, record=staged) - if self._owner is None: + if isinstance(self._append, _StandaloneAppend): + last = await self._append.write( + name=self._name, + topic=self._topic, + record=staged, + digest=digest, + size=record.ByteSize(), + ) + else: + last = await self._append.write( + name=self._name, record=staged, digest=digest + ) + if self._owner is None and self._standalone is None: await self._wake() return last @@ -1093,9 +1344,10 @@ class RedisStreamHandle: The owner is ``workflow_id``'s workflow, whose topic logs are read through the transport's output read so a staged batch is a barrier until its task - settles, or with ``activity_id`` an activity, read from the stream the - provider keeps for it: a standalone activity without ``workflow_id``, or - an activity that workflow scheduled. + settles; with ``activity_id`` an activity, read from the stream the + provider keeps for it, a standalone activity without ``workflow_id`` or an + activity that workflow scheduled; or with ``stream_id`` a standalone + stream, read from the logs under its hash until it is sealed. """ def __init__( @@ -1105,6 +1357,8 @@ def __init__( workflow_id: str | None, run_id: str | None, activity_id: str | None = None, + *, + stream_id: str | None = None, ) -> None: """Address the owner's topics; ``run_id`` decides whose close ends a read.""" self._streams = streams @@ -1112,23 +1366,28 @@ def __init__( self._workflow_id = workflow_id self._run_id = run_id self._owner: _ActivityOwner | None = None - if activity_id is not None: + self._standalone: _StandaloneOwner | None = None + converter = client.data_converter + if stream_id is not None: + # No owner, so nothing to code the bodies under. + self._standalone = _StandaloneOwner(client.namespace, stream_id) + elif activity_id is not None: self._owner = _ActivityOwner( client.namespace, workflow_id, activity_id, run_id ) - context = self._owner.context() + converter = converter.with_context(self._owner.context()) else: if workflow_id is None: raise ValueError( - "a stream handle needs a workflow_id or an activity_id" + "a stream handle needs a workflow_id, an activity_id or a stream_id" + ) + converter = converter.with_context( + WorkflowSerializationContext( + namespace=client.namespace, workflow_id=workflow_id ) - context = WorkflowSerializationContext( - namespace=client.namespace, workflow_id=workflow_id ) self._converter = client.data_converter.payload_converter - self._codec: StreamPayloadCodec[bytes] = StreamPayloadCodec( - client.data_converter.with_context(context), bytes - ) + self._codec: StreamPayloadCodec[bytes] = StreamPayloadCodec(converter, bytes) def read( self, @@ -1163,12 +1422,76 @@ def read( # a foreign one fails this call, not the first iteration. tail = last if last is not None else (0 if after == END else None) position = None if tail is not None else _position(after) + if self._standalone is not None: + return self._read_standalone( + self._standalone, topic, position, after, result_type, tail=tail + ) if self._owner is not None: return self._read_owned( self._owner, topic, position, after, result_type, tail=tail ) return self._read(topic, position, after, result_type, tail=tail) + async def _read_standalone( + self, + owner: _StandaloneOwner, + topic: str, + position: Offset | None, + after: Cursor, + result_type: type | None, + *, + tail: int | None = None, + ) -> AsyncGenerator[StreamRecord[Any], None]: + backend = self._streams._require_backend() + store = backend._client + meta = owner.meta(_prefix(backend)) + await _require_standalone(store, meta, owner) + name = owner.key(_prefix(backend), topic) + if tail is not None: + position = await _tail_after(store, name, tail) + after = ( + BEGINNING + if position is None + else mint_cursor(_PROVIDER, position.token) + ) + decoder = RecordDecoder( + self._converter, result_type, after=after, warn=logger.warning + ) + if position is not None and not await _retained(store, name, position): + raise StreamCursorError( + f"cursor {after.token!r} names a record on {topic!r} that the " + f"stream's policy has dropped" + ) + start = _BEGINNING_SENTINEL if position is None else position.token + block = int(self._streams._poll.total_seconds() * 1000) or None + closed = False + while True: + found: Any = await store.xread( + {name: start}, count=_READ_BATCH, block=block + ) + entries = found[0][1] if found else [] + for entry_id, fields in entries: + placed = _to_record(entry_id, fields) + assert placed.offset is not None + start = placed.offset.token + if placed.kind is not TransportRecordKind.DATA: + continue + minted = mint_cursor(_PROVIDER, placed.offset.token) + wire = _parse( + minted, await self._codec.decode(placed.payload), logger.warning + ) + if wire is None: + continue + for record in decoder.decode(minted, wire): + yield record + if entries: + continue + if closed: + return + # One more pass after learning of the seal, so a record that landed + # between the read and the seal is not lost. + closed = await _sealed(store, meta) + async def _read_owned( self, owner: _ActivityOwner, @@ -1405,14 +1728,18 @@ async def latest(self, *, topic: str | StreamTopic[Any]) -> Cursor: """ topic, _ = resolve_topic(topic) backend = self._streams._require_backend() + if self._standalone is not None: + await _require_standalone( + backend._client, + self._standalone.meta(_prefix(backend)), + self._standalone, + ) + return await _newest( + backend._client, self._standalone.key(_prefix(backend), topic) + ) if self._owner is not None: owner = await _resolve_owner(self._client, self._owner) - newest: Any = await backend._client.xrevrange( - owner.key(_prefix(backend), topic), "+", "-", count=1 - ) - if not newest: - return BEGINNING - return mint_cursor(_PROVIDER, _text(newest[0][0])) + return await _newest(backend._client, owner.key(_prefix(backend), topic)) assert self._workflow_id is not None chain = await _chain(self._client, self._workflow_id) try: @@ -1428,6 +1755,8 @@ async def latest(self, *, topic: str | StreamTopic[Any]) -> Cursor: def ref(self, *, topic: str | StreamTopic[Any] | None = None) -> StreamRef: """A ref to ``topic`` of this owner's stream, pinned as this handle is.""" + if self._standalone is not None: + return StreamRef.for_standalone(self._standalone.stream_id, topic=topic) if self._owner is not None: return StreamRef.for_activity( self._owner.activity_id, @@ -1441,11 +1770,25 @@ def ref(self, *, topic: str | StreamTopic[Any] | None = None) -> StreamRef: ) async def close(self) -> None: - """Refuse: an owned stream ends with its owner, not by a caller.""" - raise ValueError( - "only a standalone stream can be closed; this handle is on an owned " - "stream, which ends when its workflow or activity does" - ) + """Seal a standalone stream. An owned stream ends with its owner, not by a caller. + + The seal is a flag in the stream's hash that every append reads, so a + later append is refused with ``StreamClosedError`` and a read ends once + it has delivered the retained tail. Idempotent. + + Raises: + ValueError: This handle is on a workflow's or an activity's stream. + StreamNotFoundError: The stream was never created. + """ + if self._standalone is None: + raise ValueError( + "only a standalone stream can be closed; this handle is on an owned " + "stream, which ends when its workflow or activity does" + ) + backend = self._streams._require_backend() + meta = self._standalone.meta(_prefix(backend)) + await _require_standalone(backend._client, meta, self._standalone) + await backend._client.hset(meta, "sealed", "1") def producer( self, @@ -1465,6 +1808,7 @@ def producer( producer_id, attempt, owner=self._owner, + standalone=self._standalone, ) @@ -1610,26 +1954,49 @@ async def create_standalone_stream( max_records: int | None = None, max_bytes: int | None = None, ) -> RedisStreamHandle: - """Refuse: this provider keeps no stream without an owner. + """Create the standalone stream ``stream_id``, or find it with the same policy. + + The policy is written once into the stream's hash and applied on every + append to any of its topics. ``retention`` left unset takes this + provider's default window; the other two bounds are off unless set. Raises: - StreamUnsupportedError: Always. + ValueError: ``stream_id`` is empty, a bound is not positive, or + the stream exists with a different policy. """ - raise StreamUnsupportedError( - "the redis provider does not host standalone streams" + if not stream_id: + raise ValueError("stream_id must not be empty") + if retention is not None and retention <= timedelta(0): + raise ValueError("retention must be positive") + if max_records is not None and max_records <= 0: + raise ValueError("max_records must be positive") + if max_bytes is not None and max_bytes <= 0: + raise ValueError("max_bytes must be positive") + backend = self._require_backend() + owner = _StandaloneOwner(client.namespace, stream_id) + policy = _policy_fields( + self._retention if retention is None else retention, max_records, max_bytes ) + outcome = await backend._client.register_script(_CREATE_STANDALONE_LUA)( + keys=[owner.meta(_prefix(backend))], args=policy + ) + if _text(outcome) == "conflict": + raise ValueError( + f"{owner} exists with another policy; a policy is set when the " + "stream is created and does not change" + ) + return RedisStreamHandle(self, client, None, None, stream_id=stream_id) def get_standalone_stream_handle( self, client: Client, stream_id: str ) -> RedisStreamHandle: - """Refuse: this provider keeps no stream without an owner. + """A handle on the standalone stream ``stream_id``. - Raises: - StreamUnsupportedError: Always. + Nothing is checked here: a ``read``, ``latest``, ``producer`` or + ``close`` on a stream that was never created raises + :class:`temporalio.streams.StreamNotFoundError` when it is used. """ - raise StreamUnsupportedError( - "the redis provider does not host standalone streams" - ) + return RedisStreamHandle(self, client, None, None, stream_id=stream_id) async def close(self) -> None: """Release the Redis connection this provider opened; a caller's stays open.""" diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py index 680dc6e8d..56f67a578 100644 --- a/tests/streams/test_redis_provider.py +++ b/tests/streams/test_redis_provider.py @@ -30,6 +30,7 @@ _ActivityOwner, _drive, _position, + _StandaloneOwner, ) @@ -293,3 +294,16 @@ async def test_the_default_window_is_an_age_and_can_be_turned_off(): assert unbounded._require_backend().describe_window() == "no retention" finally: await unbounded.close() + + +def test_standalone_keys_have_their_own_owner_component(): + owner = _StandaloneOwner("ns", "shared") + assert owner.meta("p") == "p:ns:standalone/shared" + assert owner.key("p", "t") == "p:ns:standalone/shared:t" + # A topic log always has one more component than the hash, and every id and + # topic is encoded, so no name can land on the hash or on another topic. + assert owner.key("p", "meta") != owner.meta("p") + tricky = _StandaloneOwner("n:s", "a/b:c") + assert tricky.meta("p") == "p:n%3As:standalone/a%2Fb%3Ac" + assert tricky.key("p", "t/u") == "p:n%3As:standalone/a%2Fb%3Ac:t%2Fu" + assert str(owner) == "standalone stream 'shared'" diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index cd42ae0ca..074f6c4e2 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -283,11 +283,6 @@ async def host(workflow_id: str) -> None: # provider, where the case wants it at the call; its own live module # covers the trimmed floor. truncate=None, - # Matched by encoded bytes for now, so a retry through a codec that - # differs on every call is not recognized; standalone streams are - # not hosted yet. - detects_divergent_retries=False, - hosts_standalone_streams=False, ) for handle in hosts.values(): await handle.terminate() From cbe1ec4b8166b096fb8b88a4fb0f80814f3dc10d Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 30 Sep 2026 03:52:26 -0700 Subject: [PATCH 85/96] Declared the Redis standalone stream's byte bound and open-stream age trim. The conformance suite now holds a provider to the standalone capabilities it declares. The Redis append script keeps a byte total per topic and trims by age on every append, so both hold while the stream is open. --- tests/streams/test_streams_conformance.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index fd42cf648..eb272d079 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -295,6 +295,11 @@ async def host(workflow_id: str) -> None: # provider, where the case wants it at the call; its own live module # covers the trimmed floor. truncate=None, + # A standalone stream's append script keeps a byte total per topic + # and trims by age on every append, so both bounds hold while the + # stream is open. + bounds_standalone_bytes=True, + trims_open_stream_by_age=True, ) for handle in hosts.values(): await handle.terminate() From 67f80ea92005b1c7e6ebe92a7a2fd31e411f9f3d Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 30 Sep 2026 21:30:05 -0700 Subject: [PATCH 86/96] Took an aborted batch's entries out of the shared log. A completion the server rejects, such as a closing task with a wake Signal buffered under it, leaves the batch it staged with a token no marker will name; the re-run stages the batch again, and the dead stage stayed in the log once a reader or the worker aborted it. On the one-log layout those entries count against every window and bound, so the abort now deletes them and the stage's hashes stand in for them, and the worker settles a run's pending stages against History when it evicts the run rather than leaving them for a reader to find. --- temporalio/streams/providers/redis.py | 45 +++++++++++++++++++++ temporalio/worker/_workflow.py | 11 ++++++ tests/streams/test_redis_replay.py | 56 ++++++++++++++++++++++++++- 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index b40199db0..30bf24c08 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -180,6 +180,7 @@ OutputStageNotFoundError, OutputStageResolutionError, OutputStageStatus, + OutputStreamRecord, StagedOutputRecord, ) from temporalio.contrib.external_workflow_streams._output_client import ( @@ -844,6 +845,50 @@ async def stage_output( await self._trim(manifest.stream_key) return stage + async def abort_output(self, manifest: OutputStageManifest) -> OutputStage: + """Resolve the stage as aborted and take its entries out of the log. + + A batch whose task was not accepted yields nothing to any reader, and + on this provider the workflow's own entries share the log with the + producers' records, so leaving them would count them against every + window and bound the log holds. The stage's hashes stay, status and + offsets included, so a reader that captured the barrier settles it the + same way. + """ + stage = await super().abort_output(manifest) + if stage.records: + await self._client.xdel( + self.stream_key(manifest.stream_key), + *(record.offset.serialize() for record in stage.records), + ) + return stage + + async def _output_stage_from_offsets( + self, + manifest: OutputStageManifest, + status: OutputStageStatus, + encoded_offsets: str, + ) -> OutputStage: + # An aborted stage's entries are gone from the log here, so they are + # stood in for by their positions: the stage still names what it held. + if status is not OutputStageStatus.ABORTED: + return await super()._output_stage_from_offsets( + manifest, status, encoded_offsets + ) + offsets = [Offset(value) for value in encoded_offsets.split(",") if value] + if len(offsets) != manifest.record_count: + raise StreamIntegrityError( + "an output stage's stored offsets do not match its manifest" + ) + return OutputStage( + manifest, + tuple( + OutputStreamRecord(TransportRecordKind.DATA, b"", offset) + for offset in offsets + ), + status, + ) + async def read_range( self, key: StreamKey, first: Offset, last: Offset ) -> list[TransportRecord]: diff --git a/temporalio/worker/_workflow.py b/temporalio/worker/_workflow.py index 66f511845..9cfe27350 100644 --- a/temporalio/worker/_workflow.py +++ b/temporalio/worker/_workflow.py @@ -905,6 +905,17 @@ async def _handle_cache_eviction( # skips the instance's eviction job entirely, and a Run whose # watchers outlived it would keep a backend connection open forever. self._external_stream_runtimes.pop(act.run_id, None) + # A Run evicted because its completion was not accepted may have + # staged a batch that no marker will ever name. History already + # says so when the task was failed or timed out, so the stage is + # settled here instead of waiting for a reader to find the barrier; + # an undecided one stays pending for the reader, as before. + pending = self._pending_external_output_stages.get(act.run_id) + if pending: + await self._promote_external_output( + run_id=act.run_id, + workflow_id=pending[0].stream_key.workflow_id, + ) self._pending_external_output_stages.pop(act.run_id, None) if self._external_stream_manager is not None: await self._external_stream_manager.evict_run(act.run_id) diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 26543701f..b1f3ebc3e 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -43,7 +43,8 @@ StreamProducerError, ) from temporalio.streams._wire import to_wire -from temporalio.streams.providers.redis import RedisStreams, _chain +from temporalio.streams.providers import redis as redis_provider +from temporalio.streams.providers.redis import RedisStreams, _chain, _TopicLogBackend from temporalio.worker import Replayer, Worker from tests.streams.test_streams_conformance import StreamHost, take from tests.streams.test_streams_workflow import ( @@ -1159,3 +1160,56 @@ async def test_a_workflow_reader_at_end_skips_what_was_there_and_replays( assert await asyncio.wait_for(result, 60) == "new" history = await handle.fetch_history() await Replayer(workflows=[FromNowOnGo], plugins=[provider]).replay_workflow(history) + + +async def test_a_batch_staged_for_a_rejected_task_does_not_stay_in_the_log( + live_client: Client, monkeypatch: pytest.MonkeyPatch +): + # The first stage takes longer than the Workflow Task timeout, so the server + # times the task out and the worker's completion is rejected; the run is + # evicted and run again, and the second attempt stages the same batch under + # a new token. The first stage can never be named by a marker. It is + # settled against History when the run is evicted and its entries leave the + # log, so the log holds the batch once and nothing counts the dead one. + class SlowFirstStage(_TopicLogBackend): + delayed = False + + async def stage_output(self, manifest: Any, records: Any) -> Any: + if not SlowFirstStage.delayed: + SlowFirstStage.delayed = True + await asyncio.sleep(3) + return await super().stage_output(manifest, records) + + monkeypatch.setattr(redis_provider, "_TopicLogBackend", SlowFirstStage) + provider = RedisStreams( + url=redis_url(), key_prefix=f"streams-redis-{uuid.uuid4().hex}" + ) + workflow_id = f"streams-redis-rejected-{uuid.uuid4().hex}" + try: + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[OneLine], + plugins=[provider], + ): + handle = await live_client.start_workflow( + OneLine.run, + id=workflow_id, + task_queue=f"tq-{workflow_id}", + task_timeout=timedelta(seconds=1), + ) + await asyncio.wait_for(handle.result(), 60) + assert SlowFirstStage.delayed + # Counted before any reader could settle a barrier: the worker did. + assert ( + await _log_length(provider, live_client, workflow_id, DECISIONS.name) + == 2 + ) + stream = provider.get_stream_handle(live_client, workflow_id) + records = [r async for r in stream.read(topic=DECISIONS)] + assert [(r.kind, r.value) for r in records] == [ + (RecordKind.DATA, {"from": "workflow"}), + (RecordKind.FINISH, None), + ] + finally: + await provider.close() From 78aedddd2619e66996bdc599cf871286181b7a94 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 10:01:12 -0700 Subject: [PATCH 87/96] Aborted only History-rejected output stages at eviction. The eviction-time reconcile also recommitted a stage whose marker is authoritative but whose one promotion had failed, which took the repair away from the cold client and broke the post-report commit failure contract. Eviction now applies an abort decision only, and a stage History has committed stays pending for the client. --- temporalio/worker/_workflow.py | 62 ++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/temporalio/worker/_workflow.py b/temporalio/worker/_workflow.py index 9cfe27350..a723b0fc1 100644 --- a/temporalio/worker/_workflow.py +++ b/temporalio/worker/_workflow.py @@ -905,17 +905,7 @@ async def _handle_cache_eviction( # skips the instance's eviction job entirely, and a Run whose # watchers outlived it would keep a backend connection open forever. self._external_stream_runtimes.pop(act.run_id, None) - # A Run evicted because its completion was not accepted may have - # staged a batch that no marker will ever name. History already - # says so when the task was failed or timed out, so the stage is - # settled here instead of waiting for a reader to find the barrier; - # an undecided one stays pending for the reader, as before. - pending = self._pending_external_output_stages.get(act.run_id) - if pending: - await self._promote_external_output( - run_id=act.run_id, - workflow_id=pending[0].stream_key.workflow_id, - ) + await self._abort_dead_external_output(run_id=act.run_id) self._pending_external_output_stages.pop(act.run_id, None) if self._external_stream_manager is not None: await self._external_stream_manager.evict_run(act.run_id) @@ -1304,6 +1294,56 @@ async def _promote_external_output( else: self._pending_external_output_stages.pop(run_id, None) + async def _abort_dead_external_output(self, *, run_id: str) -> None: + """Abort the staged batches of an evicted Run that History has rejected. + + A Run evicted because its completion was not accepted may have staged + a batch that no marker will ever name. History already says so when + the task was failed or timed out, so such a stage is aborted here + instead of waiting for a reader to find the barrier. A stage whose + marker is authoritative is never re-promoted from here: promotion is + attempted once after the completion, and a stage it left pending is + owed to a cold client's repair, as the contract tests pin down. + """ + backend = self._external_stream_backend + client = self._client + pending = self._pending_external_output_stages.get(run_id) + if backend is None or client is None or not pending: + return + + from temporalio.contrib.external_workflow_streams._output_client import ( + _apply_output_stage_decision, + _history_decision, + _HistoryDecision, + ) + + for manifest in pending: + workflow_id = manifest.stream_key.workflow_id + try: + decision = await _history_decision( + client=client, + workflow_id=workflow_id, + manifest=manifest, + ) + if decision is not _HistoryDecision.ABORT: + continue + await _apply_output_stage_decision( + backend=backend, + manifest=manifest, + decision=decision, + ) + except asyncio.CancelledError: + raise + except Exception as err: + self._stream_metrics.record(err) + logger.warning( + "Could not abort the dead external output stage %s for " + "Workflow %s at eviction; it remains pending for a client", + manifest.stage_token, + workflow_id, + exc_info=True, + ) + def _replay_stream_converters( self, plan: Any ) -> dict[int, temporalio.converter.DataConverter]: From 78454f4c4690529ed4ad92d9084e020bc5ceced2 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 11:46:14 -0700 Subject: [PATCH 88/96] Woke Redis readers with the entry id as the wake position. The counter derives from the id so producers and workers order wakes alike, and the server's NOT_FOUND ends a wake without a describe. --- temporalio/streams/providers/redis.py | 59 ++++++++++++++++++++-- tests/streams/test_redis_provider.py | 70 ++++++++++++++++++++++++--- tests/streams/test_redis_replay.py | 69 ++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 11 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 30bf24c08..225ae98d8 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -193,6 +193,10 @@ _text, _to_record, ) +from temporalio.contrib.external_workflow_streams._redis import ( + _parse as _parse_entry_id, +) +from temporalio.contrib.external_workflow_streams._wake import WakeTransport from temporalio.converter import ( ActivitySerializationContext, SerializationContext, @@ -256,6 +260,26 @@ _WAKE_RETRY_WINDOW: Final = timedelta(seconds=5) _WAKE_RETRY_BACKOFF: Final = timedelta(milliseconds=200) +#: Bits of a wake counter given to an entry id's sequence part. Redis assigns +#: sequence numbers from 0 within one millisecond, so a million appends to one +#: log inside the same millisecond would be needed to reach the cap. +_WAKE_SEQUENCE_BITS: Final = 20 + + +def _wake_counter(offset: Offset) -> int: + """A wake counter from a Redis entry id ``-``. + + ``ms * 2**20 + min(seq, 2**20 - 1)``, which increases with the id's own + order and fits the server's signed 64-bit counter for any millisecond + timestamp before the year 2248. Ids past the cap within one millisecond + share a counter, which only lets a later wake fold into a pending one that + reports an earlier id of that same millisecond. + """ + ms, seq = _parse_entry_id(offset) + cap = (1 << _WAKE_SEQUENCE_BITS) - 1 + return (ms << _WAKE_SEQUENCE_BITS) + min(seq, cap) + + # The transport's per-task batch limits are a backpressure point that awaits # inside the workflow, and a synchronous publish has nowhere to await. Lifted # to where they cannot fire, so a task's publishes stage as one batch. @@ -762,10 +786,16 @@ def __init__( key_prefix: str, retention: timedelta | None, max_len: int | None, + wake_transport: WakeTransport = "auto", ) -> None: super().__init__(client=client, key_prefix=key_prefix) self._retention = retention self._max_len = max_len + self.wake_transport = wake_transport + + def wake_counter_for(self, offset: Offset) -> int: + """The entry id's own order, so producers and workers rank wakes alike.""" + return _wake_counter(offset) def stream_key(self, key: StreamKey) -> str: """The topic's log, whichever direction the transport asks for.""" @@ -1101,6 +1131,12 @@ async def _chain(client: Client, workflow_id: str) -> WorkflowChainKey: _CLOSING_REFUSAL: Final = "workflow is closing" +def _chain_ended(error: WakeNotAcknowledgedError) -> bool: + """Whether the server refused a wake because the chain has ended.""" + cause = error.__cause__ + return isinstance(cause, RPCError) and cause.status == RPCStatusCode.NOT_FOUND + + def _refused_as_closing(error: WakeNotAcknowledgedError) -> bool: """Whether the server refused a wake because the run is closing.""" return _CLOSING_REFUSAL in str(error) @@ -1318,10 +1354,10 @@ async def _place(self, records: list[WireRecord]) -> Offset | None: name=self._name, record=staged, digest=digest ) if self._owner is None and self._standalone is None: - await self._wake() + await self._wake(last) return last - async def _wake(self) -> None: + async def _wake(self, position: Offset | None) -> None: """Wake the consuming workflow, following the chain past a closing run. The records are appended before this is called; what can fail is @@ -1343,14 +1379,19 @@ async def _wake(self) -> None: the log and finds the record, the transport's own rule for a record appended before a park. Any other refusal that outlasts the window is raised. + + ``NOT_FOUND`` answers the chain question without a describe: the wake + call names the chain's first run, and the server refuses it that way + only once the chain has ended. A Signal, which names the Workflow ID + alone, refuses an ended run with it too. """ deadline = time.monotonic() + _WAKE_RETRY_WINDOW.total_seconds() while True: try: - await self._input.wake() + await self._input.wake(position=position) return except WakeNotAcknowledgedError as error: - if await self._chain_is_terminal(): + if _chain_ended(error) or await self._chain_is_terminal(): return if time.monotonic() >= deadline: if _refused_as_closing(error): @@ -1876,6 +1917,7 @@ def __init__( poll_interval: timedelta = timedelta(milliseconds=500), retention: timedelta | None = DEFAULT_RETENTION, max_len: int | None = None, + wake_transport: WakeTransport = "auto", ) -> None: """Create the provider. @@ -1908,7 +1950,14 @@ def __init__( It must exceed the largest batch a task publishes, or a stage is trimmed before its commit; a batch at or above it is refused where it is staged. + wake_transport: How a producer and a worker wake a workflow after + an append. ``"wake"`` uses the server's wake call, which + records no History event; ``"signal"`` uses the reserved + Signal, which does; ``"auto"`` tries the wake call and falls + back to the Signal on a server without it. """ + if wake_transport not in ("auto", "wake", "signal"): + raise ValueError(f"unknown wake transport {wake_transport!r}") if retention is not None and retention <= timedelta(0): raise ValueError("retention must be positive") if max_len is not None and max_len < 1: @@ -1922,6 +1971,7 @@ def __init__( self._poll = poll_interval self._retention = retention self._max_len = max_len + self._wake_transport: WakeTransport = wake_transport def _require_backend(self) -> _TopicLogBackend: if self._backend is None: @@ -1942,6 +1992,7 @@ def _require_backend(self) -> _TopicLogBackend: key_prefix=self._key_prefix, retention=self._retention, max_len=self._max_len, + wake_transport=self._wake_transport, ) return self._backend diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py index 56f67a578..4330d8703 100644 --- a/tests/streams/test_redis_provider.py +++ b/tests/streams/test_redis_provider.py @@ -18,6 +18,7 @@ StreamError as TransportStreamError, ) from temporalio.contrib.external_workflow_streams._backend import StreamKey +from temporalio.contrib.external_workflow_streams._record import Offset from temporalio.contrib.external_workflow_streams._redis import RedisStreamBackend from temporalio.converter import DataConverter from temporalio.service import RPCError, RPCStatusCode @@ -31,6 +32,7 @@ _drive, _position, _StandaloneOwner, + _wake_counter, ) @@ -64,9 +66,11 @@ def __init__(self, refusals: int, error: Exception | None = None) -> None: self._refusals = refusals self._error = error self.calls = 0 + self.positions: list[Any] = [] - async def wake(self) -> list[str]: + async def wake(self, *, position: Any = None) -> list[str]: self.calls += 1 + self.positions.append(position) if self.calls <= self._refusals: raise self._error or WakeNotAcknowledgedError( "workflow operation can not be applied because workflow is closing", @@ -99,7 +103,7 @@ async def test_a_wake_refused_by_a_closing_run_is_sent_again_to_its_successor(): # to a successor, which is where the records already are. client = _ChainClient(WorkflowExecutionStatus.RUNNING) wakes = _RefusingInput(refusals=1) - await _waking_producer(client, wakes)._wake() + await _waking_producer(client, wakes)._wake(None) assert (wakes.calls, client.describes) == (2, 1) @@ -111,7 +115,7 @@ async def test_a_refused_wake_waits_for_the_successor_to_become_current(): WorkflowExecutionStatus.CONTINUED_AS_NEW, WorkflowExecutionStatus.RUNNING ) wakes = _RefusingInput(refusals=2) - await _waking_producer(client, wakes)._wake() + await _waking_producer(client, wakes)._wake(None) assert (wakes.calls, client.describes) == (3, 2) @@ -133,7 +137,7 @@ async def test_a_wake_refused_by_a_finished_chain_is_the_ordinary_ending( ): client = _ChainClient(ending) wakes = _RefusingInput(refusals=99) - await _waking_producer(client, wakes)._wake() + await _waking_producer(client, wakes)._wake(None) assert wakes.calls == 1 @@ -144,7 +148,7 @@ async def test_a_wake_refused_as_closing_for_the_whole_window_is_dropped(): # next park rechecks the log and finds it; the producer is not failed. client = _ChainClient(WorkflowExecutionStatus.RUNNING) wakes = _RefusingInput(refusals=99) - await _waking_producer(client, wakes)._wake() + await _waking_producer(client, wakes)._wake(None) assert wakes.calls > 1 @@ -155,15 +159,67 @@ async def test_a_wake_refused_for_another_reason_for_the_whole_window_is_raised( refusals=99, error=WakeNotAcknowledgedError("signal rate limited", pending=[]) ) with pytest.raises(WakeNotAcknowledgedError, match="rate limited"): - await _waking_producer(client, wakes)._wake() + await _waking_producer(client, wakes)._wake(None) assert wakes.calls > 1 +@pytest.mark.usefixtures("quick_wake_retries") +async def test_a_wake_refused_as_not_found_ends_without_a_describe(): + # The wake call names the chain's first run, so the server's NOT_FOUND + # already says the chain has ended. + client = _ChainClient(WorkflowExecutionStatus.RUNNING) + refusal = WakeNotAcknowledgedError("gone", pending=[]) + refusal.__cause__ = RPCError("gone", RPCStatusCode.NOT_FOUND, b"") + wakes = _RefusingInput(refusals=99, error=refusal) + await _waking_producer(client, wakes)._wake(None) + assert (wakes.calls, client.describes) == (1, 0) + + +async def test_a_wake_reports_the_entry_it_follows(): + client = _ChainClient(WorkflowExecutionStatus.RUNNING) + wakes = _RefusingInput(refusals=0) + placed = Offset("1700000000000-4") + await _waking_producer(client, wakes)._wake(placed) + assert wakes.positions == [placed] + + +def test_a_wake_counter_follows_the_entry_id_order(): + ids = ["1700000000000-0", "1700000000000-1", "1700000000001-0", "1800000000000-7"] + counters = [_wake_counter(Offset(entry)) for entry in ids] + assert counters == sorted(counters) + assert len(set(counters)) == len(counters) + assert _wake_counter(Offset("1700000000000-3")) == 1700000000000 * 2**20 + 3 + + +def test_a_wake_counter_caps_the_sequence_inside_its_millisecond(): + capped = _wake_counter(Offset(f"5-{2**20 + 9}")) + assert capped == 5 * 2**20 + 2**20 - 1 + assert capped < _wake_counter(Offset("6-0")) + + +def test_a_wake_counter_fits_the_servers_signed_64_bit_field(): + year_2200_ms = 7258118400000 + assert _wake_counter(Offset(f"{year_2200_ms}-{2**20}")) < 2**63 + + +def test_the_wake_transport_reaches_the_backend_both_sides_share(): + streams = RedisStreams(client=_NoRedis(), wake_transport="signal") + backend = streams._require_backend() + assert backend.wake_transport == "signal" + assert backend.wake_counter_for(Offset("2-1")) == _wake_counter(Offset("2-1")) + assert RedisStreams(client=_NoRedis())._require_backend().wake_transport == "auto" + + +def test_an_unknown_wake_transport_is_refused_at_construction(): + with pytest.raises(ValueError, match="wake transport"): + RedisStreams(wake_transport=cast(Any, "carrier-pigeon")) + + async def test_a_wake_the_store_could_not_send_is_a_storage_error(): client = _ChainClient(WorkflowExecutionStatus.RUNNING) wakes = _RefusingInput(refusals=1, error=TransportStreamError("no connection")) with pytest.raises(StreamError, match="could not be sent"): - await _waking_producer(client, wakes)._wake() + await _waking_producer(client, wakes)._wake(None) assert (wakes.calls, client.describes) == (1, 0) diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index b1f3ebc3e..4258e69e5 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -138,6 +138,75 @@ async def read_everything() -> list[Any]: assert all(r.producer_id == "model" and r.attempt == 1 for r in inputs) +async def _serves_wakes(client: Client) -> bool: + """Whether the server implements the wake call, probed with a wake to nobody.""" + import temporalio.api.common.v1 + import temporalio.api.workflow.v1 + import temporalio.api.workflowservice.v1 + from temporalio.service import RPCError, RPCStatusCode + + try: + await client.workflow_service.wake_workflow_execution( + temporalio.api.workflowservice.v1.WakeWorkflowExecutionRequest( + namespace=client.namespace, + workflow_execution=temporalio.api.common.v1.WorkflowExecution( + workflow_id=f"streams-redis-probe-{uuid.uuid4().hex}" + ), + wake=temporalio.api.workflow.v1.Wake(source="probe", counter=1), + ) + ) + except RPCError as error: + return error.status != RPCStatusCode.UNIMPLEMENTED + return True + + +async def test_an_outside_producer_wakes_the_reader_without_a_signal( + live_client: Client, +): + if not await _serves_wakes(live_client): + pytest.skip("the server does not implement WakeWorkflowExecution") + # The wake transport rather than "auto", so a fallback to the Signal fails + # the History check below instead of passing quietly. + provider = RedisStreams( + url=redis_url(), + key_prefix=f"streams-redis-{uuid.uuid4().hex}", + wake_transport="wake", + ) + workflow_id = f"streams-redis-wake-{uuid.uuid4().hex}" + try: + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[ContractLoop], + plugins=[provider], + max_cached_workflows=100, + ): + handle = await live_client.start_workflow( + ContractLoop.run, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + stream = provider.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="model", attempt=1) + # Spaced past the reader's idle timeout, so the reader parks between + # appends and only a wake from outside can move it. + for n in (1, 2, 3): + await producer.append({"n": n}) + await asyncio.sleep(2) + await producer.finish() + + result = await asyncio.wait_for(handle.result(), 60) + assert [entry.get("n") for entry in result[:3]] == [1, 2, 3] + assert result[3] == {"kind": "finish", "producer": "model"} + + signalled = [ + event + async for event in handle.fetch_history_events() + if event.HasField("workflow_execution_signaled_event_attributes") + ] + assert signalled == [] + finally: + await provider.close() + + async def test_an_outside_producer_and_the_workflow_share_a_topic( live_client: Client, provider: RedisStreams ): From a7c8a421bb71dcbd4ebf9181674c8f354e142308 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 18:20:09 -0700 Subject: [PATCH 89/96] Satisfied basedpyright on the Redis provider's wake transport guard. --- temporalio/streams/providers/redis.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 225ae98d8..dd382a19a 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -133,7 +133,7 @@ from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine, Sequence from dataclasses import dataclass, replace from datetime import timedelta -from typing import Any, Final, Generic, TypeVar +from typing import Any, Final, Generic, TypeVar, get_args from urllib.parse import quote from google.protobuf.message import DecodeError @@ -1956,7 +1956,8 @@ def __init__( Signal, which does; ``"auto"`` tries the wake call and falls back to the Signal on a server without it. """ - if wake_transport not in ("auto", "wake", "signal"): + # Checked against the alias so an untyped caller still gets a ValueError. + if wake_transport not in get_args(WakeTransport): raise ValueError(f"unknown wake transport {wake_transport!r}") if retention is not None and retention <= timedelta(0): raise ValueError("retention must be positive") From 98b734986c60ed06dd8599b8019d2a30bbc81c42 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 19:04:37 -0700 Subject: [PATCH 90/96] Accepted the channel transport on the Redis provider. --- temporalio/streams/providers/redis.py | 10 ++++++---- tests/streams/test_redis_provider.py | 2 ++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index dd382a19a..5af0b6c02 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -1951,10 +1951,12 @@ def __init__( is trimmed before its commit; a batch at or above it is refused where it is staged. wake_transport: How a producer and a worker wake a workflow after - an append. ``"wake"`` uses the server's wake call, which - records no History event; ``"signal"`` uses the reserved - Signal, which does; ``"auto"`` tries the wake call and falls - back to the Signal on a server without it. + an append. ``"channel"`` notifies the stream's channel, which + wakes every subscribed reader with the entry id as the + position; ``"wake"`` uses the server's deprecated wake call; + ``"signal"`` uses the reserved Signal, which writes a History + event; ``"auto"`` tries them in that order and steps down on + a server without the call. """ # Checked against the alias so an untyped caller still gets a ValueError. if wake_transport not in get_args(WakeTransport): diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py index 4330d8703..3dd45eed8 100644 --- a/tests/streams/test_redis_provider.py +++ b/tests/streams/test_redis_provider.py @@ -208,6 +208,8 @@ def test_the_wake_transport_reaches_the_backend_both_sides_share(): assert backend.wake_transport == "signal" assert backend.wake_counter_for(Offset("2-1")) == _wake_counter(Offset("2-1")) assert RedisStreams(client=_NoRedis())._require_backend().wake_transport == "auto" + channel = RedisStreams(client=_NoRedis(), wake_transport="channel") + assert channel._require_backend().wake_transport == "channel" def test_an_unknown_wake_transport_is_refused_at_construction(): From a10ced0b1dd11f63ba5281a5cc7c364389544fb3 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 19:04:37 -0700 Subject: [PATCH 91/96] Covered the Redis reader woken through the channel. The live case asks for the channel outright and checks the subscribed event, the notified scheduled event with the entry id as position, and no Signal. The shared conformance case runs on Redis through the wakes_by_notification capability. --- tests/streams/test_redis_replay.py | 80 +++++++++++++++++++++++ tests/streams/test_streams_conformance.py | 3 + 2 files changed, 83 insertions(+) diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 4258e69e5..c206edb3a 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -1282,3 +1282,83 @@ async def stage_output(self, manifest: Any, records: Any) -> Any: ] finally: await provider.close() + + +async def _serves_channels(client: Client) -> bool: + """Whether the server implements notification channels, asked of a probe channel.""" + from temporalio.contrib.external_workflow_streams._wake import server_has_channels + + return bool(await server_has_channels(client)) + + +async def test_an_outside_producer_wakes_the_reader_through_the_channel( + live_client: Client, +): + if not await _serves_channels(live_client): + pytest.skip("the server does not implement notification channels") + from temporalio.contrib.external_workflow_streams._record import Offset + + # The channel outright rather than "auto", so a step down to the wake call + # or the Signal fails the History checks below instead of passing quietly. + provider = RedisStreams( + url=redis_url(), + key_prefix=f"streams-redis-{uuid.uuid4().hex}", + wake_transport="channel", + ) + workflow_id = f"streams-redis-channel-{uuid.uuid4().hex}" + try: + async with Worker( + live_client, + task_queue=f"tq-{workflow_id}", + workflows=[ContractLoop], + plugins=[provider], + max_cached_workflows=100, + ): + handle = await live_client.start_workflow( + ContractLoop.run, id=workflow_id, task_queue=f"tq-{workflow_id}" + ) + stream = provider.get_stream_handle(live_client, workflow_id) + producer = stream.producer(topic=INPUTS, producer_id="model", attempt=1) + # Spaced past the reader's idle timeout, so the reader parks between + # appends and only a notification from outside can move it. + for n in (1, 2, 3): + await producer.append({"n": n}) + await asyncio.sleep(2) + await producer.finish() + + result = await asyncio.wait_for(handle.result(), 60) + assert [entry.get("n") for entry in result[:3]] == [1, 2, 3] + assert result[3] == {"kind": "finish", "producer": "model"} + + events = [e async for e in handle.fetch_history_events()] + signalled = [ + e + for e in events + if e.HasField("workflow_execution_signaled_event_attributes") + ] + assert signalled == [], "a Signal woke the reader" + subscribed = [ + e.workflow_notification_channel_subscribed_event_attributes.channel + for e in events + if e.HasField( + "workflow_notification_channel_subscribed_event_attributes" + ) + ] + assert len(subscribed) == 1, "the run subscribes once per channel" + notified = [ + n + for e in events + if e.HasField("workflow_task_scheduled_event_attributes") + for n in e.workflow_task_scheduled_event_attributes.notifications + ] + assert notified, "no Workflow Task was scheduled with a notification" + assert {n.channel for n in notified} == set(subscribed) + # The position is the appended entry id and the counter derives + # from it, so producers and workers order the same wakes alike. + positioned = [note for note in notified if note.position] + assert positioned, "no notification carried the store's position" + for note in positioned: + offset = Offset(note.position.decode()) + assert note.counter == redis_provider._wake_counter(offset) + finally: + await provider.close() diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index 83f64a2d1..f550ad3d1 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -305,6 +305,9 @@ async def host(workflow_id: str) -> None: # stream is open. bounds_standalone_bytes=True, trims_open_stream_by_age=True, + # An outside append notifies the stream's channel, and the + # reader's worker subscribes to it on the task that opens the read. + wakes_by_notification=True, ) for handle in hosts.values(): await handle.terminate() From 98692781ea0b1f6b7bdb99cf74aa139fe56026ce Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 19:16:12 -0700 Subject: [PATCH 92/96] Chose the reset point by the task that consumed the record. A task index names a different task on a server with channels, where the task that opens the reader carries the subscribe command and ends at once. --- tests/streams/test_redis_replay.py | 63 +++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index c206edb3a..3cf136c3f 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -925,16 +925,54 @@ async def _completed_tasks(client: Client, workflow_id: str, run_id: str) -> lis ] -async def _reset_at(client: Client, workflow_id: str, run_id: str, *, task: int) -> str: - """Reset ``run_id`` at its ``task``-th completed Workflow Task; the new run id. +_MARKER_DETAILS_KEY = "external_stream" + + +async def _consuming_tasks(client: Client, workflow_id: str, run_id: str) -> list[int]: + """The completed Workflow Tasks whose marker delivered a record, in order. + + Read from the markers rather than counted by position: which task opens + the reader, and whether that task can stay open for the first record, + depends on the server, so a task index names a different task from one + server to the next while the marker says what each task consumed. + """ + from temporalio.bridge.proto.external_data import ExternalStreamMarkerData + from temporalio.contrib.external_workflow_streams._annotation import ( + decode_annotation, + ) + + handle = client.get_workflow_handle(workflow_id, run_id=run_id) + completed: int | None = None + consuming: list[int] = [] + async for event in handle.fetch_history_events(): + if event.HasField("workflow_task_completed_event_attributes"): + completed = event.event_id + continue + if not event.HasField("marker_recorded_event_attributes"): + continue + details = event.marker_recorded_event_attributes.details + if _MARKER_DETAILS_KEY not in details or completed is None: + continue + data = ExternalStreamMarkerData() + data.ParseFromString(details[_MARKER_DETAILS_KEY].payloads[0].data) + annotation = decode_annotation(data.replay_annotation) + if any(segment.runs for segment in annotation.segments): + consuming.append(completed) + return consuming + + +async def _reset_at( + client: Client, workflow_id: str, run_id: str, *, finish_event_id: int +) -> str: + """Reset ``run_id`` at the Workflow Task completed by ``finish_event_id``. The server keeps History up to that task's completion and runs the task again, so the tasks before it are inherited and the task itself is not. + Returns the new run id. """ from temporalio.api.common.v1 import WorkflowExecution from temporalio.api.workflowservice.v1 import ResetWorkflowExecutionRequest - completed = await _completed_tasks(client, workflow_id, run_id) response = await client.workflow_service.reset_workflow_execution( ResetWorkflowExecutionRequest( namespace=client.namespace, @@ -942,7 +980,7 @@ async def _reset_at(client: Client, workflow_id: str, run_id: str, *, task: int) workflow_id=workflow_id, run_id=run_id ), reason="streams: reset mid-stream", - workflow_task_finish_event_id=completed[task], + workflow_task_finish_event_id=finish_event_id, request_id=uuid.uuid4().hex, ) ) @@ -952,7 +990,7 @@ async def _reset_at(client: Client, workflow_id: str, run_id: str, *, task: int) async def _consume_two_then_nudge( live_client: Client, provider: RedisStreams, workflow_id: str, task_queue: str ) -> tuple[str, Any, Any]: - """Two records in two tasks, then a task that consumes nothing; the base run.""" + """Two records, each consumed by a task of its own, then a nudged task; the base run.""" handle = await live_client.start_workflow( NudgedLoop.run, id=workflow_id, task_queue=task_queue ) @@ -992,7 +1030,10 @@ async def test_a_reset_run_replays_the_inherited_ranges_and_continues( base_run, stream, producer = await _consume_two_then_nudge( live_client, provider, workflow_id, f"tq-{workflow_id}" ) - reset_run = await _reset_at(live_client, workflow_id, base_run, task=-1) + last_task = (await _completed_tasks(live_client, workflow_id, base_run))[-1] + reset_run = await _reset_at( + live_client, workflow_id, base_run, finish_event_id=last_task + ) assert reset_run != base_run await producer.append({"n": 3}) @@ -1016,8 +1057,9 @@ async def test_a_reset_run_replays_the_inherited_ranges_and_continues( async def test_a_reset_point_task_is_run_again_from_the_log( live_client: Client, provider: RedisStreams ): - # Reset at the second consuming task's completion: the first task is - # inherited and the second is run again. The record it consumed is still + # Reset at the completion of the task that consumed the second record: the + # tasks before it are inherited, the one that consumed the first record + # among them, and this one is run again. The record it consumed is still # in the log, so the reset run reads it again and publishes again, and an # outside reader sees that decision from both runs. workflow_id = f"streams-redis-reset-rerun-{uuid.uuid4().hex}" @@ -1031,7 +1073,10 @@ async def test_a_reset_point_task_is_run_again_from_the_log( base_run, stream, producer = await _consume_two_then_nudge( live_client, provider, workflow_id, f"tq-{workflow_id}" ) - reset_run = await _reset_at(live_client, workflow_id, base_run, task=1) + second = (await _consuming_tasks(live_client, workflow_id, base_run))[1] + reset_run = await _reset_at( + live_client, workflow_id, base_run, finish_event_id=second + ) await producer.append({"n": 3}) await producer.finish() From 3bf0c94ddd8f80c70847b4c4055706f9767b80bd Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 20:05:06 -0700 Subject: [PATCH 93/96] Derived the Redis sweep counter from the entry id rule. A wake without a position takes the current time with the largest sequence, so it orders above every entry appended before now and is not folded under the channel's latest. --- temporalio/streams/providers/redis.py | 10 ++++++++++ tests/streams/test_redis_provider.py | 11 +++++++++++ tests/streams/test_redis_replay.py | 1 + 3 files changed, 22 insertions(+) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 5af0b6c02..6a138d969 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -797,6 +797,16 @@ def wake_counter_for(self, offset: Offset) -> int: """The entry id's own order, so producers and workers rank wakes alike.""" return _wake_counter(offset) + def wake_counter_now(self) -> int: + """The entry id rule applied to the clock, with the largest sequence. + + Above every entry appended before now, so a wake that reports no + position, the worker's shutdown sweep, is not folded under the + channel's latest notification. + """ + now_ms = int(time.time() * 1000) + return (now_ms << _WAKE_SEQUENCE_BITS) + (1 << _WAKE_SEQUENCE_BITS) - 1 + def stream_key(self, key: StreamKey) -> str: """The topic's log, whichever direction the transport asks for.""" return super().stream_key(replace(key, direction=StreamDirection.INPUT)) diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py index 3dd45eed8..4a2da97f3 100644 --- a/tests/streams/test_redis_provider.py +++ b/tests/streams/test_redis_provider.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import time from datetime import timedelta from types import SimpleNamespace from typing import Any, cast @@ -202,6 +203,16 @@ def test_a_wake_counter_fits_the_servers_signed_64_bit_field(): assert _wake_counter(Offset(f"{year_2200_ms}-{2**20}")) < 2**63 +def test_a_positionless_wake_counter_follows_the_entry_id_rule(): + backend = RedisStreams(client=_NoRedis())._require_backend() + before = _wake_counter(Offset(f"{int(time.time() * 1000)}-{2**20 - 1}")) + now = backend.wake_counter_now() + after = _wake_counter(Offset(f"{int(time.time() * 1000) + 1}-0")) + assert before <= now < after + # Any entry appended before the call orders below it. + assert now > _wake_counter(Offset(f"{int(time.time() * 1000) - 1}-7")) + + def test_the_wake_transport_reaches_the_backend_both_sides_share(): streams = RedisStreams(client=_NoRedis(), wake_transport="signal") backend = streams._require_backend() diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 3cf136c3f..8951d1a2f 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -1336,6 +1336,7 @@ async def _serves_channels(client: Client) -> bool: return bool(await server_has_channels(client)) +@pytest.mark.needs_channel_server async def test_an_outside_producer_wakes_the_reader_through_the_channel( live_client: Client, ): From 401616e352012cffc9c324b4077b1cbeedeeccd5 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 23:33:09 -0700 Subject: [PATCH 94/96] Covered the Redis reader woken through its linked channel. The provider passes the owner through the shared channel address, so nothing changes in it beyond the transport's documentation; the live channel case gains a linked variant and the case on the removed transport goes. --- temporalio/streams/providers/redis.py | 14 +- tests/streams/test_redis_provider.py | 5 +- tests/streams/test_redis_replay.py | 182 +++++++++------------- tests/streams/test_streams_conformance.py | 7 +- 4 files changed, 93 insertions(+), 115 deletions(-) diff --git a/temporalio/streams/providers/redis.py b/temporalio/streams/providers/redis.py index 6a138d969..4171bac6d 100644 --- a/temporalio/streams/providers/redis.py +++ b/temporalio/streams/providers/redis.py @@ -1961,12 +1961,14 @@ def __init__( is trimmed before its commit; a batch at or above it is refused where it is staged. wake_transport: How a producer and a worker wake a workflow after - an append. ``"channel"`` notifies the stream's channel, which - wakes every subscribed reader with the entry id as the - position; ``"wake"`` uses the server's deprecated wake call; - ``"signal"`` uses the reserved Signal, which writes a History - event; ``"auto"`` tries them in that order and steps down on - a server without the call. + an append. ``"channel"`` notifies the stream's channel with + the entry id as the position: on a server with channels + linked to a workflow the channel lives in the reading + workflow's own state and nothing subscribes, on one with + independent channels every subscribed reader is woken. + ``"signal"`` uses the reserved Signal, which writes a + History event. ``"auto"`` notifies the channel and steps + down to the Signal on a server without channels. """ # Checked against the alias so an untyped caller still gets a ValueError. if wake_transport not in get_args(WakeTransport): diff --git a/tests/streams/test_redis_provider.py b/tests/streams/test_redis_provider.py index 4a2da97f3..3a3ee0870 100644 --- a/tests/streams/test_redis_provider.py +++ b/tests/streams/test_redis_provider.py @@ -166,8 +166,9 @@ async def test_a_wake_refused_for_another_reason_for_the_whole_window_is_raised( @pytest.mark.usefixtures("quick_wake_retries") async def test_a_wake_refused_as_not_found_ends_without_a_describe(): - # The wake call names the chain's first run, so the server's NOT_FOUND - # already says the chain has ended. + # The server answers NOT_FOUND for a chain that has ended, whether the + # wake went as a Signal or to the owner's linked channel, so nothing is + # left to describe. client = _ChainClient(WorkflowExecutionStatus.RUNNING) refusal = WakeNotAcknowledgedError("gone", pending=[]) refusal.__cause__ = RPCError("gone", RPCStatusCode.NOT_FOUND, b"") diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 8951d1a2f..78155f56b 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -138,75 +138,6 @@ async def read_everything() -> list[Any]: assert all(r.producer_id == "model" and r.attempt == 1 for r in inputs) -async def _serves_wakes(client: Client) -> bool: - """Whether the server implements the wake call, probed with a wake to nobody.""" - import temporalio.api.common.v1 - import temporalio.api.workflow.v1 - import temporalio.api.workflowservice.v1 - from temporalio.service import RPCError, RPCStatusCode - - try: - await client.workflow_service.wake_workflow_execution( - temporalio.api.workflowservice.v1.WakeWorkflowExecutionRequest( - namespace=client.namespace, - workflow_execution=temporalio.api.common.v1.WorkflowExecution( - workflow_id=f"streams-redis-probe-{uuid.uuid4().hex}" - ), - wake=temporalio.api.workflow.v1.Wake(source="probe", counter=1), - ) - ) - except RPCError as error: - return error.status != RPCStatusCode.UNIMPLEMENTED - return True - - -async def test_an_outside_producer_wakes_the_reader_without_a_signal( - live_client: Client, -): - if not await _serves_wakes(live_client): - pytest.skip("the server does not implement WakeWorkflowExecution") - # The wake transport rather than "auto", so a fallback to the Signal fails - # the History check below instead of passing quietly. - provider = RedisStreams( - url=redis_url(), - key_prefix=f"streams-redis-{uuid.uuid4().hex}", - wake_transport="wake", - ) - workflow_id = f"streams-redis-wake-{uuid.uuid4().hex}" - try: - async with Worker( - live_client, - task_queue=f"tq-{workflow_id}", - workflows=[ContractLoop], - plugins=[provider], - max_cached_workflows=100, - ): - handle = await live_client.start_workflow( - ContractLoop.run, id=workflow_id, task_queue=f"tq-{workflow_id}" - ) - stream = provider.get_stream_handle(live_client, workflow_id) - producer = stream.producer(topic=INPUTS, producer_id="model", attempt=1) - # Spaced past the reader's idle timeout, so the reader parks between - # appends and only a wake from outside can move it. - for n in (1, 2, 3): - await producer.append({"n": n}) - await asyncio.sleep(2) - await producer.finish() - - result = await asyncio.wait_for(handle.result(), 60) - assert [entry.get("n") for entry in result[:3]] == [1, 2, 3] - assert result[3] == {"kind": "finish", "producer": "model"} - - signalled = [ - event - async for event in handle.fetch_history_events() - if event.HasField("workflow_execution_signaled_event_attributes") - ] - assert signalled == [] - finally: - await provider.close() - - async def test_an_outside_producer_and_the_workflow_share_a_topic( live_client: Client, provider: RedisStreams ): @@ -1329,23 +1260,64 @@ async def stage_output(self, manifest: Any, records: Any) -> Any: await provider.close() -async def _serves_channels(client: Client) -> bool: - """Whether the server implements notification channels, asked of a probe channel.""" - from temporalio.contrib.external_workflow_streams._wake import server_has_channels +async def _channel_support(client: Client) -> Any: + """What the server offers the readers: no channels, independent ones or linked ones.""" + from tests.contrib.external_workflow_streams.conftest import server_channel_support - return bool(await server_has_channels(client)) + return await server_channel_support(client) @pytest.mark.needs_channel_server async def test_an_outside_producer_wakes_the_reader_through_the_channel( live_client: Client, ): - if not await _serves_channels(live_client): + from temporalio.contrib.external_workflow_streams._wake import ChannelSupport + + support = await _channel_support(live_client) + if support is ChannelSupport.NONE: pytest.skip("the server does not implement notification channels") + if support is ChannelSupport.LINKED: + pytest.skip("a workflow-owned stream listens on its linked channel there") + events = await _consume_three_through_the_channel(live_client) + subscribed = _subscribed(events) + assert len(subscribed) == 1, "the run subscribes once per channel" + notified = _notified(events) + assert {n.channel for n in notified} == set(subscribed) + assert not any(n.HasField("linked_to") for n in notified) + + +@pytest.mark.needs_linked_server +async def test_an_outside_producer_wakes_the_reader_through_its_linked_channel( + live_client: Client, +): + """The stream's channel is the reading workflow's own, so nothing subscribes.""" + from temporalio.contrib.external_workflow_streams._wake import ChannelSupport + + if await _channel_support(live_client) is not ChannelSupport.LINKED: + pytest.skip("the server does not serve channels linked to a workflow") + events = await _consume_three_through_the_channel(live_client) + assert _subscribed(events) == [], "the owner is the listener by construction" + notified = _notified(events) + assert len({n.channel for n in notified}) == 1 + [workflow_id] = { + e.workflow_execution_started_event_attributes.workflow_id + for e in events + if e.HasField("workflow_execution_started_event_attributes") + } or {""} + assert {n.linked_to.workflow_id for n in notified} == {workflow_id} + + +async def _consume_three_through_the_channel(live_client: Client) -> list[Any]: + """Runs the contract loop with three spaced appends and returns its History. + + The channel outright rather than ``"auto"``, so a step down to the Signal + fails the checks here instead of passing quietly. Common to both channel + kinds: no Signal, a notification per wake, and the entry id as the + position with the counter derived from it, so producers and workers order + the same wakes alike. + """ from temporalio.contrib.external_workflow_streams._record import Offset - # The channel outright rather than "auto", so a step down to the wake call - # or the Signal fails the History checks below instead of passing quietly. provider = RedisStreams( url=redis_url(), key_prefix=f"streams-redis-{uuid.uuid4().hex}", @@ -1377,34 +1349,34 @@ async def test_an_outside_producer_wakes_the_reader_through_the_channel( assert result[3] == {"kind": "finish", "producer": "model"} events = [e async for e in handle.fetch_history_events()] - signalled = [ - e - for e in events - if e.HasField("workflow_execution_signaled_event_attributes") - ] - assert signalled == [], "a Signal woke the reader" - subscribed = [ - e.workflow_notification_channel_subscribed_event_attributes.channel - for e in events - if e.HasField( - "workflow_notification_channel_subscribed_event_attributes" - ) - ] - assert len(subscribed) == 1, "the run subscribes once per channel" - notified = [ - n - for e in events - if e.HasField("workflow_task_scheduled_event_attributes") - for n in e.workflow_task_scheduled_event_attributes.notifications - ] - assert notified, "no Workflow Task was scheduled with a notification" - assert {n.channel for n in notified} == set(subscribed) - # The position is the appended entry id and the counter derives - # from it, so producers and workers order the same wakes alike. - positioned = [note for note in notified if note.position] - assert positioned, "no notification carried the store's position" - for note in positioned: - offset = Offset(note.position.decode()) - assert note.counter == redis_provider._wake_counter(offset) finally: await provider.close() + signalled = [ + e for e in events if e.HasField("workflow_execution_signaled_event_attributes") + ] + assert signalled == [], "a Signal woke the reader" + notified = _notified(events) + assert notified, "no Workflow Task was scheduled with a notification" + positioned = [note for note in notified if note.position] + assert positioned, "no notification carried the store's position" + for note in positioned: + offset = Offset(note.position.decode()) + assert note.counter == redis_provider._wake_counter(offset) + return events + + +def _subscribed(events: list[Any]) -> list[str]: + return [ + e.workflow_notification_channel_subscribed_event_attributes.channel + for e in events + if e.HasField("workflow_notification_channel_subscribed_event_attributes") + ] + + +def _notified(events: list[Any]) -> list[Any]: + return [ + n + for e in events + if e.HasField("workflow_task_scheduled_event_attributes") + for n in e.workflow_task_scheduled_event_attributes.notifications + ] diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index d5e2fdf4b..21b08e4ab 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -309,9 +309,12 @@ async def host(workflow_id: str) -> None: # stream is open. bounds_standalone_bytes=True, trims_open_stream_by_age=True, - # An outside append notifies the stream's channel, and the - # reader's worker subscribes to it on the task that opens the read. + # An outside append notifies the stream's channel, addressed to + # the workflow that owns the stream; the reader's worker subscribes + # to it on the task that opens the read where the server has no + # linked kind, and listens by construction where it has. wakes_by_notification=True, + wakes_by_linked_notification=True, ) for handle in hosts.values(): await handle.terminate() From 04f802527fd6045ff3b8a3771fc49478c6b6fcc3 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 2 Oct 2026 03:52:15 -0700 Subject: [PATCH 95/96] Checked where the Redis reader's subscription lands. The channel case finds the subscribed event after a marker, so the task that opened the reader stayed retained until it left. The provider case's note says the same. --- tests/streams/test_redis_replay.py | 10 ++++++++++ tests/streams/test_streams_conformance.py | 7 ++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 78155f56b..647b2e820 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -1281,6 +1281,16 @@ async def test_an_outside_producer_wakes_the_reader_through_the_channel( events = await _consume_three_through_the_channel(live_client) subscribed = _subscribed(events) assert len(subscribed) == 1, "the run subscribes once per channel" + [index] = [ + i + for i, e in enumerate(events) + if e.HasField("workflow_notification_channel_subscribed_event_attributes") + ] + # Core issues the subscription after the marker of the completion that + # ends the task, so the task that opened the reader stayed retained. + assert events[index - 1].HasField("marker_recorded_event_attributes"), ( + "the subscription did not wait for the completion that ends the task" + ) notified = _notified(events) assert {n.channel for n in notified} == set(subscribed) assert not any(n.HasField("linked_to") for n in notified) diff --git a/tests/streams/test_streams_conformance.py b/tests/streams/test_streams_conformance.py index fa51ec71d..2c52f10b7 100644 --- a/tests/streams/test_streams_conformance.py +++ b/tests/streams/test_streams_conformance.py @@ -310,9 +310,10 @@ async def host(workflow_id: str) -> None: bounds_standalone_bytes=True, trims_open_stream_by_age=True, # An outside append notifies the stream's channel, addressed to - # the workflow that owns the stream; the reader's worker subscribes - # to it on the task that opens the read where the server has no - # linked kind, and listens by construction where it has. + # the workflow that owns the stream; the reader's run is + # subscribed to it when the task that opened the read ends where + # the server has no linked kind, and listens by construction + # where it has. wakes_by_notification=True, wakes_by_linked_notification=True, ) From 7718b1ef273a5d80596a5fa53ee7e881cc4c8617 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 2 Oct 2026 17:54:26 -0700 Subject: [PATCH 96/96] Read the linked owner as an execution in the Redis replay case. The notified scheduled event names the owner as a `temporal.api.common.v1.Execution`, so the case checks the type and the business id through the public helper. --- tests/streams/test_redis_replay.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/streams/test_redis_replay.py b/tests/streams/test_redis_replay.py index 647b2e820..9d078a712 100644 --- a/tests/streams/test_redis_replay.py +++ b/tests/streams/test_redis_replay.py @@ -23,6 +23,7 @@ from temporalio import workflow from temporalio.client import Client +from temporalio.common import Execution, ExecutionType from temporalio.contrib.external_workflow_streams import ( RecordKind as TransportRecordKind, ) @@ -1314,7 +1315,10 @@ async def test_an_outside_producer_wakes_the_reader_through_its_linked_channel( for e in events if e.HasField("workflow_execution_started_event_attributes") } or {""} - assert {n.linked_to.workflow_id for n in notified} == {workflow_id} + owners = {Execution.from_proto(n.linked_to) for n in notified} + assert {(owner.type, owner.business_id) for owner in owners} == { + (ExecutionType.WORKFLOW, workflow_id) + } async def _consume_three_through_the_channel(live_client: Client) -> list[Any]: