From ccd0e97365553ba67038ba2aaf4674c72cee7025 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 20:38:26 -0700 Subject: [PATCH 01/46] Pointed Core at a commit that vendors the Stream API. sdk-python takes its API files from Core's vendored copy. That commit adds the Stream protos and WIT from the api repo until the api Stream change merges. --- 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 c675c3246..5240dfec3 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit c675c32469af707e1c979a7b808c70b4a0608b82 +Subproject commit 5240dfec3a849f792e030aee57df0cafe9806840 From b874f86be6712fb3a4d016e5f55f8503ce0038e5 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 20:38:26 -0700 Subject: [PATCH 02/46] Taught the generator scripts to build the Stream package. The Stream package needs a newer nexgen than the existing packages, so it has its own pin until the api WITs and a nexgen release agree. The payload visitor covers every generated system Nexus package, and mypy skips the Stream package like the other generated one. --- pyproject.toml | 5 +++ scripts/gen_nexus_system_api.py | 80 ++++++++++++++++++++++++++------- scripts/gen_payload_visitor.py | 34 +++++++++----- 3 files changed, 92 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2e25bb1fc..07435e517 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -173,12 +173,17 @@ exclude = [ 'temporalio/api', 'temporalio/bridge/proto', 'temporalio/nexus/system/workflow_service', + 'temporalio/nexus/system/stream_service', ] [[tool.mypy.overrides]] module = "temporalio.nexus.system.workflow_service.*" ignore_errors = true +[[tool.mypy.overrides]] +module = "temporalio.nexus.system.stream_service.*" +ignore_errors = true + [tool.pydocstyle] convention = "google" # https://github.com/PyCQA/pydocstyle/issues/363#issuecomment-625563088 diff --git a/scripts/gen_nexus_system_api.py b/scripts/gen_nexus_system_api.py index 3b16ce1bb..b8a4b8ca9 100644 --- a/scripts/gen_nexus_system_api.py +++ b/scripts/gen_nexus_system_api.py @@ -23,9 +23,14 @@ ) wit_deps_dir = wit_input_dir / "deps" workflow_output_dir = base_dir / "temporalio" / "nexus" / "system" / "workflow_service" +stream_output_dir = base_dir / "temporalio" / "nexus" / "system" / "stream_service" notification_output_dir = base_dir / "temporalio" / "nexus" / "notifications" workflow_init_path = base_dir / "temporalio" / "workflow" / "__init__.py" NEX_GEN_VERSION = "0.2.8" +# The Stream package needs a newer nexgen, which rejects the other WITs. One pin is enough +# again once the api WITs and a nexgen release agree. No release has nexgen commit bf3c49c9 +# yet, so NEX_GEN_STREAM_BIN points at a build of it. +NEX_GEN_STREAM_VERSION = "0.2.12" proto_files = [ gen_protos.api_proto_dir / "temporal" @@ -36,10 +41,22 @@ gen_protos.api_proto_dir / "temporal" / "api" + / "streamservice" + / "v1" + / "request_response.proto", + gen_protos.api_proto_dir + / "temporal" + / "api" / "notificationservice" / "v1" / "request_response.proto", ] +# The Workflow functions of these generated packages are exported by temporalio.workflow. +# They are in import order, so that sorting the imports keeps them between the markers. +workflow_export_packages = [ + ("temporalio.nexus.system.stream_service.workflow", stream_output_dir / "workflow"), + ("temporalio.nexus.system.workflow_service", workflow_output_dir), +] def nex_gen_command() -> list[str]: @@ -66,6 +83,20 @@ def nex_gen_command() -> list[str]: return ["nexgen"] +def nex_gen_stream_command() -> list[str]: + bin_path = os.environ.get("NEX_GEN_STREAM_BIN") + if not bin_path: + raise RuntimeError( + "NEX_GEN_STREAM_BIN must point at a nexgen build of commit bf3c49c9" + ) + version = subprocess.check_output([bin_path, "--version"], text=True).strip() + if version != f"nexgen {NEX_GEN_STREAM_VERSION}": + raise RuntimeError( + f"NEX_GEN_STREAM_BIN is {version}, not nexgen {NEX_GEN_STREAM_VERSION}" + ) + return [bin_path] + + def build_descriptor_set(descriptor_path: Path) -> None: subprocess.check_call( [ @@ -85,14 +116,15 @@ def generate_package( output_dir: Path, descriptor_path: Path, *, - native_api: bool, + flags: list[str], ) -> None: - args = [*command, "python", str(wit_input_dir / wit_name), str(wit_deps_dir)] - if native_api: - args.append("--native-api") subprocess.check_call( [ - *args, + *command, + "python", + str(wit_input_dir / wit_name), + str(wit_deps_dir), + *flags, "--system-nexus", "--support-package", "temporalio.nexus.system._support", @@ -132,17 +164,26 @@ def replace_marker_block( def generate_workflow_exports() -> None: - exports = generated_exports(workflow_output_dir / "__init__.py") + package_exports = [ + (module, generated_exports(package_dir / "__init__.py")) + for module, package_dir in workflow_export_packages + ] import_block = [ "# BEGIN GENERATED NEXUS SYSTEM EXPORTS\n", - "from temporalio.nexus.system.workflow_service import (\n", - *[f" {export},\n" for export in exports], - ")\n", + *[ + line + for module, exports in package_exports + for line in [ + f"from {module} import (\n", + *[f" {export},\n" for export in exports], + ")\n", + ] + ], "# END GENERATED NEXUS SYSTEM EXPORTS\n", ] all_block = [ " # BEGIN GENERATED NEXUS SYSTEM __ALL__\n", - *[f' "{export}",\n' for export in exports], + *[f' "{export}",\n' for _, exports in package_exports for export in exports], " # END GENERATED NEXUS SYSTEM __ALL__\n", ] content = workflow_init_path.read_text() @@ -162,6 +203,7 @@ def generate_workflow_exports() -> None: def generate_nexus_system_api() -> None: required_paths = [ wit_input_dir / "workflow-service.wit", + wit_input_dir / "stream-service.wit", wit_input_dir / "notification-service.wit", wit_deps_dir, *proto_files, @@ -173,23 +215,31 @@ def generate_nexus_system_api() -> None: descriptor_path = Path(temp_dir) / "temporal_api.bin" build_descriptor_set(descriptor_path) command = nex_gen_command() - for wit_name, output_dir, native_api in ( - ("workflow-service.wit", workflow_output_dir, True), - ("notification-service.wit", notification_output_dir, False), + stream_command = nex_gen_stream_command() + for wit_name, output_dir, package_command, flags in ( + ("workflow-service.wit", workflow_output_dir, command, ["--native-api"]), + ( + "stream-service.wit", + stream_output_dir, + stream_command, + ["--targets", "workflow,client"], + ), + ("notification-service.wit", notification_output_dir, command, []), ): shutil.rmtree(output_dir, ignore_errors=True) output_dir.parent.mkdir(parents=True, exist_ok=True) generate_package( - command, + package_command, wit_name, output_dir, descriptor_path, - native_api=native_api, + flags=flags, ) workflow_output_dir.parent.joinpath("__init__.py").touch() generate_workflow_exports() format_paths = [ str(workflow_output_dir), + str(stream_output_dir), str(notification_output_dir), str(workflow_init_path), ] diff --git a/scripts/gen_payload_visitor.py b/scripts/gen_payload_visitor.py index ba5ddc112..eb41d7286 100644 --- a/scripts/gen_payload_visitor.py +++ b/scripts/gen_payload_visitor.py @@ -24,20 +24,30 @@ def discover_system_nexus_roots() -> list[Descriptor]: - module_path = base_dir / "temporalio" / "nexus" / "system" / "workflow_service" - spec = spec_from_file_location( - "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}") - module = module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) + services: list[type] = [] + system_path = base_dir / "temporalio" / "nexus" / "system" + for module_path in sorted(p.parent for p in system_path.glob("*/services.py")): + spec = spec_from_file_location( + f"temporalio_nexus_system_{module_path.name}", + 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}" + ) + module = module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + services.extend( + value + for value in vars(module.services).values() + if isinstance(value, type) and nexusrpc.get_service_definition(value) + ) + annotations = [a for service in services for a in get_type_hints(service).values()] roots: list[Descriptor] = [] - for annotation in get_type_hints(module._services.WorkflowService).values(): + for annotation in annotations: if get_origin(annotation) is not nexusrpc.Operation: continue for operation_type in get_args(annotation): From 02645debab64852cf2f73fc3dc948d54194d501c Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 20:38:26 -0700 Subject: [PATCH 03/46] Generated the Stream protos, bindings, visitor and Workflow exports. Written by `poe gen-protos` with NEX_GEN_BIN at nexgen 0.2.8 and NEX_GEN_STREAM_BIN at a build of nexgen commit bf3c49c9. Nothing in this commit is edited by hand. --- temporalio/api/enums/v1/__init__.py | 4 + temporalio/api/enums/v1/stream_pb2.py | 31 + temporalio/api/enums/v1/stream_pb2.pyi | 175 +++ temporalio/api/stream/__init__.py | 0 temporalio/api/stream/v1/__init__.py | 29 + temporalio/api/stream/v1/message_pb2.py | 58 + temporalio/api/stream/v1/message_pb2.pyi | 465 ++++++ temporalio/api/streamservice/__init__.py | 0 temporalio/api/streamservice/v1/__init__.py | 25 + .../streamservice/v1/request_response_pb2.py | 54 + .../streamservice/v1/request_response_pb2.pyi | 446 ++++++ .../v1/request_response_pb2_grpc.py | 4 + .../v1/request_response_pb2_grpc.pyi | 21 + temporalio/bridge/_visitor.py | 64 + .../nexus/system/stream_service/__init__.py | 71 + .../_resource_context_manager.py | 93 ++ .../system/stream_service/client/__init__.py | 11 + .../stream_service/client/_extensions.py | 180 +++ .../system/stream_service/client/_poll.py | 77 + .../client/_resources/__init__.py | 3 + .../client/_resources/stream.py | 259 ++++ .../nexus/system/stream_service/models.py | 1371 +++++++++++++++++ .../nexus/system/stream_service/services.py | 53 + .../stream_service/workflow/__init__.py | 77 + .../workflow/_resources/__init__.py | 9 + .../workflow/_resources/stream.py | 212 +++ .../workflow/_system_nexus_interceptor.py | 299 ++++ .../workflow/operations/__init__.py | 3 + .../workflow/operations/create_stream.py | 94 ++ temporalio/workflow/__init__.py | 6 + 30 files changed, 4194 insertions(+) create mode 100644 temporalio/api/enums/v1/stream_pb2.py create mode 100644 temporalio/api/enums/v1/stream_pb2.pyi 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 create mode 100644 temporalio/api/streamservice/__init__.py create mode 100644 temporalio/api/streamservice/v1/__init__.py create mode 100644 temporalio/api/streamservice/v1/request_response_pb2.py create mode 100644 temporalio/api/streamservice/v1/request_response_pb2.pyi create mode 100644 temporalio/api/streamservice/v1/request_response_pb2_grpc.py create mode 100644 temporalio/api/streamservice/v1/request_response_pb2_grpc.pyi create mode 100644 temporalio/nexus/system/stream_service/__init__.py create mode 100644 temporalio/nexus/system/stream_service/_resource_context_manager.py create mode 100644 temporalio/nexus/system/stream_service/client/__init__.py create mode 100644 temporalio/nexus/system/stream_service/client/_extensions.py create mode 100644 temporalio/nexus/system/stream_service/client/_poll.py create mode 100644 temporalio/nexus/system/stream_service/client/_resources/__init__.py create mode 100644 temporalio/nexus/system/stream_service/client/_resources/stream.py create mode 100644 temporalio/nexus/system/stream_service/models.py create mode 100644 temporalio/nexus/system/stream_service/services.py create mode 100644 temporalio/nexus/system/stream_service/workflow/__init__.py create mode 100644 temporalio/nexus/system/stream_service/workflow/_resources/__init__.py create mode 100644 temporalio/nexus/system/stream_service/workflow/_resources/stream.py create mode 100644 temporalio/nexus/system/stream_service/workflow/_system_nexus_interceptor.py create mode 100644 temporalio/nexus/system/stream_service/workflow/operations/__init__.py create mode 100644 temporalio/nexus/system/stream_service/workflow/operations/create_stream.py diff --git a/temporalio/api/enums/v1/__init__.py b/temporalio/api/enums/v1/__init__.py index 518a2f32b..a48c7a44d 100644 --- a/temporalio/api/enums/v1/__init__.py +++ b/temporalio/api/enums/v1/__init__.py @@ -44,6 +44,7 @@ from .query_pb2 import QueryRejectCondition, QueryResultType from .reset_pb2 import ResetReapplyExcludeType, ResetReapplyType, ResetType from .schedule_pb2 import ScheduleOverlapPolicy +from .stream_pb2 import StreamErrorReason, StreamIdConflictPolicy, StreamStatus from .task_queue_pb2 import ( BuildIdTaskReachability, DescribeTaskQueueMode, @@ -120,6 +121,9 @@ "Severity", "SignalExternalWorkflowExecutionFailedCause", "StartChildWorkflowExecutionFailedCause", + "StreamErrorReason", + "StreamIdConflictPolicy", + "StreamStatus", "SuggestContinueAsNewReason", "TaskQueueKind", "TaskQueueType", diff --git a/temporalio/api/enums/v1/stream_pb2.py b/temporalio/api/enums/v1/stream_pb2.py new file mode 100644 index 000000000..b2e79a0e9 --- /dev/null +++ b/temporalio/api/enums/v1/stream_pb2.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: temporal/api/enums/v1/stream.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 symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n"temporal/api/enums/v1/stream.proto\x12\x15temporal.api.enums.v1*z\n\x0cStreamStatus\x12\x1d\n\x19STREAM_STATUS_UNSPECIFIED\x10\x00\x12\x16\n\x12STREAM_STATUS_OPEN\x10\x01\x12\x18\n\x14STREAM_STATUS_CLOSED\x10\x02\x12\x19\n\x15STREAM_STATUS_EXPIRED\x10\x03*\x93\x01\n\x16StreamIdConflictPolicy\x12)\n%STREAM_ID_CONFLICT_POLICY_UNSPECIFIED\x10\x00\x12"\n\x1eSTREAM_ID_CONFLICT_POLICY_FAIL\x10\x01\x12*\n&STREAM_ID_CONFLICT_POLICY_USE_EXISTING\x10\x02*\xe8\x04\n\x11StreamErrorReason\x12#\n\x1fSTREAM_ERROR_REASON_UNSPECIFIED\x10\x00\x12!\n\x1dSTREAM_ERROR_REASON_NOT_FOUND\x10\x01\x12\x1f\n\x1bSTREAM_ERROR_REASON_EXPIRED\x10\x02\x12\x1e\n\x1aSTREAM_ERROR_REASON_CLOSED\x10\x03\x12\x1e\n\x1aSTREAM_ERROR_REASON_FENCED\x10\x04\x12(\n$STREAM_ERROR_REASON_SEQUENCE_TOO_LOW\x10\x05\x12%\n!STREAM_ERROR_REASON_SEQ_DIVERGENT\x10\x06\x12!\n\x1dSTREAM_ERROR_REASON_NOT_OWNER\x10\x07\x12&\n"STREAM_ERROR_REASON_FORMAT_TOO_NEW\x10\x08\x12\'\n#STREAM_ERROR_REASON_OFFSET_TOO_HIGH\x10\t\x12!\n\x1dSTREAM_ERROR_REASON_TOO_LARGE\x10\n\x12\x1f\n\x1bSTREAM_ERROR_REASON_INVALID\x10\x0b\x12+\n\'STREAM_ERROR_REASON_STORAGE_UNAVAILABLE\x10\x0c\x12#\n\x1fSTREAM_ERROR_REASON_UNSUPPORTED\x10\r\x12&\n"STREAM_ERROR_REASON_ALREADY_EXISTS\x10\x0e\x12\'\n#STREAM_ERROR_REASON_STORAGE_CORRUPT\x10\x0f\x42\x83\x01\n\x18io.temporal.api.enums.v1B\x0bStreamProtoP\x01Z!go.temporal.io/api/enums/v1;enums\xaa\x02\x17Temporalio.Api.Enums.V1\xea\x02\x1aTemporalio::Api::Enums::V1b\x06proto3' +) + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, __name__, globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b"\n\030io.temporal.api.enums.v1B\013StreamProtoP\001Z!go.temporal.io/api/enums/v1;enums\252\002\027Temporalio.Api.Enums.V1\352\002\032Temporalio::Api::Enums::V1" + _STREAMSTATUS._serialized_start = 61 + _STREAMSTATUS._serialized_end = 183 + _STREAMIDCONFLICTPOLICY._serialized_start = 186 + _STREAMIDCONFLICTPOLICY._serialized_end = 333 + _STREAMERRORREASON._serialized_start = 336 + _STREAMERRORREASON._serialized_end = 952 +# @@protoc_insertion_point(module_scope) diff --git a/temporalio/api/enums/v1/stream_pb2.pyi b/temporalio/api/enums/v1/stream_pb2.pyi new file mode 100644 index 000000000..34cc68539 --- /dev/null +++ b/temporalio/api/enums/v1/stream_pb2.pyi @@ -0,0 +1,175 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" + +import builtins +import sys +import typing + +import google.protobuf.descriptor +import google.protobuf.internal.enum_type_wrapper + +if sys.version_info >= (3, 10): + import typing as typing_extensions +else: + import typing_extensions + +DESCRIPTOR: google.protobuf.descriptor.FileDescriptor + +class _StreamStatus: + ValueType = typing.NewType("ValueType", builtins.int) + V: typing_extensions.TypeAlias = ValueType + +class _StreamStatusEnumTypeWrapper( + google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[ + _StreamStatus.ValueType + ], + builtins.type, +): + DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor + STREAM_STATUS_UNSPECIFIED: _StreamStatus.ValueType # 0 + STREAM_STATUS_OPEN: _StreamStatus.ValueType # 1 + STREAM_STATUS_CLOSED: _StreamStatus.ValueType # 2 + STREAM_STATUS_EXPIRED: _StreamStatus.ValueType # 3 + +class StreamStatus(_StreamStatus, metaclass=_StreamStatusEnumTypeWrapper): + """Experimental. Might change in the future. + The state of a Stream, as a describe gives it. + (-- api-linter: core::0216::synonyms=disabled + aip.dev/not-precedent: Named consistently with WorkflowExecutionStatus. --) + """ + +STREAM_STATUS_UNSPECIFIED: StreamStatus.ValueType # 0 +STREAM_STATUS_OPEN: StreamStatus.ValueType # 1 +STREAM_STATUS_CLOSED: StreamStatus.ValueType # 2 +STREAM_STATUS_EXPIRED: StreamStatus.ValueType # 3 +global___StreamStatus = StreamStatus + +class _StreamIdConflictPolicy: + ValueType = typing.NewType("ValueType", builtins.int) + V: typing_extensions.TypeAlias = ValueType + +class _StreamIdConflictPolicyEnumTypeWrapper( + google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[ + _StreamIdConflictPolicy.ValueType + ], + builtins.type, +): + DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor + STREAM_ID_CONFLICT_POLICY_UNSPECIFIED: _StreamIdConflictPolicy.ValueType # 0 + STREAM_ID_CONFLICT_POLICY_FAIL: _StreamIdConflictPolicy.ValueType # 1 + """The create fails with ALREADY_EXISTS.""" + STREAM_ID_CONFLICT_POLICY_USE_EXISTING: _StreamIdConflictPolicy.ValueType # 2 + """The create gets the open Stream, which keeps its own Retention.""" + +class StreamIdConflictPolicy( + _StreamIdConflictPolicy, metaclass=_StreamIdConflictPolicyEnumTypeWrapper +): + """Experimental. Might change in the future. + What a create does when its Stream exists. Unspecified is FAIL, as for a Workflow start. + """ + +STREAM_ID_CONFLICT_POLICY_UNSPECIFIED: StreamIdConflictPolicy.ValueType # 0 +STREAM_ID_CONFLICT_POLICY_FAIL: StreamIdConflictPolicy.ValueType # 1 +"""The create fails with ALREADY_EXISTS.""" +STREAM_ID_CONFLICT_POLICY_USE_EXISTING: StreamIdConflictPolicy.ValueType # 2 +"""The create gets the open Stream, which keeps its own Retention.""" +global___StreamIdConflictPolicy = StreamIdConflictPolicy + +class _StreamErrorReason: + ValueType = typing.NewType("ValueType", builtins.int) + V: typing_extensions.TypeAlias = ValueType + +class _StreamErrorReasonEnumTypeWrapper( + google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[ + _StreamErrorReason.ValueType + ], + builtins.type, +): + DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor + STREAM_ERROR_REASON_UNSPECIFIED: _StreamErrorReason.ValueType # 0 + STREAM_ERROR_REASON_NOT_FOUND: _StreamErrorReason.ValueType # 1 + """Nobody created the Stream. NOT_FOUND.""" + STREAM_ERROR_REASON_EXPIRED: _StreamErrorReason.ValueType # 2 + """The Stream's Retention or its last-write TTL passed. NOT_FOUND.""" + STREAM_ERROR_REASON_CLOSED: _StreamErrorReason.ValueType # 3 + """The Stream is closed, or the SDK's writer is closed. FAILED_PRECONDITION.""" + STREAM_ERROR_REASON_FENCED: _StreamErrorReason.ValueType # 4 + """The Producer's epoch is lower than the newest one. FAILED_PRECONDITION.""" + STREAM_ERROR_REASON_SEQUENCE_TOO_LOW: _StreamErrorReason.ValueType # 5 + """The batch has a lower `sequence` than the last one of its epoch. FAILED_PRECONDITION.""" + STREAM_ERROR_REASON_SEQ_DIVERGENT: _StreamErrorReason.ValueType # 6 + """The batch has the `sequence` of the last one of its epoch, and other content. + FAILED_PRECONDITION. + """ + STREAM_ERROR_REASON_NOT_OWNER: _StreamErrorReason.ValueType # 7 + """The caller may not create or close this Stream. FAILED_PRECONDITION.""" + STREAM_ERROR_REASON_FORMAT_TOO_NEW: _StreamErrorReason.ValueType # 8 + """A newer format version wrote the Stream. FAILED_PRECONDITION.""" + STREAM_ERROR_REASON_OFFSET_TOO_HIGH: _StreamErrorReason.ValueType # 9 + """The offset is past the newest offset, for example after a failover lost the newest Records. + A read from it works again once the Stream has Records up to it. OUT_OF_RANGE. + """ + STREAM_ERROR_REASON_TOO_LARGE: _StreamErrorReason.ValueType # 10 + """A Record or a batch is over the Store's limit. INVALID_ARGUMENT.""" + STREAM_ERROR_REASON_INVALID: _StreamErrorReason.ValueType # 11 + """The request is malformed. INVALID_ARGUMENT.""" + STREAM_ERROR_REASON_STORAGE_UNAVAILABLE: _StreamErrorReason.ValueType # 12 + """The Store failed or did not answer. A retry can succeed. UNAVAILABLE.""" + STREAM_ERROR_REASON_UNSUPPORTED: _StreamErrorReason.ValueType # 13 + """The Store lacks a capability that the call needs. UNIMPLEMENTED.""" + STREAM_ERROR_REASON_ALREADY_EXISTS: _StreamErrorReason.ValueType # 14 + """A create with the FAIL conflict policy is for a Stream that exists. ALREADY_EXISTS.""" + STREAM_ERROR_REASON_STORAGE_CORRUPT: _StreamErrorReason.ValueType # 15 + """The Store gave back data that isn't valid, such as a stored value with the wrong length. A + retry reads the same data. DATA_LOSS. + """ + +class StreamErrorReason( + _StreamErrorReason, metaclass=_StreamErrorReasonEnumTypeWrapper +): + """Experimental. Might change in the future. + Why a Stream call failed. Each reason has one gRPC status code, and several reasons share a + code. So code matches on the reason, and not on the status code. An SDK raises one error type + that carries the reason. + """ + +STREAM_ERROR_REASON_UNSPECIFIED: StreamErrorReason.ValueType # 0 +STREAM_ERROR_REASON_NOT_FOUND: StreamErrorReason.ValueType # 1 +"""Nobody created the Stream. NOT_FOUND.""" +STREAM_ERROR_REASON_EXPIRED: StreamErrorReason.ValueType # 2 +"""The Stream's Retention or its last-write TTL passed. NOT_FOUND.""" +STREAM_ERROR_REASON_CLOSED: StreamErrorReason.ValueType # 3 +"""The Stream is closed, or the SDK's writer is closed. FAILED_PRECONDITION.""" +STREAM_ERROR_REASON_FENCED: StreamErrorReason.ValueType # 4 +"""The Producer's epoch is lower than the newest one. FAILED_PRECONDITION.""" +STREAM_ERROR_REASON_SEQUENCE_TOO_LOW: StreamErrorReason.ValueType # 5 +"""The batch has a lower `sequence` than the last one of its epoch. FAILED_PRECONDITION.""" +STREAM_ERROR_REASON_SEQ_DIVERGENT: StreamErrorReason.ValueType # 6 +"""The batch has the `sequence` of the last one of its epoch, and other content. +FAILED_PRECONDITION. +""" +STREAM_ERROR_REASON_NOT_OWNER: StreamErrorReason.ValueType # 7 +"""The caller may not create or close this Stream. FAILED_PRECONDITION.""" +STREAM_ERROR_REASON_FORMAT_TOO_NEW: StreamErrorReason.ValueType # 8 +"""A newer format version wrote the Stream. FAILED_PRECONDITION.""" +STREAM_ERROR_REASON_OFFSET_TOO_HIGH: StreamErrorReason.ValueType # 9 +"""The offset is past the newest offset, for example after a failover lost the newest Records. +A read from it works again once the Stream has Records up to it. OUT_OF_RANGE. +""" +STREAM_ERROR_REASON_TOO_LARGE: StreamErrorReason.ValueType # 10 +"""A Record or a batch is over the Store's limit. INVALID_ARGUMENT.""" +STREAM_ERROR_REASON_INVALID: StreamErrorReason.ValueType # 11 +"""The request is malformed. INVALID_ARGUMENT.""" +STREAM_ERROR_REASON_STORAGE_UNAVAILABLE: StreamErrorReason.ValueType # 12 +"""The Store failed or did not answer. A retry can succeed. UNAVAILABLE.""" +STREAM_ERROR_REASON_UNSUPPORTED: StreamErrorReason.ValueType # 13 +"""The Store lacks a capability that the call needs. UNIMPLEMENTED.""" +STREAM_ERROR_REASON_ALREADY_EXISTS: StreamErrorReason.ValueType # 14 +"""A create with the FAIL conflict policy is for a Stream that exists. ALREADY_EXISTS.""" +STREAM_ERROR_REASON_STORAGE_CORRUPT: StreamErrorReason.ValueType # 15 +"""The Store gave back data that isn't valid, such as a stored value with the wrong length. A +retry reads the same data. DATA_LOSS. +""" +global___StreamErrorReason = StreamErrorReason 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..73024c06b --- /dev/null +++ b/temporalio/api/stream/v1/__init__.py @@ -0,0 +1,29 @@ +from .message_pb2 import ( + ActivityEpoch, + ActivityProducer, + AppendRecord, + ClientProducer, + LocalActivityEpoch, + LocalActivityProducer, + Producer, + StreamErrorDetails, + StreamRecord, + StreamRef, + WorkflowProducer, + WorkflowTaskEpoch, +) + +__all__ = [ + "ActivityEpoch", + "ActivityProducer", + "AppendRecord", + "ClientProducer", + "LocalActivityEpoch", + "LocalActivityProducer", + "Producer", + "StreamErrorDetails", + "StreamRecord", + "StreamRef", + "WorkflowProducer", + "WorkflowTaskEpoch", +] diff --git a/temporalio/api/stream/v1/message_pb2.py b/temporalio/api/stream/v1/message_pb2.py new file mode 100644 index 000000000..5bac69d28 --- /dev/null +++ b/temporalio/api/stream/v1/message_pb2.py @@ -0,0 +1,58 @@ +# -*- 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 symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + +from temporalio.api.common.v1 import ( + message_pb2 as temporal_dot_api_dot_common_dot_v1_dot_message__pb2, +) +from temporalio.api.enums.v1 import ( + stream_pb2 as temporal_dot_api_dot_enums_dot_v1_dot_stream__pb2, +) + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n$temporal/api/stream/v1/message.proto\x12\x16temporal.api.stream.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a$temporal/api/common/v1/message.proto\x1a"temporal/api/enums/v1/stream.proto"&\n\tStreamRef\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05store\x18\x03 \x01(\t"p\n\x11WorkflowTaskEpoch\x12\x18\n\x10started_event_id\x18\x01 \x01(\x03\x12\x0f\n\x07\x61ttempt\x18\x02 \x01(\x05\x12\x30\n\x0cstarted_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp"^\n\x12LocalActivityEpoch\x12\x37\n\x04task\x18\x01 \x01(\x0b\x32).temporal.api.stream.v1.WorkflowTaskEpoch\x12\x0f\n\x07\x61ttempt\x18\x02 \x01(\x05"\\\n\rActivityEpoch\x12:\n\x16\x61ttempt_scheduled_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07\x61ttempt\x18\x02 \x01(\x05"\\\n\x10WorkflowProducer\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x38\n\x05\x65poch\x18\x02 \x01(\x0b\x32).temporal.api.stream.v1.WorkflowTaskEpoch"b\n\x15LocalActivityProducer\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x39\n\x05\x65poch\x18\x02 \x01(\x0b\x32*.temporal.api.stream.v1.LocalActivityEpoch"X\n\x10\x41\x63tivityProducer\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x34\n\x05\x65poch\x18\x02 \x01(\x0b\x32%.temporal.api.stream.v1.ActivityEpoch"\x10\n\x0e\x43lientProducer"\xef\x02\n\x08Producer\x12\n\n\x02id\x18\x01 \x01(\t\x12<\n\x08workflow\x18\x02 \x01(\x0b\x32(.temporal.api.stream.v1.WorkflowProducerH\x00\x12G\n\x0elocal_activity\x18\x03 \x01(\x0b\x32-.temporal.api.stream.v1.LocalActivityProducerH\x00\x12\x45\n\x11workflow_activity\x18\x04 \x01(\x0b\x32(.temporal.api.stream.v1.ActivityProducerH\x00\x12G\n\x13standalone_activity\x18\x05 \x01(\x0b\x32(.temporal.api.stream.v1.ActivityProducerH\x00\x12\x38\n\x06\x63lient\x18\x06 \x01(\x0b\x32&.temporal.api.stream.v1.ClientProducerH\x00\x42\x06\n\x04kind"=\n\x0c\x41ppendRecord\x12-\n\x04\x62ody\x18\x01 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload"\x81\x01\n\x0cStreamRecord\x12\x32\n\x08producer\x18\x01 \x01(\x0b\x32 .temporal.api.stream.v1.Producer\x12-\n\x04\x62ody\x18\x02 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x0e\n\x06offset\x18\x03 \x01(\x0c"N\n\x12StreamErrorDetails\x12\x38\n\x06reason\x18\x01 \x01(\x0e\x32(.temporal.api.enums.v1.StreamErrorReasonB\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' +) + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, __name__, globals()) +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" + _STREAMREF._serialized_start = 171 + _STREAMREF._serialized_end = 209 + _WORKFLOWTASKEPOCH._serialized_start = 211 + _WORKFLOWTASKEPOCH._serialized_end = 323 + _LOCALACTIVITYEPOCH._serialized_start = 325 + _LOCALACTIVITYEPOCH._serialized_end = 419 + _ACTIVITYEPOCH._serialized_start = 421 + _ACTIVITYEPOCH._serialized_end = 513 + _WORKFLOWPRODUCER._serialized_start = 515 + _WORKFLOWPRODUCER._serialized_end = 607 + _LOCALACTIVITYPRODUCER._serialized_start = 609 + _LOCALACTIVITYPRODUCER._serialized_end = 707 + _ACTIVITYPRODUCER._serialized_start = 709 + _ACTIVITYPRODUCER._serialized_end = 797 + _CLIENTPRODUCER._serialized_start = 799 + _CLIENTPRODUCER._serialized_end = 815 + _PRODUCER._serialized_start = 818 + _PRODUCER._serialized_end = 1185 + _APPENDRECORD._serialized_start = 1187 + _APPENDRECORD._serialized_end = 1248 + _STREAMRECORD._serialized_start = 1251 + _STREAMRECORD._serialized_end = 1380 + _STREAMERRORDETAILS._serialized_start = 1382 + _STREAMERRORDETAILS._serialized_end = 1460 +# @@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..a8cc73b9c --- /dev/null +++ b/temporalio/api/stream/v1/message_pb2.pyi @@ -0,0 +1,465 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" + +import builtins +import typing + +import google.protobuf.descriptor +import google.protobuf.message +import google.protobuf.timestamp_pb2 + +import temporalio.api.common.v1.message_pb2 +import temporalio.api.enums.v1.stream_pb2 + +DESCRIPTOR: google.protobuf.descriptor.FileDescriptor + +@typing.final +class StreamRef(google.protobuf.message.Message): + """Experimental. Might change in the future. + Identifies one Stream. The id is opaque to callers. A Stream has no incarnations. A create of a + closed or expired Stream fails, and it doesn't make a second Stream with the same id. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + ID_FIELD_NUMBER: builtins.int + STORE_FIELD_NUMBER: builtins.int + id: builtins.str + store: builtins.str + """The name of the Store that holds the Stream, as the Worker and client config register it, + so every process routes a ref it receives. With no name, the SDK picks the Store from its + config. Only an SDK sets and reads it, since only the SDK knows the name that its config + gives a Store. A request carries the whole ref, and the server and a Store ignore this + field. + """ + def __init__( + self, + *, + id: builtins.str = ..., + store: builtins.str = ..., + ) -> None: ... + def ClearField( + self, field_name: typing.Literal["id", b"id", "store", b"store"] + ) -> None: ... + +global___StreamRef = StreamRef + +@typing.final +class WorkflowTaskEpoch(google.protobuf.message.Message): + """Experimental. Might change in the future. + One Workflow Task attempt of a run. It orders by started_event_id, then attempt, then + started_time. An epoch orders the attempts of one Producer only, and a Producer belongs to one + run. So a reset run and the next run of a chain have other Producers, and their epochs start + again. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + STARTED_EVENT_ID_FIELD_NUMBER: builtins.int + ATTEMPT_FIELD_NUMBER: builtins.int + STARTED_TIME_FIELD_NUMBER: builtins.int + started_event_id: builtins.int + """Required.""" + attempt: builtins.int + """Required. 1 or more.""" + @property + def started_time(self) -> google.protobuf.timestamp_pb2.Timestamp: + """Tells a reused started event id apart, for example after a dropped speculative task. Core + leaves it empty when the server gave the task no started time. + """ + + def __init__( + self, + *, + started_event_id: builtins.int = ..., + attempt: builtins.int = ..., + started_time: google.protobuf.timestamp_pb2.Timestamp | None = ..., + ) -> None: ... + def HasField( + self, field_name: typing.Literal["started_time", b"started_time"] + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing.Literal[ + "attempt", + b"attempt", + "started_event_id", + b"started_event_id", + "started_time", + b"started_time", + ], + ) -> None: ... + +global___WorkflowTaskEpoch = WorkflowTaskEpoch + +@typing.final +class LocalActivityEpoch(google.protobuf.message.Message): + """Experimental. Might change in the future. + The attempt of a Local Activity. It orders by task, then attempt, so the same call in a later + Workflow Task is a higher epoch. The Producer carries the run. Records of two runs have no + order by epoch, only their order in the Stream. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + TASK_FIELD_NUMBER: builtins.int + ATTEMPT_FIELD_NUMBER: builtins.int + attempt: builtins.int + """Required. The Local Activity's own attempt in that Workflow Task, 1 or more.""" + @property + def task(self) -> global___WorkflowTaskEpoch: + """Required. The Workflow Task attempt that dispatched the Local Activity.""" + + def __init__( + self, + *, + task: global___WorkflowTaskEpoch | None = ..., + attempt: builtins.int = ..., + ) -> None: ... + def HasField( + self, field_name: typing.Literal["task", b"task"] + ) -> builtins.bool: ... + def ClearField( + self, field_name: typing.Literal["attempt", b"attempt", "task", b"task"] + ) -> None: ... + +global___LocalActivityEpoch = LocalActivityEpoch + +@typing.final +class ActivityEpoch(google.protobuf.message.Message): + """Experimental. Might change in the future. + The attempt of an Activity. It orders by attempt_scheduled_time, then attempt. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + ATTEMPT_SCHEDULED_TIME_FIELD_NUMBER: builtins.int + ATTEMPT_FIELD_NUMBER: builtins.int + attempt: builtins.int + """Required. 1 or more.""" + @property + def attempt_scheduled_time(self) -> google.protobuf.timestamp_pb2.Timestamp: + """Required. When the server scheduled this attempt. An Activity reset starts the attempt count + again, so only this time orders the attempts across a reset. + """ + + def __init__( + self, + *, + attempt_scheduled_time: google.protobuf.timestamp_pb2.Timestamp | None = ..., + attempt: builtins.int = ..., + ) -> None: ... + def HasField( + self, + field_name: typing.Literal["attempt_scheduled_time", b"attempt_scheduled_time"], + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing.Literal[ + "attempt", b"attempt", "attempt_scheduled_time", b"attempt_scheduled_time" + ], + ) -> None: ... + +global___ActivityEpoch = ActivityEpoch + +@typing.final +class WorkflowProducer(google.protobuf.message.Message): + """Experimental. Might change in the future. + A Workflow run Producer. On the server's Streams, a Workflow writes as this Producer in its + Workflow Task. On a Store, a Workflow appends as a Local Activity Producer, and this Producer + only closes the Stream. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + RUN_ID_FIELD_NUMBER: builtins.int + EPOCH_FIELD_NUMBER: builtins.int + run_id: builtins.str + """Required. The run id of the Workflow run, not of its chain. Each run is its own Producer.""" + @property + def epoch(self) -> global___WorkflowTaskEpoch: + """The Workflow Task of the write. A close on a Store runs after the Workflow Task and sends + no epoch. + """ + + def __init__( + self, + *, + run_id: builtins.str = ..., + epoch: global___WorkflowTaskEpoch | None = ..., + ) -> None: ... + def HasField( + self, field_name: typing.Literal["epoch", b"epoch"] + ) -> builtins.bool: ... + def ClearField( + self, field_name: typing.Literal["epoch", b"epoch", "run_id", b"run_id"] + ) -> None: ... + +global___WorkflowProducer = WorkflowProducer + +@typing.final +class LocalActivityProducer(google.protobuf.message.Message): + """Experimental. Might change in the future. + A Local Activity Producer, one for each call of a run. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + RUN_ID_FIELD_NUMBER: builtins.int + EPOCH_FIELD_NUMBER: builtins.int + run_id: builtins.str + """Required. The run id of the Workflow run.""" + @property + def epoch(self) -> global___LocalActivityEpoch: + """Required.""" + + def __init__( + self, + *, + run_id: builtins.str = ..., + epoch: global___LocalActivityEpoch | None = ..., + ) -> None: ... + def HasField( + self, field_name: typing.Literal["epoch", b"epoch"] + ) -> builtins.bool: ... + def ClearField( + self, field_name: typing.Literal["epoch", b"epoch", "run_id", b"run_id"] + ) -> None: ... + +global___LocalActivityProducer = LocalActivityProducer + +@typing.final +class ActivityProducer(google.protobuf.message.Message): + """Experimental. Might change in the future. + An Activity Producer, for an Activity of a Workflow or a Standalone Activity. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + RUN_ID_FIELD_NUMBER: builtins.int + EPOCH_FIELD_NUMBER: builtins.int + run_id: builtins.str + """The run id of the Workflow run that scheduled the Activity. Empty for a Standalone + Activity, whose own run id is in the Producer id. + """ + @property + def epoch(self) -> global___ActivityEpoch: + """Required.""" + + def __init__( + self, + *, + run_id: builtins.str = ..., + epoch: global___ActivityEpoch | None = ..., + ) -> None: ... + def HasField( + self, field_name: typing.Literal["epoch", b"epoch"] + ) -> builtins.bool: ... + def ClearField( + self, field_name: typing.Literal["epoch", b"epoch", "run_id", b"run_id"] + ) -> None: ... + +global___ActivityProducer = ActivityProducer + +@typing.final +class ClientProducer(google.protobuf.message.Message): + """Experimental. Might change in the future. + A client Producer. A client has no run and no epoch. So nothing fences a client's writes, and a + reader never hides them. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___ClientProducer = ClientProducer + +@typing.final +class Producer(google.protobuf.message.Message): + """Experimental. Might change in the future. + The code that wrote a batch, which is one unit of work. Its id stays the same across attempts, + and an epoch is one attempt. The kind's case orders its epochs. After a write of a higher epoch, + a write of a lower epoch of the same Producer fails with FENCED, and a reader may hide the + Records of the lower epoch. The rule is the same for every kind that has an epoch. A client has + none. The epochs of two Producers never compare. A new run has new Producers, so no write + fences or supersedes the Records of an earlier run, such as the base run of a reset. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + ID_FIELD_NUMBER: builtins.int + WORKFLOW_FIELD_NUMBER: builtins.int + LOCAL_ACTIVITY_FIELD_NUMBER: builtins.int + WORKFLOW_ACTIVITY_FIELD_NUMBER: builtins.int + STANDALONE_ACTIVITY_FIELD_NUMBER: builtins.int + CLIENT_FIELD_NUMBER: builtins.int + id: builtins.str + """Required. Stable across attempts, such as `wf:` for the Workflow Producer of a run. + It is unique in one Stream. The id of a run-based kind has its run id, so it stays unique + when Producers of more than one namespace write to one Stream. + """ + @property + def workflow(self) -> global___WorkflowProducer: ... + @property + def local_activity(self) -> global___LocalActivityProducer: ... + @property + def workflow_activity(self) -> global___ActivityProducer: + """An Activity that a Workflow scheduled.""" + + @property + def standalone_activity(self) -> global___ActivityProducer: + """An Activity with no Workflow. Both Activity cases order their epochs the same way. The + case tells which identity the Producer has, since only the first has a Workflow run. + """ + + @property + def client(self) -> global___ClientProducer: ... + def __init__( + self, + *, + id: builtins.str = ..., + workflow: global___WorkflowProducer | None = ..., + local_activity: global___LocalActivityProducer | None = ..., + workflow_activity: global___ActivityProducer | None = ..., + standalone_activity: global___ActivityProducer | None = ..., + client: global___ClientProducer | None = ..., + ) -> None: ... + def HasField( + self, + field_name: typing.Literal[ + "client", + b"client", + "kind", + b"kind", + "local_activity", + b"local_activity", + "standalone_activity", + b"standalone_activity", + "workflow", + b"workflow", + "workflow_activity", + b"workflow_activity", + ], + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing.Literal[ + "client", + b"client", + "id", + b"id", + "kind", + b"kind", + "local_activity", + b"local_activity", + "standalone_activity", + b"standalone_activity", + "workflow", + b"workflow", + "workflow_activity", + b"workflow_activity", + ], + ) -> None: ... + def WhichOneof( + self, oneof_group: typing.Literal["kind", b"kind"] + ) -> ( + typing.Literal[ + "workflow", + "local_activity", + "workflow_activity", + "standalone_activity", + "client", + ] + | None + ): ... + +global___Producer = Producer + +@typing.final +class AppendRecord(google.protobuf.message.Message): + """Experimental. Might change in the future. + One Record of a batch to append. The Store adds the batch's Producer and an offset. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + BODY_FIELD_NUMBER: builtins.int + @property + def body(self) -> temporalio.api.common.v1.message_pb2.Payload: + """Required.""" + + def __init__( + self, + *, + body: temporalio.api.common.v1.message_pb2.Payload | None = ..., + ) -> None: ... + def HasField( + self, field_name: typing.Literal["body", b"body"] + ) -> builtins.bool: ... + def ClearField(self, field_name: typing.Literal["body", b"body"]) -> None: ... + +global___AppendRecord = AppendRecord + +@typing.final +class StreamRecord(google.protobuf.message.Message): + """Experimental. Might change in the future. + One Record that a Producer appended, as a read gives it. A read gives only Records. A close + stores no Record, so a read tells of it with the done flag of its response. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + PRODUCER_FIELD_NUMBER: builtins.int + BODY_FIELD_NUMBER: builtins.int + OFFSET_FIELD_NUMBER: builtins.int + offset: builtins.bytes + """Required. The Record's offset. An offset is ordered and unique in one Stream, and it is + sparse. Offsets have gaps, so they don't count Records. An offset is never empty. A read + gives its Stream, so the read checks the offset against that Stream. + """ + @property + def producer(self) -> global___Producer: ... + @property + def body(self) -> temporalio.api.common.v1.message_pb2.Payload: ... + def __init__( + self, + *, + producer: global___Producer | None = ..., + body: temporalio.api.common.v1.message_pb2.Payload | None = ..., + offset: builtins.bytes = ..., + ) -> None: ... + def HasField( + self, field_name: typing.Literal["body", b"body", "producer", b"producer"] + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing.Literal[ + "body", b"body", "offset", b"offset", "producer", b"producer" + ], + ) -> None: ... + +global___StreamRecord = StreamRecord + +@typing.final +class StreamErrorDetails(google.protobuf.message.Message): + """Experimental. Might change in the future. + The details of a failed Stream call, in the gRPC status details and in the details of the + application failure of a Nexus operation. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + REASON_FIELD_NUMBER: builtins.int + reason: temporalio.api.enums.v1.stream_pb2.StreamErrorReason.ValueType + def __init__( + self, + *, + reason: temporalio.api.enums.v1.stream_pb2.StreamErrorReason.ValueType = ..., + ) -> None: ... + def ClearField(self, field_name: typing.Literal["reason", b"reason"]) -> None: ... + +global___StreamErrorDetails = StreamErrorDetails diff --git a/temporalio/api/streamservice/__init__.py b/temporalio/api/streamservice/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/temporalio/api/streamservice/v1/__init__.py b/temporalio/api/streamservice/v1/__init__.py new file mode 100644 index 000000000..40801cf9a --- /dev/null +++ b/temporalio/api/streamservice/v1/__init__.py @@ -0,0 +1,25 @@ +from .request_response_pb2 import ( + AppendRecordsRequest, + AppendRecordsResponse, + CloseStreamRequest, + CloseStreamResponse, + CreateStreamRequest, + CreateStreamResponse, + DescribeStreamRequest, + DescribeStreamResponse, + ReadRecordsRequest, + ReadRecordsResponse, +) + +__all__ = [ + "AppendRecordsRequest", + "AppendRecordsResponse", + "CloseStreamRequest", + "CloseStreamResponse", + "CreateStreamRequest", + "CreateStreamResponse", + "DescribeStreamRequest", + "DescribeStreamResponse", + "ReadRecordsRequest", + "ReadRecordsResponse", +] diff --git a/temporalio/api/streamservice/v1/request_response_pb2.py b/temporalio/api/streamservice/v1/request_response_pb2.py new file mode 100644 index 000000000..161aa0c07 --- /dev/null +++ b/temporalio/api/streamservice/v1/request_response_pb2.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: temporal/api/streamservice/v1/request_response.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 symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import duration_pb2 as google_dot_protobuf_dot_duration__pb2 + +from temporalio.api.enums.v1 import ( + stream_pb2 as temporal_dot_api_dot_enums_dot_v1_dot_stream__pb2, +) +from temporalio.api.stream.v1 import ( + message_pb2 as temporal_dot_api_dot_stream_dot_v1_dot_message__pb2, +) + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n4temporal/api/streamservice/v1/request_response.proto\x12\x1dtemporal.api.streamservice.v1\x1a\x1egoogle/protobuf/duration.proto\x1a"temporal/api/enums/v1/stream.proto\x1a$temporal/api/stream/v1/message.proto"\xbe\x01\n\x13\x43reateStreamRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x35\n\nstream_ref\x18\x02 \x01(\x0b\x32!.temporal.api.stream.v1.StreamRef\x12I\n\x12id_conflict_policy\x18\x03 \x01(\x0e\x32-.temporal.api.enums.v1.StreamIdConflictPolicy\x12\x12\n\nrequest_id\x18\x04 \x01(\t"W\n\x14\x43reateStreamResponse\x12.\n\x03ref\x18\x01 \x01(\x0b\x32!.temporal.api.stream.v1.StreamRef\x12\x0f\n\x07\x63reated\x18\x02 \x01(\x08"\xd6\x01\n\x14\x41ppendRecordsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12.\n\x03ref\x18\x02 \x01(\x0b\x32!.temporal.api.stream.v1.StreamRef\x12\x32\n\x08producer\x18\x03 \x01(\x0b\x32 .temporal.api.stream.v1.Producer\x12\x10\n\x08sequence\x18\x04 \x01(\x03\x12\x35\n\x07records\x18\x05 \x03(\x0b\x32$.temporal.api.stream.v1.AppendRecord"B\n\x15\x41ppendRecordsResponse\x12\x14\n\x0c\x66irst_offset\x18\x01 \x01(\x0c\x12\x13\n\x0blast_offset\x18\x02 \x01(\x0c"\x8b\x01\n\x12\x43loseStreamRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12.\n\x03ref\x18\x02 \x01(\x0b\x32!.temporal.api.stream.v1.StreamRef\x12\x32\n\x08producer\x18\x03 \x01(\x0b\x32 .temporal.api.stream.v1.Producer"\x15\n\x13\x43loseStreamResponse"\xbd\x01\n\x12ReadRecordsRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12.\n\x03ref\x18\x02 \x01(\x0b\x32!.temporal.api.stream.v1.StreamRef\x12\x13\n\x0b\x66rom_offset\x18\x03 \x01(\x0c\x12\x13\n\x0bmax_records\x18\x04 \x01(\x05\x12\'\n\x04wait\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x11\n\tmax_bytes\x18\x06 \x01(\x03"o\n\x13ReadRecordsResponse\x12\x35\n\x07records\x18\x01 \x03(\x0b\x32$.temporal.api.stream.v1.StreamRecord\x12\x13\n\x0blast_offset\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64one\x18\x03 \x01(\x08"Z\n\x15\x44\x65scribeStreamRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12.\n\x03ref\x18\x02 \x01(\x0b\x32!.temporal.api.stream.v1.StreamRef"b\n\x16\x44\x65scribeStreamResponse\x12\x33\n\x06status\x18\x01 \x01(\x0e\x32#.temporal.api.enums.v1.StreamStatus\x12\x13\n\x0blast_offset\x18\x02 \x01(\x0c\x42\xb4\x01\n io.temporal.api.streamservice.v1B\x14RequestResponseProtoP\x01Z1go.temporal.io/api/streamservice/v1;streamservice\xaa\x02\x1fTemporalio.Api.StreamService.V1\xea\x02"Temporalio::Api::StreamService::V1b\x06proto3' +) + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, __name__, globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\n io.temporal.api.streamservice.v1B\024RequestResponseProtoP\001Z1go.temporal.io/api/streamservice/v1;streamservice\252\002\037Temporalio.Api.StreamService.V1\352\002"Temporalio::Api::StreamService::V1' + _CREATESTREAMREQUEST._serialized_start = 194 + _CREATESTREAMREQUEST._serialized_end = 384 + _CREATESTREAMRESPONSE._serialized_start = 386 + _CREATESTREAMRESPONSE._serialized_end = 473 + _APPENDRECORDSREQUEST._serialized_start = 476 + _APPENDRECORDSREQUEST._serialized_end = 690 + _APPENDRECORDSRESPONSE._serialized_start = 692 + _APPENDRECORDSRESPONSE._serialized_end = 758 + _CLOSESTREAMREQUEST._serialized_start = 761 + _CLOSESTREAMREQUEST._serialized_end = 900 + _CLOSESTREAMRESPONSE._serialized_start = 902 + _CLOSESTREAMRESPONSE._serialized_end = 923 + _READRECORDSREQUEST._serialized_start = 926 + _READRECORDSREQUEST._serialized_end = 1115 + _READRECORDSRESPONSE._serialized_start = 1117 + _READRECORDSRESPONSE._serialized_end = 1228 + _DESCRIBESTREAMREQUEST._serialized_start = 1230 + _DESCRIBESTREAMREQUEST._serialized_end = 1320 + _DESCRIBESTREAMRESPONSE._serialized_start = 1322 + _DESCRIBESTREAMRESPONSE._serialized_end = 1420 +# @@protoc_insertion_point(module_scope) diff --git a/temporalio/api/streamservice/v1/request_response_pb2.pyi b/temporalio/api/streamservice/v1/request_response_pb2.pyi new file mode 100644 index 000000000..5ae5b08e5 --- /dev/null +++ b/temporalio/api/streamservice/v1/request_response_pb2.pyi @@ -0,0 +1,446 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" + +import builtins +import collections.abc +import typing + +import google.protobuf.descriptor +import google.protobuf.duration_pb2 +import google.protobuf.internal.containers +import google.protobuf.message + +import temporalio.api.enums.v1.stream_pb2 +import temporalio.api.stream.v1.message_pb2 + +DESCRIPTOR: google.protobuf.descriptor.FileDescriptor + +@typing.final +class CreateStreamRequest(google.protobuf.message.Message): + """Experimental. Might change in the future. + Creates the Stream. When the Stream exists, FAIL fails with ALREADY_EXISTS, and USE_EXISTING + gets it with created false. A retry of the create that made the Stream, with the same + request_id, gets created true. Each policy fails with CLOSED on a closed Stream and EXPIRED on + an expired one. A create takes no Retention. The Store config holds it, as a namespace holds + the Retention of its Workflows. + (-- api-linter: core::0133=disabled + aip.dev/not-precedent: The request identifies a Stream by namespace and ref. --) + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + NAMESPACE_FIELD_NUMBER: builtins.int + STREAM_REF_FIELD_NUMBER: builtins.int + ID_CONFLICT_POLICY_FIELD_NUMBER: builtins.int + REQUEST_ID_FIELD_NUMBER: builtins.int + namespace: builtins.str + """The caller's namespace. Only read by the server. The SDK sets it, and the SDK Stores don't + read it. + """ + id_conflict_policy: ( + temporalio.api.enums.v1.stream_pb2.StreamIdConflictPolicy.ValueType + ) + """What the create does when the Stream exists. Unspecified is FAIL.""" + request_id: builtins.str + """Unique for each create call, and the same on its retries. The Store keeps the request_id of + the create that made the Stream, so a retry after a lost answer doesn't get ALREADY_EXISTS. + """ + @property + def stream_ref(self) -> temporalio.api.stream.v1.message_pb2.StreamRef: + """Required. The ref of the Stream to create. Its id is the Stream id, and is never empty.""" + + def __init__( + self, + *, + namespace: builtins.str = ..., + stream_ref: temporalio.api.stream.v1.message_pb2.StreamRef | None = ..., + id_conflict_policy: temporalio.api.enums.v1.stream_pb2.StreamIdConflictPolicy.ValueType = ..., + request_id: builtins.str = ..., + ) -> None: ... + def HasField( + self, field_name: typing.Literal["stream_ref", b"stream_ref"] + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing.Literal[ + "id_conflict_policy", + b"id_conflict_policy", + "namespace", + b"namespace", + "request_id", + b"request_id", + "stream_ref", + b"stream_ref", + ], + ) -> None: ... + +global___CreateStreamRequest = CreateStreamRequest + +@typing.final +class CreateStreamResponse(google.protobuf.message.Message): + """Experimental. Might change in the future. + The Stream that a create made or got. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + REF_FIELD_NUMBER: builtins.int + CREATED_FIELD_NUMBER: builtins.int + created: builtins.bool + """False when USE_EXISTING got a Stream that existed.""" + @property + def ref(self) -> temporalio.api.stream.v1.message_pb2.StreamRef: + """The ref of the Stream, which the caller passes on as data. The SDK sets the name of the + Store that it picked, also when the request left the name empty. The server gives the ref + back as the request had it, since it doesn't know that name. + """ + + def __init__( + self, + *, + ref: temporalio.api.stream.v1.message_pb2.StreamRef | None = ..., + created: builtins.bool = ..., + ) -> None: ... + def HasField(self, field_name: typing.Literal["ref", b"ref"]) -> builtins.bool: ... + def ClearField( + self, field_name: typing.Literal["created", b"created", "ref", b"ref"] + ) -> None: ... + +global___CreateStreamResponse = CreateStreamResponse + +@typing.final +class AppendRecordsRequest(google.protobuf.message.Message): + """Experimental. Might change in the future. + Appends one batch of Records as one Producer. The Store writes a resent batch, with the same + Producer, epoch and sequence, only once. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + NAMESPACE_FIELD_NUMBER: builtins.int + REF_FIELD_NUMBER: builtins.int + PRODUCER_FIELD_NUMBER: builtins.int + SEQUENCE_FIELD_NUMBER: builtins.int + RECORDS_FIELD_NUMBER: builtins.int + namespace: builtins.str + """The caller's namespace. Only read by the server. The SDK sets it, and the SDK Stores don't + read it. + """ + sequence: builtins.int + """The batch number in this epoch. With the Producer and epoch it is the dedupe key. Zero takes + the next number of the epoch. Such a batch has no dedupe, so a retry writes it again. + """ + @property + def ref(self) -> temporalio.api.stream.v1.message_pb2.StreamRef: + """Required.""" + + @property + def producer(self) -> temporalio.api.stream.v1.message_pb2.Producer: + """Required. The Producer of the batch. An append and a close of one Producer carry the same + Producer, so the Store orders each write the same way. + """ + + @property + def records( + self, + ) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[ + temporalio.api.stream.v1.message_pb2.AppendRecord + ]: ... + def __init__( + self, + *, + namespace: builtins.str = ..., + ref: temporalio.api.stream.v1.message_pb2.StreamRef | None = ..., + producer: temporalio.api.stream.v1.message_pb2.Producer | None = ..., + sequence: builtins.int = ..., + records: collections.abc.Iterable[ + temporalio.api.stream.v1.message_pb2.AppendRecord + ] + | None = ..., + ) -> None: ... + def HasField( + self, field_name: typing.Literal["producer", b"producer", "ref", b"ref"] + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing.Literal[ + "namespace", + b"namespace", + "producer", + b"producer", + "records", + b"records", + "ref", + b"ref", + "sequence", + b"sequence", + ], + ) -> None: ... + +global___AppendRecordsRequest = AppendRecordsRequest + +@typing.final +class AppendRecordsResponse(google.protobuf.message.Message): + """Experimental. Might change in the future. + Where the batch's Records are in the Stream. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + FIRST_OFFSET_FIELD_NUMBER: builtins.int + LAST_OFFSET_FIELD_NUMBER: builtins.int + first_offset: builtins.bytes + last_offset: builtins.bytes + def __init__( + self, + *, + first_offset: builtins.bytes = ..., + last_offset: builtins.bytes = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing.Literal[ + "first_offset", b"first_offset", "last_offset", b"last_offset" + ], + ) -> None: ... + +global___AppendRecordsResponse = AppendRecordsResponse + +@typing.final +class CloseStreamRequest(google.protobuf.message.Message): + """Experimental. Might change in the future. + Closes a Stream. Readers drain what is left, then end, and later appends fail with CLOSED. A + close writes no Record and carries no value, so code that has a last value publishes it first. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + NAMESPACE_FIELD_NUMBER: builtins.int + REF_FIELD_NUMBER: builtins.int + PRODUCER_FIELD_NUMBER: builtins.int + namespace: builtins.str + """The caller's namespace. Only read by the server. The SDK sets it, and the SDK Stores don't + read it. + """ + @property + def ref(self) -> temporalio.api.stream.v1.message_pb2.StreamRef: + """Required.""" + + @property + def producer(self) -> temporalio.api.stream.v1.message_pb2.Producer: + """Required. The Producer that closes the Stream.""" + + def __init__( + self, + *, + namespace: builtins.str = ..., + ref: temporalio.api.stream.v1.message_pb2.StreamRef | None = ..., + producer: temporalio.api.stream.v1.message_pb2.Producer | None = ..., + ) -> None: ... + def HasField( + self, field_name: typing.Literal["producer", b"producer", "ref", b"ref"] + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing.Literal[ + "namespace", b"namespace", "producer", b"producer", "ref", b"ref" + ], + ) -> None: ... + +global___CloseStreamRequest = CloseStreamRequest + +@typing.final +class CloseStreamResponse(google.protobuf.message.Message): + """Experimental. Might change in the future. + A close answers when the Store has the close. It gives nothing back. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___CloseStreamResponse = CloseStreamResponse + +@typing.final +class ReadRecordsRequest(google.protobuf.message.Message): + """Experimental. Might change in the future. + Reads one page of Records after an offset. At the end of the Stream, the read waits for a new + Record, so readers long poll. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + NAMESPACE_FIELD_NUMBER: builtins.int + REF_FIELD_NUMBER: builtins.int + FROM_OFFSET_FIELD_NUMBER: builtins.int + MAX_RECORDS_FIELD_NUMBER: builtins.int + WAIT_FIELD_NUMBER: builtins.int + MAX_BYTES_FIELD_NUMBER: builtins.int + namespace: builtins.str + """The caller's namespace. Only read by the server. The SDK sets it, and the SDK Stores don't + read it. + """ + from_offset: builtins.bytes + """Read after this offset. It fails with OFFSET_TOO_HIGH if it is past the newest offset. + Leave empty to read from the beginning. To read only new Records, a caller gets last_offset + from a describe and reads after it. + (-- api-linter: core::0140::prepositions=disabled + aip.dev/not-precedent: "from" marks the start of a range of offsets. --) + """ + max_records: builtins.int + """The most Records in one page. Zero is 1000.""" + max_bytes: builtins.int + """The most Record bytes in one page, so a page fits the caller's limit, such as a History + marker. A larger Record still comes alone, so the read always moves on. Zero is no cap. + """ + @property + def ref(self) -> temporalio.api.stream.v1.message_pb2.StreamRef: + """Required.""" + + @property + def wait(self) -> google.protobuf.duration_pb2.Duration: + """How long the read waits for a new Record before it answers with an empty page. Empty answers + at once, and a wait longer than 60 s waits 60 s. + """ + + def __init__( + self, + *, + namespace: builtins.str = ..., + ref: temporalio.api.stream.v1.message_pb2.StreamRef | None = ..., + from_offset: builtins.bytes = ..., + max_records: builtins.int = ..., + wait: google.protobuf.duration_pb2.Duration | None = ..., + max_bytes: builtins.int = ..., + ) -> None: ... + def HasField( + self, field_name: typing.Literal["ref", b"ref", "wait", b"wait"] + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing.Literal[ + "from_offset", + b"from_offset", + "max_bytes", + b"max_bytes", + "max_records", + b"max_records", + "namespace", + b"namespace", + "ref", + b"ref", + "wait", + b"wait", + ], + ) -> None: ... + +global___ReadRecordsRequest = ReadRecordsRequest + +@typing.final +class ReadRecordsResponse(google.protobuf.message.Message): + """Experimental. Might change in the future. + One page of a read. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + RECORDS_FIELD_NUMBER: builtins.int + LAST_OFFSET_FIELD_NUMBER: builtins.int + DONE_FIELD_NUMBER: builtins.int + last_offset: builtins.bytes + """The last offset that this read covered, which is the offset of the newest Record of the + page. The next read gives it as from_offset and starts after it. An empty page gives the + request's from_offset, and that is empty when the request had none. So it is always + known, and an empty value means that the next read starts as this one did. + """ + done: builtins.bool + """True when the Stream is closed and this read gave its last Record. It is the only sign of a + close in a read, since a close stores no Record. A read of a closed Stream with no Record + left answers at once with done. + """ + @property + def records( + self, + ) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[ + temporalio.api.stream.v1.message_pb2.StreamRecord + ]: ... + def __init__( + self, + *, + records: collections.abc.Iterable[ + temporalio.api.stream.v1.message_pb2.StreamRecord + ] + | None = ..., + last_offset: builtins.bytes = ..., + done: builtins.bool = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing.Literal[ + "done", b"done", "last_offset", b"last_offset", "records", b"records" + ], + ) -> None: ... + +global___ReadRecordsResponse = ReadRecordsResponse + +@typing.final +class DescribeStreamRequest(google.protobuf.message.Message): + """Experimental. Might change in the future. + Gets the status and the newest offset of a Stream. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + NAMESPACE_FIELD_NUMBER: builtins.int + REF_FIELD_NUMBER: builtins.int + namespace: builtins.str + """The caller's namespace. Only read by the server. The SDK sets it, and the SDK Stores don't + read it. + """ + @property + def ref(self) -> temporalio.api.stream.v1.message_pb2.StreamRef: + """Required.""" + + def __init__( + self, + *, + namespace: builtins.str = ..., + ref: temporalio.api.stream.v1.message_pb2.StreamRef | None = ..., + ) -> None: ... + def HasField(self, field_name: typing.Literal["ref", b"ref"]) -> builtins.bool: ... + def ClearField( + self, field_name: typing.Literal["namespace", b"namespace", "ref", b"ref"] + ) -> None: ... + +global___DescribeStreamRequest = DescribeStreamRequest + +@typing.final +class DescribeStreamResponse(google.protobuf.message.Message): + """Experimental. Might change in the future. + The state of a Stream. It has no Retention, since the Retention is a setting of the Store and + not of one Stream. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + STATUS_FIELD_NUMBER: builtins.int + LAST_OFFSET_FIELD_NUMBER: builtins.int + status: temporalio.api.enums.v1.stream_pb2.StreamStatus.ValueType + last_offset: builtins.bytes + """The newest offset, of a Record. Empty when the Stream has none.""" + def __init__( + self, + *, + status: temporalio.api.enums.v1.stream_pb2.StreamStatus.ValueType = ..., + last_offset: builtins.bytes = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing.Literal["last_offset", b"last_offset", "status", b"status"], + ) -> None: ... + +global___DescribeStreamResponse = DescribeStreamResponse diff --git a/temporalio/api/streamservice/v1/request_response_pb2_grpc.py b/temporalio/api/streamservice/v1/request_response_pb2_grpc.py new file mode 100644 index 000000000..bf947056a --- /dev/null +++ b/temporalio/api/streamservice/v1/request_response_pb2_grpc.py @@ -0,0 +1,4 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" + +import grpc diff --git a/temporalio/api/streamservice/v1/request_response_pb2_grpc.pyi b/temporalio/api/streamservice/v1/request_response_pb2_grpc.pyi new file mode 100644 index 000000000..de236ac81 --- /dev/null +++ b/temporalio/api/streamservice/v1/request_response_pb2_grpc.pyi @@ -0,0 +1,21 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" + +import abc +import collections.abc +import typing + +import grpc +import grpc.aio + +_T = typing.TypeVar("_T") + +class _MaybeAsyncIterator( + collections.abc.AsyncIterator[_T], + collections.abc.Iterator[_T], + metaclass=abc.ABCMeta, +): ... +class _ServicerContext(grpc.ServicerContext, grpc.aio.ServicerContext): # type: ignore[misc, type-arg] + ... diff --git a/temporalio/bridge/_visitor.py b/temporalio/bridge/_visitor.py index ead09a986..8832b56bc 100644 --- a/temporalio/bridge/_visitor.py +++ b/temporalio/bridge/_visitor.py @@ -639,6 +639,70 @@ async def _visit_temporal_api_notificationservice_v1_OnCompleteResponse( ) -> None: pass + async def _visit_temporal_api_streamservice_v1_CreateStreamRequest( + self, fs: VisitorFunctions, o: Any + ) -> None: + pass + + async def _visit_temporal_api_streamservice_v1_CreateStreamResponse( + self, fs: VisitorFunctions, o: Any + ) -> None: + pass + + async def _visit_temporal_api_stream_v1_AppendRecord( + self, fs: VisitorFunctions, o: Any + ): + if o.HasField("body"): + await self._visit_temporal_api_common_v1_Payload(fs, o.body) + + async def _visit_temporal_api_streamservice_v1_AppendRecordsRequest( + self, fs: VisitorFunctions, o: Any + ): + for v in o.records: + await self._visit_temporal_api_stream_v1_AppendRecord(fs, v) + + async def _visit_temporal_api_streamservice_v1_AppendRecordsResponse( + self, fs: VisitorFunctions, o: Any + ) -> None: + pass + + async def _visit_temporal_api_streamservice_v1_CloseStreamRequest( + self, fs: VisitorFunctions, o: Any + ) -> None: + pass + + async def _visit_temporal_api_streamservice_v1_CloseStreamResponse( + self, fs: VisitorFunctions, o: Any + ) -> None: + pass + + async def _visit_temporal_api_streamservice_v1_ReadRecordsRequest( + self, fs: VisitorFunctions, o: Any + ) -> None: + pass + + async def _visit_temporal_api_stream_v1_StreamRecord( + self, fs: VisitorFunctions, o: Any + ): + if o.HasField("body"): + await self._visit_temporal_api_common_v1_Payload(fs, o.body) + + async def _visit_temporal_api_streamservice_v1_ReadRecordsResponse( + self, fs: VisitorFunctions, o: Any + ): + for v in o.records: + await self._visit_temporal_api_stream_v1_StreamRecord(fs, v) + + async def _visit_temporal_api_streamservice_v1_DescribeStreamRequest( + self, fs: VisitorFunctions, o: Any + ) -> None: + pass + + async def _visit_temporal_api_streamservice_v1_DescribeStreamResponse( + self, fs: VisitorFunctions, o: Any + ) -> None: + pass + async def _visit_temporal_api_common_v1_Header(self, fs: VisitorFunctions, o: Any): for v in o.fields.values(): await self._visit_temporal_api_common_v1_Payload(fs, v) diff --git a/temporalio/nexus/system/stream_service/__init__.py b/temporalio/nexus/system/stream_service/__init__.py new file mode 100644 index 000000000..35b198843 --- /dev/null +++ b/temporalio/nexus/system/stream_service/__init__.py @@ -0,0 +1,71 @@ +# Generated by nexgen v0.2.12. DO NOT EDIT! + +from __future__ import annotations + +from .models import ( + ActivityEpoch, + ActivityProducer, + AppendRecord, + AppendRecordsRequest, + AppendRecordsResponse, + ClientProducer, + CloseStreamRequest, + CloseStreamResponse, + CreateStreamRequest, + CreateStreamResult, + DescribeStreamRequest, + DescribeStreamResponse, + LocalActivityEpoch, + LocalActivityProducer, + Producer, + ProducerKind, + ProducerKindClient, + ProducerKindLocalActivity, + ProducerKindStandaloneActivity, + ProducerKindWorkflow, + ProducerKindWorkflowActivity, + ReadRecordsRequest, + ReadRecordsResponse, + StreamIdConflictPolicy, + StreamRecord, + StreamRef, + StreamStatus, + Timestamp, + WorkflowProducer, + WorkflowTaskEpoch, +) +from .services import StreamService + +__all__ = [ + "ActivityEpoch", + "ActivityProducer", + "AppendRecord", + "AppendRecordsRequest", + "AppendRecordsResponse", + "ClientProducer", + "CloseStreamRequest", + "CloseStreamResponse", + "CreateStreamRequest", + "CreateStreamResult", + "DescribeStreamRequest", + "DescribeStreamResponse", + "LocalActivityEpoch", + "LocalActivityProducer", + "Producer", + "ProducerKind", + "ProducerKindWorkflow", + "ProducerKindLocalActivity", + "ProducerKindWorkflowActivity", + "ProducerKindStandaloneActivity", + "ProducerKindClient", + "ReadRecordsRequest", + "ReadRecordsResponse", + "StreamIdConflictPolicy", + "StreamRecord", + "StreamRef", + "StreamStatus", + "Timestamp", + "WorkflowProducer", + "WorkflowTaskEpoch", + "StreamService", +] diff --git a/temporalio/nexus/system/stream_service/_resource_context_manager.py b/temporalio/nexus/system/stream_service/_resource_context_manager.py new file mode 100644 index 000000000..86dd49966 --- /dev/null +++ b/temporalio/nexus/system/stream_service/_resource_context_manager.py @@ -0,0 +1,93 @@ +# Generated by nexgen v0.2.12. DO NOT EDIT! + +from __future__ import annotations + +import collections.abc +import functools +import types +import typing + +import typing_extensions + + +class _Closable(typing.Protocol): + async def __aenter__(self) -> object: ... + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: types.TracebackType | None, + ) -> None: ... + + +_ResourceT = typing.TypeVar("_ResourceT", bound=_Closable) +_P = typing_extensions.ParamSpec("_P") + + +@typing.final +class ResourceContextManager( + collections.abc.Coroutine[typing.Any, typing.Any, _ResourceT], + typing.Generic[_ResourceT], +): + """The create call. You can use it two ways. Await it to get the resource and manage it + yourself. Or open it with ``async with`` to get the resource and close it at the end of the + block. The ``async with`` form does the create for you, so you do not add a separate await.""" + + def __init__( + self, call: collections.abc.Coroutine[typing.Any, typing.Any, _ResourceT] + ) -> None: + self._call: collections.abc.Coroutine[typing.Any, typing.Any, _ResourceT] = call + self._resource: _ResourceT | None = None + self.__name__: str = getattr(call, "__name__", type(self).__name__) + self.__qualname__: str = getattr(call, "__qualname__", self.__name__) + + @typing_extensions.override + def __await__(self) -> collections.abc.Generator[typing.Any, None, _ResourceT]: + return self._call.__await__() + + @typing_extensions.override + def send(self, value: typing.Any) -> typing.Any: + return self._call.send(value) + + @typing_extensions.override + def throw( + self, + typ: typing.Any, + val: typing.Any = None, + tb: types.TracebackType | None = None, + ) -> typing.Any: + return self._call.throw(typ, val, tb) + + @typing_extensions.override + def close(self) -> None: + self._call.close() + + async def __aenter__(self) -> _ResourceT: + resource = await self._call + _ = await resource.__aenter__() + self._resource = resource + return resource + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: types.TracebackType | None, + ) -> None: + if self._resource is not None: + await self._resource.__aexit__(exc_type, exc, tb) + + +def resource_context_manager( + function: collections.abc.Callable[ + _P, collections.abc.Coroutine[typing.Any, typing.Any, _ResourceT] + ], +) -> collections.abc.Callable[_P, ResourceContextManager[_ResourceT]]: + """Wraps a call that returns a closable resource in a ``ResourceContextManager``.""" + + @functools.wraps(function) + def call(*args: _P.args, **kwargs: _P.kwargs) -> ResourceContextManager[_ResourceT]: + return ResourceContextManager(function(*args, **kwargs)) + + return call diff --git a/temporalio/nexus/system/stream_service/client/__init__.py b/temporalio/nexus/system/stream_service/client/__init__.py new file mode 100644 index 000000000..d2df633d4 --- /dev/null +++ b/temporalio/nexus/system/stream_service/client/__init__.py @@ -0,0 +1,11 @@ +# Generated by nexgen v0.2.12. DO NOT EDIT! + +from __future__ import annotations + +from ._extensions import StreamServiceClientExtension +from ._resources.stream import Stream + +__all__ = [ + "StreamServiceClientExtension", + "Stream", +] diff --git a/temporalio/nexus/system/stream_service/client/_extensions.py b/temporalio/nexus/system/stream_service/client/_extensions.py new file mode 100644 index 000000000..c96bb985f --- /dev/null +++ b/temporalio/nexus/system/stream_service/client/_extensions.py @@ -0,0 +1,180 @@ +# Generated by nexgen v0.2.12. DO NOT EDIT! + +from __future__ import annotations + +import abc +import collections.abc +import datetime +import typing +import uuid as _uuid + +import temporalio.nexus.system + +if typing.TYPE_CHECKING: + import temporalio.client + +import temporalio.nexus.system._support as _support + +from .._resource_context_manager import resource_context_manager +from ..models import ( + CreateStreamRequest, + CreateStreamResult, + StreamIdConflictPolicy, + StreamRef, +) +from ..services import StreamService +from ._resources.stream import Stream + +_NexusServiceT = typing.TypeVar("_NexusServiceT") +BodyT = typing.TypeVar("BodyT") + + +class StreamServiceClientExtension(abc.ABC): + """Starts the operations of ``StreamService`` from code outside a Workflow. + + A client class inherits this class and provides the SDK's ``create_nexus_client`` + method. + + .. warning:: + This API is experimental and subject to change. + """ + + @abc.abstractmethod + def create_nexus_client( + self, + service: type[_NexusServiceT] | str, + endpoint: str, + ) -> temporalio.client.NexusClient[_NexusServiceT]: + """Return the Nexus client that starts the operations of ``service``.""" + ... + + @typing.overload + def get_stream( + self, + ref: StreamRef[BodyT], + *, + created: bool | None = None, + body_type: None = None, + ) -> Stream[BodyT]: ... + @typing.overload + def get_stream( + self, + ref: StreamRef[BodyT] | StreamRef[object], + *, + created: bool | None = None, + body_type: type[BodyT], + ) -> Stream[BodyT]: ... + def get_stream( + self, + ref: StreamRef[typing.Any], + *, + created: bool | None = None, + body_type: typing.Any = None, + ) -> Stream[typing.Any]: + """Return the ``Stream`` handle for this ref.""" + arguments = typing.get_args(getattr(ref, "__orig_class__", None)) + # None means no explicit type, so use the ref's type argument, else object. + if body_type is None: + body_type = arguments[0] if arguments else object + elif arguments and arguments[0] is not object and arguments[0] != body_type: + raise TypeError("The decoding type must match the typed ref") + if arguments != (body_type,): + ref = StreamRef[body_type](id=ref.id, store=ref.store) + return Stream[body_type]( + self.create_nexus_client( + service=StreamService, + endpoint=temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT, + ), + ref=ref, + created=created, + ) + + async def _stream_service_create_stream( + self, + request: CreateStreamRequest[BodyT], + body_type: type[BodyT], + *, + rpc_metadata: collections.abc.Mapping[str, str | bytes] | None = None, + rpc_timeout: datetime.timedelta | None = None, + ) -> Stream[BodyT]: + nexus_client = self.create_nexus_client( + service=StreamService, + endpoint=temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT, + ) + handle = await nexus_client.start_operation( + operation="CreateStream", + arg=request, + id=f"{_uuid.uuid4()}", + result_type=CreateStreamResult[body_type], + rpc_metadata=rpc_metadata or {}, + rpc_timeout=rpc_timeout, + ) + result = await handle.result() + if result.ref is None: + raise RuntimeError("CreateStream answered without `ref`") + resource = Stream[body_type]( + nexus_client, + ref=result.ref, + created=result.created or None, + ) + return resource + + @typing.overload + @resource_context_manager + async def create_stream( + self, + *, + id: str, + store: str | None = None, + id_conflict_policy: StreamIdConflictPolicy | None = None, + body_type: type[BodyT], + rpc_metadata: collections.abc.Mapping[str, str | bytes] | None = None, + rpc_timeout: datetime.timedelta | None = None, + ) -> Stream[BodyT]: ... + + @typing.overload + @resource_context_manager + async def create_stream( + self, + *, + id: str, + store: str | None = None, + id_conflict_policy: StreamIdConflictPolicy | None = None, + body_type: None = None, + rpc_metadata: collections.abc.Mapping[str, str | bytes] | None = None, + rpc_timeout: datetime.timedelta | None = None, + ) -> Stream[object]: ... + + @resource_context_manager + async def create_stream( + self, + *, + id: str, + store: str | None = None, + id_conflict_policy: StreamIdConflictPolicy | None = None, + body_type: typing.Any = None, + rpc_metadata: collections.abc.Mapping[str, str | bytes] | None = None, + rpc_timeout: datetime.timedelta | None = None, + ) -> Stream[typing.Any]: + """ + Args: + id: The Stream id, 1 to 4096 bytes. + store: Registered Store name. With none, the SDK picks one. Only an SDK + reads it. + id_conflict_policy: What the create does when the Stream exists. Unspecified + is fail. + """ + if body_type is None: + body_type = typing.Any + stream_ref = StreamRef( + id=id, + store=store, + ) + request = CreateStreamRequest( + stream_ref=stream_ref, + id_conflict_policy=id_conflict_policy, + request_id=_support.request_id(), + ) + return await self._stream_service_create_stream( + request, body_type, rpc_metadata=rpc_metadata, rpc_timeout=rpc_timeout + ) diff --git a/temporalio/nexus/system/stream_service/client/_poll.py b/temporalio/nexus/system/stream_service/client/_poll.py new file mode 100644 index 000000000..a653d84f9 --- /dev/null +++ b/temporalio/nexus/system/stream_service/client/_poll.py @@ -0,0 +1,77 @@ +# Generated by nexgen v0.2.12. DO NOT EDIT! + +from __future__ import annotations + +import collections +import collections.abc +import typing + +_PollRequestT = typing.TypeVar("_PollRequestT") +_PollResponseT = typing.TypeVar("_PollResponseT") +_PollItemT = typing.TypeVar("_PollItemT") + + +@typing.final +class PollIterator(typing.Generic[_PollRequestT, _PollItemT]): + """Yields each item of the pages of a poll. + + ``next_request`` is the request to resume with. After an item, the resume helper gives + it. Without a resume helper, it reads the item's page again. After a page's last item, + it is the page helper's request, which is ``None`` after the last page. + """ + + def __init__( + self, + request: _PollRequestT, + pages: collections.abc.AsyncIterator[_PollResponseT], + items: collections.abc.Callable[ + [_PollResponseT], collections.abc.Sequence[_PollItemT] | None + ], + next_page: collections.abc.Callable[ + [_PollRequestT, _PollResponseT], _PollRequestT | None + ], + resume: collections.abc.Callable[[_PollRequestT, _PollItemT], _PollRequestT] + | None, + ) -> None: + async def read( + page_request: _PollRequestT, + ) -> tuple[_PollRequestT | None, collections.deque[_PollItemT]]: + response = await anext(pages) + # The page iterator keeps its requests to itself, so the page helper runs here + # again. It runs before the caller gets an item, so a changed item can't change + # the next request. + return next_page(page_request, response), collections.deque( + items(response) or () + ) + + self.next_request: _PollRequestT | None = request + self._read: collections.abc.Callable[ + [_PollRequestT], + collections.abc.Awaitable[ + tuple[_PollRequestT | None, collections.deque[_PollItemT]] + ], + ] = read + self._resume: ( + collections.abc.Callable[[_PollRequestT, _PollItemT], _PollRequestT] | None + ) = resume + self._page: collections.deque[_PollItemT] = collections.deque() + self._page_request: _PollRequestT = request + self._page_next: _PollRequestT | None = None + + def __aiter__(self) -> PollIterator[_PollRequestT, _PollItemT]: + return self + + async def __anext__(self) -> _PollItemT: + while not self._page: + if self.next_request is None: + raise StopAsyncIteration + self._page_request = self.next_request + self._page_next, self._page = await self._read(self._page_request) + if not self._page: + self.next_request = self._page_next + item = self._page.popleft() + if not self._page: + self.next_request = self._page_next + elif self._resume is not None: + self.next_request = self._resume(self._page_request, item) + return item diff --git a/temporalio/nexus/system/stream_service/client/_resources/__init__.py b/temporalio/nexus/system/stream_service/client/_resources/__init__.py new file mode 100644 index 000000000..250b6a42b --- /dev/null +++ b/temporalio/nexus/system/stream_service/client/_resources/__init__.py @@ -0,0 +1,3 @@ +# Generated by nexgen v0.2.12. DO NOT EDIT! + +from __future__ import annotations diff --git a/temporalio/nexus/system/stream_service/client/_resources/stream.py b/temporalio/nexus/system/stream_service/client/_resources/stream.py new file mode 100644 index 000000000..ff447fa75 --- /dev/null +++ b/temporalio/nexus/system/stream_service/client/_resources/stream.py @@ -0,0 +1,259 @@ +# Generated by nexgen v0.2.12. DO NOT EDIT! + +from __future__ import annotations + +import collections.abc +import datetime +import types +import typing +import uuid as _uuid + +import typing_extensions + +if typing.TYPE_CHECKING: + import temporalio.client + +import temporalio.nexus.system._support as _support + +from ...models import ( + AppendRecord, + AppendRecordsRequest, + AppendRecordsResponse, + CloseStreamRequest, + DescribeStreamRequest, + DescribeStreamResponse, + Producer, + ReadRecordsRequest, + ReadRecordsResponse, + StreamRecord, + StreamRef, +) +from ...services import StreamService +from .._poll import PollIterator + +BodyT = typing.TypeVar("BodyT") + + +class Stream(typing.Generic[BodyT]): + """One Stream. Build it from a stream-ref, or get it from a create.""" + + ref: StreamRef[BodyT] + created: bool | None + + def __init__( + self, + nexus_client: temporalio.client.NexusClient[typing.Any], + /, + *, + ref: StreamRef[BodyT], + created: bool | None, + ) -> None: + self._nexus_client: temporalio.client.NexusClient[typing.Any] = nexus_client + if type(self) is not Stream: + raise TypeError("Generic resource subclasses are not supported") + self.ref = ref + self.created = created + + @property + def body_type(self) -> type[BodyT]: + arguments = typing.get_args(getattr(self.ref, "__orig_class__", None)) + return typing.cast("type[BodyT]", arguments[0] if arguments else object) + + async def __aenter__(self) -> typing_extensions.Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: types.TracebackType | None, + ) -> None: + await self.close() + + async def append( + self, + *records: BodyT, + producer: Producer | None = None, + rpc_metadata: collections.abc.Mapping[str, str | bytes] | None = None, + rpc_timeout: datetime.timedelta | None = None, + ) -> AppendRecordsResponse: + """Append values, and return the offsets of the batch.""" + request = AppendRecordsRequest( + ref=self.ref, + producer=producer if producer is not None else _support.default_producer(), + records=[AppendRecord(body=value) for value in records], + ) + handle = await self._nexus_client.start_operation( + operation=StreamService.append_records, + arg=request, + id=f"{_uuid.uuid4()}", + rpc_metadata=rpc_metadata or {}, + rpc_timeout=rpc_timeout, + ) + return await handle.result() + + async def close( + self, + producer: Producer | None = None, + *, + rpc_metadata: collections.abc.Mapping[str, str | bytes] | None = None, + rpc_timeout: datetime.timedelta | None = None, + ) -> None: + """Close the Stream. Readers drain what is left, then end.""" + request = CloseStreamRequest( + ref=self.ref, + producer=producer if producer is not None else _support.default_producer(), + ) + handle = await self._nexus_client.start_operation( + operation=StreamService.close_stream, + arg=request, + id=f"{_uuid.uuid4()}", + rpc_metadata=rpc_metadata or {}, + rpc_timeout=rpc_timeout, + ) + _ = await handle.result() + + async def read( + self, + from_offset: bytes | None = None, + max_records: int | None = None, + wait: datetime.timedelta | None = None, + max_bytes: int | None = None, + *, + rpc_metadata: collections.abc.Mapping[str, str | bytes] | None = None, + rpc_timeout: datetime.timedelta | None = None, + ) -> ReadRecordsResponse[BodyT]: + """Read one page of Records. The poll iterator reads them all.""" + request = ReadRecordsRequest( + ref=self.ref, + from_offset=from_offset, + max_records=max_records, + wait=wait, + max_bytes=max_bytes, + ) + handle = await self._nexus_client.start_operation( + operation="ReadRecords", + arg=request, + id=f"{_uuid.uuid4()}", + result_type=ReadRecordsResponse[self.body_type], + rpc_metadata=rpc_metadata or {}, + rpc_timeout=rpc_timeout, + ) + return await handle.result() + + async def iter_read( + self, + from_offset: bytes | None = None, + max_records: int | None = None, + wait: datetime.timedelta | None = None, + max_bytes: int | None = None, + *, + rpc_metadata: collections.abc.Mapping[str, str | bytes] | None = None, + rpc_timeout: datetime.timedelta | None = None, + ) -> collections.abc.AsyncGenerator[ReadRecordsResponse[BodyT], None]: + """Yield each response of ``read`` until the poll helper stops. + + Read one page of Records. The poll iterator reads them all. + + Args: + from_offset: Read after this offset. Empty is the beginning. A too high + offset fails. + max_records: About this many Records per page, 1 to 1000. + wait: How long to wait for a new Record before an empty answer. At most 60 + s. + max_bytes: The maximum Record bytes in a page. A larger Record is still + returned alone. + """ + request = ReadRecordsRequest( + ref=self.ref, + from_offset=from_offset, + max_records=max_records, + wait=wait, + max_bytes=max_bytes, + ) + nexus_client = self._nexus_client + while True: + operation_handle = await nexus_client.start_operation( + operation="ReadRecords", + arg=request, + id=f"{_uuid.uuid4()}", + result_type=ReadRecordsResponse[self.body_type], + rpc_metadata=rpc_metadata or {}, + rpc_timeout=rpc_timeout, + ) + response = await operation_handle.result() + next_request = _support.next_read_request(request, response) + yield response + if next_request is None: + return + request = next_request + + def iter_read_items( + self, + from_offset: bytes | None = None, + max_records: int | None = None, + wait: datetime.timedelta | None = None, + max_bytes: int | None = None, + *, + rpc_metadata: collections.abc.Mapping[str, str | bytes] | None = None, + rpc_timeout: datetime.timedelta | None = None, + ) -> PollIterator[ReadRecordsRequest[BodyT], StreamRecord[BodyT]]: + """Yield each item of ``iter_read``, page by page. + + ``next_request`` resumes after the last item that the iterator yielded. It is + ``None`` after the last page. + + Read one page of Records. The poll iterator reads them all. + + Args: + from_offset: Read after this offset. Empty is the beginning. A too high + offset fails. + max_records: About this many Records per page, 1 to 1000. + wait: How long to wait for a new Record before an empty answer. At most 60 + s. + max_bytes: The maximum Record bytes in a page. A larger Record is still + returned alone. + """ + request = ReadRecordsRequest( + ref=self.ref, + from_offset=from_offset, + max_records=max_records, + wait=wait, + max_bytes=max_bytes, + ) + return PollIterator( + request, + self.iter_read( + from_offset=from_offset, + max_records=max_records, + wait=wait, + max_bytes=max_bytes, + rpc_metadata=rpc_metadata, + rpc_timeout=rpc_timeout, + ), + lambda response: response.records, + _support.next_read_request, + _support.resume_read_request, + ) + + async def describe( + self, + *, + rpc_metadata: collections.abc.Mapping[str, str | bytes] | None = None, + rpc_timeout: datetime.timedelta | None = None, + ) -> DescribeStreamResponse: + """Describe the Stream's status and newest offset.""" + request = DescribeStreamRequest( + ref=self.ref, + ) + handle = await self._nexus_client.start_operation( + operation=StreamService.describe_stream, + arg=request, + id=f"{_uuid.uuid4()}", + rpc_metadata=rpc_metadata or {}, + rpc_timeout=rpc_timeout, + ) + return await handle.result() + + +__all__ = ["Stream"] diff --git a/temporalio/nexus/system/stream_service/models.py b/temporalio/nexus/system/stream_service/models.py new file mode 100644 index 000000000..4848a2ea5 --- /dev/null +++ b/temporalio/nexus/system/stream_service/models.py @@ -0,0 +1,1371 @@ +# Generated by nexgen v0.2.12. DO NOT EDIT! + +from __future__ import annotations + +import dataclasses +import datetime +import enum +import typing + +import google.protobuf.timestamp_pb2 +import typing_extensions + +import temporalio.api.enums.v1.stream_pb2 +import temporalio.api.stream.v1.message_pb2 +import temporalio.api.streamservice.v1.request_response_pb2 +import temporalio.converter +import temporalio.nexus.system._support as _support + + +class StreamIdConflictPolicy(enum.IntEnum): + StreamIdConflictPolicyUnspecified = 0 + StreamIdConflictPolicyFail = 1 + StreamIdConflictPolicyUseExisting = 2 + + +class StreamStatus(enum.IntEnum): + StreamStatusUnspecified = 0 + StreamStatusOpen = 1 + StreamStatusClosed = 2 + StreamStatusExpired = 3 + + +def _stream_status_from_proto(value: int) -> StreamStatus: + try: + return StreamStatus(value) + except ValueError: + # A newer peer can send a case that this enum doesn't know. + return StreamStatus.StreamStatusUnspecified + + +BodyT = typing.TypeVar("BodyT") +_BodyT = typing_extensions.TypeVar("_BodyT", default=object) + + +@dataclasses.dataclass(slots=True) +class CreateStreamRequest(typing.Generic[BodyT]): + """Create a Stream. If it exists, fail, or with use-existing get the open one.""" + + stream_ref: StreamRef[BodyT] + id_conflict_policy: StreamIdConflictPolicy | None = None + request_id: str | None = None + + +class _CreateStreamRequestTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + CreateStreamRequest[BodyT], + temporalio.api.streamservice.v1.request_response_pb2.CreateStreamRequest, + ] +): + transfer_type: ( + type[temporalio.api.streamservice.v1.request_response_pb2.CreateStreamRequest] + | None + ) = temporalio.api.streamservice.v1.request_response_pb2.CreateStreamRequest + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.streamservice.v1.request_response_pb2.CreateStreamRequest, + type_hint: type[CreateStreamRequest[BodyT]], + ) -> CreateStreamRequest[BodyT]: + (body_type,) = typing.get_args(type_hint) or (typing.Any,) + if not value.HasField("stream_ref"): + raise ValueError("missing required field CreateStreamRequest.stream_ref") + stream_ref = typing.cast( + StreamRef[BodyT], + _StreamRefTransferTypeConverter().from_transfer_type( + value.stream_ref, StreamRef[body_type] + ), + ) + return CreateStreamRequest( + stream_ref=stream_ref, + id_conflict_policy=StreamIdConflictPolicy(value.id_conflict_policy) + if value.id_conflict_policy != 0 + else None, + request_id=value.request_id if bool(value.request_id) else None, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: CreateStreamRequest[BodyT], + ) -> temporalio.api.streamservice.v1.request_response_pb2.CreateStreamRequest: + message = ( + temporalio.api.streamservice.v1.request_response_pb2.CreateStreamRequest() + ) + message.stream_ref.CopyFrom( + _StreamRefTransferTypeConverter().to_transfer_type(value.stream_ref) + ) + if value.id_conflict_policy is not None: + message.id_conflict_policy = ( + temporalio.api.enums.v1.stream_pb2.StreamIdConflictPolicy.ValueType( + value.id_conflict_policy + ) + ) + if value.request_id is not None: + message.request_id = value.request_id + return message + + +temporalio.converter.transfer_type_convertible( + _CreateStreamRequestTransferTypeConverter[typing.Any] +)(CreateStreamRequest) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass +class StreamRef(typing.Generic[_BodyT]): + """Identifies one Stream. The id is opaque to callers.""" + + id: str + store: str | None = None + + +class _StreamRefTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + StreamRef[BodyT], temporalio.api.stream.v1.message_pb2.StreamRef + ] +): + transfer_type: type[temporalio.api.stream.v1.message_pb2.StreamRef] | None = ( + temporalio.api.stream.v1.message_pb2.StreamRef + ) + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.stream.v1.message_pb2.StreamRef, + type_hint: type[StreamRef[BodyT]], + ) -> StreamRef[BodyT]: + (_body_type,) = typing.get_args(type_hint) or (typing.Any,) + if not value.id: + raise ValueError("missing required field StreamRef.id") + id = value.id + return type_hint( + id=id, + store=value.store if bool(value.store) else None, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: StreamRef[BodyT], + ) -> temporalio.api.stream.v1.message_pb2.StreamRef: + message = temporalio.api.stream.v1.message_pb2.StreamRef() + message.id = value.id + if value.store is not None: + message.store = value.store + return message + + +temporalio.converter.transfer_type_convertible( + _StreamRefTransferTypeConverter[typing.Any] +)(StreamRef) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class CreateStreamResult(typing.Generic[BodyT]): + ref: StreamRef[BodyT] | None = None + created: bool | None = None + + +class _CreateStreamResultTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + CreateStreamResult[BodyT], + temporalio.api.streamservice.v1.request_response_pb2.CreateStreamResponse, + ] +): + transfer_type: ( + type[temporalio.api.streamservice.v1.request_response_pb2.CreateStreamResponse] + | None + ) = temporalio.api.streamservice.v1.request_response_pb2.CreateStreamResponse + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.streamservice.v1.request_response_pb2.CreateStreamResponse, + type_hint: type[CreateStreamResult[BodyT]], + ) -> CreateStreamResult[BodyT]: + (body_type,) = typing.get_args(type_hint) or (typing.Any,) + return CreateStreamResult( + ref=typing.cast( + StreamRef[BodyT], + _StreamRefTransferTypeConverter().from_transfer_type( + value.ref, StreamRef[body_type] + ), + ) + if value.HasField("ref") + else None, + created=value.created if bool(value.created) else None, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: CreateStreamResult[BodyT], + ) -> temporalio.api.streamservice.v1.request_response_pb2.CreateStreamResponse: + message = ( + temporalio.api.streamservice.v1.request_response_pb2.CreateStreamResponse() + ) + if value.ref is not None: + message.ref.CopyFrom( + _StreamRefTransferTypeConverter().to_transfer_type(value.ref) + ) + if value.created is not None: + message.created = value.created + return message + + +temporalio.converter.transfer_type_convertible( + _CreateStreamResultTransferTypeConverter[typing.Any] +)(CreateStreamResult) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class AppendRecordsRequest(typing.Generic[BodyT]): + """Append one batch. The Store keeps a resent batch once, by Producer, epoch and + sequence. + """ + + ref: StreamRef[BodyT] + records: list[AppendRecord[BodyT]] + producer: Producer + sequence: int | None = None + + +class _AppendRecordsRequestTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + AppendRecordsRequest[BodyT], + temporalio.api.streamservice.v1.request_response_pb2.AppendRecordsRequest, + ] +): + transfer_type: ( + type[temporalio.api.streamservice.v1.request_response_pb2.AppendRecordsRequest] + | None + ) = temporalio.api.streamservice.v1.request_response_pb2.AppendRecordsRequest + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.streamservice.v1.request_response_pb2.AppendRecordsRequest, + type_hint: type[AppendRecordsRequest[BodyT]], + ) -> AppendRecordsRequest[BodyT]: + (body_type,) = typing.get_args(type_hint) or (typing.Any,) + if not value.HasField("ref"): + raise ValueError("missing required field AppendRecordsRequest.ref") + ref = typing.cast( + StreamRef[BodyT], + _StreamRefTransferTypeConverter().from_transfer_type( + value.ref, StreamRef[body_type] + ), + ) + records = [ + typing.cast( + AppendRecord[BodyT], + _AppendRecordTransferTypeConverter().from_transfer_type( + item, AppendRecord[body_type] + ), + ) + for item in value.records + ] + if not value.HasField("producer"): + raise ValueError("missing required field AppendRecordsRequest.producer") + producer = _ProducerTransferTypeConverter().from_transfer_type( + value.producer, Producer + ) + return AppendRecordsRequest( + ref=ref, + records=records, + producer=producer, + sequence=value.sequence if value.sequence != 0 else None, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: AppendRecordsRequest[BodyT], + ) -> temporalio.api.streamservice.v1.request_response_pb2.AppendRecordsRequest: + message = ( + temporalio.api.streamservice.v1.request_response_pb2.AppendRecordsRequest() + ) + message.ref.CopyFrom( + _StreamRefTransferTypeConverter().to_transfer_type(value.ref) + ) + for element in value.records: + item = message.records.add() + item.CopyFrom( + _AppendRecordTransferTypeConverter().to_transfer_type(element) + ) + message.producer.CopyFrom( + _ProducerTransferTypeConverter().to_transfer_type(value.producer) + ) + if value.sequence is not None: + message.sequence = value.sequence + return message + + +temporalio.converter.transfer_type_convertible( + _AppendRecordsRequestTransferTypeConverter[typing.Any] +)(AppendRecordsRequest) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class AppendRecord(typing.Generic[BodyT]): + """One Record to append.""" + + body: BodyT + + +class _AppendRecordTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + AppendRecord[BodyT], temporalio.api.stream.v1.message_pb2.AppendRecord + ] +): + transfer_type: type[temporalio.api.stream.v1.message_pb2.AppendRecord] | None = ( + temporalio.api.stream.v1.message_pb2.AppendRecord + ) + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.stream.v1.message_pb2.AppendRecord, + type_hint: type[AppendRecord[BodyT]], + ) -> AppendRecord[BodyT]: + (body_type,) = typing.get_args(type_hint) or (typing.Any,) + if not value.HasField("body"): + raise ValueError("missing required field AppendRecord.body") + body = typing.cast(BodyT, _support.payload_from_proto(value.body, body_type)) + return AppendRecord( + body=body, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: AppendRecord[BodyT], + ) -> temporalio.api.stream.v1.message_pb2.AppendRecord: + message = temporalio.api.stream.v1.message_pb2.AppendRecord() + message.body.CopyFrom(_support.payload_to_proto(value.body)) + return message + + +temporalio.converter.transfer_type_convertible( + _AppendRecordTransferTypeConverter[typing.Any] +)(AppendRecord) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class Producer: + """The code that wrote a batch. A higher epoch fences and supersedes a lower one.""" + + id: str + kind: ProducerKind | None = None + + +class _ProducerTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + Producer, temporalio.api.stream.v1.message_pb2.Producer + ] +): + transfer_type: type[temporalio.api.stream.v1.message_pb2.Producer] | None = ( + temporalio.api.stream.v1.message_pb2.Producer + ) + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.stream.v1.message_pb2.Producer, + type_hint: type[Producer], + ) -> Producer: + if not value.id: + raise ValueError("missing required field Producer.id") + id = value.id + _oneof_kind: ( + ProducerKindWorkflow + | ProducerKindLocalActivity + | ProducerKindWorkflowActivity + | ProducerKindStandaloneActivity + | ProducerKindClient + | None + ) + match value.WhichOneof("kind"): + case "workflow": + _oneof_kind = ProducerKindWorkflow( + _WorkflowProducerTransferTypeConverter().from_transfer_type( + value.workflow, WorkflowProducer + ) + ) + case "local_activity": + _oneof_kind = ProducerKindLocalActivity( + _LocalActivityProducerTransferTypeConverter().from_transfer_type( + value.local_activity, LocalActivityProducer + ) + ) + case "workflow_activity": + _oneof_kind = ProducerKindWorkflowActivity( + _ActivityProducerTransferTypeConverter().from_transfer_type( + value.workflow_activity, ActivityProducer + ) + ) + case "standalone_activity": + _oneof_kind = ProducerKindStandaloneActivity( + _ActivityProducerTransferTypeConverter().from_transfer_type( + value.standalone_activity, ActivityProducer + ) + ) + case "client": + _oneof_kind = ProducerKindClient( + _ClientProducerTransferTypeConverter().from_transfer_type( + value.client, ClientProducer + ) + ) + case None: + _oneof_kind = None + return Producer( + id=id, + kind=_oneof_kind, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: Producer, + ) -> temporalio.api.stream.v1.message_pb2.Producer: + message = temporalio.api.stream.v1.message_pb2.Producer() + message.id = value.id + if value.kind is not None: + match value.kind: + case ProducerKindWorkflow(): + message.workflow.CopyFrom( + _WorkflowProducerTransferTypeConverter().to_transfer_type( + value.kind.value + ) + ) + case ProducerKindLocalActivity(): + message.local_activity.CopyFrom( + _LocalActivityProducerTransferTypeConverter().to_transfer_type( + value.kind.value + ) + ) + case ProducerKindWorkflowActivity(): + message.workflow_activity.CopyFrom( + _ActivityProducerTransferTypeConverter().to_transfer_type( + value.kind.value + ) + ) + case ProducerKindStandaloneActivity(): + message.standalone_activity.CopyFrom( + _ActivityProducerTransferTypeConverter().to_transfer_type( + value.kind.value + ) + ) + case ProducerKindClient(): + message.client.CopyFrom( + _ClientProducerTransferTypeConverter().to_transfer_type( + value.kind.value + ) + ) + return message + + +temporalio.converter.transfer_type_convertible(_ProducerTransferTypeConverter)(Producer) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class WorkflowProducer: + """A Workflow run Producer.""" + + run_id: str + epoch: WorkflowTaskEpoch | None = None + + +class _WorkflowProducerTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + WorkflowProducer, temporalio.api.stream.v1.message_pb2.WorkflowProducer + ] +): + transfer_type: ( + type[temporalio.api.stream.v1.message_pb2.WorkflowProducer] | None + ) = temporalio.api.stream.v1.message_pb2.WorkflowProducer + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.stream.v1.message_pb2.WorkflowProducer, + type_hint: type[WorkflowProducer], + ) -> WorkflowProducer: + if not value.run_id: + raise ValueError("missing required field WorkflowProducer.run_id") + run_id = value.run_id + return WorkflowProducer( + run_id=run_id, + epoch=_WorkflowTaskEpochTransferTypeConverter().from_transfer_type( + value.epoch, WorkflowTaskEpoch + ) + if value.HasField("epoch") + else None, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: WorkflowProducer, + ) -> temporalio.api.stream.v1.message_pb2.WorkflowProducer: + message = temporalio.api.stream.v1.message_pb2.WorkflowProducer() + message.run_id = value.run_id + if value.epoch is not None: + message.epoch.CopyFrom( + _WorkflowTaskEpochTransferTypeConverter().to_transfer_type(value.epoch) + ) + return message + + +temporalio.converter.transfer_type_convertible(_WorkflowProducerTransferTypeConverter)( + WorkflowProducer +) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class WorkflowTaskEpoch: + """A Workflow Task attempt of the run.""" + + started_event_id: int + attempt: int + started_time: Timestamp | None = None + + +class _WorkflowTaskEpochTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + WorkflowTaskEpoch, temporalio.api.stream.v1.message_pb2.WorkflowTaskEpoch + ] +): + transfer_type: ( + type[temporalio.api.stream.v1.message_pb2.WorkflowTaskEpoch] | None + ) = temporalio.api.stream.v1.message_pb2.WorkflowTaskEpoch + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.stream.v1.message_pb2.WorkflowTaskEpoch, + type_hint: type[WorkflowTaskEpoch], + ) -> WorkflowTaskEpoch: + started_event_id = value.started_event_id + attempt = value.attempt + return WorkflowTaskEpoch( + started_event_id=started_event_id, + attempt=attempt, + started_time=_TimestampTransferTypeConverter().from_transfer_type( + value.started_time, Timestamp + ) + if value.HasField("started_time") + else None, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: WorkflowTaskEpoch, + ) -> temporalio.api.stream.v1.message_pb2.WorkflowTaskEpoch: + message = temporalio.api.stream.v1.message_pb2.WorkflowTaskEpoch() + message.started_event_id = value.started_event_id + message.attempt = value.attempt + if value.started_time is not None: + message.started_time.CopyFrom( + _TimestampTransferTypeConverter().to_transfer_type(value.started_time) + ) + return message + + +temporalio.converter.transfer_type_convertible(_WorkflowTaskEpochTransferTypeConverter)( + WorkflowTaskEpoch +) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class Timestamp: + seconds: int + nanos: int + + +class _TimestampTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + Timestamp, google.protobuf.timestamp_pb2.Timestamp + ] +): + transfer_type: type[google.protobuf.timestamp_pb2.Timestamp] | None = ( + google.protobuf.timestamp_pb2.Timestamp + ) + + @typing_extensions.override + def from_transfer_type( + self, + value: google.protobuf.timestamp_pb2.Timestamp, + type_hint: type[Timestamp], + ) -> Timestamp: + seconds = value.seconds + nanos = value.nanos + return Timestamp( + seconds=seconds, + nanos=nanos, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: Timestamp, + ) -> google.protobuf.timestamp_pb2.Timestamp: + message = google.protobuf.timestamp_pb2.Timestamp() + message.seconds = value.seconds + message.nanos = value.nanos + return message + + +temporalio.converter.transfer_type_convertible(_TimestampTransferTypeConverter)( + Timestamp +) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class LocalActivityProducer: + """A Local Activity Producer.""" + + run_id: str + epoch: LocalActivityEpoch + + +class _LocalActivityProducerTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + LocalActivityProducer, + temporalio.api.stream.v1.message_pb2.LocalActivityProducer, + ] +): + transfer_type: ( + type[temporalio.api.stream.v1.message_pb2.LocalActivityProducer] | None + ) = temporalio.api.stream.v1.message_pb2.LocalActivityProducer + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.stream.v1.message_pb2.LocalActivityProducer, + type_hint: type[LocalActivityProducer], + ) -> LocalActivityProducer: + if not value.run_id: + raise ValueError("missing required field LocalActivityProducer.run_id") + run_id = value.run_id + if not value.HasField("epoch"): + raise ValueError("missing required field LocalActivityProducer.epoch") + epoch = _LocalActivityEpochTransferTypeConverter().from_transfer_type( + value.epoch, LocalActivityEpoch + ) + return LocalActivityProducer( + run_id=run_id, + epoch=epoch, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: LocalActivityProducer, + ) -> temporalio.api.stream.v1.message_pb2.LocalActivityProducer: + message = temporalio.api.stream.v1.message_pb2.LocalActivityProducer() + message.run_id = value.run_id + message.epoch.CopyFrom( + _LocalActivityEpochTransferTypeConverter().to_transfer_type(value.epoch) + ) + return message + + +temporalio.converter.transfer_type_convertible( + _LocalActivityProducerTransferTypeConverter +)(LocalActivityProducer) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class LocalActivityEpoch: + """A Local Activity attempt. Its Workflow Task orders it first.""" + + task: WorkflowTaskEpoch + attempt: int + + +class _LocalActivityEpochTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + LocalActivityEpoch, temporalio.api.stream.v1.message_pb2.LocalActivityEpoch + ] +): + transfer_type: ( + type[temporalio.api.stream.v1.message_pb2.LocalActivityEpoch] | None + ) = temporalio.api.stream.v1.message_pb2.LocalActivityEpoch + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.stream.v1.message_pb2.LocalActivityEpoch, + type_hint: type[LocalActivityEpoch], + ) -> LocalActivityEpoch: + if not value.HasField("task"): + raise ValueError("missing required field LocalActivityEpoch.task") + task = _WorkflowTaskEpochTransferTypeConverter().from_transfer_type( + value.task, WorkflowTaskEpoch + ) + attempt = value.attempt + return LocalActivityEpoch( + task=task, + attempt=attempt, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: LocalActivityEpoch, + ) -> temporalio.api.stream.v1.message_pb2.LocalActivityEpoch: + message = temporalio.api.stream.v1.message_pb2.LocalActivityEpoch() + message.task.CopyFrom( + _WorkflowTaskEpochTransferTypeConverter().to_transfer_type(value.task) + ) + message.attempt = value.attempt + return message + + +temporalio.converter.transfer_type_convertible( + _LocalActivityEpochTransferTypeConverter +)(LocalActivityEpoch) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True, kw_only=True) +class ActivityProducer: + """An Activity Producer.""" + + run_id: str | None = None + epoch: ActivityEpoch + + +class _ActivityProducerTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + ActivityProducer, temporalio.api.stream.v1.message_pb2.ActivityProducer + ] +): + transfer_type: ( + type[temporalio.api.stream.v1.message_pb2.ActivityProducer] | None + ) = temporalio.api.stream.v1.message_pb2.ActivityProducer + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.stream.v1.message_pb2.ActivityProducer, + type_hint: type[ActivityProducer], + ) -> ActivityProducer: + if not value.HasField("epoch"): + raise ValueError("missing required field ActivityProducer.epoch") + epoch = _ActivityEpochTransferTypeConverter().from_transfer_type( + value.epoch, ActivityEpoch + ) + return ActivityProducer( + run_id=value.run_id if bool(value.run_id) else None, + epoch=epoch, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: ActivityProducer, + ) -> temporalio.api.stream.v1.message_pb2.ActivityProducer: + message = temporalio.api.stream.v1.message_pb2.ActivityProducer() + if value.run_id is not None: + message.run_id = value.run_id + message.epoch.CopyFrom( + _ActivityEpochTransferTypeConverter().to_transfer_type(value.epoch) + ) + return message + + +temporalio.converter.transfer_type_convertible(_ActivityProducerTransferTypeConverter)( + ActivityProducer +) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class ActivityEpoch: + """An Activity or Standalone Activity attempt.""" + + attempt_scheduled_time: Timestamp + attempt: int + + +class _ActivityEpochTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + ActivityEpoch, temporalio.api.stream.v1.message_pb2.ActivityEpoch + ] +): + transfer_type: type[temporalio.api.stream.v1.message_pb2.ActivityEpoch] | None = ( + temporalio.api.stream.v1.message_pb2.ActivityEpoch + ) + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.stream.v1.message_pb2.ActivityEpoch, + type_hint: type[ActivityEpoch], + ) -> ActivityEpoch: + if not value.HasField("attempt_scheduled_time"): + raise ValueError( + "missing required field ActivityEpoch.attempt_scheduled_time" + ) + attempt_scheduled_time = _TimestampTransferTypeConverter().from_transfer_type( + value.attempt_scheduled_time, Timestamp + ) + attempt = value.attempt + return ActivityEpoch( + attempt_scheduled_time=attempt_scheduled_time, + attempt=attempt, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: ActivityEpoch, + ) -> temporalio.api.stream.v1.message_pb2.ActivityEpoch: + message = temporalio.api.stream.v1.message_pb2.ActivityEpoch() + message.attempt_scheduled_time.CopyFrom( + _TimestampTransferTypeConverter().to_transfer_type( + value.attempt_scheduled_time + ) + ) + message.attempt = value.attempt + return message + + +temporalio.converter.transfer_type_convertible(_ActivityEpochTransferTypeConverter)( + ActivityEpoch +) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class ClientProducer: + """A client Producer. It has no run and no epoch, so nothing fences it.""" + + pass + + +class _ClientProducerTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + ClientProducer, temporalio.api.stream.v1.message_pb2.ClientProducer + ] +): + transfer_type: type[temporalio.api.stream.v1.message_pb2.ClientProducer] | None = ( + temporalio.api.stream.v1.message_pb2.ClientProducer + ) + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.stream.v1.message_pb2.ClientProducer, + type_hint: type[ClientProducer], + ) -> ClientProducer: + return ClientProducer() + + @typing_extensions.override + def to_transfer_type( + self, + value: ClientProducer, + ) -> temporalio.api.stream.v1.message_pb2.ClientProducer: + message = temporalio.api.stream.v1.message_pb2.ClientProducer() + return message + + +temporalio.converter.transfer_type_convertible(_ClientProducerTransferTypeConverter)( + ClientProducer +) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class AppendRecordsResponse: + first_offset: bytes | None = None + last_offset: bytes | None = None + + +class _AppendRecordsResponseTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + AppendRecordsResponse, + temporalio.api.streamservice.v1.request_response_pb2.AppendRecordsResponse, + ] +): + transfer_type: ( + type[temporalio.api.streamservice.v1.request_response_pb2.AppendRecordsResponse] + | None + ) = temporalio.api.streamservice.v1.request_response_pb2.AppendRecordsResponse + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.streamservice.v1.request_response_pb2.AppendRecordsResponse, + type_hint: type[AppendRecordsResponse], + ) -> AppendRecordsResponse: + return AppendRecordsResponse( + first_offset=value.first_offset if bool(value.first_offset) else None, + last_offset=value.last_offset if bool(value.last_offset) else None, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: AppendRecordsResponse, + ) -> temporalio.api.streamservice.v1.request_response_pb2.AppendRecordsResponse: + message = ( + temporalio.api.streamservice.v1.request_response_pb2.AppendRecordsResponse() + ) + if value.first_offset is not None: + message.first_offset = value.first_offset + if value.last_offset is not None: + message.last_offset = value.last_offset + return message + + +temporalio.converter.transfer_type_convertible( + _AppendRecordsResponseTransferTypeConverter +)(AppendRecordsResponse) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class CloseStreamRequest(typing.Generic[BodyT]): + """Close a Stream. Readers drain what is left, then end. Later appends are refused.""" + + ref: StreamRef[BodyT] + producer: Producer + + +class _CloseStreamRequestTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + CloseStreamRequest[BodyT], + temporalio.api.streamservice.v1.request_response_pb2.CloseStreamRequest, + ] +): + transfer_type: ( + type[temporalio.api.streamservice.v1.request_response_pb2.CloseStreamRequest] + | None + ) = temporalio.api.streamservice.v1.request_response_pb2.CloseStreamRequest + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.streamservice.v1.request_response_pb2.CloseStreamRequest, + type_hint: type[CloseStreamRequest[BodyT]], + ) -> CloseStreamRequest[BodyT]: + (body_type,) = typing.get_args(type_hint) or (typing.Any,) + if not value.HasField("ref"): + raise ValueError("missing required field CloseStreamRequest.ref") + ref = typing.cast( + StreamRef[BodyT], + _StreamRefTransferTypeConverter().from_transfer_type( + value.ref, StreamRef[body_type] + ), + ) + if not value.HasField("producer"): + raise ValueError("missing required field CloseStreamRequest.producer") + producer = _ProducerTransferTypeConverter().from_transfer_type( + value.producer, Producer + ) + return CloseStreamRequest( + ref=ref, + producer=producer, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: CloseStreamRequest[BodyT], + ) -> temporalio.api.streamservice.v1.request_response_pb2.CloseStreamRequest: + message = ( + temporalio.api.streamservice.v1.request_response_pb2.CloseStreamRequest() + ) + message.ref.CopyFrom( + _StreamRefTransferTypeConverter().to_transfer_type(value.ref) + ) + message.producer.CopyFrom( + _ProducerTransferTypeConverter().to_transfer_type(value.producer) + ) + return message + + +temporalio.converter.transfer_type_convertible( + _CloseStreamRequestTransferTypeConverter[typing.Any] +)(CloseStreamRequest) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class CloseStreamResponse: + pass + + +class _CloseStreamResponseTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + CloseStreamResponse, + temporalio.api.streamservice.v1.request_response_pb2.CloseStreamResponse, + ] +): + transfer_type: ( + type[temporalio.api.streamservice.v1.request_response_pb2.CloseStreamResponse] + | None + ) = temporalio.api.streamservice.v1.request_response_pb2.CloseStreamResponse + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.streamservice.v1.request_response_pb2.CloseStreamResponse, + type_hint: type[CloseStreamResponse], + ) -> CloseStreamResponse: + return CloseStreamResponse() + + @typing_extensions.override + def to_transfer_type( + self, + value: CloseStreamResponse, + ) -> temporalio.api.streamservice.v1.request_response_pb2.CloseStreamResponse: + message = ( + temporalio.api.streamservice.v1.request_response_pb2.CloseStreamResponse() + ) + return message + + +temporalio.converter.transfer_type_convertible( + _CloseStreamResponseTransferTypeConverter +)(CloseStreamResponse) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class ReadRecordsRequest(typing.Generic[BodyT]): + """Read Records after an offset.""" + + ref: StreamRef[BodyT] + from_offset: bytes | None = None + max_records: int | None = None + wait: datetime.timedelta | None = None + max_bytes: int | None = None + + +class _ReadRecordsRequestTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + ReadRecordsRequest[BodyT], + temporalio.api.streamservice.v1.request_response_pb2.ReadRecordsRequest, + ] +): + transfer_type: ( + type[temporalio.api.streamservice.v1.request_response_pb2.ReadRecordsRequest] + | None + ) = temporalio.api.streamservice.v1.request_response_pb2.ReadRecordsRequest + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.streamservice.v1.request_response_pb2.ReadRecordsRequest, + type_hint: type[ReadRecordsRequest[BodyT]], + ) -> ReadRecordsRequest[BodyT]: + (body_type,) = typing.get_args(type_hint) or (typing.Any,) + if not value.HasField("ref"): + raise ValueError("missing required field ReadRecordsRequest.ref") + ref = typing.cast( + StreamRef[BodyT], + _StreamRefTransferTypeConverter().from_transfer_type( + value.ref, StreamRef[body_type] + ), + ) + return ReadRecordsRequest( + ref=ref, + from_offset=value.from_offset if bool(value.from_offset) else None, + max_records=value.max_records if value.max_records != 0 else None, + wait=_support.duration_from_proto(value.wait) + if value.HasField("wait") + else None, + max_bytes=value.max_bytes if value.max_bytes != 0 else None, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: ReadRecordsRequest[BodyT], + ) -> temporalio.api.streamservice.v1.request_response_pb2.ReadRecordsRequest: + message = ( + temporalio.api.streamservice.v1.request_response_pb2.ReadRecordsRequest() + ) + message.ref.CopyFrom( + _StreamRefTransferTypeConverter().to_transfer_type(value.ref) + ) + if value.from_offset is not None: + message.from_offset = value.from_offset + if value.max_records is not None: + message.max_records = value.max_records + if value.wait is not None: + message.wait.CopyFrom(_support.duration_to_proto(value.wait)) + if value.max_bytes is not None: + message.max_bytes = value.max_bytes + return message + + +temporalio.converter.transfer_type_convertible( + _ReadRecordsRequestTransferTypeConverter[typing.Any] +)(ReadRecordsRequest) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True, kw_only=True) +class ReadRecordsResponse(typing.Generic[BodyT]): + records: list[StreamRecord[BodyT]] + last_offset: bytes | None = None + done: bool + + +class _ReadRecordsResponseTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + ReadRecordsResponse[BodyT], + temporalio.api.streamservice.v1.request_response_pb2.ReadRecordsResponse, + ] +): + transfer_type: ( + type[temporalio.api.streamservice.v1.request_response_pb2.ReadRecordsResponse] + | None + ) = temporalio.api.streamservice.v1.request_response_pb2.ReadRecordsResponse + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.streamservice.v1.request_response_pb2.ReadRecordsResponse, + type_hint: type[ReadRecordsResponse[BodyT]], + ) -> ReadRecordsResponse[BodyT]: + (body_type,) = typing.get_args(type_hint) or (typing.Any,) + records = [ + typing.cast( + StreamRecord[BodyT], + _StreamRecordTransferTypeConverter().from_transfer_type( + item, StreamRecord[body_type] + ), + ) + for item in value.records + ] + done = value.done + return ReadRecordsResponse( + records=records, + last_offset=value.last_offset if bool(value.last_offset) else None, + done=done, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: ReadRecordsResponse[BodyT], + ) -> temporalio.api.streamservice.v1.request_response_pb2.ReadRecordsResponse: + message = ( + temporalio.api.streamservice.v1.request_response_pb2.ReadRecordsResponse() + ) + for element in value.records: + item = message.records.add() + item.CopyFrom( + _StreamRecordTransferTypeConverter().to_transfer_type(element) + ) + if value.last_offset is not None: + message.last_offset = value.last_offset + message.done = value.done + return message + + +temporalio.converter.transfer_type_convertible( + _ReadRecordsResponseTransferTypeConverter[typing.Any] +)(ReadRecordsResponse) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True, kw_only=True) +class StreamRecord(typing.Generic[BodyT]): + """One Record that a Producer appended, as a read gives it.""" + + body: BodyT + producer: Producer | None = None + offset: bytes + + +class _StreamRecordTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + StreamRecord[BodyT], temporalio.api.stream.v1.message_pb2.StreamRecord + ] +): + transfer_type: type[temporalio.api.stream.v1.message_pb2.StreamRecord] | None = ( + temporalio.api.stream.v1.message_pb2.StreamRecord + ) + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.stream.v1.message_pb2.StreamRecord, + type_hint: type[StreamRecord[BodyT]], + ) -> StreamRecord[BodyT]: + (body_type,) = typing.get_args(type_hint) or (typing.Any,) + if not value.HasField("body"): + raise ValueError("missing required field StreamRecord.body") + body = typing.cast(BodyT, _support.payload_from_proto(value.body, body_type)) + if not value.offset: + raise ValueError("missing required field StreamRecord.offset") + offset = value.offset + return StreamRecord( + body=body, + producer=_ProducerTransferTypeConverter().from_transfer_type( + value.producer, Producer + ) + if value.HasField("producer") + else None, + offset=offset, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: StreamRecord[BodyT], + ) -> temporalio.api.stream.v1.message_pb2.StreamRecord: + message = temporalio.api.stream.v1.message_pb2.StreamRecord() + message.body.CopyFrom(_support.payload_to_proto(value.body)) + if value.producer is not None: + message.producer.CopyFrom( + _ProducerTransferTypeConverter().to_transfer_type(value.producer) + ) + message.offset = value.offset + return message + + +temporalio.converter.transfer_type_convertible( + _StreamRecordTransferTypeConverter[typing.Any] +)(StreamRecord) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class DescribeStreamRequest(typing.Generic[BodyT]): + ref: StreamRef[BodyT] + + +class _DescribeStreamRequestTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + DescribeStreamRequest[BodyT], + temporalio.api.streamservice.v1.request_response_pb2.DescribeStreamRequest, + ] +): + transfer_type: ( + type[temporalio.api.streamservice.v1.request_response_pb2.DescribeStreamRequest] + | None + ) = temporalio.api.streamservice.v1.request_response_pb2.DescribeStreamRequest + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.streamservice.v1.request_response_pb2.DescribeStreamRequest, + type_hint: type[DescribeStreamRequest[BodyT]], + ) -> DescribeStreamRequest[BodyT]: + (body_type,) = typing.get_args(type_hint) or (typing.Any,) + if not value.HasField("ref"): + raise ValueError("missing required field DescribeStreamRequest.ref") + ref = typing.cast( + StreamRef[BodyT], + _StreamRefTransferTypeConverter().from_transfer_type( + value.ref, StreamRef[body_type] + ), + ) + return DescribeStreamRequest( + ref=ref, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: DescribeStreamRequest[BodyT], + ) -> temporalio.api.streamservice.v1.request_response_pb2.DescribeStreamRequest: + message = ( + temporalio.api.streamservice.v1.request_response_pb2.DescribeStreamRequest() + ) + message.ref.CopyFrom( + _StreamRefTransferTypeConverter().to_transfer_type(value.ref) + ) + return message + + +temporalio.converter.transfer_type_convertible( + _DescribeStreamRequestTransferTypeConverter[typing.Any] +)(DescribeStreamRequest) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class DescribeStreamResponse: + status: StreamStatus | None = None + last_offset: bytes | None = None + + +class _DescribeStreamResponseTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + DescribeStreamResponse, + temporalio.api.streamservice.v1.request_response_pb2.DescribeStreamResponse, + ] +): + transfer_type: ( + type[ + temporalio.api.streamservice.v1.request_response_pb2.DescribeStreamResponse + ] + | None + ) = temporalio.api.streamservice.v1.request_response_pb2.DescribeStreamResponse + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.streamservice.v1.request_response_pb2.DescribeStreamResponse, + type_hint: type[DescribeStreamResponse], + ) -> DescribeStreamResponse: + return DescribeStreamResponse( + status=_stream_status_from_proto(value.status) + if value.status != 0 + else None, + last_offset=value.last_offset if bool(value.last_offset) else None, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: DescribeStreamResponse, + ) -> temporalio.api.streamservice.v1.request_response_pb2.DescribeStreamResponse: + message = temporalio.api.streamservice.v1.request_response_pb2.DescribeStreamResponse() + if value.status is not None: + message.status = temporalio.api.enums.v1.stream_pb2.StreamStatus.ValueType( + value.status + ) + if value.last_offset is not None: + message.last_offset = value.last_offset + return message + + +temporalio.converter.transfer_type_convertible( + _DescribeStreamResponseTransferTypeConverter +)(DescribeStreamResponse) # pyright: ignore[reportUnusedCallResult] + + +@dataclasses.dataclass(slots=True) +class ProducerKindWorkflow: + value: WorkflowProducer + + +@dataclasses.dataclass(slots=True) +class ProducerKindLocalActivity: + value: LocalActivityProducer + + +@dataclasses.dataclass(slots=True) +class ProducerKindWorkflowActivity: + value: ActivityProducer + + +@dataclasses.dataclass(slots=True) +class ProducerKindStandaloneActivity: + value: ActivityProducer + + +@dataclasses.dataclass(slots=True) +class ProducerKindClient: + value: ClientProducer + + +ProducerKind = ( + ProducerKindWorkflow + | ProducerKindLocalActivity + | ProducerKindWorkflowActivity + | ProducerKindStandaloneActivity + | ProducerKindClient +) +"""The kind of a Producer, with its run and epoch.""" diff --git a/temporalio/nexus/system/stream_service/services.py b/temporalio/nexus/system/stream_service/services.py new file mode 100644 index 000000000..76231c550 --- /dev/null +++ b/temporalio/nexus/system/stream_service/services.py @@ -0,0 +1,53 @@ +# Generated by nexgen v0.2.12. DO NOT EDIT! + +from __future__ import annotations + +import typing + +from nexusrpc import Operation, service + +from .models import ( + AppendRecordsRequest, + AppendRecordsResponse, + CloseStreamRequest, + CloseStreamResponse, + CreateStreamRequest, + CreateStreamResult, + DescribeStreamRequest, + DescribeStreamResponse, + ReadRecordsRequest, + ReadRecordsResponse, +) + + +@service(name="temporal.api.streamservice.v1.StreamService") +class StreamService: + """ + .. warning:: + This API is experimental and subject to change. + """ + + create_stream: Operation[ + CreateStreamRequest[typing.Any], + CreateStreamResult[typing.Any], + ] = Operation(name="CreateStream") + + append_records: Operation[ + AppendRecordsRequest[typing.Any], + AppendRecordsResponse, + ] = Operation(name="AppendRecords") + + close_stream: Operation[ + CloseStreamRequest[typing.Any], + CloseStreamResponse, + ] = Operation(name="CloseStream") + + read_records: Operation[ + ReadRecordsRequest[typing.Any], + ReadRecordsResponse[typing.Any], + ] = Operation(name="ReadRecords") + + describe_stream: Operation[ + DescribeStreamRequest[typing.Any], + DescribeStreamResponse, + ] = Operation(name="DescribeStream") diff --git a/temporalio/nexus/system/stream_service/workflow/__init__.py b/temporalio/nexus/system/stream_service/workflow/__init__.py new file mode 100644 index 000000000..12b9baa97 --- /dev/null +++ b/temporalio/nexus/system/stream_service/workflow/__init__.py @@ -0,0 +1,77 @@ +# Generated by nexgen v0.2.12. DO NOT EDIT! + +from __future__ import annotations + +import collections.abc +import typing + +import nexusrpc + +import temporalio.converter + +from .. import services as _services +from ._resources.stream import get_stream +from .operations.create_stream import create_stream + +__all__ = [ + "create_stream", + "get_stream", +] + + +_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__: dict[ + tuple[str, str], _NexusOperationInfo[typing.Any, typing.Any] +] = { + ( + "temporal.api.streamservice.v1.StreamService", + "CreateStream", + ): _NexusOperationInfo( + operation=_services.StreamService.create_stream, + ), + ( + "temporal.api.streamservice.v1.StreamService", + "AppendRecords", + ): _NexusOperationInfo( + operation=_services.StreamService.append_records, + ), + ( + "temporal.api.streamservice.v1.StreamService", + "CloseStream", + ): _NexusOperationInfo( + operation=_services.StreamService.close_stream, + ), + ( + "temporal.api.streamservice.v1.StreamService", + "ReadRecords", + ): _NexusOperationInfo( + operation=_services.StreamService.read_records, + ), + ( + "temporal.api.streamservice.v1.StreamService", + "DescribeStream", + ): _NexusOperationInfo( + operation=_services.StreamService.describe_stream, + ), +} diff --git a/temporalio/nexus/system/stream_service/workflow/_resources/__init__.py b/temporalio/nexus/system/stream_service/workflow/_resources/__init__.py new file mode 100644 index 000000000..04fda4c2b --- /dev/null +++ b/temporalio/nexus/system/stream_service/workflow/_resources/__init__.py @@ -0,0 +1,9 @@ +# Generated by nexgen v0.2.12. DO NOT EDIT! + +from __future__ import annotations + +from .stream import Stream + +__all__ = [ + "Stream", +] diff --git a/temporalio/nexus/system/stream_service/workflow/_resources/stream.py b/temporalio/nexus/system/stream_service/workflow/_resources/stream.py new file mode 100644 index 000000000..af00070c2 --- /dev/null +++ b/temporalio/nexus/system/stream_service/workflow/_resources/stream.py @@ -0,0 +1,212 @@ +# Generated by nexgen v0.2.12. DO NOT EDIT! + +from __future__ import annotations + +import datetime +import types +import typing + +import typing_extensions + +import temporalio.nexus.system +import temporalio.nexus.system._support as _support +import temporalio.workflow + +from ...models import ( + AppendRecord, + AppendRecordsRequest, + AppendRecordsResponse, + CloseStreamRequest, + CloseStreamResponse, + DescribeStreamRequest, + DescribeStreamResponse, + Producer, + ReadRecordsRequest, + ReadRecordsResponse, + StreamRef, +) + +BodyT = typing.TypeVar("BodyT") + + +class Stream(typing.Generic[BodyT]): + """One Stream. Build it from a stream-ref, or get it from a create.""" + + ref: StreamRef[BodyT] + created: bool | None + + def __init__( + self, + ref: StreamRef[BodyT], + created: bool | None, + ) -> None: + if type(self) is not Stream: + raise TypeError("Generic resource subclasses are not supported") + self.ref = ref + self.created = created + + @property + def body_type(self) -> type[BodyT]: + arguments = typing.get_args(getattr(self.ref, "__orig_class__", None)) + return typing.cast("type[BodyT]", arguments[0] if arguments else object) + + async def __aenter__(self) -> typing_extensions.Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: types.TracebackType | None, + ) -> None: + await self.close() + + async def append( + self, + *records: BodyT, + producer: Producer | None = None, + ) -> AppendRecordsResponse: + """Append values, and return the offsets of the batch.""" + request = AppendRecordsRequest( + ref=self.ref, + producer=producer if producer is not None else _support.workflow_producer(), + records=[AppendRecord(body=value) for value in records], + ) + handle = await _append_records(request) + return await handle + + async def close( + self, + producer: Producer | None = None, + ) -> None: + """Close the Stream. Readers drain what is left, then end.""" + request = CloseStreamRequest( + ref=self.ref, + producer=producer if producer is not None else _support.workflow_producer(), + ) + handle = await _close_stream(request) + await handle + + async def read( + self, + from_offset: bytes | None = None, + max_records: int | None = None, + wait: datetime.timedelta | None = None, + max_bytes: int | None = None, + ) -> ReadRecordsResponse[BodyT]: + """Read one page of Records. The poll iterator reads them all.""" + request = ReadRecordsRequest( + ref=self.ref, + from_offset=from_offset, + max_records=max_records, + wait=wait, + max_bytes=max_bytes, + ) + handle = await _read_records(request, self.body_type) + return await handle + + async def describe( + self, + ) -> DescribeStreamResponse: + """Describe the Stream's status and newest offset.""" + request = DescribeStreamRequest( + ref=self.ref, + ) + handle = await _describe_stream(request) + return await handle + + +@typing.overload +def get_stream( + ref: StreamRef[BodyT], + *, + created: bool | None = None, + body_type: None = None, +) -> Stream[BodyT]: ... + + +@typing.overload +def get_stream( + ref: StreamRef[BodyT] | StreamRef[object], + *, + created: bool | None = None, + body_type: type[BodyT], +) -> Stream[BodyT]: ... + + +def get_stream( + ref: StreamRef[typing.Any], + *, + created: bool | None = None, + body_type: typing.Any = None, +) -> Stream[typing.Any]: + """Return the ``Stream`` handle for this ref.""" + arguments = typing.get_args(getattr(ref, "__orig_class__", None)) + # None means no explicit type, so use the ref's type argument, else object. + if body_type is None: + body_type = arguments[0] if arguments else object + elif arguments and arguments[0] is not object and arguments[0] != body_type: + raise TypeError("The decoding type must match the typed ref") + if arguments != (body_type,): + ref = StreamRef[body_type](id=ref.id, store=ref.store) + return Stream[body_type]( + ref=ref, + created=created, + ) + + +async def _append_records( + request: AppendRecordsRequest[BodyT], +) -> temporalio.workflow.NexusOperationHandle[AppendRecordsResponse,]: + nexus_client = temporalio.workflow.create_nexus_client( + service="temporal.api.streamservice.v1.StreamService", + endpoint=temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT, + ) + return await nexus_client.start_operation( + operation="AppendRecords", + input=request, + output_type=AppendRecordsResponse, + ) + + +async def _close_stream( + request: CloseStreamRequest[BodyT], +) -> temporalio.workflow.NexusOperationHandle[CloseStreamResponse,]: + nexus_client = temporalio.workflow.create_nexus_client( + service="temporal.api.streamservice.v1.StreamService", + endpoint=temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT, + ) + return await nexus_client.start_operation( + operation="CloseStream", + input=request, + output_type=CloseStreamResponse, + ) + + +async def _read_records( + request: ReadRecordsRequest[BodyT], + body_type: type[BodyT], +) -> temporalio.workflow.NexusOperationHandle[ReadRecordsResponse[BodyT],]: + nexus_client = temporalio.workflow.create_nexus_client( + service="temporal.api.streamservice.v1.StreamService", + endpoint=temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT, + ) + return await nexus_client.start_operation( + operation="ReadRecords", + input=request, + output_type=ReadRecordsResponse[body_type], + ) + + +async def _describe_stream( + request: DescribeStreamRequest[BodyT], +) -> temporalio.workflow.NexusOperationHandle[DescribeStreamResponse,]: + nexus_client = temporalio.workflow.create_nexus_client( + service="temporal.api.streamservice.v1.StreamService", + endpoint=temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT, + ) + return await nexus_client.start_operation( + operation="DescribeStream", + input=request, + output_type=DescribeStreamResponse, + ) diff --git a/temporalio/nexus/system/stream_service/workflow/_system_nexus_interceptor.py b/temporalio/nexus/system/stream_service/workflow/_system_nexus_interceptor.py new file mode 100644 index 000000000..fa1196c89 --- /dev/null +++ b/temporalio/nexus/system/stream_service/workflow/_system_nexus_interceptor.py @@ -0,0 +1,299 @@ +# Generated by nexgen v0.2.12. 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.streamservice.v1.StreamService" + and input.operation_name == "CreateStream" + ): + typed_input = typing.cast( + "StartNexusOperationInput[models.CreateStreamRequest[typing.Any], models.CreateStreamResult[typing.Any]]", + input, + ) + # The dispatch check above establishes that this operation's response type is _OutputT. + return typing.cast( + "temporalio.workflow.NexusOperationHandle[_OutputT]", + await interceptor.start_create_stream( + typed_input.input, typed_input.output_type + ), + ) + if ( + input.service == "temporal.api.streamservice.v1.StreamService" + and input.operation_name == "AppendRecords" + ): + typed_input = typing.cast( + "StartNexusOperationInput[models.AppendRecordsRequest[typing.Any], models.AppendRecordsResponse]", + input, + ) + # The dispatch check above establishes that this operation's response type is _OutputT. + return typing.cast( + "temporalio.workflow.NexusOperationHandle[_OutputT]", + await interceptor.start_append_records(typed_input.input), + ) + if ( + input.service == "temporal.api.streamservice.v1.StreamService" + and input.operation_name == "CloseStream" + ): + typed_input = typing.cast( + "StartNexusOperationInput[models.CloseStreamRequest[typing.Any], models.CloseStreamResponse]", + input, + ) + # The dispatch check above establishes that this operation's response type is _OutputT. + return typing.cast( + "temporalio.workflow.NexusOperationHandle[_OutputT]", + await interceptor.start_close_stream(typed_input.input), + ) + if ( + input.service == "temporal.api.streamservice.v1.StreamService" + and input.operation_name == "ReadRecords" + ): + typed_input = typing.cast( + "StartNexusOperationInput[models.ReadRecordsRequest[typing.Any], models.ReadRecordsResponse[typing.Any]]", + input, + ) + # The dispatch check above establishes that this operation's response type is _OutputT. + return typing.cast( + "temporalio.workflow.NexusOperationHandle[_OutputT]", + await interceptor.start_read_records( + typed_input.input, typed_input.output_type + ), + ) + if ( + input.service == "temporal.api.streamservice.v1.StreamService" + and input.operation_name == "DescribeStream" + ): + typed_input = typing.cast( + "StartNexusOperationInput[models.DescribeStreamRequest[typing.Any], models.DescribeStreamResponse]", + input, + ) + # The dispatch check above establishes that this operation's response type is _OutputT. + return typing.cast( + "temporalio.workflow.NexusOperationHandle[_OutputT]", + await interceptor.start_describe_stream(typed_input.input), + ) + return await interceptor.start_system_nexus_operation(input) + + +class _SystemNexusWorkflowOutboundInterceptorBase(abc.ABC): + @abc.abstractmethod + def _next_system_nexus_interceptor( + self, + ) -> _SystemNexusWorkflowOutboundInterceptorBase: ... + + @abc.abstractmethod + async def start_system_nexus_operation( + self, + input: StartNexusOperationInput[_InputT, _OutputT], + ) -> temporalio.workflow.NexusOperationHandle[_OutputT]: + """Intercept a System Nexus operation through the generic hook.""" + ... + + async def start_create_stream( + self, + request: models.CreateStreamRequest[typing.Any], + output_type: type[models.CreateStreamResult[typing.Any]] | None = None, + ) -> temporalio.workflow.NexusOperationHandle[ + models.CreateStreamResult[typing.Any] + ]: + """Intercept the CreateStream operation.""" + return await self._next_system_nexus_interceptor().start_create_stream( + request, output_type + ) + + async def start_append_records( + self, request: models.AppendRecordsRequest[typing.Any] + ) -> temporalio.workflow.NexusOperationHandle[models.AppendRecordsResponse]: + """Intercept the AppendRecords operation.""" + return await self._next_system_nexus_interceptor().start_append_records(request) + + async def start_close_stream( + self, request: models.CloseStreamRequest[typing.Any] + ) -> temporalio.workflow.NexusOperationHandle[models.CloseStreamResponse]: + """Intercept the CloseStream operation.""" + return await self._next_system_nexus_interceptor().start_close_stream(request) + + async def start_read_records( + self, + request: models.ReadRecordsRequest[typing.Any], + output_type: type[models.ReadRecordsResponse[typing.Any]] | None = None, + ) -> temporalio.workflow.NexusOperationHandle[ + models.ReadRecordsResponse[typing.Any] + ]: + """Intercept the ReadRecords operation.""" + return await self._next_system_nexus_interceptor().start_read_records( + request, output_type + ) + + async def start_describe_stream( + self, request: models.DescribeStreamRequest[typing.Any] + ) -> temporalio.workflow.NexusOperationHandle[models.DescribeStreamResponse]: + """Intercept the DescribeStream operation.""" + return await self._next_system_nexus_interceptor().start_describe_stream( + 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_create_stream( + self, + request: models.CreateStreamRequest[typing.Any], + output_type: type[models.CreateStreamResult[typing.Any]] | None = None, + ) -> temporalio.workflow.NexusOperationHandle[ + models.CreateStreamResult[typing.Any] + ]: + 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.streamservice.v1.StreamService", + operation="CreateStream", + input=request, + output_type=typing.cast( + "type[models.CreateStreamResult[typing.Any]]", + models.CreateStreamResult, + ) + if output_type is None + else output_type, + schedule_to_close_timeout=None, + schedule_to_start_timeout=None, + start_to_close_timeout=None, + cancellation_type=NexusOperationCancellationType.WAIT_COMPLETED, + headers=None, + summary=None, + ) + ) + + async def start_append_records( + self, request: models.AppendRecordsRequest[typing.Any] + ) -> temporalio.workflow.NexusOperationHandle[models.AppendRecordsResponse]: + 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.streamservice.v1.StreamService", + operation="AppendRecords", + input=request, + output_type=models.AppendRecordsResponse, + schedule_to_close_timeout=None, + schedule_to_start_timeout=None, + start_to_close_timeout=None, + cancellation_type=NexusOperationCancellationType.WAIT_COMPLETED, + headers=None, + summary=None, + ) + ) + + async def start_close_stream( + self, request: models.CloseStreamRequest[typing.Any] + ) -> temporalio.workflow.NexusOperationHandle[models.CloseStreamResponse]: + 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.streamservice.v1.StreamService", + operation="CloseStream", + input=request, + output_type=models.CloseStreamResponse, + schedule_to_close_timeout=None, + schedule_to_start_timeout=None, + start_to_close_timeout=None, + cancellation_type=NexusOperationCancellationType.WAIT_COMPLETED, + headers=None, + summary=None, + ) + ) + + async def start_read_records( + self, + request: models.ReadRecordsRequest[typing.Any], + output_type: type[models.ReadRecordsResponse[typing.Any]] | None = None, + ) -> temporalio.workflow.NexusOperationHandle[ + models.ReadRecordsResponse[typing.Any] + ]: + 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.streamservice.v1.StreamService", + operation="ReadRecords", + input=request, + output_type=typing.cast( + "type[models.ReadRecordsResponse[typing.Any]]", + models.ReadRecordsResponse, + ) + if output_type is None + else output_type, + schedule_to_close_timeout=None, + schedule_to_start_timeout=None, + start_to_close_timeout=None, + cancellation_type=NexusOperationCancellationType.WAIT_COMPLETED, + headers=None, + summary=None, + ) + ) + + async def start_describe_stream( + self, request: models.DescribeStreamRequest[typing.Any] + ) -> temporalio.workflow.NexusOperationHandle[models.DescribeStreamResponse]: + 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.streamservice.v1.StreamService", + operation="DescribeStream", + input=request, + output_type=models.DescribeStreamResponse, + 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/stream_service/workflow/operations/__init__.py b/temporalio/nexus/system/stream_service/workflow/operations/__init__.py new file mode 100644 index 000000000..250b6a42b --- /dev/null +++ b/temporalio/nexus/system/stream_service/workflow/operations/__init__.py @@ -0,0 +1,3 @@ +# Generated by nexgen v0.2.12. DO NOT EDIT! + +from __future__ import annotations diff --git a/temporalio/nexus/system/stream_service/workflow/operations/create_stream.py b/temporalio/nexus/system/stream_service/workflow/operations/create_stream.py new file mode 100644 index 000000000..12e5f6b46 --- /dev/null +++ b/temporalio/nexus/system/stream_service/workflow/operations/create_stream.py @@ -0,0 +1,94 @@ +# Generated by nexgen v0.2.12. DO NOT EDIT! + +from __future__ import annotations + +import typing + +import temporalio.nexus.system +import temporalio.nexus.system._support as _support +import temporalio.workflow + +from ..._resource_context_manager import resource_context_manager +from ...models import ( + CreateStreamRequest, + CreateStreamResult, + StreamIdConflictPolicy, + StreamRef, +) +from .._resources import Stream + +BodyT = typing.TypeVar("BodyT") + + +async def _create_stream( + request: CreateStreamRequest[BodyT], + body_type: type[BodyT], +) -> Stream[BodyT]: + nexus_client = temporalio.workflow.create_nexus_client( + service="temporal.api.streamservice.v1.StreamService", + endpoint=temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT, + ) + handle = await nexus_client.start_operation( + operation="CreateStream", + input=request, + output_type=CreateStreamResult[body_type], + ) + result = await handle + if result.ref is None: + raise RuntimeError("CreateStream answered without `ref`") + return Stream[body_type]( + ref=result.ref, + created=result.created or None, + ) + + +@typing.overload +@resource_context_manager +async def create_stream( + *, + id: str, + store: str | None = None, + id_conflict_policy: StreamIdConflictPolicy | None = None, + body_type: type[BodyT], +) -> Stream[BodyT]: ... + + +@typing.overload +@resource_context_manager +async def create_stream( + *, + id: str, + store: str | None = None, + id_conflict_policy: StreamIdConflictPolicy | None = None, + body_type: None = None, +) -> Stream[object]: ... + + +@resource_context_manager +async def create_stream( + *, + id: str, + store: str | None = None, + id_conflict_policy: StreamIdConflictPolicy | None = None, + body_type: typing.Any = None, +) -> Stream[typing.Any]: + """ + Args: + id: The Stream id, 1 to 4096 bytes. + store: Registered Store name. With none, the SDK picks one. Only an SDK reads + it. + id_conflict_policy: What the create does when the Stream exists. Unspecified is + fail. + """ + if body_type is None: + body_type = typing.Any + stream_ref = StreamRef( + id=id, + store=store, + ) + request = CreateStreamRequest( + stream_ref=stream_ref, + id_conflict_policy=id_conflict_policy, + request_id=_support.request_id(), + ) + return await _create_stream(request, body_type) diff --git a/temporalio/workflow/__init__.py b/temporalio/workflow/__init__.py index c631fd88e..60b02cc54 100644 --- a/temporalio/workflow/__init__.py +++ b/temporalio/workflow/__init__.py @@ -3,6 +3,10 @@ from __future__ import annotations # BEGIN GENERATED NEXUS SYSTEM EXPORTS +from temporalio.nexus.system.stream_service.workflow import ( + create_stream, + get_stream, +) from temporalio.nexus.system.workflow_service import ( signal_with_start_workflow, ) @@ -329,6 +333,8 @@ "ReturnType", "SelfType", # BEGIN GENERATED NEXUS SYSTEM __ALL__ + "create_stream", + "get_stream", "signal_with_start_workflow", # END GENERATED NEXUS SYSTEM __ALL__ ] From 7f2eca05b643172a06482cdc584440a5d299c41d Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 20:38:26 -0700 Subject: [PATCH 04/46] Added the support functions that the generated Stream code calls. The generated code calls these by name for request ids, Producers and read paging. The system Nexus serialization lookup also knows the Stream operations. --- temporalio/nexus/system/__init__.py | 7 +- temporalio/nexus/system/_support.py | 124 +++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/temporalio/nexus/system/__init__.py b/temporalio/nexus/system/__init__.py index 1f8f7c6d2..4b6910185 100644 --- a/temporalio/nexus/system/__init__.py +++ b/temporalio/nexus/system/__init__.py @@ -182,9 +182,14 @@ def _get_serialization_context( # pyright: ignore[reportUnusedFunction] request: Any, ) -> temporalio.converter.SerializationContext | None: """Return the serialization context for a system Nexus operation.""" + from .stream_service.workflow import ( + __nexus_operation_registry__ as stream_operation_registry, + ) from .workflow_service import __nexus_operation_registry__ - operation_info = __nexus_operation_registry__.get((service, operation)) + operation_info = __nexus_operation_registry__.get( + (service, operation) + ) or stream_operation_registry.get((service, operation)) if operation_info is None or operation_info.serialization_context is None: return None return operation_info.serialization_context(request) diff --git a/temporalio/nexus/system/_support.py b/temporalio/nexus/system/_support.py index 15a8e8010..fac980f2b 100644 --- a/temporalio/nexus/system/_support.py +++ b/temporalio/nexus/system/_support.py @@ -1,9 +1,15 @@ import collections.abc +import dataclasses +import functools import typing -from datetime import timedelta +import urllib.parse +import uuid +from datetime import datetime, timedelta import google.protobuf.duration_pb2 +import google.protobuf.timestamp_pb2 +import temporalio.activity import temporalio.api.common.v1.message_pb2 as common_pb2 import temporalio.api.enums.v1.workflow_pb2 as workflow_enums_pb2 import temporalio.api.failure.v1.message_pb2 as failure_pb2 @@ -13,12 +19,128 @@ import temporalio.converter import temporalio.nexus.system +if typing.TYPE_CHECKING: + from temporalio.nexus.system.stream_service.models import ( + Producer, + ReadRecordsRequest, + ReadRecordsResponse, + StreamRecord, + ) + +_BodyT = typing.TypeVar("_BodyT") + class SignalWithStartWorkflowModelRequest(typing.Protocol): namespace: str id: str +def request_id() -> str: + import temporalio.workflow + + # Workflow code must get the same id on replay. + if temporalio.workflow.in_workflow(): + return str(temporalio.workflow.uuid4()) + return str(uuid.uuid4()) + + +def _producer_id(*parts: str) -> str: + # Quoting keeps a ':' inside an id from shifting the parts. + return ":".join(urllib.parse.quote(part, safe="") for part in parts) + + +# One id for the process, because nothing fences a client write. +@functools.cache +def _client_producer_id() -> str: + return _producer_id("client", str(uuid.uuid4())) + + +def _timestamp(value: datetime) -> google.protobuf.timestamp_pb2.Timestamp: + proto = google.protobuf.timestamp_pb2.Timestamp() + proto.FromDatetime(value) + return proto + + +def default_producer() -> "Producer": + from temporalio.nexus.system.stream_service.models import ( + ActivityEpoch, + ActivityProducer, + ClientProducer, + Producer, + ProducerKindClient, + ProducerKindStandaloneActivity, + ProducerKindWorkflowActivity, + Timestamp, + ) + + if not temporalio.activity.in_activity(): + return Producer( + id=_client_producer_id(), kind=ProducerKindClient(ClientProducer()) + ) + info = temporalio.activity.info() + if info.is_local: + raise RuntimeError( + "A Stream write from a Local Activity needs the Workflow Task of the " + "Activity, which the SDK doesn't give yet" + ) + attempt_time = _timestamp(info.current_attempt_scheduled_time) + epoch = ActivityEpoch( + attempt_scheduled_time=Timestamp( + seconds=attempt_time.seconds, nanos=attempt_time.nanos + ), + attempt=info.attempt, + ) + if info.workflow_run_id is not None: + # The scheduled time stays the same across attempts, and it tells apart two uses + # of one activity id in a run. + schedule_key = str(_timestamp(info.scheduled_time).ToNanoseconds()) + return Producer( + id=_producer_id( + "act", info.workflow_run_id, info.activity_id, schedule_key + ), + kind=ProducerKindWorkflowActivity( + ActivityProducer(run_id=info.workflow_run_id, epoch=epoch) + ), + ) + if info.activity_run_id is None: + raise RuntimeError("A Standalone Activity has no activity run id") + return Producer( + id=_producer_id("sa", info.activity_id, info.activity_run_id), + kind=ProducerKindStandaloneActivity(ActivityProducer(epoch=epoch)), + ) + + +def workflow_producer() -> "Producer": + import temporalio.workflow + from temporalio.nexus.system.stream_service.models import ( + Producer, + ProducerKindWorkflow, + WorkflowProducer, + ) + + run_id = temporalio.workflow.info().run_id + return Producer( + id=_producer_id("wf", run_id), + kind=ProducerKindWorkflow(WorkflowProducer(run_id=run_id)), + ) + + +def next_read_request( + request: "ReadRecordsRequest[_BodyT]", response: "ReadRecordsResponse[_BodyT]" +) -> "ReadRecordsRequest[_BodyT] | None": + if response.done or (request.wait is None and not response.records): + return None + if response.last_offset is None: + return request + return dataclasses.replace(request, from_offset=response.last_offset) + + +def resume_read_request( + request: "ReadRecordsRequest[_BodyT]", item: "StreamRecord[_BodyT]" +) -> "ReadRecordsRequest[_BodyT]": + return dataclasses.replace(request, from_offset=item.offset) + + def retry_policy_from_proto( proto: common_pb2.RetryPolicy, ) -> temporalio.common.RetryPolicy: From 20815db1b4706d2a032d79718ed9dee2495e87ee Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 20:38:26 -0700 Subject: [PATCH 05/46] Routed a client's Stream operations to a Stream Store. Client inherits the generated extension, so its Stream calls are the generated ones. A Store answers them in place of the server, with the conversion and the codec of the system Nexus path. --- temporalio/client/__init__.py | 3 + temporalio/client/_client.py | 21 ++- temporalio/client/_impl.py | 43 +++++ temporalio/client/_stream_store.py | 160 +++++++++++++++++ tests/helpers/stream_store.py | 80 +++++++++ tests/nexus/test_stream_store.py | 268 +++++++++++++++++++++++++++++ 6 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 temporalio/client/_stream_store.py create mode 100644 tests/helpers/stream_store.py create mode 100644 tests/nexus/test_stream_store.py diff --git a/temporalio/client/__init__.py b/temporalio/client/__init__.py index 2eef41a39..2473af8aa 100644 --- a/temporalio/client/__init__.py +++ b/temporalio/client/__init__.py @@ -185,6 +185,7 @@ ScheduleUpdate, ScheduleUpdateInput, ) +from ._stream_store import StreamStore, StreamStoreCallOptions from ._worker_versioning import ( BuildIdOp, BuildIdOpAddNewCompatible, @@ -350,6 +351,8 @@ "TaskReachabilityType", "CloudOperationsClient", "Plugin", + "StreamStore", + "StreamStoreCallOptions", "Callback", "_ClientImpl", "_apply_headers", diff --git a/temporalio/client/_client.py b/temporalio/client/_client.py index efef5cb52..c58d8f926 100644 --- a/temporalio/client/_client.py +++ b/temporalio/client/_client.py @@ -25,6 +25,7 @@ import temporalio.api.workflowservice.v1 import temporalio.common import temporalio.converter +import temporalio.nexus.system.stream_service.client import temporalio.runtime import temporalio.service import temporalio.workflow @@ -94,6 +95,7 @@ ScheduleBackfill, ScheduleHandle, ) +from ._stream_store import StreamStore from ._worker_versioning import ( BuildIdOp, TaskReachabilityType, @@ -114,7 +116,9 @@ from ._plugin import Plugin -class Client: +class Client( + temporalio.nexus.system.stream_service.client.StreamServiceClientExtension +): """Client for accessing Temporal. Most users will use :py:meth:`connect` to create a client. The @@ -156,6 +160,7 @@ async def connect( grpc_compression: GrpcCompression = GrpcCompression.GZIP, payload_limits: PayloadLimitsConfig = PayloadLimitsConfig(), header_codec_behavior: HeaderCodecBehavior = HeaderCodecBehavior.NO_CODEC, + stream_stores: Sequence[StreamStore] = (), ) -> Self: """Connect to a Temporal server. @@ -225,6 +230,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_stores: Stores that answer the Stream operations of this + client instead of the server. At most one is supported. + + .. warning:: + This API is experimental and subject to change. """ connect_config = temporalio.service.ConnectConfig( target_host=target_host, @@ -262,6 +272,7 @@ def make_lambda( default_workflow_query_reject_condition=default_workflow_query_reject_condition, header_codec_behavior=header_codec_behavior, plugins=plugins, + stream_stores=stream_stores, ) def __init__( @@ -275,6 +286,7 @@ def __init__( default_workflow_query_reject_condition: None | (temporalio.common.QueryRejectCondition) = None, header_codec_behavior: HeaderCodecBehavior = HeaderCodecBehavior.NO_CODEC, + stream_stores: Sequence[StreamStore] = (), ): """Create a Temporal client from a service client. @@ -289,6 +301,7 @@ def __init__( interceptors=interceptors, default_workflow_query_reject_condition=default_workflow_query_reject_condition, header_codec_behavior=header_codec_behavior, + stream_stores=stream_stores, ) self._initial_config = config.copy() @@ -298,6 +311,10 @@ def __init__( self._init_from_config(config) def _init_from_config(self, config: ClientConfig): + if len(config["stream_stores"]) > 1: + raise ValueError( + "More than one Stream Store needs a selector, which isn't supported yet" + ) self._config = config # Iterate over interceptors in reverse building the impl @@ -3029,6 +3046,7 @@ class ClientConnectConfig(TypedDict, total=False): grpc_compression: GrpcCompression payload_limits: PayloadLimitsConfig header_codec_behavior: HeaderCodecBehavior + stream_stores: Sequence[StreamStore] class ClientConfig(TypedDict, total=False): @@ -3043,3 +3061,4 @@ class ClientConfig(TypedDict, total=False): temporalio.common.QueryRejectCondition | None ] header_codec_behavior: Required[HeaderCodecBehavior] + stream_stores: Required[Sequence[StreamStore]] diff --git a/temporalio/client/_impl.py b/temporalio/client/_impl.py index 72986ffa7..bc490f729 100644 --- a/temporalio/client/_impl.py +++ b/temporalio/client/_impl.py @@ -33,6 +33,7 @@ import temporalio.exceptions import temporalio.nexus import temporalio.nexus._operation_context +import temporalio.nexus.system from temporalio.activity import ActivityCancellationDetails from temporalio.converter import ( ActivitySerializationContext, @@ -125,6 +126,12 @@ ScheduleUpdate, ScheduleUpdateInput, ) +from ._stream_store import ( + _STREAM_SERVICE, + StreamStore, + _call_stream_store, + _stream_response, +) from ._worker_versioning import WorkerBuildIdVersionSets, WorkerTaskReachability from ._workflow import ( WorkflowExecutionAsyncIterator, @@ -1565,6 +1572,13 @@ async def start_nexus_operation( self, input: StartNexusOperationInput ) -> NexusOperationHandle[Any]: """Start a nexus operation and return a handle to it.""" + stream_stores = self._client._config["stream_stores"] + if ( + stream_stores + and temporalio.nexus.system.is_system_endpoint(input.endpoint) + and input.service == _STREAM_SERVICE.name + ): + return await self._start_stream_store_operation(stream_stores[0], input) nexus_context = temporalio.converter.NexusSerializationContext( endpoint=input.endpoint, service=input.service, @@ -1648,6 +1662,35 @@ async def start_nexus_operation( operation=input.operation, ) + async def _start_stream_store_operation( + self, store: StreamStore, input: StartNexusOperationInput + ) -> NexusOperationHandle[Any]: + data_converter = self._client.data_converter + serialization_context = temporalio.nexus.system._get_serialization_context( + input.service, input.operation, input.arg + ) + if serialization_context is not None: + data_converter = data_converter.with_context(serialization_context) + payload_converter = temporalio.nexus.system._get_payload_converter( + data_converter.payload_converter, data_converter.failure_converter + ) + [request] = payload_converter.to_payloads([input.arg]) + response = await _call_stream_store( + store, data_converter, input.operation, request.data + ) + handle: NexusOperationHandle[Any] = NexusOperationHandle( + self._client, + input.id, + result_type=input.result_type, + endpoint=input.endpoint, + service=input.service, + operation=input.operation, + ) + handle._known_outcome = _stream_response( + payload_converter, input.operation, input.result_type, response + ) + return handle + async def describe_nexus_operation( self, input: DescribeNexusOperationInput ) -> NexusOperationExecutionDescription: diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py new file mode 100644 index 000000000..73cfce4e2 --- /dev/null +++ b/temporalio/client/_stream_store.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import dataclasses +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable +from datetime import timedelta +from typing import Any + +import google.protobuf.message +import nexusrpc + +import temporalio.activity +import temporalio.bridge._visitor +import temporalio.common +import temporalio.converter +import temporalio.converter._payload_converter +import temporalio.nexus.system.stream_service + + +@dataclasses.dataclass(frozen=True) +class StreamStoreCallOptions: + """Options of the Local Activity that calls a Stream Store for Workflow code. + + Workflow code can't do I/O, so a worker calls its Stream Store from a Local + Activity. + + .. warning:: + This API is experimental and subject to change. + """ + + start_to_close_timeout: timedelta | None = timedelta(seconds=60) + """Most time for one attempt.""" + + schedule_to_close_timeout: timedelta | None = None + """Most time for all attempts.""" + + retry_policy: temporalio.common.RetryPolicy = dataclasses.field( + default_factory=lambda: temporalio.common.RetryPolicy(maximum_attempts=1) + ) + """Retries of a failed call. The default is one attempt, since an append that runs + again writes its Records again.""" + + def __post_init__(self) -> None: + """Check that a timeout is set.""" + if not self.start_to_close_timeout and not self.schedule_to_close_timeout: + raise ValueError( + "start_to_close_timeout or schedule_to_close_timeout must be set" + ) + + +class StreamStore(ABC): + """A Store that answers the Stream operations of a client. + + A client with a Store sends each Stream operation to it instead of the server. + + .. warning:: + This API is experimental and subject to change. + """ + + @property + @abstractmethod + def name(self) -> str: + """Name of the Store.""" + ... + + @property + def call_options(self) -> StreamStoreCallOptions: + """Options of the Local Activity that calls this Store for Workflow code.""" + return StreamStoreCallOptions() + + @abstractmethod + async def call(self, operation: str, request: bytes) -> bytes: + """Answer one Stream operation. + + Args: + operation: The Nexus operation name, such as ``CreateStream``, + ``AppendRecords``, ``ReadRecords``, ``CloseStream`` or + ``DescribeStream``. + request: The serialized request message of + ``temporal.api.streamservice.v1``. + + Returns: + The serialized response message of ``temporal.api.streamservice.v1``. + """ + ... + + +_STREAM_STORE_ACTIVITY = "__temporal_stream_store_call" + + +def _stream_service() -> nexusrpc.ServiceDefinition: + service = nexusrpc.get_service_definition( + temporalio.nexus.system.stream_service.StreamService + ) + if service is None: + raise RuntimeError("The generated StreamService has no service definition") + return service + + +_STREAM_SERVICE = _stream_service() + + +def _proto_type(model_type: Any) -> type[google.protobuf.message.Message]: + converter = temporalio.converter._payload_converter._get_transfer_type_converter( + model_type + ) + if converter is None or converter.transfer_type is None: + raise TypeError(f"{model_type} has no proto transfer type") + return converter.transfer_type + + +async def _call_stream_store( + store: StreamStore, + data_converter: temporalio.converter.DataConverter, + operation: str, + request: bytes, +) -> bytes: + if not data_converter.payload_codec: + return await store.call(operation, request) + # The worker package imports the client, so a module level import would cycle. + from temporalio.bridge.worker import _Visitor + + definition = _STREAM_SERVICE.operation_definitions[operation] + visitor = temporalio.bridge._visitor.PayloadVisitor(skip_search_attributes=True) + request_message = _proto_type(definition.input_type).FromString(request) + await visitor.visit( + _Visitor(data_converter._encode_payload_sequence), request_message + ) + response = await store.call(operation, request_message.SerializeToString()) + response_message = _proto_type(definition.output_type).FromString(response) + await visitor.visit( + _Visitor(data_converter._decode_payload_sequence), response_message + ) + return response_message.SerializeToString() + + +def _stream_response( # pyright: ignore[reportUnusedFunction] + payload_converter: temporalio.converter.PayloadConverter, + operation: str, + result_type: type | None, + response: bytes, +) -> Any: + definition = _STREAM_SERVICE.operation_definitions[operation] + [payload] = payload_converter.to_payloads( + [_proto_type(definition.output_type).FromString(response)] + ) + [result] = payload_converter.from_payloads( + [payload], [result_type] if result_type else None + ) + return result + + +def _stream_store_activity( # pyright: ignore[reportUnusedFunction] + store: StreamStore, data_converter: temporalio.converter.DataConverter +) -> Callable[[str, bytes], Awaitable[bytes]]: + @temporalio.activity.defn(name=_STREAM_STORE_ACTIVITY) + async def call(operation: str, request: bytes) -> bytes: + return await _call_stream_store(store, data_converter, operation, request) + + return call diff --git a/tests/helpers/stream_store.py b/tests/helpers/stream_store.py new file mode 100644 index 000000000..d3c7968de --- /dev/null +++ b/tests/helpers/stream_store.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +import temporalio.api.enums.v1 +import temporalio.api.stream.v1 +import temporalio.api.streamservice.v1 +from temporalio.client import StreamStore + + +@dataclass +class _MemoryStream: + records: list[temporalio.api.stream.v1.StreamRecord] = field(default_factory=list) + closed: bool = False + + +class MemoryStreamStore(StreamStore): + def __init__(self) -> None: + self.streams: dict[str, _MemoryStream] = {} + + @property + def name(self) -> str: + return "memory" + + async def call(self, operation: str, request: bytes) -> bytes: + service = temporalio.api.streamservice.v1 + enums = temporalio.api.enums.v1 + if operation == "CreateStream": + create = service.CreateStreamRequest.FromString(request) + created = create.stream_ref.id not in self.streams + use_existing = ( + enums.StreamIdConflictPolicy.STREAM_ID_CONFLICT_POLICY_USE_EXISTING + ) + if not created and create.id_conflict_policy != use_existing: + raise RuntimeError(f"Stream {create.stream_ref.id} exists") + self.streams.setdefault(create.stream_ref.id, _MemoryStream()) + return service.CreateStreamResponse( + ref=create.stream_ref, created=created + ).SerializeToString() + if operation == "AppendRecords": + append = service.AppendRecordsRequest.FromString(request) + stream = self.streams[append.ref.id] + if stream.closed: + raise RuntimeError(f"Stream {append.ref.id} is closed") + for record in append.records: + offset = (len(stream.records) + 1).to_bytes(8, "big") + stream.records.append( + temporalio.api.stream.v1.StreamRecord( + body=record.body, producer=append.producer, offset=offset + ) + ) + appended = stream.records[len(stream.records) - len(append.records) :] + return service.AppendRecordsResponse( + first_offset=appended[0].offset if appended else b"", + last_offset=appended[-1].offset if appended else b"", + ).SerializeToString() + if operation == "ReadRecords": + read = service.ReadRecordsRequest.FromString(request) + stream = self.streams[read.ref.id] + start = int.from_bytes(read.from_offset, "big") if read.from_offset else 0 + page = stream.records[start : start + (read.max_records or 1000)] + return service.ReadRecordsResponse( + records=page, + last_offset=page[-1].offset if page else read.from_offset, + done=stream.closed and start + len(page) == len(stream.records), + ).SerializeToString() + if operation == "CloseStream": + close = service.CloseStreamRequest.FromString(request) + self.streams[close.ref.id].closed = True + return service.CloseStreamResponse().SerializeToString() + if operation == "DescribeStream": + describe = service.DescribeStreamRequest.FromString(request) + stream = self.streams[describe.ref.id] + return service.DescribeStreamResponse( + status=enums.StreamStatus.STREAM_STATUS_CLOSED + if stream.closed + else enums.StreamStatus.STREAM_STATUS_OPEN, + last_offset=stream.records[-1].offset if stream.records else b"", + ).SerializeToString() + raise ValueError(f"Unknown Stream operation {operation}") diff --git a/tests/nexus/test_stream_store.py b/tests/nexus/test_stream_store.py new file mode 100644 index 000000000..cbd2ba0ea --- /dev/null +++ b/tests/nexus/test_stream_store.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import ast +import dataclasses +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest import mock + +import google.protobuf.timestamp_pb2 +import pytest + +import temporalio +import temporalio.activity +import temporalio.api.common.v1 +import temporalio.api.stream.v1 +import temporalio.api.workflowservice.v1 +import temporalio.nexus.system +import temporalio.nexus.system._support +import temporalio.workflow +from temporalio.client import Client +from temporalio.converter import DataConverter +from temporalio.nexus.system.stream_service import ( + ClientProducer, + DescribeStreamRequest, + Producer, + ProducerKindClient, + StreamRef, + StreamService, +) +from temporalio.nexus.system.stream_service.client import ( + Stream, + StreamServiceClientExtension, +) +from temporalio.testing import ActivityEnvironment +from tests.helpers.stream_store import MemoryStreamStore +from tests.test_converter import SimpleCodec + + +def _offset(n: int) -> bytes: + return n.to_bytes(8, "big") + + +async def _create_stream(client: Client, store: MemoryStreamStore) -> Stream[str]: + config = client.config() + config["stream_stores"] = [store] + return await Client(**config).create_stream( + id=f"stream-{uuid.uuid4()}", body_type=str + ) + + +async def _append_in_activity( + client: Client, info: temporalio.activity.Info +) -> temporalio.api.stream.v1.Producer: + store = MemoryStreamStore() + stream = await _create_stream(client, store) + env = ActivityEnvironment() + env.info = info + await env.run(stream.append, "a") + [record] = store.streams[stream.ref.id].records + return record.producer + + +def _timestamp(value: datetime) -> google.protobuf.timestamp_pb2.Timestamp: + proto = google.protobuf.timestamp_pb2.Timestamp() + proto.FromDatetime(value) + return proto + + +async def test_client_stream_uses_store(client: Client): + config = client.config() + config["stream_stores"] = [MemoryStreamStore()] + stream_client = Client(**config) + + stream = await stream_client.create_stream( + id=f"stream-{uuid.uuid4()}", body_type=str + ) + assert stream.created + appended = await stream.append("a", "b") + assert (appended.first_offset, appended.last_offset) == (_offset(1), _offset(2)) + records = [record async for record in stream.iter_read_items()] + assert [(record.body, record.offset) for record in records] == [ + ("a", _offset(1)), + ("b", _offset(2)), + ] + + other = stream_client.get_stream(stream.ref, body_type=str) + assert [record.body async for record in other.iter_read_items()] == ["a", "b"] + + await stream.close() + page = await stream.read(from_offset=records[-1].offset) + assert page.done + assert page.records == [] + + +async def test_client_stream_store_uses_payload_codec(client: Client): + store = MemoryStreamStore() + config = client.config() + config["data_converter"] = DataConverter(payload_codec=SimpleCodec()) + config["stream_stores"] = [store] + stream_client = Client(**config) + + stream = await stream_client.create_stream( + id=f"stream-{uuid.uuid4()}", body_type=str + ) + await stream.append("a", "b") + stored = [record.body for record in store.streams[stream.ref.id].records] + assert [body.metadata["simple-codec"] for body in stored] == [b"true", b"true"] + assert [ + temporalio.api.common.v1.Payloads.FromString(body.data).payloads[0].data + for body in stored + ] == [b'"a"', b'"b"'] + assert [record.body async for record in stream.iter_read_items()] == ["a", "b"] + + +async def test_client_append_has_client_producer(client: Client): + store = MemoryStreamStore() + stream = await _create_stream(client, store) + await stream.append("a") + [record] = store.streams[stream.ref.id].records + assert record.producer.id.startswith("client:") + assert record.producer.WhichOneof("kind") == "client" + + +async def test_workflow_activity_append_has_activity_producer(client: Client): + scheduled = datetime(2026, 11, 12, 10, 0, 0, 123456, tzinfo=timezone.utc) + attempt_scheduled = scheduled + timedelta(seconds=30) + producer = await _append_in_activity( + client, + dataclasses.replace( + ActivityEnvironment.default_info(), + activity_id="step:1", + attempt=3, + workflow_run_id="run-1", + scheduled_time=scheduled, + current_attempt_scheduled_time=attempt_scheduled, + ), + ) + epoch_nanos = ( + (scheduled - datetime(1970, 1, 1, tzinfo=timezone.utc)) + // timedelta(microseconds=1) + * 1000 + ) + assert producer.id == f"act:run-1:step%3A1:{epoch_nanos}" + assert producer.WhichOneof("kind") == "workflow_activity" + assert producer.workflow_activity.run_id == "run-1" + assert producer.workflow_activity.epoch.attempt == 3 + assert producer.workflow_activity.epoch.attempt_scheduled_time == _timestamp( + attempt_scheduled + ) + + +async def test_standalone_activity_append_has_activity_producer(client: Client): + attempt_scheduled = datetime(2026, 11, 12, 10, 0, 30, tzinfo=timezone.utc) + producer = await _append_in_activity( + client, + dataclasses.replace( + ActivityEnvironment.default_info(), + activity_id="job", + activity_run_id="job-run", + attempt=2, + workflow_id=None, + workflow_namespace=None, + workflow_run_id=None, + workflow_type=None, + current_attempt_scheduled_time=attempt_scheduled, + ), + ) + assert producer.id == "sa:job:job-run" + assert producer.WhichOneof("kind") == "standalone_activity" + assert producer.standalone_activity.run_id == "" + assert producer.standalone_activity.epoch.attempt == 2 + assert producer.standalone_activity.epoch.attempt_scheduled_time == _timestamp( + attempt_scheduled + ) + + +async def test_local_activity_append_fails(client: Client): + with pytest.raises(RuntimeError, match="Local Activity"): + await _append_in_activity( + client, + dataclasses.replace(ActivityEnvironment.default_info(), is_local=True), + ) + + +async def test_explicit_producer_is_sent(client: Client): + store = MemoryStreamStore() + stream = await _create_stream(client, store) + with mock.patch.object( + temporalio.nexus.system._support, "default_producer" + ) as default_producer: + await stream.append( + "a", producer=Producer(id="mine", kind=ProducerKindClient(ClientProducer())) + ) + default_producer.assert_not_called() + [record] = store.streams[stream.ref.id].records + assert record.producer.id == "mine" + assert record.producer.WhichOneof("kind") == "client" + + +def test_client_stream_methods_are_generated(): + assert "create_stream" not in vars(Client) + assert "get_stream" not in vars(Client) + assert Client.create_stream is StreamServiceClientExtension.create_stream + assert Client.get_stream is StreamServiceClientExtension.get_stream + + +def test_no_hand_written_stream_classes(): + root = Path(temporalio.__file__).parent + generated = root / "nexus" / "system" / "stream_service" + names = {"Stream", "WorkflowStream", "StreamCalls", "StreamClient"} + found: list[tuple[str, str]] = [] + for source in sorted(root.rglob("*.py")): + if generated in source.parents: + continue + for node in ast.walk(ast.parse(source.read_text(encoding="utf8"))): + if isinstance(node, ast.ClassDef) and node.name in names: + found.append((source.relative_to(root).as_posix(), node.name)) + # The Workflow Streams contrib library is older than the generated Stream API and + # doesn't use it. + found.remove(("contrib/workflow_streams/_stream.py", "WorkflowStream")) + assert found == [] + for name in ("Stream", "WorkflowStream"): + value = getattr(temporalio.workflow, name, None) + assert value is None or value.__module__.startswith( + "temporalio.nexus.system.stream_service." + ) + + +async def test_client_without_stream_store_starts_nexus_operation(client: Client): + requests: list[ + temporalio.api.workflowservice.v1.StartNexusOperationExecutionRequest + ] = [] + + async def start_nexus_operation_execution( + req: temporalio.api.workflowservice.v1.StartNexusOperationExecutionRequest, + **_: object, + ) -> temporalio.api.workflowservice.v1.StartNexusOperationExecutionResponse: + requests.append(req) + return temporalio.api.workflowservice.v1.StartNexusOperationExecutionResponse( + run_id="run" + ) + + nexus_client = client.create_nexus_client( + StreamService, temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT + ) + with mock.patch.object( + client.workflow_service, + "start_nexus_operation_execution", + start_nexus_operation_execution, + ): + handle = await nexus_client.start_operation( + StreamService.describe_stream, + DescribeStreamRequest(ref=StreamRef(id="stream")), + id="operation", + ) + assert handle.run_id == "run" + [request] = requests + assert request.endpoint == temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT + assert request.service == "temporal.api.streamservice.v1.StreamService" + assert request.operation == "DescribeStream" + + +async def test_client_rejects_two_stream_stores(client: Client): + config = client.config() + config["stream_stores"] = [MemoryStreamStore(), MemoryStreamStore()] + with pytest.raises(ValueError, match="selector"): + Client(**config) From a92a5821db1dbc248911d972c5bc8eacf7a6fbcb Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 20:38:26 -0700 Subject: [PATCH 06/46] Ran Workflow Stream operations in a Local Activity with a Store. Workflow code can't do I/O, so a Worker whose client has a Stream Store calls it from an internal Local Activity. The Replayer takes the Stores too, so a replay makes the same commands. --- temporalio/worker/_activity.py | 8 +- temporalio/worker/_interceptor.py | 11 +- temporalio/worker/_replayer.py | 11 + temporalio/worker/_worker.py | 27 +- temporalio/worker/_workflow.py | 4 + temporalio/worker/_workflow_instance.py | 177 +++++++++--- tests/nexus/test_stream_store_workflow.py | 324 ++++++++++++++++++++++ 7 files changed, 515 insertions(+), 47 deletions(-) create mode 100644 tests/nexus/test_stream_store_workflow.py diff --git a/temporalio/worker/_activity.py b/temporalio/worker/_activity.py index 2af18dc5f..040e163fa 100644 --- a/temporalio/worker/_activity.py +++ b/temporalio/worker/_activity.py @@ -34,6 +34,7 @@ import temporalio.converter import temporalio.converter._serialization_context import temporalio.exceptions +from temporalio.client._stream_store import _STREAM_STORE_ACTIVITY from temporalio.converter import ( StorageDriverActivityInfo, StorageDriverStoreContext, @@ -732,8 +733,11 @@ async def _execute_activity( # now even though the info() can't be intercepted and heartbeat() will # fail. The interceptors may want to use the info() during init. impl: ActivityInboundInterceptor = _ActivityInboundImpl(self, running_activity) - for interceptor in reversed(list(self._interceptors)): - impl = interceptor.intercept_activity(impl) + # Users see a Stream call through the Workflow interceptors, so this internal + # Activity skips theirs. + if info.activity_type != _STREAM_STORE_ACTIVITY: + for interceptor in reversed(list(self._interceptors)): + impl = interceptor.intercept_activity(impl) impl.init(_ActivityOutboundImpl(self, running_activity.info)) return await impl.execute_activity(input) diff --git a/temporalio/worker/_interceptor.py b/temporalio/worker/_interceptor.py index 6196f5efe..9cfab5402 100644 --- a/temporalio/worker/_interceptor.py +++ b/temporalio/worker/_interceptor.py @@ -21,6 +21,9 @@ import temporalio.nexus import temporalio.nexus._util import temporalio.workflow +from temporalio.nexus.system.stream_service.workflow._system_nexus_interceptor import ( + _SystemNexusWorkflowOutboundInterceptorBase as _StreamSystemNexusInterceptorBase, +) from temporalio.nexus.system.workflow_service._system_nexus_interceptor import ( _SystemNexusWorkflowOutboundInterceptorBase, ) @@ -455,7 +458,9 @@ async def handle_update_handler(self, input: HandleUpdateInput) -> Any: return await self.next.handle_update_handler(input) -class WorkflowOutboundInterceptor(_SystemNexusWorkflowOutboundInterceptorBase): +class WorkflowOutboundInterceptor( + _SystemNexusWorkflowOutboundInterceptorBase, _StreamSystemNexusInterceptorBase +): """Outbound interceptor to wrap calls made from within workflows. This should be extended by any workflow outbound interceptors. @@ -472,9 +477,7 @@ def __init__(self, next: WorkflowOutboundInterceptor) -> None: """ self.next = next - def _next_system_nexus_interceptor( - self, - ) -> _SystemNexusWorkflowOutboundInterceptorBase: + def _next_system_nexus_interceptor(self) -> WorkflowOutboundInterceptor: return self.next def continue_as_new(self, input: ContinueAsNewInput) -> NoReturn: diff --git a/temporalio/worker/_replayer.py b/temporalio/worker/_replayer.py index 62a27b29c..66649613e 100644 --- a/temporalio/worker/_replayer.py +++ b/temporalio/worker/_replayer.py @@ -56,6 +56,7 @@ def __init__( runtime: temporalio.runtime.Runtime | None = None, disable_safe_workflow_eviction: bool = False, header_codec_behavior: HeaderCodecBehavior = HeaderCodecBehavior.NO_CODEC, + stream_stores: Sequence[temporalio.client.StreamStore] = (), ) -> None: """Create a replayer to replay workflows from history. @@ -64,6 +65,10 @@ def __init__( the replayer that were passed to the worker when the workflow originally ran. + ``stream_stores`` takes the Stream Stores of the client that the + worker had, so a replay makes the same commands. A replay never calls + them. + 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 will be shared across all replay calls and never explicitly shut down. @@ -86,6 +91,7 @@ def __init__( runtime=runtime, disable_safe_workflow_eviction=disable_safe_workflow_eviction, header_codec_behavior=header_codec_behavior, + stream_stores=stream_stores, ) self._initial_config = self._config.copy() self._default_workflow_logic_flags = set(_DEFAULT_ENABLED_WORKFLOW_LOGIC_FLAGS) @@ -251,6 +257,7 @@ def on_eviction_hook( self._config.get("data_converter") or temporalio.converter.DataConverter.default ) + stream_stores = self._config.get("stream_stores") workflow_worker = _WorkflowWorker( bridge_worker=lambda: bridge_worker, namespace=self._config.get("namespace", "ReplayNamespace"), @@ -287,6 +294,9 @@ def on_eviction_hook( default_workflow_logic_flags=frozenset( self._default_workflow_logic_flags ), + stream_store_call_options=stream_stores[0].call_options + if stream_stores + else None, ) external_storage = data_converter.external_storage storage_driver_types = ( @@ -435,6 +445,7 @@ class ReplayerConfig(TypedDict, total=False): runtime: temporalio.runtime.Runtime | None disable_safe_workflow_eviction: bool header_codec_behavior: HeaderCodecBehavior + stream_stores: Sequence[temporalio.client.StreamStore] @dataclass(frozen=True) diff --git a/temporalio/worker/_worker.py b/temporalio/worker/_worker.py index e06bb99a4..ef41e1dff 100644 --- a/temporalio/worker/_worker.py +++ b/temporalio/worker/_worker.py @@ -24,6 +24,7 @@ import temporalio.common import temporalio.runtime import temporalio.service +from temporalio.client._stream_store import _stream_store_activity from temporalio.common import ( HeaderCodecBehavior, VersioningBehavior, @@ -498,7 +499,14 @@ def _init_from_config(self, client: temporalio.client.Client, config: WorkerConf or bridge_client.config.runtime or temporalio.runtime.Runtime.default() ) - activities = config.get("activities") + activities = list(config.get("activities") or []) + stream_stores = client_config["stream_stores"] + if stream_stores and config.get("workflows"): + activities.append( + _stream_store_activity( + stream_stores[0], client_config["data_converter"] + ) + ) if activities: _warn_if_activity_executor_max_workers_is_inconsistent(config) self._activity_worker = _ActivityWorker( @@ -578,6 +586,9 @@ def check_activity(activity: str): encode_headers=client_config["header_codec_behavior"] != HeaderCodecBehavior.NO_CODEC, max_workflow_task_external_storage_concurrency=max_workflow_task_external_storage_concurrency, + stream_store_call_options=stream_stores[0].call_options + if stream_stores + else None, ) tuner = config.get("tuner") @@ -667,7 +678,9 @@ def check_activity(activity: str): enable_workflows=self._workflow_worker is not None, enable_local_activities=self._activity_worker is not None and self._workflow_worker is not None, - enable_remote_activities=self._activity_worker is not None + # The Stream Store Local Activity alone doesn't make this worker + # poll for Activity Tasks. + enable_remote_activities=bool(config.get("activities")) and not config.get("no_remote_activities"), enable_nexus=self._nexus_worker is not None, ), @@ -751,13 +764,21 @@ def client(self, value: temporalio.client.Client) -> None: Changing the client will make sure the worker starts using it for the next calls it makes. However, outstanding client calls will still complete with the existing client. The new client cannot be "lazy" and - must be using the same runtime as the current client. + must be using the same runtime and Stream Stores as the current client. """ bridge_client = _extract_bridge_client_for_worker(value) if self._runtime is not bridge_client._runtime: raise ValueError( "New client is not on the same runtime as the existing client" ) + # Running Workflows route their Stream calls by the Store that the worker + # started with, so a new client can't change it. + if list(value.config(active_config=True)["stream_stores"]) != list( + self.client.config(active_config=True)["stream_stores"] + ): + raise ValueError( + "New client doesn't have the Stream Stores of the existing client" + ) assert bridge_client._bridge_client self._bridge_worker.replace_client(bridge_client._bridge_client) self._config["client"] = value diff --git a/temporalio/worker/_workflow.py b/temporalio/worker/_workflow.py index d8380a6b9..5dfc64c1b 100644 --- a/temporalio/worker/_workflow.py +++ b/temporalio/worker/_workflow.py @@ -27,6 +27,7 @@ import temporalio.exceptions import temporalio.workflow from temporalio.bridge.worker import PollShutdownError +from temporalio.client._stream_store import StreamStoreCallOptions from temporalio.converter import StorageDriverStoreContext, StorageDriverWorkflowInfo from temporalio.worker.workflow_sandbox._runner import SandboxedWorkflowRunner @@ -128,6 +129,7 @@ def __init__( encode_headers: bool, max_workflow_task_external_storage_concurrency: int, default_workflow_logic_flags: frozenset[_WorkflowLogicFlag] | None = None, + stream_store_call_options: StreamStoreCallOptions | 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")) @@ -197,6 +199,7 @@ def __init__( self._max_workflow_task_external_storage_concurrency = ( max_workflow_task_external_storage_concurrency ) + self._stream_store_call_options = stream_store_call_options self._throw_after_activation: Exception | None = None # If debug mode is enabled, disable deadlock detection @@ -819,6 +822,7 @@ def _create_workflow_instance( nexus_serialization_context=temporalio.converter._serialization_context._propagated_nexus_serialization_context( init ), + stream_store_call_options=self._stream_store_call_options, ) 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 2dd0d50ea..251998535 100644 --- a/temporalio/worker/_workflow_instance.py +++ b/temporalio/worker/_workflow_instance.py @@ -59,7 +59,19 @@ import temporalio.exceptions import temporalio.nexus.system import temporalio.workflow +from temporalio.client._stream_store import ( + _STREAM_SERVICE, + _STREAM_STORE_ACTIVITY, + StreamStoreCallOptions, + _stream_response, +) from temporalio.converter import StorageDriverStoreContext, StorageDriverWorkflowInfo +from temporalio.nexus.system.stream_service.workflow._system_nexus_interceptor import ( + _start_system_nexus_operation as _start_stream_system_nexus_operation, +) +from temporalio.nexus.system.stream_service.workflow._system_nexus_interceptor import ( + _SystemNexusWorkflowOutboundInterceptorTerminal as _StreamSystemNexusInterceptorTerminal, +) from temporalio.nexus.system.workflow_service._system_nexus_interceptor import ( _start_system_nexus_operation, _SystemNexusWorkflowOutboundInterceptorTerminal, @@ -185,6 +197,9 @@ class WorkflowInstanceDetails: temporalio.converter.NexusSerializationContext | None ) = None """Serialization context of the Nexus operation handler that started the workflow, if any.""" + stream_store_call_options: StreamStoreCallOptions | None = None + """Options of the Local Activity that calls the worker's Stream Store, or None when + the worker has no Stream Store.""" class WorkflowInstance(ABC): @@ -301,6 +316,7 @@ def __init__(self, det: WorkflowInstanceDetails) -> None: # the Nexus caller, which decodes them with the handler's Nexus context. A retried or cron run # reads the previous run's result and failure with the same context. self._nexus_serialization_context = det.nexus_serialization_context + self._stream_store_call_options = det.stream_store_call_options if self._nexus_serialization_context: self._result_payload_converter = self._payload_converter_with_context( self._nexus_serialization_context @@ -1830,23 +1846,25 @@ async def workflow_start_nexus_operation( event_groups: Sequence[temporalio.workflow.EventGroup] | None = None, ) -> temporalio.workflow.NexusOperationHandle[OutputT]: if temporalio.nexus.system.is_system_endpoint(endpoint): - return await _start_system_nexus_operation( - self._outbound, - StartNexusOperationInput( - endpoint=temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT, - service=service, - operation=operation, - input=input, - output_type=output_type, - schedule_to_close_timeout=schedule_to_close_timeout, - schedule_to_start_timeout=schedule_to_start_timeout, - start_to_close_timeout=start_to_close_timeout, - cancellation_type=cancellation_type, - headers=None, - summary=summary, - event_groups=event_groups, - ), + system_input = StartNexusOperationInput( + endpoint=temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT, + service=service, + operation=operation, + input=input, + output_type=output_type, + schedule_to_close_timeout=schedule_to_close_timeout, + schedule_to_start_timeout=schedule_to_start_timeout, + start_to_close_timeout=start_to_close_timeout, + cancellation_type=cancellation_type, + headers=None, + summary=summary, + event_groups=event_groups, ) + if service == _STREAM_SERVICE.name: + return await _start_stream_system_nexus_operation( + self._outbound, system_input + ) + return await _start_system_nexus_operation(self._outbound, system_input) return await self._outbound.start_nexus_operation( StartNexusOperationInput( endpoint=endpoint, @@ -2299,25 +2317,7 @@ async def operation_handle_fn() -> OutputT: ) if temporalio.nexus.system.is_system_endpoint(input.endpoint): - serialization_context = temporalio.nexus.system._get_serialization_context( - input.service, - input.operation_name, - input.input, - ) - user_payload_converter = self._context_free_payload_converter - user_failure_converter = self._context_free_failure_converter - if serialization_context is not None: - user_payload_converter = self._payload_converter_with_context( - serialization_context - ) - user_failure_converter = self._failure_converter_with_context( - serialization_context - ) - payload_converter = temporalio.nexus.system._get_payload_converter( - user_payload_converter, - user_failure_converter, - ) - failure_converter = user_failure_converter + payload_converter, failure_converter = self._system_nexus_converters(input) else: serialization_context = temporalio.converter.NexusSerializationContext( endpoint=input.endpoint, @@ -2355,9 +2355,93 @@ async def _intercept_system_nexus_operation( async def _schedule_system_nexus_operation( self, input: StartNexusOperationInput[Any, OutputT] - ) -> _NexusOperationHandle[OutputT]: + ) -> temporalio.workflow.NexusOperationHandle[OutputT]: + options = self._stream_store_call_options + if options is not None and input.service == _STREAM_SERVICE.name: + return self._start_stream_store_operation(input, options) return await self._outbound_start_nexus_operation(input) + def _system_nexus_converters( + self, input: StartNexusOperationInput[Any, Any] + ) -> tuple[ + temporalio.converter.PayloadConverter, temporalio.converter.FailureConverter + ]: + serialization_context = temporalio.nexus.system._get_serialization_context( + input.service, + input.operation_name, + input.input, + ) + user_payload_converter = self._context_free_payload_converter + user_failure_converter = self._context_free_failure_converter + if serialization_context is not None: + user_payload_converter = self._payload_converter_with_context( + serialization_context + ) + user_failure_converter = self._failure_converter_with_context( + serialization_context + ) + payload_converter = temporalio.nexus.system._get_payload_converter( + user_payload_converter, + user_failure_converter, + ) + return payload_converter, user_failure_converter + + def _start_stream_store_operation( + self, + input: StartNexusOperationInput[Any, OutputT], + options: StreamStoreCallOptions, + ) -> temporalio.workflow.NexusOperationHandle[OutputT]: + # A close must run after the Workflow Task commits. Workflow reads come later. + if input.operation_name in ("CloseStream", "ReadRecords"): + raise temporalio.exceptions.ApplicationError( + f"{input.operation_name} from Workflow code with a Stream Store isn't " + "supported yet", + non_retryable=True, + ) + # Workflow code builds the request, so the Local Activity does only the I/O. + payload_converter, _ = self._system_nexus_converters(input) + [request] = payload_converter.to_payloads([input.input]) + activity = self._outbound_schedule_activity( + StartLocalActivityInput( + activity=_STREAM_STORE_ACTIVITY, + args=(input.operation_name, request.data), + activity_id=None, + schedule_to_close_timeout=options.schedule_to_close_timeout, + schedule_to_start_timeout=None, + start_to_close_timeout=options.start_to_close_timeout, + retry_policy=options.retry_policy, + local_retry_threshold=None, + cancellation_type=temporalio.workflow.ActivityCancellationType.TRY_CANCEL, + headers={}, + summary=input.summary, + event_groups=input.event_groups, + arg_types=[str, bytes], + ret_type=bytes, + ) + ) + + async def result() -> OutputT: + try: + response = await activity + except temporalio.exceptions.ActivityError as err: + # Workflow code gets the Store's error, not the Local Activity's wrapper. + if isinstance( + err.cause, + ( + temporalio.exceptions.ApplicationError, + temporalio.exceptions.TimeoutError, + ), + ): + raise err.cause + raise + return _stream_response( + payload_converter, input.operation_name, input.output_type, response + ) + + return _StreamStoreOperationHandle( + self.create_task(result(), name=f"stream store: {input.operation_name}") + ) + #### Miscellaneous helpers #### # These are in alphabetical order. @@ -3360,7 +3444,9 @@ async def handle_update_handler(self, input: HandleUpdateInput) -> Any: class _WorkflowOutboundImpl( - _SystemNexusWorkflowOutboundInterceptorTerminal, WorkflowOutboundInterceptor + _SystemNexusWorkflowOutboundInterceptorTerminal, + _StreamSystemNexusInterceptorTerminal, + WorkflowOutboundInterceptor, ): def __init__(self, instance: _WorkflowInstanceImpl) -> None: # type: ignore # We are intentionally not calling the base class's __init__ here @@ -3407,7 +3493,7 @@ async def _intercept_system_nexus_operation( async def start_system_nexus_operation( self, input: StartNexusOperationInput[Any, OutputT] - ) -> _NexusOperationHandle[OutputT]: + ) -> temporalio.workflow.NexusOperationHandle[OutputT]: return await self._instance._schedule_system_nexus_operation(input) def start_local_activity( @@ -3854,6 +3940,21 @@ async def cancel( await self._instance._cancel_external_workflow(command) +class _StreamStoreOperationHandle(temporalio.workflow.NexusOperationHandle[OutputT]): + def __init__(self, task: asyncio.Task[OutputT]) -> None: + self._task = task + + def cancel(self) -> bool: + return self._task.cancel() + + def __await__(self) -> Generator[Any, Any, OutputT]: + return self._task.__await__() + + @property + def operation_token(self) -> str | None: + return None + + class _NexusOperationHandle(temporalio.workflow.NexusOperationHandle[OutputT]): def __init__( self, diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py new file mode 100644 index 000000000..c7c15cd20 --- /dev/null +++ b/tests/nexus/test_stream_store_workflow.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import asyncio +import uuid +from datetime import timedelta +from typing import Any + +import pytest + +import temporalio.exceptions +from temporalio import activity, workflow +from temporalio.api.enums.v1 import EventType +from temporalio.client import ( + Client, + StreamStore, + StreamStoreCallOptions, + WorkflowHandle, +) +from temporalio.common import RetryPolicy +from temporalio.converter import DataConverter +from temporalio.exceptions import ApplicationError +from temporalio.nexus.system.stream_service import ( + Producer, + ProducerKindWorkflow, + StreamRef, + WorkflowProducer, +) +from temporalio.nexus.system.stream_service.workflow import create_stream, get_stream +from temporalio.worker import ( + ActivityInboundInterceptor, + ExecuteActivityInput, + Interceptor, + Replayer, +) +from tests.helpers import assert_task_fail_eventually, new_worker +from tests.helpers.stream_store import MemoryStreamStore +from tests.test_converter import SimpleCodec + +# Workflow code writes these values, so the History shows only whether a Record body +# leaks into it. +_BODIES = ["first-record-body", "second-record-body"] + + +@workflow.defn +class AppendWorkflow: + @workflow.run + async def run(self, stream_id: str) -> StreamRef[str]: + stream = await create_stream(id=stream_id, body_type=str) + await stream.append(*_BODIES) + return stream.ref + + +@workflow.defn +class UnsupportedCallsWorkflow: + @workflow.run + async def run(self, stream_id: str) -> list[str]: + stream = await create_stream(id=stream_id, body_type=str) + errors: list[str] = [] + for call in (stream.close, stream.read): + try: + await call() + except ApplicationError as err: + assert err.non_retryable + errors.append(err.message) + try: + async with get_stream(stream.ref): + pass + except ApplicationError as err: + errors.append(err.message) + return errors + + +@workflow.defn +class AppendResultWorkflow: + @workflow.run + async def run(self, stream_id: str) -> str: + stream = await create_stream(id=stream_id, body_type=str) + try: + await stream.append("a") + except ApplicationError as err: + return f"ApplicationError {err.type}: {err.message}" + except temporalio.exceptions.TimeoutError as err: + return f"TimeoutError {err.type and err.type.name}" + return "appended" + + +@activity.defn +async def user_activity() -> None: + pass + + +@workflow.defn +class AppendAndActivityWorkflow: + @workflow.run + async def run(self, stream_id: str) -> None: + stream = await create_stream(id=stream_id, body_type=str) + await stream.append("a") + await workflow.execute_local_activity( + user_activity, start_to_close_timeout=timedelta(seconds=10) + ) + + +class FailingStreamStore(StreamStore): + @property + def name(self) -> str: + return "failing" + + async def call(self, operation: str, request: bytes) -> bytes: + raise AssertionError(f"A replay called the Store with {operation}") + + +class FlakyAppendStore(MemoryStreamStore): + def __init__( + self, + options: StreamStoreCallOptions, + *, + failures: int = 0, + delay: timedelta = timedelta(), + ) -> None: + super().__init__() + self._options = options + self._failures = failures + self._delay = delay + self.appends = 0 + + @property + def call_options(self) -> StreamStoreCallOptions: + return self._options + + async def call(self, operation: str, request: bytes) -> bytes: + if operation == "AppendRecords": + self.appends += 1 + await asyncio.sleep(self._delay.total_seconds()) + if self.appends <= self._failures: + raise RuntimeError("append failed") + return await super().call(operation, request) + + +class RecordingInterceptor(Interceptor): + def __init__(self) -> None: + self.activity_types: list[str] = [] + + def intercept_activity( + self, next: ActivityInboundInterceptor + ) -> ActivityInboundInterceptor: + return _RecordingActivityInbound(next, self.activity_types) + + +class _RecordingActivityInbound(ActivityInboundInterceptor): + def __init__( + self, next: ActivityInboundInterceptor, activity_types: list[str] + ) -> None: + super().__init__(next) + self._activity_types = activity_types + + async def execute_activity(self, input: ExecuteActivityInput) -> Any: + self._activity_types.append(activity.info().activity_type) + return await super().execute_activity(input) + + +def _with_store(client: Client, store: StreamStore) -> Client: + config = client.config() + config["stream_stores"] = [store] + return Client(**config) + + +async def _run_append_workflow( + client: Client, +) -> tuple[WorkflowHandle[AppendWorkflow, StreamRef[str]], StreamRef[str]]: + async with new_worker(client, AppendWorkflow) as worker: + handle = await client.start_workflow( + AppendWorkflow.run, + f"stream-{uuid.uuid4()}", + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + return handle, await handle.result() + + +async def _append_result(client: Client, store: StreamStore) -> str: + stream_client = _with_store(client, store) + async with new_worker(stream_client, AppendResultWorkflow) as worker: + return await stream_client.execute_workflow( + AppendResultWorkflow.run, + f"stream-{uuid.uuid4()}", + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + + +async def test_workflow_stream_uses_store(client: Client): + stream_client = _with_store(client, MemoryStreamStore()) + handle, ref = await _run_append_workflow(stream_client) + + stream = stream_client.get_stream(ref, body_type=str) + records = [record async for record in stream.iter_read_items()] + assert [record.body for record in records] == _BODIES + run_id = handle.result_run_id + assert run_id + producer = Producer( + id=f"wf:{run_id}", + kind=ProducerKindWorkflow(WorkflowProducer(run_id=run_id)), + ) + assert [record.producer for record in records] == [producer, producer] + + +async def test_workflow_stream_store_uses_payload_codec(client: Client): + store = MemoryStreamStore() + config = client.config() + config["data_converter"] = DataConverter(payload_codec=SimpleCodec()) + config["stream_stores"] = [store] + stream_client = Client(**config) + _, ref = await _run_append_workflow(stream_client) + + stored = [record.body for record in store.streams[ref.id].records] + assert [body.metadata["simple-codec"] for body in stored] == [b"true", b"true"] + stream = stream_client.get_stream(ref, body_type=str) + assert [record.body async for record in stream.iter_read_items()] == _BODIES + + +async def test_workflow_stream_history_has_no_bodies(client: Client): + stream_client = _with_store(client, MemoryStreamStore()) + handle, _ = await _run_append_workflow(stream_client) + events = (await handle.fetch_history()).events + markers = [ + event.marker_recorded_event_attributes.marker_name + for event in events + if event.event_type == EventType.EVENT_TYPE_MARKER_RECORDED + ] + assert markers == ["core_local_activity", "core_local_activity"] + for event in events: + for body in _BODIES: + assert body.encode() not in event.SerializeToString() + + +async def test_workflow_stream_replay_does_not_call_store(client: Client): + stream_client = _with_store(client, MemoryStreamStore()) + handle, _ = await _run_append_workflow(stream_client) + await Replayer( + workflows=[AppendWorkflow], stream_stores=[FailingStreamStore()] + ).replay_workflow(await handle.fetch_history()) + + +async def test_workflow_stream_without_store_starts_nexus_operation(client: Client): + async with new_worker(client, AppendWorkflow) as worker: + handle = await client.start_workflow( + AppendWorkflow.run, + f"stream-{uuid.uuid4()}", + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=30), + ) + # The dev server doesn't serve the Stream service, so it rejects the Nexus + # command that only the system Nexus path makes. + await assert_task_fail_eventually( + handle, message_contains="temporal.api.streamservice.v1.StreamService" + ) + await handle.terminate() + + +async def test_workflow_stream_close_and_read_fail_with_store(client: Client): + stream_client = _with_store(client, MemoryStreamStore()) + async with new_worker(stream_client, UnsupportedCallsWorkflow) as worker: + errors = await stream_client.execute_workflow( + UnsupportedCallsWorkflow.run, + f"stream-{uuid.uuid4()}", + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + assert errors == [ + "CloseStream from Workflow code with a Stream Store isn't supported yet", + "ReadRecords from Workflow code with a Stream Store isn't supported yet", + "CloseStream from Workflow code with a Stream Store isn't supported yet", + ] + + +async def test_workflow_stream_store_error_reaches_workflow_code(client: Client): + store = FlakyAppendStore(StreamStoreCallOptions(), failures=1) + result = await _append_result(client, store) + assert result == "ApplicationError RuntimeError: append failed" + assert store.appends == 1 + + +async def test_workflow_stream_store_call_options_retry(client: Client): + options = StreamStoreCallOptions( + retry_policy=RetryPolicy( + initial_interval=timedelta(milliseconds=10), maximum_attempts=2 + ) + ) + store = FlakyAppendStore(options, failures=1) + assert await _append_result(client, store) == "appended" + assert store.appends == 2 + + +async def test_workflow_stream_store_call_options_timeout(client: Client): + options = StreamStoreCallOptions(start_to_close_timeout=timedelta(milliseconds=200)) + store = FlakyAppendStore(options, delay=timedelta(seconds=5)) + assert await _append_result(client, store) == "TimeoutError START_TO_CLOSE" + + +async def test_worker_keeps_its_stream_stores(client: Client): + stream_client = _with_store(client, MemoryStreamStore()) + async with new_worker(stream_client, AppendWorkflow) as worker: + with pytest.raises(ValueError, match="Stream Stores"): + worker.client = client + worker.client = stream_client + + +async def test_activity_interceptors_skip_stream_store_activity(client: Client): + interceptor = RecordingInterceptor() + stream_client = _with_store(client, MemoryStreamStore()) + async with new_worker( + stream_client, + AppendAndActivityWorkflow, + activities=[user_activity], + interceptors=[interceptor], + ) as worker: + await stream_client.execute_workflow( + AppendAndActivityWorkflow.run, + f"stream-{uuid.uuid4()}", + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + assert interceptor.activity_types == ["user_activity"] From 0c0f2e15b48961440f2811d88093424bea66824f Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 21:44:04 -0700 Subject: [PATCH 07/46] Gave an Activity's info the Workflow Task of a Local Activity. Core sends the Workflow Task that dispatched a Local Activity. A write from a Local Activity needs it for its Producer epoch. --- temporalio/activity.py | 26 +++++++++++++++++++++++ temporalio/worker/_activity.py | 9 ++++++++ tests/worker/test_activity.py | 39 ++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/temporalio/activity.py b/temporalio/activity.py index bf1b4cb73..5cc113b84 100644 --- a/temporalio/activity.py +++ b/temporalio/activity.py @@ -91,6 +91,24 @@ def decorator(fn: CallableType) -> CallableType: return decorator +@dataclass(frozen=True) +class WorkflowTaskAttempt: + """One attempt of a Workflow Task. + + .. warning:: + This API is experimental and unstable. + """ + + started_event_id: int + """ID of the task's ``WorkflowTaskStarted`` event.""" + + attempt: int + """Attempt of the task.""" + + started_time: datetime | None + """When the server started the task. None if the server did not send it.""" + + @dataclass(frozen=True) class Info: """Information about the running activity. @@ -155,6 +173,14 @@ class Info: This API is experimental and unstable. """ + workflow_task: WorkflowTaskAttempt | None = None + """For a Local Activity, the Workflow Task in which the Worker dispatched this execution. + None for other Activities. + + .. warning:: + This API is experimental and unstable. + """ + @property def in_workflow(self) -> bool: """Was this activity started by a workflow?""" diff --git a/temporalio/worker/_activity.py b/temporalio/worker/_activity.py index 040e163fa..04846db1b 100644 --- a/temporalio/worker/_activity.py +++ b/temporalio/worker/_activity.py @@ -692,6 +692,15 @@ async def _execute_activity( if not started_by_workflow else None, nexus_serialization_context=nexus_context, + workflow_task=temporalio.activity.WorkflowTaskAttempt( + started_event_id=start.workflow_task.started_event_id, + attempt=start.workflow_task.attempt, + started_time=_proto_to_datetime(start.workflow_task.started_time) + if start.workflow_task.HasField("started_time") + else None, + ) + if start.HasField("workflow_task") + else None, ) if self._encode_headers: diff --git a/tests/worker/test_activity.py b/tests/worker/test_activity.py index 5b21c3d0a..8dee94ee3 100644 --- a/tests/worker/test_activity.py +++ b/tests/worker/test_activity.py @@ -2208,3 +2208,42 @@ async def test_local_activities_with_no_remote_activities_option(client: Client) ) assert result == "Hello from local activity, Temporal!" + + +@activity.defn +async def capture_workflow_task() -> list[int] | None: + task = activity.info().workflow_task + return None if task is None else [task.started_event_id, task.attempt] + + +@workflow.defn(sandboxed=False) +class WorkflowTaskInfoWorkflow: + @workflow.run + async def run(self) -> list[list[int] | None]: + local = await workflow.execute_local_activity( + capture_workflow_task, start_to_close_timeout=timedelta(seconds=5) + ) + regular = await workflow.execute_activity( + capture_workflow_task, start_to_close_timeout=timedelta(seconds=5) + ) + return [local, regular] + + +async def test_activity_info_workflow_task(client: Client): + task_queue = f"tq-{uuid.uuid4()}" + async with Worker( + client, + task_queue=task_queue, + activities=[capture_workflow_task], + workflows=[WorkflowTaskInfoWorkflow], + ): + local, regular = await client.execute_workflow( + WorkflowTaskInfoWorkflow.run, + id=f"workflow-{uuid.uuid4()}", + task_queue=task_queue, + ) + assert local is not None + started_event_id, attempt = local + assert started_event_id > 0 + assert attempt == 1 + assert regular is None From e4c2b3201a2ac84c9404486f7623618859f6037b Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 21:49:05 -0700 Subject: [PATCH 08/46] Wrote a Workflow's Stream append as its Local Activity's Producer. On a Store, a Workflow appends as a Local Activity Producer, and the Store refuses the Workflow Producer. Its epoch is the Workflow Task that ran the Local Activity, so the Store fences the write of a failed task. --- temporalio/client/_stream_store.py | 21 ++++ temporalio/nexus/system/_support.py | 34 +++++- tests/nexus/test_stream_store.py | 28 ++++- tests/nexus/test_stream_store_workflow.py | 132 ++++++++++++++++++++-- 4 files changed, 203 insertions(+), 12 deletions(-) diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py index 73cfce4e2..c03252d74 100644 --- a/temporalio/client/_stream_store.py +++ b/temporalio/client/_stream_store.py @@ -10,10 +10,12 @@ import nexusrpc import temporalio.activity +import temporalio.api.streamservice.v1 import temporalio.bridge._visitor import temporalio.common import temporalio.converter import temporalio.converter._payload_converter +import temporalio.nexus.system._support import temporalio.nexus.system.stream_service @@ -155,6 +157,25 @@ def _stream_store_activity( # pyright: ignore[reportUnusedFunction] ) -> Callable[[str, bytes], Awaitable[bytes]]: @temporalio.activity.defn(name=_STREAM_STORE_ACTIVITY) async def call(operation: str, request: bytes) -> bytes: + if operation == "AppendRecords": + request = _with_local_activity_producer(request) return await _call_stream_store(store, data_converter, operation, request) return call + + +def _with_local_activity_producer(request: bytes) -> bytes: + message = temporalio.api.streamservice.v1.AppendRecordsRequest.FromString(request) + # On a Store, a Workflow appends as the Local Activity that runs the append, so the + # Store can fence the write of a failed Workflow Task. + if message.producer.WhichOneof("kind") != "workflow": + return request + converter = temporalio.converter._payload_converter._get_transfer_type_converter( + temporalio.nexus.system.stream_service.Producer + ) + if converter is None: + raise TypeError("The generated Producer has no proto transfer type") + message.producer.CopyFrom( + converter.to_transfer_type(temporalio.nexus.system._support.default_producer()) + ) + return message.SerializeToString() diff --git a/temporalio/nexus/system/_support.py b/temporalio/nexus/system/_support.py index fac980f2b..e943c2481 100644 --- a/temporalio/nexus/system/_support.py +++ b/temporalio/nexus/system/_support.py @@ -66,11 +66,15 @@ def default_producer() -> "Producer": ActivityEpoch, ActivityProducer, ClientProducer, + LocalActivityEpoch, + LocalActivityProducer, Producer, ProducerKindClient, + ProducerKindLocalActivity, ProducerKindStandaloneActivity, ProducerKindWorkflowActivity, Timestamp, + WorkflowTaskEpoch, ) if not temporalio.activity.in_activity(): @@ -79,9 +83,33 @@ def default_producer() -> "Producer": ) info = temporalio.activity.info() if info.is_local: - raise RuntimeError( - "A Stream write from a Local Activity needs the Workflow Task of the " - "Activity, which the SDK doesn't give yet" + task = info.workflow_task + if task is None or info.workflow_run_id is None: + raise RuntimeError( + "A Stream write from a Local Activity needs its Workflow Task, and the " + "Worker's Core gave none" + ) + started_time = None + if task.started_time is not None: + proto_time = _timestamp(task.started_time) + started_time = Timestamp(seconds=proto_time.seconds, nanos=proto_time.nanos) + # The activity id of a Local Activity is its sequence number in the run, so a + # retried Workflow Task writes as the same Producer with a higher epoch. + return Producer( + id=_producer_id("la", info.workflow_run_id, info.activity_id), + kind=ProducerKindLocalActivity( + LocalActivityProducer( + run_id=info.workflow_run_id, + epoch=LocalActivityEpoch( + task=WorkflowTaskEpoch( + started_event_id=task.started_event_id, + attempt=task.attempt, + started_time=started_time, + ), + attempt=info.attempt, + ), + ) + ), ) attempt_time = _timestamp(info.current_attempt_scheduled_time) epoch = ActivityEpoch( diff --git a/tests/nexus/test_stream_store.py b/tests/nexus/test_stream_store.py index cbd2ba0ea..3cf6bfaa7 100644 --- a/tests/nexus/test_stream_store.py +++ b/tests/nexus/test_stream_store.py @@ -175,8 +175,32 @@ async def test_standalone_activity_append_has_activity_producer(client: Client): ) -async def test_local_activity_append_fails(client: Client): - with pytest.raises(RuntimeError, match="Local Activity"): +async def test_local_activity_append_has_local_activity_producer(client: Client): + task_started = datetime(2026, 11, 12, 10, 0, 0, 123456, tzinfo=timezone.utc) + producer = await _append_in_activity( + client, + dataclasses.replace( + ActivityEnvironment.default_info(), + activity_id="3", + attempt=2, + is_local=True, + workflow_run_id="run:1", + workflow_task=temporalio.activity.WorkflowTaskAttempt( + started_event_id=7, attempt=1, started_time=task_started + ), + ), + ) + assert producer.id == "la:run%3A1:3" + assert producer.WhichOneof("kind") == "local_activity" + assert producer.local_activity.run_id == "run:1" + assert producer.local_activity.epoch.task.started_event_id == 7 + assert producer.local_activity.epoch.task.attempt == 1 + assert producer.local_activity.epoch.task.started_time == _timestamp(task_started) + assert producer.local_activity.epoch.attempt == 2 + + +async def test_local_activity_append_without_workflow_task_fails(client: Client): + with pytest.raises(RuntimeError, match="Workflow Task"): await _append_in_activity( client, dataclasses.replace(ActivityEnvironment.default_info(), is_local=True), diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py index c7c15cd20..482df2b58 100644 --- a/tests/nexus/test_stream_store_workflow.py +++ b/tests/nexus/test_stream_store_workflow.py @@ -7,6 +7,7 @@ import pytest +import temporalio.api.stream.v1 import temporalio.exceptions from temporalio import activity, workflow from temporalio.api.enums.v1 import EventType @@ -20,10 +21,12 @@ from temporalio.converter import DataConverter from temporalio.exceptions import ApplicationError from temporalio.nexus.system.stream_service import ( + ClientProducer, Producer, - ProducerKindWorkflow, + ProducerKindClient, + ProducerKindLocalActivity, + StreamIdConflictPolicy, StreamRef, - WorkflowProducer, ) from temporalio.nexus.system.stream_service.workflow import create_stream, get_stream from temporalio.worker import ( @@ -100,6 +103,52 @@ async def run(self, stream_id: str) -> None: ) +_fail_next_task = True + + +@workflow.defn(sandboxed=False) +class AppendThenFailTaskOnceWorkflow: + @workflow.run + async def run(self, stream_id: str) -> None: + stream = await create_stream( + id=stream_id, + body_type=str, + id_conflict_policy=StreamIdConflictPolicy.StreamIdConflictPolicyUseExisting, + ) + await stream.append("a") + global _fail_next_task + if _fail_next_task: + _fail_next_task = False + raise RuntimeError("Failed the Workflow Task once") + + +@activity.defn +async def append_from_local_activity(stream_id: str) -> None: + stream = activity.client().get_stream(StreamRef(id=stream_id), body_type=str) + await stream.append("a") + + +@workflow.defn +class UserLocalActivityAppendWorkflow: + @workflow.run + async def run(self, stream_id: str) -> None: + await workflow.execute_local_activity( + append_from_local_activity, + stream_id, + start_to_close_timeout=timedelta(seconds=10), + ) + + +@workflow.defn +class ExplicitProducerWorkflow: + @workflow.run + async def run(self, stream_id: str) -> None: + stream = await create_stream(id=stream_id, body_type=str) + await stream.append( + "a", producer=Producer(id="mine", kind=ProducerKindClient(ClientProducer())) + ) + + class FailingStreamStore(StreamStore): @property def name(self) -> str: @@ -197,11 +246,15 @@ async def test_workflow_stream_uses_store(client: Client): assert [record.body for record in records] == _BODIES run_id = handle.result_run_id assert run_id - producer = Producer( - id=f"wf:{run_id}", - kind=ProducerKindWorkflow(WorkflowProducer(run_id=run_id)), - ) - assert [record.producer for record in records] == [producer, producer] + first, second = (record.producer for record in records) + assert first is not None + assert first == second + # The create is the run's first Local Activity, so the append is its second. + assert first.id == f"la:{run_id}:2" + assert isinstance(first.kind, ProducerKindLocalActivity) + assert first.kind.value.run_id == run_id + assert first.kind.value.epoch.task.started_event_id > 0 + assert first.kind.value.epoch.attempt == 1 async def test_workflow_stream_store_uses_payload_codec(client: Client): @@ -322,3 +375,68 @@ async def test_activity_interceptors_skip_stream_store_activity(client: Client): task_queue=worker.task_queue, ) assert interceptor.activity_types == ["user_activity"] + + +async def test_workflow_task_retry_appends_with_higher_epoch(client: Client): + global _fail_next_task + _fail_next_task = True + store = MemoryStreamStore() + stream_client = _with_store(client, store) + stream_id = f"stream-{uuid.uuid4()}" + async with new_worker(stream_client, AppendThenFailTaskOnceWorkflow) as worker: + await stream_client.execute_workflow( + AppendThenFailTaskOnceWorkflow.run, + stream_id, + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + first, second = (record.producer for record in store.streams[stream_id].records) + assert first.id == second.id + + def epoch(producer: temporalio.api.stream.v1.Producer) -> tuple[int, int, int]: + local = producer.local_activity.epoch + return (local.task.started_event_id, local.task.attempt, local.attempt) + + assert epoch(second) > epoch(first) + + +async def test_user_local_activity_append_has_local_activity_producer(client: Client): + store = MemoryStreamStore() + stream_client = _with_store(client, store) + stream = await stream_client.create_stream( + id=f"stream-{uuid.uuid4()}", body_type=str + ) + async with new_worker( + stream_client, + UserLocalActivityAppendWorkflow, + activities=[append_from_local_activity], + ) as worker: + handle = await stream_client.start_workflow( + UserLocalActivityAppendWorkflow.run, + stream.ref.id, + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + await handle.result() + [record] = store.streams[stream.ref.id].records + run_id = handle.result_run_id + assert record.producer.id == f"la:{run_id}:1" + assert record.producer.local_activity.run_id == run_id + assert record.producer.local_activity.epoch.task.started_event_id > 0 + assert record.producer.local_activity.epoch.attempt == 1 + + +async def test_workflow_explicit_producer_reaches_store(client: Client): + store = MemoryStreamStore() + stream_client = _with_store(client, store) + stream_id = f"stream-{uuid.uuid4()}" + async with new_worker(stream_client, ExplicitProducerWorkflow) as worker: + await stream_client.execute_workflow( + ExplicitProducerWorkflow.run, + stream_id, + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + [record] = store.streams[stream_id].records + assert record.producer.id == "mine" + assert record.producer.WhichOneof("kind") == "client" From a644cf53f09b1ceea732c2746429641913f1244a Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 22:04:34 -0700 Subject: [PATCH 09/46] Pointed Core at the commit with the Stream crate. The bridge links the Stream runtime from Core's new `temporalio-streams` crate. The Core commits add no proto that the SDK generates from. --- 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 5240dfec3..42614ce4d 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 5240dfec3a849f792e030aee57df0cafe9806840 +Subproject commit 42614ce4d9d4dfafe8c25afd34b46e6077efeebc From 1f154d751b0b430cdf742293cd6c6bcd33660d71 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 22:08:14 -0700 Subject: [PATCH 10/46] Bound the Stream runtime in the bridge behind a `streams` feature. A Store on the bridge needs the crate's runtime and its memory Store. The feature is off by default, and the build tasks and wheels turn it on. --- pyproject.toml | 12 +++-- temporalio/bridge/Cargo.lock | 83 +++++++++++++++++++++++++++++++- temporalio/bridge/Cargo.toml | 5 ++ temporalio/bridge/src/lib.rs | 21 ++++++++ temporalio/bridge/src/streams.rs | 68 ++++++++++++++++++++++++++ temporalio/bridge/streams.py | 50 +++++++++++++++++++ 6 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 temporalio/bridge/src/streams.rs create mode 100644 temporalio/bridge/streams.py diff --git a/pyproject.toml b/pyproject.toml index 07435e517..bf0c16cd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,8 +93,8 @@ dev = [ ] [tool.poe.tasks] -build-develop = "uv run maturin develop --uv" -build-develop-with-release = { cmd = "uv run maturin develop --release --uv" } +build-develop = "uv run maturin develop --uv --features streams" +build-develop-with-release = { cmd = "uv run maturin develop --release --uv --features streams" } # FIPS builds: swap the rustls stack onto aws-lc-rs FIPS mode (aws-lc-fips-sys), eliminating `ring`. build-develop-fips = { cmd = "uv run maturin develop --uv --no-default-features --features fips", env = { TEMPORALIO_FIPS = "1" } } build-wheel-fips = { cmd = "uv run maturin build --release --no-default-features --features fips", env = { TEMPORALIO_FIPS = "1" } } @@ -153,18 +153,20 @@ filterwarnings = [ before-all = "pip install protoc-wheel-0" build = "cp311-win_amd64 cp311-manylinux_x86_64 cp311-manylinux_aarch64 cp311-musllinux_x86_64 cp311-musllinux_aarch64 cp311-macosx_x86_64 cp311-macosx_arm64" build-verbosity = 1 +# The official wheels link the Stream runtime. +environment = { MATURIN_PEP517_ARGS = "--features streams" } [tool.cibuildwheel.macos] -environment = { MACOSX_DEPLOYMENT_TARGET = "10.12" } +environment = { MACOSX_DEPLOYMENT_TARGET = "10.12", MATURIN_PEP517_ARGS = "--features streams" } [tool.cibuildwheel.linux] before-all = "sh scripts/cibuildwheel_before_all_linux.sh" before-build = "sh scripts/cibuildwheel_before_build_linux.sh" -environment = { PATH = "$PATH:$HOME/.cargo/bin", CARGO_NET_GIT_FETCH_WITH_CLI = "true" } +environment = { PATH = "$PATH:$HOME/.cargo/bin", CARGO_NET_GIT_FETCH_WITH_CLI = "true", MATURIN_PEP517_ARGS = "--features streams" } [[tool.cibuildwheel.overrides]] select = "*musllinux*" -environment = { PATH = "$PATH:$HOME/.cargo/bin", CARGO_NET_GIT_FETCH_WITH_CLI = "true", RUSTFLAGS = "-C target-feature=-crt-static" } +environment = { PATH = "$PATH:$HOME/.cargo/bin", CARGO_NET_GIT_FETCH_WITH_CLI = "true", RUSTFLAGS = "-C target-feature=-crt-static", MATURIN_PEP517_ARGS = "--features streams" } [tool.mypy] ignore_missing_imports = true diff --git a/temporalio/bridge/Cargo.lock b/temporalio/bridge/Cargo.lock index 4e2c0a2f6..a679e1b67 100644 --- a/temporalio/bridge/Cargo.lock +++ b/temporalio/bridge/Cargo.lock @@ -181,6 +181,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bon" version = "3.10.3" @@ -265,7 +274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -343,6 +352,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -376,6 +394,16 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.24.1" @@ -433,6 +461,16 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "7.0.0" @@ -728,6 +766,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2390,6 +2438,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2593,6 +2652,7 @@ dependencies = [ "temporalio-client", "temporalio-common", "temporalio-sdk-core", + "temporalio-streams", "tokio", "tokio-rustls", "tokio-stream", @@ -2776,6 +2836,21 @@ dependencies = [ "zip", ] +[[package]] +name = "temporalio-streams" +version = "0.1.0" +dependencies = [ + "async-trait", + "parking_lot", + "prost", + "prost-wkt-types", + "sha2", + "temporalio-common", + "thiserror", + "tokio", + "tonic", +] + [[package]] name = "termtree" version = "0.5.1" @@ -3141,6 +3216,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "typetag" version = "0.2.23" diff --git a/temporalio/bridge/Cargo.toml b/temporalio/bridge/Cargo.toml index 4b23ae6ce..5b50670b6 100644 --- a/temporalio/bridge/Cargo.toml +++ b/temporalio/bridge/Cargo.toml @@ -37,6 +37,9 @@ tls-aws-lc = [ "tokio-rustls/aws_lc_rs", ] fips = ["tls-aws-lc", "dep:aws-lc-rs"] +# Off by default, so a source build doesn't compile the Stream runtime. The official builds turn it +# on. +streams = ["dep:temporalio-streams"] [dependencies] anyhow = "1.0" @@ -72,6 +75,8 @@ temporalio-sdk-core = { version = "0.11.0", path = "./sdk-core/crates/sdk-core", "envconfig", "prometheus", ] } +# The Stream runtime and its memory Store, from the submodule. +temporalio-streams = { path = "./sdk-core/crates/streams", optional = true } tokio = "1.26" # Matches the sdk-core client crate's rustls stack; used to build the custom # server certificate verifier for ClientTlsConfig.verification_server_name. diff --git a/temporalio/bridge/src/lib.rs b/temporalio/bridge/src/lib.rs index a4fda26db..83f81d60a 100644 --- a/temporalio/bridge/src/lib.rs +++ b/temporalio/bridge/src/lib.rs @@ -6,6 +6,7 @@ mod client_rpc_generated; mod envconfig; mod metric; mod runtime; +mod streams; mod testing; mod worker; @@ -48,6 +49,14 @@ fn temporal_sdk_bridge(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(init_runtime, m)?)?; m.add_function(wrap_pyfunction!(raise_in_thread, m)?)?; + // Stream stuff + m.add("StreamCallError", py.get_type::())?; + #[cfg(feature = "streams")] + { + m.add_class::()?; + m.add_function(wrap_pyfunction!(new_memory_stream_runtime, m)?)?; + } + // Testing stuff m.add_class::()?; m.add_function(wrap_pyfunction!(start_dev_server, m)?)?; @@ -104,6 +113,18 @@ fn raise_in_thread(py: Python, thread_id: std::os::raw::c_long, exc: &Bound<'_, runtime::raise_in_thread(py, thread_id, exc) } +#[cfg(feature = "streams")] +#[pyfunction] +#[pyo3(signature = (runtime_ref, store_name, retention=None, last_write_ttl=None))] +fn new_memory_stream_runtime( + runtime_ref: &runtime::RuntimeRef, + store_name: String, + retention: Option, + last_write_ttl: Option, +) -> PyResult { + streams::new_memory_stream_runtime(runtime_ref, store_name, retention, last_write_ttl) +} + #[pyfunction] fn start_dev_server<'a>( py: Python<'a>, diff --git a/temporalio/bridge/src/streams.rs b/temporalio/bridge/src/streams.rs new file mode 100644 index 000000000..3bfae31c3 --- /dev/null +++ b/temporalio/bridge/src/streams.rs @@ -0,0 +1,68 @@ +//! Bindings to the Stream runtime of the `temporalio-streams` crate. Python calls the five Stream +//! service operations by name, with the encoded request, and gets the encoded response. + +use pyo3::exceptions::PyException; +#[cfg(feature = "streams")] +use pyo3::prelude::*; + +#[cfg(feature = "streams")] +use crate::runtime; + +// The exception args are the gRPC code, the message and the encoded details, as in `RPCError`. +pyo3::create_exception!(temporal_sdk_bridge, StreamCallError, PyException); + +/// The crate's runtime object. Each Store on the bridge holds one. +#[cfg(feature = "streams")] +#[pyclass(frozen)] +pub struct StreamRuntimeRef { + streams: std::sync::Arc, + runtime: runtime::Runtime, +} + +#[cfg(feature = "streams")] +#[pymethods] +impl StreamRuntimeRef { + /// Runs one Stream operation on an encoded request. The future gives the encoded response. + fn call<'p>( + &self, + py: Python<'p>, + operation: String, + request: Vec, + ) -> PyResult> { + self.runtime + .assert_same_process("call the Stream runtime")?; + let streams = self.streams.clone(); + self.runtime.future_into_py(py, async move { + streams.call(&operation, &request).await.map_err(call_error) + }) + } +} + +#[cfg(feature = "streams")] +fn call_error(err: temporalio_streams::StreamError) -> PyErr { + StreamCallError::new_err((err.code() as u32, err.message.clone(), err.details())) +} + +/// Builds the runtime on a new memory Store. A `None` duration takes the crate's default. +#[cfg(feature = "streams")] +pub fn new_memory_stream_runtime( + runtime_ref: &runtime::RuntimeRef, + store_name: String, + retention: Option, + last_write_ttl: Option, +) -> PyResult { + let runtime = runtime_ref.runtime.clone(); + runtime.assert_same_process("create a Stream runtime")?; + // The runtime can spawn Tokio tasks, which need an entered Tokio runtime. + let _guard = runtime.core.tokio_handle().enter(); + let meter = runtime.core.telemetry().get_temporal_metric_meter(); + let streams = temporalio_streams::MemoryStreamRuntime::new( + store_name, + temporalio_streams::MemoryStreamOptions { + retention, + last_write_ttl, + }, + meter, + ); + Ok(StreamRuntimeRef { streams, runtime }) +} diff --git a/temporalio/bridge/streams.py b/temporalio/bridge/streams.py new file mode 100644 index 000000000..4195e8d8d --- /dev/null +++ b/temporalio/bridge/streams.py @@ -0,0 +1,50 @@ +"""The Stream runtime that this bridge build links. (unstable) + +Nothing in this module should be considered stable. The API may change. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +import temporalio.bridge.runtime +import temporalio.bridge.temporal_sdk_bridge + +StreamCallError: type[Exception] = temporalio.bridge.temporal_sdk_bridge.StreamCallError + + +class StreamRuntime: + """The runtime of the ``temporalio-streams`` crate on one Store.""" + + @staticmethod + def new_memory( + runtime: temporalio.bridge.runtime.Runtime, + *, + name: str, + retention: timedelta | None, + last_write_ttl: timedelta | None, + ) -> StreamRuntime: + """Build the runtime on a new memory Store. + + Raises: + RuntimeError: The bridge build has no Stream runtime. + """ + new = getattr( + temporalio.bridge.temporal_sdk_bridge, "new_memory_stream_runtime", None + ) + if new is None: + raise RuntimeError("This build of temporalio has no Stream runtime") + return StreamRuntime(new(runtime._ref, name, retention, last_write_ttl)) + + def __init__(self, ref: Any) -> None: + """Wrap the bridge's runtime object. Use ``new_memory`` instead.""" + self._ref = ref + + async def call(self, operation: str, request: bytes) -> bytes: + """Run one Stream operation on an encoded request and return the encoded response. + + Raises: + StreamCallError: The call failed, with the gRPC code, message and details as args. + """ + return await self._ref.call(operation, request) From 1f0993a63a4ca07dca240cadbb8272acb47ff756 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 22:14:45 -0700 Subject: [PATCH 11/46] Raised one `StreamError` for every failed Stream call. Client, Activity and Workflow code can catch one class and match on its reason. The details carry the reason, so Workflow code rebuilds the same error from a Local Activity's failure. --- temporalio/bridge/streams.py | 11 ++- temporalio/exceptions.py | 81 +++++++++++++++++++++++ temporalio/worker/_workflow_instance.py | 12 ++++ tests/nexus/test_stream_store.py | 50 ++++++++++++++ tests/nexus/test_stream_store_workflow.py | 21 +++++- 5 files changed, 170 insertions(+), 5 deletions(-) diff --git a/temporalio/bridge/streams.py b/temporalio/bridge/streams.py index 4195e8d8d..9ced5c207 100644 --- a/temporalio/bridge/streams.py +++ b/temporalio/bridge/streams.py @@ -10,6 +10,7 @@ import temporalio.bridge.runtime import temporalio.bridge.temporal_sdk_bridge +import temporalio.exceptions StreamCallError: type[Exception] = temporalio.bridge.temporal_sdk_bridge.StreamCallError @@ -45,6 +46,12 @@ async def call(self, operation: str, request: bytes) -> bytes: """Run one Stream operation on an encoded request and return the encoded response. Raises: - StreamCallError: The call failed, with the gRPC code, message and details as args. + temporalio.exceptions.StreamError: The call failed. """ - return await self._ref.call(operation, request) + try: + return await self._ref.call(operation, request) + except StreamCallError as err: + _, message, details = err.args + raise temporalio.exceptions._stream_error_from_call( + message, details + ) from None diff --git a/temporalio/exceptions.py b/temporalio/exceptions.py index 03a623c21..94ca75b08 100644 --- a/temporalio/exceptions.py +++ b/temporalio/exceptions.py @@ -8,6 +8,7 @@ import temporalio.api.enums.v1 import temporalio.api.failure.v1 +import temporalio.api.stream.v1 class TemporalError(Exception): @@ -198,6 +199,86 @@ def category(self) -> ApplicationErrorCategory: return self._category +_STREAM_ERROR_REASON_UNSPECIFIED = ( + temporalio.api.enums.v1.StreamErrorReason.STREAM_ERROR_REASON_UNSPECIFIED +) + +# The one reason that a retry can fix. +_STREAM_RETRYABLE_REASONS: frozenset[int] = frozenset( + {temporalio.api.enums.v1.StreamErrorReason.STREAM_ERROR_REASON_STORAGE_UNAVAILABLE} +) + + +class StreamError(ApplicationError): + """A failed Stream call. ``reason`` tells the cases apart. + + One class covers every failed call in client, Activity and Workflow code, so match + on ``reason`` and not on the type. The reason is a ``StreamErrorReason``, such as + ``STREAM_ERROR_REASON_NOT_FOUND`` (nobody created the Stream), + ``STREAM_ERROR_REASON_CLOSED`` (the Stream is closed), or + ``STREAM_ERROR_REASON_ALREADY_EXISTS`` (a strict create of a Stream that exists). + A Store outage, ``STREAM_ERROR_REASON_STORAGE_UNAVAILABLE``, is the one retryable + reason. + + .. warning:: + This API is experimental and subject to change. + """ + + def __init__( + self, + message: str, + *, + reason: temporalio.api.enums.v1.StreamErrorReason.ValueType | None = None, + ) -> None: + """Initialize a Stream error. ``reason`` defaults to the unspecified reason.""" + if reason is None: + reason = _STREAM_ERROR_REASON_UNSPECIFIED + # The details carry the reason, so the error keeps it when it crosses an + # Activity or a Nexus operation. + super().__init__( + message, + temporalio.api.stream.v1.StreamErrorDetails(reason=reason), + type="StreamError", + non_retryable=reason not in _STREAM_RETRYABLE_REASONS, + ) + self._reason = reason + + @property + def reason(self) -> temporalio.api.enums.v1.StreamErrorReason.ValueType: + """Why the Stream call failed.""" + return self._reason + + +def _stream_error_from_reason(reason: int, message: str) -> StreamError: + if reason not in temporalio.api.enums.v1.StreamErrorReason.values(): + # A reason that this SDK doesn't know gives the unspecified reason. + reason = _STREAM_ERROR_REASON_UNSPECIFIED + return StreamError( + message, reason=temporalio.api.enums.v1.StreamErrorReason.ValueType(reason) + ) + + +def _stream_error_from_call(message: str, details: bytes) -> StreamError: + # A Store on the bridge fails with the encoded details, as a gRPC status has them. + proto = temporalio.api.stream.v1.StreamErrorDetails.FromString(details) + return _stream_error_from_reason(proto.reason, message) + + +def _stream_error_from_failure(err: BaseException) -> StreamError | None: + # A Local Activity or a Nexus operation hands back a plain application error, so the + # error is rebuilt from its type and details. + if isinstance(err, StreamError): + return err + if not isinstance(err, ApplicationError) or err.type != "StreamError": + return None + reason: int = _STREAM_ERROR_REASON_UNSPECIFIED + for detail in err.details: + if isinstance(detail, temporalio.api.stream.v1.StreamErrorDetails): + reason = detail.reason + break + return _stream_error_from_reason(reason, err.message) + + class CancelledError(FailureError): """Error raised on workflow/activity cancellation.""" diff --git a/temporalio/worker/_workflow_instance.py b/temporalio/worker/_workflow_instance.py index 251998535..a7b42f84b 100644 --- a/temporalio/worker/_workflow_instance.py +++ b/temporalio/worker/_workflow_instance.py @@ -2423,8 +2423,20 @@ def _start_stream_store_operation( async def result() -> OutputT: try: response = await activity + except temporalio.exceptions.ApplicationError as err: + # Core hands back a failed Local Activity's error without a wrapper. + stream_error = temporalio.exceptions._stream_error_from_failure(err) + if stream_error is not None: + raise stream_error from None + raise except temporalio.exceptions.ActivityError as err: # Workflow code gets the Store's error, not the Local Activity's wrapper. + if err.cause is not None: + stream_error = temporalio.exceptions._stream_error_from_failure( + err.cause + ) + if stream_error is not None: + raise stream_error from None if isinstance( err.cause, ( diff --git a/tests/nexus/test_stream_store.py b/tests/nexus/test_stream_store.py index 3cf6bfaa7..60e85acb0 100644 --- a/tests/nexus/test_stream_store.py +++ b/tests/nexus/test_stream_store.py @@ -14,12 +14,15 @@ import temporalio.activity import temporalio.api.common.v1 import temporalio.api.stream.v1 +import temporalio.exceptions import temporalio.api.workflowservice.v1 import temporalio.nexus.system import temporalio.nexus.system._support import temporalio.workflow from temporalio.client import Client +from temporalio.api.enums.v1 import StreamErrorReason from temporalio.converter import DataConverter +from temporalio.exceptions import ApplicationError, StreamError from temporalio.nexus.system.stream_service import ( ClientProducer, DescribeStreamRequest, @@ -290,3 +293,50 @@ async def test_client_rejects_two_stream_stores(client: Client): config["stream_stores"] = [MemoryStreamStore(), MemoryStreamStore()] with pytest.raises(ValueError, match="selector"): Client(**config) + + +def test_stream_error_carries_its_reason(): + err = StreamError( + "the Stream is closed", reason=StreamErrorReason.STREAM_ERROR_REASON_CLOSED + ) + assert err.type == "StreamError" + assert err.message == "the Stream is closed" + assert err.non_retryable + assert list(err.details) == [ + temporalio.api.stream.v1.StreamErrorDetails( + reason=StreamErrorReason.STREAM_ERROR_REASON_CLOSED + ) + ] + assert not StreamError( + "down", reason=StreamErrorReason.STREAM_ERROR_REASON_STORAGE_UNAVAILABLE + ).non_retryable + assert StreamError("x").reason == ( + StreamErrorReason.STREAM_ERROR_REASON_UNSPECIFIED + ) + + +def test_stream_error_from_application_error(): + from_failure = temporalio.exceptions._stream_error_from_failure + rebuilt = from_failure( + ApplicationError( + "the Stream exists", + temporalio.api.stream.v1.StreamErrorDetails( + reason=StreamErrorReason.STREAM_ERROR_REASON_ALREADY_EXISTS + ), + type="StreamError", + ) + ) + assert isinstance(rebuilt, StreamError) + assert rebuilt.reason == StreamErrorReason.STREAM_ERROR_REASON_ALREADY_EXISTS + assert rebuilt.message == "the Stream exists" + unknown = from_failure( + ApplicationError( + "x", + temporalio.api.stream.v1.StreamErrorDetails(reason=999), + type="StreamError", + ) + ) + assert unknown is not None + assert unknown.reason == StreamErrorReason.STREAM_ERROR_REASON_UNSPECIFIED + assert from_failure(ApplicationError("x", type="RuntimeError")) is None + assert from_failure(RuntimeError("x")) is None diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py index 482df2b58..b8e0e2e42 100644 --- a/tests/nexus/test_stream_store_workflow.py +++ b/tests/nexus/test_stream_store_workflow.py @@ -10,7 +10,7 @@ import temporalio.api.stream.v1 import temporalio.exceptions from temporalio import activity, workflow -from temporalio.api.enums.v1 import EventType +from temporalio.api.enums.v1 import EventType, StreamErrorReason from temporalio.client import ( Client, StreamStore, @@ -19,7 +19,7 @@ ) from temporalio.common import RetryPolicy from temporalio.converter import DataConverter -from temporalio.exceptions import ApplicationError +from temporalio.exceptions import ApplicationError, StreamError from temporalio.nexus.system.stream_service import ( ClientProducer, Producer, @@ -80,6 +80,8 @@ async def run(self, stream_id: str) -> str: stream = await create_stream(id=stream_id, body_type=str) try: await stream.append("a") + except StreamError as err: + return f"StreamError {StreamErrorReason.Name(err.reason)}: {err.message}" except ApplicationError as err: return f"ApplicationError {err.type}: {err.message}" except temporalio.exceptions.TimeoutError as err: @@ -165,10 +167,12 @@ def __init__( *, failures: int = 0, delay: timedelta = timedelta(), + error: Exception | None = None, ) -> None: super().__init__() self._options = options self._failures = failures + self._error = error or RuntimeError("append failed") self._delay = delay self.appends = 0 @@ -181,7 +185,7 @@ async def call(self, operation: str, request: bytes) -> bytes: self.appends += 1 await asyncio.sleep(self._delay.total_seconds()) if self.appends <= self._failures: - raise RuntimeError("append failed") + raise self._error return await super().call(operation, request) @@ -334,6 +338,17 @@ async def test_workflow_stream_store_error_reaches_workflow_code(client: Client) assert store.appends == 1 +async def test_workflow_stream_store_stream_error_reaches_workflow_code( + client: Client, +): + error = StreamError( + "the Stream is closed", reason=StreamErrorReason.STREAM_ERROR_REASON_CLOSED + ) + store = FlakyAppendStore(StreamStoreCallOptions(), failures=1, error=error) + result = await _append_result(client, store) + assert result == "StreamError STREAM_ERROR_REASON_CLOSED: the Stream is closed" + + async def test_workflow_stream_store_call_options_retry(client: Client): options = StreamStoreCallOptions( retry_policy=RetryPolicy( From 43654eb5d375eeea84a9cf093e883b790344b442 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 22:19:16 -0700 Subject: [PATCH 12/46] Added a memory Stream Store and moved the Stream tests to it. Tests now run on the same Stream runtime and rules as every Store on the bridge. They check Records and Producers with the generated read, so the fake Store is gone. --- temporalio/exceptions.py | 8 +- temporalio/testing/__init__.py | 2 + temporalio/testing/_stream_store.py | 73 ++++++++++ tests/helpers/stream_store.py | 80 ----------- tests/nexus/test_stream_store.py | 168 +++++++++++++++------- tests/nexus/test_stream_store_workflow.py | 112 +++++++++++---- 6 files changed, 278 insertions(+), 165 deletions(-) create mode 100644 temporalio/testing/_stream_store.py delete mode 100644 tests/helpers/stream_store.py diff --git a/temporalio/exceptions.py b/temporalio/exceptions.py index 94ca75b08..95cebb6e1 100644 --- a/temporalio/exceptions.py +++ b/temporalio/exceptions.py @@ -258,13 +258,17 @@ def _stream_error_from_reason(reason: int, message: str) -> StreamError: ) -def _stream_error_from_call(message: str, details: bytes) -> StreamError: +def _stream_error_from_call( # pyright: ignore[reportUnusedFunction] + message: str, details: bytes +) -> StreamError: # A Store on the bridge fails with the encoded details, as a gRPC status has them. proto = temporalio.api.stream.v1.StreamErrorDetails.FromString(details) return _stream_error_from_reason(proto.reason, message) -def _stream_error_from_failure(err: BaseException) -> StreamError | None: +def _stream_error_from_failure( # pyright: ignore[reportUnusedFunction] + err: BaseException, +) -> StreamError | None: # A Local Activity or a Nexus operation hands back a plain application error, so the # error is rebuilt from its type and details. if isinstance(err, StreamError): diff --git a/temporalio/testing/__init__.py b/temporalio/testing/__init__.py index 0cd652d86..5574a0a39 100644 --- a/temporalio/testing/__init__.py +++ b/temporalio/testing/__init__.py @@ -1,11 +1,13 @@ """Test framework for workflows and activities.""" from ._activity import ActivityEnvironment +from ._stream_store import MemoryStreamStore from ._timeskipping import TimeSkipper, TimeSkippingConfig from ._workflow import WorkflowEnvironment __all__ = [ "ActivityEnvironment", + "MemoryStreamStore", "TimeSkipper", "WorkflowEnvironment", "TimeSkippingConfig", diff --git a/temporalio/testing/_stream_store.py b/temporalio/testing/_stream_store.py new file mode 100644 index 000000000..2ae419093 --- /dev/null +++ b/temporalio/testing/_stream_store.py @@ -0,0 +1,73 @@ +"""A Stream Store in the memory of the process, for tests.""" + +from __future__ import annotations + +from datetime import timedelta + +import temporalio.bridge.streams +import temporalio.runtime +from temporalio.client import StreamStore + + +class MemoryStreamStore(StreamStore): + """A Stream Store that keeps its Streams in the memory of this process, for tests. + + It runs the same Stream runtime and rules as every other Store on the bridge. A + failed call raises :py:class:`temporalio.exceptions.StreamError`. + + .. warning:: + This API is experimental and subject to change. + """ + + def __init__( + self, + *, + name: str = "memory", + retention: timedelta | None = None, + last_write_ttl: timedelta | None = None, + runtime: temporalio.runtime.Runtime | None = None, + ) -> None: + """Create a memory Store. It builds its Stream runtime at its first call. + + Args: + name: Name of the Store, which the refs of its Streams carry. + retention: How long a closed Stream stays. ``None`` takes the default of + the Stream runtime. + last_write_ttl: How long an open Stream stays after its last write. + ``None`` keeps an open Stream until its close. + runtime: Runtime whose Tokio threads run the Store. ``None`` takes + :py:meth:`temporalio.runtime.Runtime.default` at the first call. + """ + for label, value in ( + ("retention", retention), + ("last_write_ttl", last_write_ttl), + ): + if value is not None and value < timedelta(): + raise ValueError(f"{label} must not be negative") + self._name = name + self._retention = retention + self._last_write_ttl = last_write_ttl + self._runtime = runtime + self._stream_runtime: temporalio.bridge.streams.StreamRuntime | None = None + + @property + def name(self) -> str: + """Name of the Store.""" + return self._name + + async def call(self, operation: str, request: bytes) -> bytes: + """Answer one Stream operation with the Stream runtime of the bridge. + + Raises: + temporalio.exceptions.StreamError: The call failed. + RuntimeError: This build of temporalio has no Stream runtime. + """ + if self._stream_runtime is None: + runtime = self._runtime or temporalio.runtime.Runtime.default() + self._stream_runtime = temporalio.bridge.streams.StreamRuntime.new_memory( + runtime._core_runtime, + name=self._name, + retention=self._retention, + last_write_ttl=self._last_write_ttl, + ) + return await self._stream_runtime.call(operation, request) diff --git a/tests/helpers/stream_store.py b/tests/helpers/stream_store.py deleted file mode 100644 index d3c7968de..000000000 --- a/tests/helpers/stream_store.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field - -import temporalio.api.enums.v1 -import temporalio.api.stream.v1 -import temporalio.api.streamservice.v1 -from temporalio.client import StreamStore - - -@dataclass -class _MemoryStream: - records: list[temporalio.api.stream.v1.StreamRecord] = field(default_factory=list) - closed: bool = False - - -class MemoryStreamStore(StreamStore): - def __init__(self) -> None: - self.streams: dict[str, _MemoryStream] = {} - - @property - def name(self) -> str: - return "memory" - - async def call(self, operation: str, request: bytes) -> bytes: - service = temporalio.api.streamservice.v1 - enums = temporalio.api.enums.v1 - if operation == "CreateStream": - create = service.CreateStreamRequest.FromString(request) - created = create.stream_ref.id not in self.streams - use_existing = ( - enums.StreamIdConflictPolicy.STREAM_ID_CONFLICT_POLICY_USE_EXISTING - ) - if not created and create.id_conflict_policy != use_existing: - raise RuntimeError(f"Stream {create.stream_ref.id} exists") - self.streams.setdefault(create.stream_ref.id, _MemoryStream()) - return service.CreateStreamResponse( - ref=create.stream_ref, created=created - ).SerializeToString() - if operation == "AppendRecords": - append = service.AppendRecordsRequest.FromString(request) - stream = self.streams[append.ref.id] - if stream.closed: - raise RuntimeError(f"Stream {append.ref.id} is closed") - for record in append.records: - offset = (len(stream.records) + 1).to_bytes(8, "big") - stream.records.append( - temporalio.api.stream.v1.StreamRecord( - body=record.body, producer=append.producer, offset=offset - ) - ) - appended = stream.records[len(stream.records) - len(append.records) :] - return service.AppendRecordsResponse( - first_offset=appended[0].offset if appended else b"", - last_offset=appended[-1].offset if appended else b"", - ).SerializeToString() - if operation == "ReadRecords": - read = service.ReadRecordsRequest.FromString(request) - stream = self.streams[read.ref.id] - start = int.from_bytes(read.from_offset, "big") if read.from_offset else 0 - page = stream.records[start : start + (read.max_records or 1000)] - return service.ReadRecordsResponse( - records=page, - last_offset=page[-1].offset if page else read.from_offset, - done=stream.closed and start + len(page) == len(stream.records), - ).SerializeToString() - if operation == "CloseStream": - close = service.CloseStreamRequest.FromString(request) - self.streams[close.ref.id].closed = True - return service.CloseStreamResponse().SerializeToString() - if operation == "DescribeStream": - describe = service.DescribeStreamRequest.FromString(request) - stream = self.streams[describe.ref.id] - return service.DescribeStreamResponse( - status=enums.StreamStatus.STREAM_STATUS_CLOSED - if stream.closed - else enums.StreamStatus.STREAM_STATUS_OPEN, - last_offset=stream.records[-1].offset if stream.records else b"", - ).SerializeToString() - raise ValueError(f"Unknown Stream operation {operation}") diff --git a/tests/nexus/test_stream_store.py b/tests/nexus/test_stream_store.py index 60e85acb0..7504fcab6 100644 --- a/tests/nexus/test_stream_store.py +++ b/tests/nexus/test_stream_store.py @@ -3,6 +3,7 @@ import ast import dataclasses import uuid +from collections.abc import Awaitable from datetime import datetime, timedelta, timezone from pathlib import Path from unittest import mock @@ -12,15 +13,15 @@ import temporalio import temporalio.activity -import temporalio.api.common.v1 import temporalio.api.stream.v1 -import temporalio.exceptions import temporalio.api.workflowservice.v1 +import temporalio.common +import temporalio.exceptions import temporalio.nexus.system import temporalio.nexus.system._support import temporalio.workflow -from temporalio.client import Client from temporalio.api.enums.v1 import StreamErrorReason +from temporalio.client import Client from temporalio.converter import DataConverter from temporalio.exceptions import ApplicationError, StreamError from temporalio.nexus.system.stream_service import ( @@ -28,72 +29,133 @@ DescribeStreamRequest, Producer, ProducerKindClient, + ProducerKindLocalActivity, + ProducerKindStandaloneActivity, + ProducerKindWorkflowActivity, + StreamIdConflictPolicy, StreamRef, StreamService, + StreamStatus, + Timestamp, ) from temporalio.nexus.system.stream_service.client import ( Stream, StreamServiceClientExtension, ) -from temporalio.testing import ActivityEnvironment -from tests.helpers.stream_store import MemoryStreamStore +from temporalio.testing import ActivityEnvironment, MemoryStreamStore from tests.test_converter import SimpleCodec -def _offset(n: int) -> bytes: - return n.to_bytes(8, "big") +def _with_store(client: Client, store: MemoryStreamStore) -> Client: + config = client.config() + config["stream_stores"] = [store] + return Client(**config) async def _create_stream(client: Client, store: MemoryStreamStore) -> Stream[str]: - config = client.config() - config["stream_stores"] = [store] - return await Client(**config).create_stream( + return await _with_store(client, store).create_stream( id=f"stream-{uuid.uuid4()}", body_type=str ) +async def _only_producer(stream: Stream[str]) -> Producer: + [record] = [record async for record in stream.iter_read_items()] + assert record.producer is not None + return record.producer + + async def _append_in_activity( client: Client, info: temporalio.activity.Info -) -> temporalio.api.stream.v1.Producer: - store = MemoryStreamStore() - stream = await _create_stream(client, store) +) -> Producer: + stream = await _create_stream(client, MemoryStreamStore()) env = ActivityEnvironment() env.info = info await env.run(stream.append, "a") - [record] = store.streams[stream.ref.id].records - return record.producer + return await _only_producer(stream) -def _timestamp(value: datetime) -> google.protobuf.timestamp_pb2.Timestamp: +def _timestamp(value: datetime) -> Timestamp: proto = google.protobuf.timestamp_pb2.Timestamp() proto.FromDatetime(value) - return proto + return Timestamp(seconds=proto.seconds, nanos=proto.nanos) + + +async def _stream_error(call: Awaitable[object]) -> StreamError: + with pytest.raises(StreamError) as err: + await call + return err.value async def test_client_stream_uses_store(client: Client): - config = client.config() - config["stream_stores"] = [MemoryStreamStore()] - stream_client = Client(**config) + stream_client = _with_store(client, MemoryStreamStore()) stream = await stream_client.create_stream( id=f"stream-{uuid.uuid4()}", body_type=str ) assert stream.created + assert stream.ref.store == "memory" appended = await stream.append("a", "b") - assert (appended.first_offset, appended.last_offset) == (_offset(1), _offset(2)) + assert appended.first_offset is not None + assert appended.last_offset is not None + assert appended.first_offset < appended.last_offset records = [record async for record in stream.iter_read_items()] assert [(record.body, record.offset) for record in records] == [ - ("a", _offset(1)), - ("b", _offset(2)), + ("a", appended.first_offset), + ("b", appended.last_offset), ] other = stream_client.get_stream(stream.ref, body_type=str) assert [record.body async for record in other.iter_read_items()] == ["a", "b"] + described = await stream.describe() + assert described.status == StreamStatus.StreamStatusOpen + assert described.last_offset == appended.last_offset + await stream.close() + described = await stream.describe() + assert described.status == StreamStatus.StreamStatusClosed + assert described.last_offset == appended.last_offset page = await stream.read(from_offset=records[-1].offset) assert page.done assert page.records == [] + pages = [page async for page in stream.iter_read()] + assert pages[-1].done + assert [record.body for page in pages for record in page.records] == ["a", "b"] + + +async def test_client_strict_create_of_existing_stream_fails(client: Client): + stream_client = _with_store(client, MemoryStreamStore()) + stream_id = f"stream-{uuid.uuid4()}" + await stream_client.create_stream(id=stream_id, body_type=str) + err = await _stream_error(stream_client.create_stream(id=stream_id, body_type=str)) + assert err.reason == StreamErrorReason.STREAM_ERROR_REASON_ALREADY_EXISTS + assert err.non_retryable + existing = await stream_client.create_stream( + id=stream_id, + body_type=str, + id_conflict_policy=StreamIdConflictPolicy.StreamIdConflictPolicyUseExisting, + ) + # The response's `created` is false, which the generated model reads as unset. + assert not existing.created + + +async def test_client_read_or_describe_of_unknown_stream_fails(client: Client): + stream_client = _with_store(client, MemoryStreamStore()) + stream = stream_client.get_stream( + StreamRef(id=f"stream-{uuid.uuid4()}"), body_type=str + ) + for call in (stream.read(), stream.describe()): + err = await _stream_error(call) + assert err.reason == StreamErrorReason.STREAM_ERROR_REASON_NOT_FOUND + + +async def test_client_append_after_close_fails(client: Client): + stream = await _create_stream(client, MemoryStreamStore()) + await stream.append("a") + await stream.close() + err = await _stream_error(stream.append("b")) + assert err.reason == StreamErrorReason.STREAM_ERROR_REASON_CLOSED + assert err.type == "StreamError" async def test_client_stream_store_uses_payload_codec(client: Client): @@ -107,22 +169,21 @@ async def test_client_stream_store_uses_payload_codec(client: Client): id=f"stream-{uuid.uuid4()}", body_type=str ) await stream.append("a", "b") - stored = [record.body for record in store.streams[stream.ref.id].records] + # A client without the codec reads the bodies as the Store keeps them. + raw = _with_store(client, store).get_stream( + StreamRef(id=stream.ref.id), body_type=temporalio.common.RawValue + ) + stored = [record.body.payload async for record in raw.iter_read_items()] assert [body.metadata["simple-codec"] for body in stored] == [b"true", b"true"] - assert [ - temporalio.api.common.v1.Payloads.FromString(body.data).payloads[0].data - for body in stored - ] == [b'"a"', b'"b"'] assert [record.body async for record in stream.iter_read_items()] == ["a", "b"] async def test_client_append_has_client_producer(client: Client): - store = MemoryStreamStore() - stream = await _create_stream(client, store) + stream = await _create_stream(client, MemoryStreamStore()) await stream.append("a") - [record] = store.streams[stream.ref.id].records - assert record.producer.id.startswith("client:") - assert record.producer.WhichOneof("kind") == "client" + producer = await _only_producer(stream) + assert producer.id.startswith("client:") + assert isinstance(producer.kind, ProducerKindClient) async def test_workflow_activity_append_has_activity_producer(client: Client): @@ -145,10 +206,10 @@ async def test_workflow_activity_append_has_activity_producer(client: Client): * 1000 ) assert producer.id == f"act:run-1:step%3A1:{epoch_nanos}" - assert producer.WhichOneof("kind") == "workflow_activity" - assert producer.workflow_activity.run_id == "run-1" - assert producer.workflow_activity.epoch.attempt == 3 - assert producer.workflow_activity.epoch.attempt_scheduled_time == _timestamp( + assert isinstance(producer.kind, ProducerKindWorkflowActivity) + assert producer.kind.value.run_id == "run-1" + assert producer.kind.value.epoch.attempt == 3 + assert producer.kind.value.epoch.attempt_scheduled_time == _timestamp( attempt_scheduled ) @@ -170,10 +231,10 @@ async def test_standalone_activity_append_has_activity_producer(client: Client): ), ) assert producer.id == "sa:job:job-run" - assert producer.WhichOneof("kind") == "standalone_activity" - assert producer.standalone_activity.run_id == "" - assert producer.standalone_activity.epoch.attempt == 2 - assert producer.standalone_activity.epoch.attempt_scheduled_time == _timestamp( + assert isinstance(producer.kind, ProducerKindStandaloneActivity) + assert not producer.kind.value.run_id + assert producer.kind.value.epoch.attempt == 2 + assert producer.kind.value.epoch.attempt_scheduled_time == _timestamp( attempt_scheduled ) @@ -194,12 +255,12 @@ async def test_local_activity_append_has_local_activity_producer(client: Client) ), ) assert producer.id == "la:run%3A1:3" - assert producer.WhichOneof("kind") == "local_activity" - assert producer.local_activity.run_id == "run:1" - assert producer.local_activity.epoch.task.started_event_id == 7 - assert producer.local_activity.epoch.task.attempt == 1 - assert producer.local_activity.epoch.task.started_time == _timestamp(task_started) - assert producer.local_activity.epoch.attempt == 2 + assert isinstance(producer.kind, ProducerKindLocalActivity) + assert producer.kind.value.run_id == "run:1" + assert producer.kind.value.epoch.task.started_event_id == 7 + assert producer.kind.value.epoch.task.attempt == 1 + assert producer.kind.value.epoch.task.started_time == _timestamp(task_started) + assert producer.kind.value.epoch.attempt == 2 async def test_local_activity_append_without_workflow_task_fails(client: Client): @@ -211,8 +272,7 @@ async def test_local_activity_append_without_workflow_task_fails(client: Client) async def test_explicit_producer_is_sent(client: Client): - store = MemoryStreamStore() - stream = await _create_stream(client, store) + stream = await _create_stream(client, MemoryStreamStore()) with mock.patch.object( temporalio.nexus.system._support, "default_producer" ) as default_producer: @@ -220,9 +280,9 @@ async def test_explicit_producer_is_sent(client: Client): "a", producer=Producer(id="mine", kind=ProducerKindClient(ClientProducer())) ) default_producer.assert_not_called() - [record] = store.streams[stream.ref.id].records - assert record.producer.id == "mine" - assert record.producer.WhichOneof("kind") == "client" + producer = await _only_producer(stream) + assert producer.id == "mine" + assert isinstance(producer.kind, ProducerKindClient) def test_client_stream_methods_are_generated(): @@ -332,7 +392,9 @@ def test_stream_error_from_application_error(): unknown = from_failure( ApplicationError( "x", - temporalio.api.stream.v1.StreamErrorDetails(reason=999), + temporalio.api.stream.v1.StreamErrorDetails( + reason=StreamErrorReason.ValueType(999) + ), type="StreamError", ) ) diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py index b8e0e2e42..007c01527 100644 --- a/tests/nexus/test_stream_store_workflow.py +++ b/tests/nexus/test_stream_store_workflow.py @@ -7,7 +7,7 @@ import pytest -import temporalio.api.stream.v1 +import temporalio.common import temporalio.exceptions from temporalio import activity, workflow from temporalio.api.enums.v1 import EventType, StreamErrorReason @@ -26,9 +26,11 @@ ProducerKindClient, ProducerKindLocalActivity, StreamIdConflictPolicy, + StreamRecord, StreamRef, ) from temporalio.nexus.system.stream_service.workflow import create_stream, get_stream +from temporalio.testing import MemoryStreamStore from temporalio.worker import ( ActivityInboundInterceptor, ExecuteActivityInput, @@ -36,7 +38,6 @@ Replayer, ) from tests.helpers import assert_task_fail_eventually, new_worker -from tests.helpers.stream_store import MemoryStreamStore from tests.test_converter import SimpleCodec # Workflow code writes these values, so the History shows only whether a Record body @@ -89,6 +90,19 @@ async def run(self, stream_id: str) -> str: return "appended" +@workflow.defn +class AppendToStreamWorkflow: + @workflow.run + async def run(self, stream_id: str) -> str: + stream = get_stream(StreamRef(id=stream_id), body_type=str) + try: + await stream.append("a") + except StreamError as err: + assert isinstance(err, ApplicationError) + return StreamErrorReason.Name(err.reason) + return "appended" + + @activity.defn async def user_activity() -> None: pass @@ -167,12 +181,10 @@ def __init__( *, failures: int = 0, delay: timedelta = timedelta(), - error: Exception | None = None, ) -> None: super().__init__() self._options = options self._failures = failures - self._error = error or RuntimeError("append failed") self._delay = delay self.appends = 0 @@ -185,7 +197,7 @@ async def call(self, operation: str, request: bytes) -> bytes: self.appends += 1 await asyncio.sleep(self._delay.total_seconds()) if self.appends <= self._failures: - raise self._error + raise RuntimeError("append failed") return await super().call(operation, request) @@ -217,6 +229,17 @@ def _with_store(client: Client, store: StreamStore) -> Client: return Client(**config) +async def _read_all(client: Client, stream_id: str) -> list[StreamRecord[str]]: + stream = client.get_stream(StreamRef(id=stream_id), body_type=str) + return [record async for record in stream.iter_read_items()] + + +def _producers(records: list[StreamRecord[str]]) -> list[Producer]: + producers = [record.producer for record in records] + assert all(producer is not None for producer in producers) + return [producer for producer in producers if producer is not None] + + async def _run_append_workflow( client: Client, ) -> tuple[WorkflowHandle[AppendWorkflow, StreamRef[str]], StreamRef[str]]: @@ -269,7 +292,11 @@ async def test_workflow_stream_store_uses_payload_codec(client: Client): stream_client = Client(**config) _, ref = await _run_append_workflow(stream_client) - stored = [record.body for record in store.streams[ref.id].records] + # A client without the codec reads the bodies as the Store keeps them. + raw = _with_store(client, store).get_stream( + StreamRef(id=ref.id), body_type=temporalio.common.RawValue + ) + stored = [record.body.payload async for record in raw.iter_read_items()] assert [body.metadata["simple-codec"] for body in stored] == [b"true", b"true"] stream = stream_client.get_stream(ref, body_type=str) assert [record.body async for record in stream.iter_read_items()] == _BODIES @@ -338,15 +365,41 @@ async def test_workflow_stream_store_error_reaches_workflow_code(client: Client) assert store.appends == 1 -async def test_workflow_stream_store_stream_error_reaches_workflow_code( +async def test_workflow_append_to_closed_stream_raises_stream_error(client: Client): + stream_client = _with_store(client, MemoryStreamStore()) + stream = await stream_client.create_stream( + id=f"stream-{uuid.uuid4()}", body_type=str + ) + await stream.close() + async with new_worker(stream_client, AppendToStreamWorkflow) as worker: + reason = await stream_client.execute_workflow( + AppendToStreamWorkflow.run, + stream.ref.id, + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + assert reason == "STREAM_ERROR_REASON_CLOSED" + + +async def test_client_append_after_workflow_append_keeps_offset_order( client: Client, ): - error = StreamError( - "the Stream is closed", reason=StreamErrorReason.STREAM_ERROR_REASON_CLOSED - ) - store = FlakyAppendStore(StreamStoreCallOptions(), failures=1, error=error) - result = await _append_result(client, store) - assert result == "StreamError STREAM_ERROR_REASON_CLOSED: the Stream is closed" + stream_client = _with_store(client, MemoryStreamStore()) + _, ref = await _run_append_workflow(stream_client) + stream = stream_client.get_stream(ref, body_type=str) + appended = await stream.append("from-client") + records = await _read_all(stream_client, ref.id) + assert [record.body for record in records] == [*_BODIES, "from-client"] + offsets = [record.offset for record in records] + assert offsets == sorted(offsets) + assert len(set(offsets)) == len(offsets) + assert offsets[-1] == appended.last_offset + kinds = [type(producer.kind) for producer in _producers(records)] + assert kinds == [ + ProducerKindLocalActivity, + ProducerKindLocalActivity, + ProducerKindClient, + ] async def test_workflow_stream_store_call_options_retry(client: Client): @@ -395,8 +448,7 @@ async def test_activity_interceptors_skip_stream_store_activity(client: Client): async def test_workflow_task_retry_appends_with_higher_epoch(client: Client): global _fail_next_task _fail_next_task = True - store = MemoryStreamStore() - stream_client = _with_store(client, store) + stream_client = _with_store(client, MemoryStreamStore()) stream_id = f"stream-{uuid.uuid4()}" async with new_worker(stream_client, AppendThenFailTaskOnceWorkflow) as worker: await stream_client.execute_workflow( @@ -405,19 +457,19 @@ async def test_workflow_task_retry_appends_with_higher_epoch(client: Client): id=f"workflow-{uuid.uuid4()}", task_queue=worker.task_queue, ) - first, second = (record.producer for record in store.streams[stream_id].records) + first, second = _producers(await _read_all(stream_client, stream_id)) assert first.id == second.id - def epoch(producer: temporalio.api.stream.v1.Producer) -> tuple[int, int, int]: - local = producer.local_activity.epoch + def epoch(producer: Producer) -> tuple[int, int, int]: + assert isinstance(producer.kind, ProducerKindLocalActivity) + local = producer.kind.value.epoch return (local.task.started_event_id, local.task.attempt, local.attempt) assert epoch(second) > epoch(first) async def test_user_local_activity_append_has_local_activity_producer(client: Client): - store = MemoryStreamStore() - stream_client = _with_store(client, store) + stream_client = _with_store(client, MemoryStreamStore()) stream = await stream_client.create_stream( id=f"stream-{uuid.uuid4()}", body_type=str ) @@ -433,17 +485,17 @@ async def test_user_local_activity_append_has_local_activity_producer(client: Cl task_queue=worker.task_queue, ) await handle.result() - [record] = store.streams[stream.ref.id].records + [producer] = _producers(await _read_all(stream_client, stream.ref.id)) run_id = handle.result_run_id - assert record.producer.id == f"la:{run_id}:1" - assert record.producer.local_activity.run_id == run_id - assert record.producer.local_activity.epoch.task.started_event_id > 0 - assert record.producer.local_activity.epoch.attempt == 1 + assert producer.id == f"la:{run_id}:1" + assert isinstance(producer.kind, ProducerKindLocalActivity) + assert producer.kind.value.run_id == run_id + assert producer.kind.value.epoch.task.started_event_id > 0 + assert producer.kind.value.epoch.attempt == 1 async def test_workflow_explicit_producer_reaches_store(client: Client): - store = MemoryStreamStore() - stream_client = _with_store(client, store) + stream_client = _with_store(client, MemoryStreamStore()) stream_id = f"stream-{uuid.uuid4()}" async with new_worker(stream_client, ExplicitProducerWorkflow) as worker: await stream_client.execute_workflow( @@ -452,6 +504,6 @@ async def test_workflow_explicit_producer_reaches_store(client: Client): id=f"workflow-{uuid.uuid4()}", task_queue=worker.task_queue, ) - [record] = store.streams[stream_id].records - assert record.producer.id == "mine" - assert record.producer.WhichOneof("kind") == "client" + [producer] = _producers(await _read_all(stream_client, stream_id)) + assert producer.id == "mine" + assert isinstance(producer.kind, ProducerKindClient) From 7337df1e812017b919ee88b8bf4c9a21131a05f9 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 22:19:58 -0700 Subject: [PATCH 13/46] Gave a Workflow's Stream create the chain's first run id. Each run of a chain sends the same strict create. With one request id for the chain, the Store answers a later run's create as created and not as ALREADY_EXISTS. --- temporalio/nexus/system/_support.py | 5 ++-- tests/nexus/test_stream_store_workflow.py | 35 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/temporalio/nexus/system/_support.py b/temporalio/nexus/system/_support.py index e943c2481..55f53b59a 100644 --- a/temporalio/nexus/system/_support.py +++ b/temporalio/nexus/system/_support.py @@ -38,9 +38,10 @@ class SignalWithStartWorkflowModelRequest(typing.Protocol): def request_id() -> str: import temporalio.workflow - # Workflow code must get the same id on replay. + # Each run of a chain sends the same create, and the Store answers a create with the + # request id that made the Stream as a success. One id for the chain also replays. if temporalio.workflow.in_workflow(): - return str(temporalio.workflow.uuid4()) + return temporalio.workflow.info().first_execution_run_id return str(uuid.uuid4()) diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py index 007c01527..d78f2d3df 100644 --- a/tests/nexus/test_stream_store_workflow.py +++ b/tests/nexus/test_stream_store_workflow.py @@ -103,6 +103,17 @@ async def run(self, stream_id: str) -> str: return "appended" +@workflow.defn +class CreateThenContinueAsNewWorkflow: + @workflow.run + async def run(self, stream_id: str, run: int) -> list[bool]: + stream = await create_stream(id=stream_id, body_type=str) + await stream.append(f"run-{run}") + if run == 1: + workflow.continue_as_new(args=[stream_id, 2]) + return [bool(stream.created)] + + @activity.defn async def user_activity() -> None: pass @@ -381,6 +392,30 @@ async def test_workflow_append_to_closed_stream_raises_stream_error(client: Clie assert reason == "STREAM_ERROR_REASON_CLOSED" +async def test_strict_create_in_each_run_of_a_chain_succeeds(client: Client): + stream_client = _with_store(client, MemoryStreamStore()) + stream_id = f"stream-{uuid.uuid4()}" + async with new_worker(stream_client, CreateThenContinueAsNewWorkflow) as worker: + handle = await stream_client.start_workflow( + CreateThenContinueAsNewWorkflow.run, + args=[stream_id, 1], + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + assert await handle.result() == [True] + first_run_id = handle.first_execution_run_id + last_run = await stream_client.get_workflow_handle(handle.id).describe() + assert first_run_id + assert last_run.run_id != first_run_id + records = await _read_all(stream_client, stream_id) + assert [record.body for record in records] == ["run-1", "run-2"] + first, second = _producers(records) + assert isinstance(first.kind, ProducerKindLocalActivity) + assert isinstance(second.kind, ProducerKindLocalActivity) + assert first.kind.value.run_id == first_run_id + assert second.kind.value.run_id == last_run.run_id + + async def test_client_append_after_workflow_append_keeps_offset_order( client: Client, ): From 7c8932143dcbca3758f2e989979f0d2c1592c870 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 22:25:17 -0700 Subject: [PATCH 14/46] Moved Core to the head of the Stream crate. The Redis Store crate pins this Core commit, so both crates now share one Stream runtime. --- 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 42614ce4d..4bb74a0d9 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 42614ce4d9d4dfafe8c25afd34b46e6077efeebc +Subproject commit 4bb74a0d9a181017f2a8dfb056e21d480cd4bfa3 From e83221c6e472bb9a33daee65145a02f43b7d7428 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 22:25:56 -0700 Subject: [PATCH 15/46] Kept the linters out of the Core submodule. Core's crate has a Python script of its test vectors. A repo doesn't lint its submodule, so each linter now skips it. --- pyproject.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bf0c16cd4..c66631a27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -176,6 +176,8 @@ exclude = [ 'temporalio/bridge/proto', 'temporalio/nexus/system/workflow_service', 'temporalio/nexus/system/stream_service', + # The Core submodule, which has a script of the crate's test vectors + 'temporalio/bridge/sdk-core', ] [[tool.mypy.overrides]] @@ -189,7 +191,7 @@ ignore_errors = true [tool.pydocstyle] convention = "google" # https://github.com/PyCQA/pydocstyle/issues/363#issuecomment-625563088 -match_dir = "^(?!(docs|scripts|tests|api|proto|system|_support|notifications|\\.)).*" +match_dir = "^(?!(docs|scripts|tests|api|proto|system|_support|notifications|sdk-core|\\.)).*" add_ignore = [ # We like to wrap at a certain number of chars, even long summary sentences. # https://github.com/PyCQA/pydocstyle/issues/184 @@ -251,10 +253,14 @@ exclude = [ "temporalio/bridge/proto", "temporalio/bridge/_visitor.py", "tests/worker/workflow_sandbox/testmodules/proto", + # The Core submodule, which has a script of the crate's test vectors + "temporalio/bridge/sdk-core", ] [tool.ruff] target-version = "py311" +# The Core submodule, which has a script of the crate's test vectors +extend-exclude = ["temporalio/bridge/sdk-core"] [build-system] requires = ["maturin>=1.0,<2.0"] From 7f4efeb0a2add4f588164144e15df0a3ab17b746 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 22:36:34 -0700 Subject: [PATCH 16/46] Bound the Redis Stream runtime in the bridge. The Redis Store lives in its own crate, so the bridge pins it by commit under the `streams` feature. Python passes typed options and Rust fills the crate's config, so Python needs no copy of its proto. --- temporalio/bridge/Cargo.lock | 129 +++++++++++++++++++++++++++++++ temporalio/bridge/Cargo.toml | 20 ++++- temporalio/bridge/src/lib.rs | 45 +++++++++++ temporalio/bridge/src/streams.rs | 66 +++++++++++++++- temporalio/bridge/streams.py | 43 ++++++++++- 5 files changed, 296 insertions(+), 7 deletions(-) diff --git a/temporalio/bridge/Cargo.lock b/temporalio/bridge/Cargo.lock index a679e1b67..d7893d9e6 100644 --- a/temporalio/bridge/Cargo.lock +++ b/temporalio/bridge/Cargo.lock @@ -29,6 +29,32 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arc-swap" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84a22b5d51209f6de83d37a0b33d320acb8da88823e42a94c075140708f032a5" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + +[[package]] +name = "async-lock" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0876700b0d007ad25d5508865a4259c3af9af6d296bf663a0d586d0bc3369f6f" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -315,7 +341,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", + "futures-core", "memchr", + "pin-project-lite", + "tokio", + "tokio-util", ] [[package]] @@ -370,6 +400,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crc16" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" + [[package]] name = "crc32fast" version = "1.5.5" @@ -586,6 +622,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1549,6 +1605,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2089,6 +2151,39 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "redis" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db7f3d75b5a3301746bff39a767bf4c5c42506b08513527308b3875bfb64a031" +dependencies = [ + "arc-swap", + "arcstr", + "async-lock", + "backon", + "bytes", + "cfg-if", + "combine", + "crc16", + "futures-channel", + "futures-util", + "itoa", + "log", + "percent-encoding", + "pin-project-lite", + "rand 0.10.3", + "rustls", + "rustls-native-certs", + "ryu", + "sha1_smol", + "socket2", + "tokio", + "tokio-rustls", + "tokio-util", + "url", + "xxhash-rust", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2438,6 +2533,12 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -2653,6 +2754,7 @@ dependencies = [ "temporalio-common", "temporalio-sdk-core", "temporalio-streams", + "temporalio-streams-redis", "tokio", "tokio-rustls", "tokio-stream", @@ -2851,6 +2953,27 @@ dependencies = [ "tonic", ] +[[package]] +name = "temporalio-streams-redis" +version = "0.1.0" +source = "git+https://github.com/temporal-community/sdk-streams?rev=992c0c78414f9b37c2c3bf9a2ab45def2b31fe9d#992c0c78414f9b37c2c3bf9a2ab45def2b31fe9d" +dependencies = [ + "async-trait", + "aws-lc-rs", + "crc16", + "parking_lot", + "prost", + "prost-wkt-types", + "redis", + "temporalio-common", + "temporalio-streams", + "thiserror", + "tokio", + "tonic-prost-build", + "tracing", + "url", +] + [[package]] name = "termtree" version = "0.5.1" @@ -3712,6 +3835,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xxhash-rust" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" + [[package]] name = "yoke" version = "0.8.4" diff --git a/temporalio/bridge/Cargo.toml b/temporalio/bridge/Cargo.toml index 5b50670b6..83262a8ba 100644 --- a/temporalio/bridge/Cargo.toml +++ b/temporalio/bridge/Cargo.toml @@ -29,17 +29,20 @@ default = ["tls-ring"] tls-ring = [ "temporalio-sdk-core/tls-ring", "temporalio-client/tls-ring", + "temporalio-streams-redis?/tls-ring", "tokio-rustls/ring", ] tls-aws-lc = [ "temporalio-sdk-core/tls-aws-lc", "temporalio-client/tls-aws-lc", + "temporalio-streams-redis?/tls-aws-lc", "tokio-rustls/aws_lc_rs", ] -fips = ["tls-aws-lc", "dep:aws-lc-rs"] -# Off by default, so a source build doesn't compile the Stream runtime. The official builds turn it -# on. -streams = ["dep:temporalio-streams"] +fips = ["tls-aws-lc", "dep:aws-lc-rs", "temporalio-streams-redis?/fips"] +# Off by default, so a source build doesn't compile the Stream runtime and its Redis client. +# The official builds turn it on. Cargo fetches the git dependency below for every build, with +# this feature off too. +streams = ["dep:temporalio-streams", "dep:temporalio-streams-redis"] [dependencies] anyhow = "1.0" @@ -77,6 +80,8 @@ temporalio-sdk-core = { version = "0.11.0", path = "./sdk-core/crates/sdk-core", ] } # The Stream runtime and its memory Store, from the submodule. temporalio-streams = { path = "./sdk-core/crates/streams", optional = true } +# The Redis Store, which lives in its own repo. +temporalio-streams-redis = { git = "https://github.com/temporal-community/sdk-streams", rev = "992c0c78414f9b37c2c3bf9a2ab45def2b31fe9d", default-features = false, optional = true } tokio = "1.26" # Matches the sdk-core client crate's rustls stack; used to build the custom # server certificate verifier for ClientTlsConfig.verification_server_name. @@ -89,6 +94,13 @@ tonic = "0.14" tracing = "0.1" url = "2.2" +# The Redis crate takes temporalio-common and temporalio-streams from crates.io. This makes it use +# the submodule's crates, so the bridge sees one copy of each type. The versions must be semver +# compatible. +[patch.crates-io] +temporalio-common = { path = "./sdk-core/crates/common" } +temporalio-streams = { path = "./sdk-core/crates/streams" } + [profile.release] opt-level = 3 debug = false diff --git a/temporalio/bridge/src/lib.rs b/temporalio/bridge/src/lib.rs index 83f81d60a..e4cd6cad2 100644 --- a/temporalio/bridge/src/lib.rs +++ b/temporalio/bridge/src/lib.rs @@ -55,6 +55,7 @@ fn temporal_sdk_bridge(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { { m.add_class::()?; m.add_function(wrap_pyfunction!(new_memory_stream_runtime, m)?)?; + m.add_function(wrap_pyfunction!(new_redis_stream_runtime, m)?)?; } // Testing stuff @@ -125,6 +126,50 @@ fn new_memory_stream_runtime( streams::new_memory_stream_runtime(runtime_ref, store_name, retention, last_write_ttl) } +#[cfg(feature = "streams")] +#[pyfunction] +#[pyo3(signature = ( + runtime_ref, + urls, + *, + cluster, + key_prefix, + retention, + last_write_ttl, + response_timeout, + store_name, + wait_replicas, + wait_timeout, +))] +#[allow(clippy::too_many_arguments)] +fn new_redis_stream_runtime( + runtime_ref: &runtime::RuntimeRef, + urls: Vec, + cluster: bool, + key_prefix: Option, + retention: Option, + last_write_ttl: Option, + response_timeout: Option, + store_name: String, + wait_replicas: i32, + wait_timeout: Option, +) -> PyResult { + streams::new_redis_stream_runtime( + runtime_ref, + streams::RedisStreamOptions { + urls, + cluster, + key_prefix, + retention, + last_write_ttl, + response_timeout, + store_name, + wait_replicas, + wait_timeout, + }, + ) +} + #[pyfunction] fn start_dev_server<'a>( py: Python<'a>, diff --git a/temporalio/bridge/src/streams.rs b/temporalio/bridge/src/streams.rs index 3bfae31c3..4505118fb 100644 --- a/temporalio/bridge/src/streams.rs +++ b/temporalio/bridge/src/streams.rs @@ -1,5 +1,6 @@ -//! Bindings to the Stream runtime of the `temporalio-streams` crate. Python calls the five Stream -//! service operations by name, with the encoded request, and gets the encoded response. +//! Bindings to the Stream runtime of the `temporalio-streams` crate, on the memory Store or on the +//! Redis Store of `temporalio-streams-redis`. Python calls the five Stream service operations by +//! name, with the encoded request, and gets the encoded response. use pyo3::exceptions::PyException; #[cfg(feature = "streams")] @@ -66,3 +67,64 @@ pub fn new_memory_stream_runtime( ); Ok(StreamRuntimeRef { streams, runtime }) } + +/// The options of the Redis Store, as Python passes them. A `None` takes the crate's default. +#[cfg(feature = "streams")] +pub struct RedisStreamOptions { + pub urls: Vec, + pub cluster: bool, + pub key_prefix: Option, + pub retention: Option, + pub last_write_ttl: Option, + pub response_timeout: Option, + pub store_name: String, + pub wait_replicas: i32, + pub wait_timeout: Option, +} + +/// Builds the runtime on Redis. It connects at its first call, so a server that is down fails that +/// call and not this one. A bad option raises `ValueError` with the crate's message. +#[cfg(feature = "streams")] +pub fn new_redis_stream_runtime( + runtime_ref: &runtime::RuntimeRef, + options: RedisStreamOptions, +) -> PyResult { + use pyo3::exceptions::PyValueError; + use temporalio_streams_redis::{proto::RedisStreamConfig, RedisStreamRuntime, RuntimeOptions}; + + let runtime = runtime_ref.runtime.clone(); + runtime.assert_same_process("create a Stream runtime")?; + // The crate reads an empty or zero field as its default, so `None` maps to that. + let config = RedisStreamConfig { + urls: options.urls, + cluster: options.cluster, + key_prefix: options.key_prefix.unwrap_or_default(), + retention: proto_duration("retention", options.retention)?, + last_write_ttl: proto_duration("last_write_ttl", options.last_write_ttl)?, + response_timeout: proto_duration("response_timeout", options.response_timeout)?, + store_name: options.store_name, + wait_replicas: options.wait_replicas, + wait_timeout: proto_duration("wait_timeout", options.wait_timeout)?, + ..Default::default() + }; + let options = + RuntimeOptions::try_from(config).map_err(|err| PyValueError::new_err(err.to_string()))?; + // The runtime can spawn Tokio tasks, which need an entered Tokio runtime. + let _guard = runtime.core.tokio_handle().enter(); + let meter = runtime.core.telemetry().get_temporal_metric_meter(); + let streams = RedisStreamRuntime::create(options, meter); + Ok(StreamRuntimeRef { streams, runtime }) +} + +// The config's durations are the `prost-wkt-types` type that the Redis crate's protos use. The +// bridge doesn't depend on that crate, so the field's type picks the conversion. +#[cfg(feature = "streams")] +fn proto_duration(field: &str, value: Option) -> PyResult> +where + T: TryFrom, + T::Error: std::fmt::Display, +{ + value.map(T::try_from).transpose().map_err(|err| { + pyo3::exceptions::PyValueError::new_err(format!("The {field} is too long: {err}")) + }) +} diff --git a/temporalio/bridge/streams.py b/temporalio/bridge/streams.py index 9ced5c207..8b1a6b967 100644 --- a/temporalio/bridge/streams.py +++ b/temporalio/bridge/streams.py @@ -5,6 +5,7 @@ from __future__ import annotations +from collections.abc import Sequence from datetime import timedelta from typing import Any @@ -38,8 +39,48 @@ def new_memory( raise RuntimeError("This build of temporalio has no Stream runtime") return StreamRuntime(new(runtime._ref, name, retention, last_write_ttl)) + @staticmethod + def new_redis( + runtime: temporalio.bridge.runtime.Runtime, + *, + urls: Sequence[str], + cluster: bool, + key_prefix: str | None, + retention: timedelta | None, + last_write_ttl: timedelta | None, + response_timeout: timedelta | None, + name: str, + wait_replicas: int, + wait_timeout: timedelta | None, + ) -> StreamRuntime: + """Build the runtime on Redis. It connects at its first call. + + Raises: + RuntimeError: The bridge build has no Stream runtime. + ValueError: An option is bad. + """ + new = getattr( + temporalio.bridge.temporal_sdk_bridge, "new_redis_stream_runtime", None + ) + if new is None: + raise RuntimeError("This build of temporalio has no Stream runtime") + return StreamRuntime( + new( + runtime._ref, + list(urls), + cluster=cluster, + key_prefix=key_prefix, + retention=retention, + last_write_ttl=last_write_ttl, + response_timeout=response_timeout, + store_name=name, + wait_replicas=wait_replicas, + wait_timeout=wait_timeout, + ) + ) + def __init__(self, ref: Any) -> None: - """Wrap the bridge's runtime object. Use ``new_memory`` instead.""" + """Wrap the bridge's runtime object. Use ``new_memory`` or ``new_redis`` instead.""" self._ref = ref async def call(self, operation: str, request: bytes) -> bytes: From 307a5cde2473737d742c02f662e8f59d8aea95ac Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 22:36:43 -0700 Subject: [PATCH 17/46] Added the Redis Stream Store with its tests. It runs the bridge's Stream runtime on Redis and connects at its first call. The memory Store now shares its lazy runtime and its call with it through one private base. --- temporalio/bridge/streams.py | 56 +++++++ temporalio/contrib/redis/__init__.py | 1 + temporalio/contrib/redis/streams.py | 120 +++++++++++++++ temporalio/testing/_stream_store.py | 47 ++---- tests/contrib/redis/__init__.py | 0 tests/contrib/redis/test_streams.py | 213 +++++++++++++++++++++++++++ 6 files changed, 405 insertions(+), 32 deletions(-) create mode 100644 temporalio/contrib/redis/__init__.py create mode 100644 temporalio/contrib/redis/streams.py create mode 100644 tests/contrib/redis/__init__.py create mode 100644 tests/contrib/redis/test_streams.py diff --git a/temporalio/bridge/streams.py b/temporalio/bridge/streams.py index 8b1a6b967..1ff5e9cb3 100644 --- a/temporalio/bridge/streams.py +++ b/temporalio/bridge/streams.py @@ -5,13 +5,16 @@ from __future__ import annotations +from abc import abstractmethod from collections.abc import Sequence from datetime import timedelta from typing import Any import temporalio.bridge.runtime import temporalio.bridge.temporal_sdk_bridge +import temporalio.client import temporalio.exceptions +import temporalio.runtime StreamCallError: type[Exception] = temporalio.bridge.temporal_sdk_bridge.StreamCallError @@ -96,3 +99,56 @@ async def call(self, operation: str, request: bytes) -> bytes: raise temporalio.exceptions._stream_error_from_call( message, details ) from None + + +class _BridgeStreamStore(temporalio.client.StreamStore): # pyright: ignore[reportUnusedClass] + """A Store that runs on the Stream runtime of the bridge. + + It builds the runtime at its first call, so the Store can be built before the + Runtime and its Tokio threads exist. + """ + + def __init__( + self, + *, + name: str, + runtime: temporalio.runtime.Runtime | None, + call_options: temporalio.client.StreamStoreCallOptions | None = None, + ) -> None: + self._name = name + self._runtime = runtime + self._call_options = call_options + self._stream_runtime: StreamRuntime | None = None + + @property + def name(self) -> str: + """Name of the Store.""" + return self._name + + @property + def call_options(self) -> temporalio.client.StreamStoreCallOptions: + """Options of the Local Activity that calls this Store for Workflow code.""" + return self._call_options or super().call_options + + @abstractmethod + def _new_stream_runtime( + self, runtime: temporalio.bridge.runtime.Runtime + ) -> StreamRuntime: ... + + async def call(self, operation: str, request: bytes) -> bytes: + """Answer one Stream operation with the Stream runtime of the bridge. + + Raises: + temporalio.exceptions.StreamError: The call failed. + RuntimeError: This build of temporalio has no Stream runtime. + """ + if self._stream_runtime is None: + runtime = self._runtime or temporalio.runtime.Runtime.default() + self._stream_runtime = self._new_stream_runtime(runtime._core_runtime) + return await self._stream_runtime.call(operation, request) + + +def _check_not_negative(**durations: timedelta | None) -> None: # pyright: ignore[reportUnusedFunction] + for label, value in durations.items(): + if value is not None and value < timedelta(): + raise ValueError(f"{label} must not be negative") diff --git a/temporalio/contrib/redis/__init__.py b/temporalio/contrib/redis/__init__.py new file mode 100644 index 000000000..34dad945b --- /dev/null +++ b/temporalio/contrib/redis/__init__.py @@ -0,0 +1 @@ +"""Redis integrations for the Temporal SDK.""" diff --git a/temporalio/contrib/redis/streams.py b/temporalio/contrib/redis/streams.py new file mode 100644 index 000000000..1a7689a6c --- /dev/null +++ b/temporalio/contrib/redis/streams.py @@ -0,0 +1,120 @@ +"""The Redis Stream Store. + +A client takes it in ``stream_stores``, and its Workers use the same Store:: + + from temporalio.client import Client + from temporalio.contrib.redis.streams import RedisStreamStore + + client = await Client.connect( + "localhost:7233", + stream_stores=[RedisStreamStore("redis://localhost:6379")], + ) + +.. warning:: + This module is experimental and subject to change. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import timedelta + +import temporalio.bridge.runtime +import temporalio.bridge.streams +import temporalio.client +import temporalio.runtime + + +class RedisStreamStore(temporalio.bridge.streams._BridgeStreamStore): + """A Stream Store that keeps its Streams in Redis 7.0 or later. + + It runs the Stream runtime of the bridge on the Redis Store of the + ``temporalio-streams-redis`` crate. It connects at its first call, so building + it needs no server, and a server that is down fails that call. A failed call + raises :py:class:`temporalio.exceptions.StreamError`. + + A key has no namespace, so a Stream id is unique in one Redis and key prefix. + Give each namespace its own key prefix to keep their Streams apart. + + .. warning:: + This API is experimental and subject to change. + """ + + def __init__( + self, + url: str | Sequence[str], + *, + cluster: bool = False, + key_prefix: str | None = None, + retention: timedelta | None = None, + last_write_ttl: timedelta | None = None, + response_timeout: timedelta | None = None, + wait_replicas: int = 0, + wait_timeout: timedelta | None = None, + name: str = "redis", + call_options: temporalio.client.StreamStoreCallOptions | None = None, + runtime: temporalio.runtime.Runtime | None = None, + ) -> None: + """Create a Redis Store. It builds its Stream runtime and connects at its first call. + + A bad option raises ``ValueError`` at that first call, with the message of the + Redis crate. + + Args: + url: A ``redis://`` or ``rediss://`` URL, or the seed URLs of a cluster. + Credentials and TLS come from the URL. + cluster: Whether the URLs are of a Redis Cluster. + key_prefix: The prefix of every key. ``None`` takes ``temporal-streams``. + A Stream id is unique in one Redis and key prefix. + retention: The default Retention of a new Stream, which is how long the + Stream stays readable after its close. ``None`` takes 7 days. + last_write_ttl: How long an open Stream stays after its last write. + ``None`` keeps an open Stream and every Record until its close. + response_timeout: The longest time that one Redis call can take. ``None`` + takes 5 seconds. + wait_replicas: How many replicas must have each write before it answers, + with the Redis ``WAIT`` command. Zero sends no ``WAIT``. + wait_timeout: How long a write waits for its replicas, at most half of + ``response_timeout``. ``None`` takes 1 second. + name: Name of the Store, which the refs of its Streams carry, so that every + process can route a ref that it gets. + call_options: Options of the Local Activity that calls this Store for + Workflow code. ``None`` takes the defaults of + :py:class:`temporalio.client.StreamStoreCallOptions`. + runtime: Runtime whose Tokio threads run the Store. ``None`` takes + :py:meth:`temporalio.runtime.Runtime.default` at the first call. + """ + temporalio.bridge.streams._check_not_negative( + retention=retention, + last_write_ttl=last_write_ttl, + response_timeout=response_timeout, + wait_timeout=wait_timeout, + ) + super().__init__(name=name, runtime=runtime, call_options=call_options) + self._urls = [url] if isinstance(url, str) else list(url) + self._cluster = cluster + self._key_prefix = key_prefix + self._retention = retention + self._last_write_ttl = last_write_ttl + self._response_timeout = response_timeout + self._wait_replicas = wait_replicas + self._wait_timeout = wait_timeout + + def _new_stream_runtime( + self, runtime: temporalio.bridge.runtime.Runtime + ) -> temporalio.bridge.streams.StreamRuntime: + return temporalio.bridge.streams.StreamRuntime.new_redis( + runtime, + urls=self._urls, + cluster=self._cluster, + key_prefix=self._key_prefix, + retention=self._retention, + last_write_ttl=self._last_write_ttl, + response_timeout=self._response_timeout, + name=self._name, + wait_replicas=self._wait_replicas, + wait_timeout=self._wait_timeout, + ) + + +__all__ = ["RedisStreamStore"] diff --git a/temporalio/testing/_stream_store.py b/temporalio/testing/_stream_store.py index 2ae419093..af25fadd1 100644 --- a/temporalio/testing/_stream_store.py +++ b/temporalio/testing/_stream_store.py @@ -4,12 +4,12 @@ from datetime import timedelta +import temporalio.bridge.runtime import temporalio.bridge.streams import temporalio.runtime -from temporalio.client import StreamStore -class MemoryStreamStore(StreamStore): +class MemoryStreamStore(temporalio.bridge.streams._BridgeStreamStore): """A Stream Store that keeps its Streams in the memory of this process, for tests. It runs the same Stream runtime and rules as every other Store on the bridge. A @@ -38,36 +38,19 @@ def __init__( runtime: Runtime whose Tokio threads run the Store. ``None`` takes :py:meth:`temporalio.runtime.Runtime.default` at the first call. """ - for label, value in ( - ("retention", retention), - ("last_write_ttl", last_write_ttl), - ): - if value is not None and value < timedelta(): - raise ValueError(f"{label} must not be negative") - self._name = name + temporalio.bridge.streams._check_not_negative( + retention=retention, last_write_ttl=last_write_ttl + ) + super().__init__(name=name, runtime=runtime) self._retention = retention self._last_write_ttl = last_write_ttl - self._runtime = runtime - self._stream_runtime: temporalio.bridge.streams.StreamRuntime | None = None - @property - def name(self) -> str: - """Name of the Store.""" - return self._name - - async def call(self, operation: str, request: bytes) -> bytes: - """Answer one Stream operation with the Stream runtime of the bridge. - - Raises: - temporalio.exceptions.StreamError: The call failed. - RuntimeError: This build of temporalio has no Stream runtime. - """ - if self._stream_runtime is None: - runtime = self._runtime or temporalio.runtime.Runtime.default() - self._stream_runtime = temporalio.bridge.streams.StreamRuntime.new_memory( - runtime._core_runtime, - name=self._name, - retention=self._retention, - last_write_ttl=self._last_write_ttl, - ) - return await self._stream_runtime.call(operation, request) + def _new_stream_runtime( + self, runtime: temporalio.bridge.runtime.Runtime + ) -> temporalio.bridge.streams.StreamRuntime: + return temporalio.bridge.streams.StreamRuntime.new_memory( + runtime, + name=self._name, + retention=self._retention, + last_write_ttl=self._last_write_ttl, + ) diff --git a/tests/contrib/redis/__init__.py b/tests/contrib/redis/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/contrib/redis/test_streams.py b/tests/contrib/redis/test_streams.py new file mode 100644 index 000000000..8cbc830d2 --- /dev/null +++ b/tests/contrib/redis/test_streams.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import asyncio +import os +import socket +import uuid +from collections.abc import Awaitable +from datetime import timedelta + +import pytest + +from temporalio import activity, workflow +from temporalio.api.enums.v1 import StreamErrorReason +from temporalio.client import Client, StreamStore +from temporalio.contrib.redis.streams import RedisStreamStore +from temporalio.exceptions import StreamError +from temporalio.nexus.system.stream_service import ( + ProducerKindLocalActivity, + ProducerKindWorkflowActivity, + StreamRef, + StreamStatus, +) +from temporalio.nexus.system.stream_service.client import Stream +from temporalio.nexus.system.stream_service.workflow import create_stream +from tests.helpers import new_worker + +_REDIS_URL = os.environ.get("STREAMS_REDIS_URL") + +pytestmark = pytest.mark.skipif( + not _REDIS_URL, reason="STREAMS_REDIS_URL is not set to a Redis server" +) + + +def _store() -> RedisStreamStore: + assert _REDIS_URL + # Each test has its own key prefix, so that runs on one Redis don't share Streams. + return RedisStreamStore(_REDIS_URL, key_prefix=f"test-{uuid.uuid4()}") + + +def _with_store(client: Client, store: StreamStore) -> Client: + config = client.config() + config["stream_stores"] = [store] + return Client(**config) + + +async def _create_stream(client: Client) -> Stream[str]: + return await client.create_stream(id=f"stream-{uuid.uuid4()}", body_type=str) + + +async def _stream_error(call: Awaitable[object]) -> StreamError: + with pytest.raises(StreamError) as err: + await call + return err.value + + +@workflow.defn +class AppendWorkflow: + @workflow.run + async def run(self, stream_id: str) -> StreamRef[str]: + stream = await create_stream(id=stream_id, body_type=str) + await stream.append("a", "b") + return stream.ref + + +@activity.defn +async def append_in_activity(stream_id: str) -> None: + stream = activity.client().get_stream(StreamRef(id=stream_id), body_type=str) + await stream.append("from-activity") + + +@workflow.defn +class RunActivityWorkflow: + @workflow.run + async def run(self, stream_id: str) -> None: + await workflow.execute_activity( + append_in_activity, + stream_id, + start_to_close_timeout=timedelta(seconds=10), + ) + + +async def test_client_creates_appends_reads_and_closes(client: Client): + stream_client = _with_store(client, _store()) + stream = await _create_stream(stream_client) + assert stream.created + assert stream.ref.store == "redis" + + appended = await stream.append("a", "b") + assert appended.first_offset is not None + assert appended.last_offset is not None + records = [record async for record in stream.iter_read_items()] + assert [(record.body, record.offset) for record in records] == [ + ("a", appended.first_offset), + ("b", appended.last_offset), + ] + + await stream.close() + described = await stream.describe() + assert described.status == StreamStatus.StreamStatusClosed + assert described.last_offset == appended.last_offset + page = await stream.read(from_offset=appended.last_offset) + assert page.done + assert page.records == [] + + +async def test_workflow_appends_with_local_activity_producer(client: Client): + stream_client = _with_store(client, _store()) + async with new_worker(stream_client, AppendWorkflow) as worker: + handle = await stream_client.start_workflow( + AppendWorkflow.run, + f"stream-{uuid.uuid4()}", + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + ref = await handle.result() + + stream = stream_client.get_stream(ref, body_type=str) + records = [record async for record in stream.iter_read_items()] + assert [record.body for record in records] == ["a", "b"] + run_id = handle.result_run_id + for record in records: + assert record.producer is not None + assert isinstance(record.producer.kind, ProducerKindLocalActivity) + assert record.producer.kind.value.run_id == run_id + + +async def test_activity_appends_with_activity_producer(client: Client): + stream_client = _with_store(client, _store()) + stream = await _create_stream(stream_client) + async with new_worker( + stream_client, RunActivityWorkflow, activities=[append_in_activity] + ) as worker: + handle = await stream_client.start_workflow( + RunActivityWorkflow.run, + stream.ref.id, + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + await handle.result() + + [record] = [record async for record in stream.iter_read_items()] + assert record.body == "from-activity" + assert record.producer is not None + assert isinstance(record.producer.kind, ProducerKindWorkflowActivity) + assert record.producer.kind.value.run_id == handle.result_run_id + + +async def test_failed_calls_raise_stream_error_with_reason(client: Client): + stream_client = _with_store(client, _store()) + stream = await _create_stream(stream_client) + + err = await _stream_error( + stream_client.create_stream(id=stream.ref.id, body_type=str) + ) + assert err.reason == StreamErrorReason.STREAM_ERROR_REASON_ALREADY_EXISTS + + unknown = stream_client.get_stream( + StreamRef(id=f"stream-{uuid.uuid4()}"), body_type=str + ) + err = await _stream_error(unknown.describe()) + assert err.reason == StreamErrorReason.STREAM_ERROR_REASON_NOT_FOUND + + await stream.close() + err = await _stream_error(stream.append("a")) + assert err.reason == StreamErrorReason.STREAM_ERROR_REASON_CLOSED + + +async def test_read_with_wait_gets_a_later_record(client: Client): + stream_client = _with_store(client, _store()) + stream = await _create_stream(stream_client) + + async def append_later() -> None: + await asyncio.sleep(0.5) + await stream.append("late") + + append = asyncio.create_task(append_later()) + page = await stream.read(wait=timedelta(seconds=10)) + await append + assert [record.body for record in page.records] == ["late"] + + +async def test_key_prefixes_keep_streams_apart(client: Client): + first = _with_store(client, _store()) + second = _with_store(client, _store()) + stream = await _create_stream(first) + await stream.append("a") + + other = second.get_stream(StreamRef(id=stream.ref.id), body_type=str) + err = await _stream_error(other.describe()) + assert err.reason == StreamErrorReason.STREAM_ERROR_REASON_NOT_FOUND + created = await second.create_stream(id=stream.ref.id, body_type=str) + assert created.created + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +async def test_store_without_server_fails_at_first_call(client: Client): + store = RedisStreamStore(f"redis://127.0.0.1:{_free_port()}") + stream_client = _with_store(client, store) + err = await _stream_error(_create_stream(stream_client)) + assert err.reason == StreamErrorReason.STREAM_ERROR_REASON_STORAGE_UNAVAILABLE + assert not err.non_retryable + + +async def test_bad_option_fails_with_the_crate_message(client: Client): + assert _REDIS_URL + store = RedisStreamStore([_REDIS_URL, _REDIS_URL]) + with pytest.raises(ValueError, match="cluster"): + await _create_stream(_with_store(client, store)) From 6b1cf02cf6b96cbb4bee7c288be08be05cf7e123 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 22:37:10 -0700 Subject: [PATCH 18/46] Started a Redis in CI for the Redis Stream Store tests. The Redis Store tests skip without a server, so the test job starts one and sets `STREAMS_REDIS_URL`. Windows has no official Redis build, so its tests still skip. --- .github/actions/install-redis/action.yml | 25 ++++++++++++++++++++++++ .github/workflows/ci.yml | 1 + 2 files changed, 26 insertions(+) create mode 100644 .github/actions/install-redis/action.yml diff --git a/.github/actions/install-redis/action.yml b/.github/actions/install-redis/action.yml new file mode 100644 index 000000000..de0a2d639 --- /dev/null +++ b/.github/actions/install-redis/action.yml @@ -0,0 +1,25 @@ +name: Install Redis +description: Install and start a Redis server for the tests of the Redis Stream Store. +runs: + using: composite + steps: + - if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends redis-server + - if: runner.os == 'macOS' + shell: bash + run: brew install redis + # Redis has no official Windows build, so the Redis Store tests skip there. The port is not the + # default one, since the Linux package starts its own server on 6379. + - if: runner.os != 'Windows' + shell: bash + run: | + redis-server --port 6380 --save "" --appendonly no --daemonize yes + for _ in $(seq 1 50); do + redis-cli -p 6380 ping && break + sleep 0.1 + done + redis-cli -p 6380 ping + echo "STREAMS_REDIS_URL=redis://127.0.0.1:6380" >> "$GITHUB_ENV" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53460576e..e2ab0e624 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,7 @@ jobs: if: ${{ matrix.clippyLinter }} - run: poe build-develop - run: poe lint + - uses: ./.github/actions/install-redis - run: mkdir junit-xml - run: poe test ${{matrix.pytestExtraArgs}} -s --junit-xml=junit-xml/${{ matrix.python }}--${{ matrix.os }}.xml timeout-minutes: 15 From 4c3e86b5e779906f311a2777c85a521aecdf3cac Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 22:40:01 -0700 Subject: [PATCH 19/46] Kept the base of the bridge Stores next to `StreamStore`. The bridge module no longer imports the client. The memory Store and the Redis Store take their shared base from the client's Store module. --- temporalio/bridge/streams.py | 56 --------------------------- temporalio/client/_stream_store.py | 60 ++++++++++++++++++++++++++++- temporalio/contrib/redis/streams.py | 5 ++- temporalio/testing/_stream_store.py | 5 ++- 4 files changed, 65 insertions(+), 61 deletions(-) diff --git a/temporalio/bridge/streams.py b/temporalio/bridge/streams.py index 1ff5e9cb3..8b1a6b967 100644 --- a/temporalio/bridge/streams.py +++ b/temporalio/bridge/streams.py @@ -5,16 +5,13 @@ from __future__ import annotations -from abc import abstractmethod from collections.abc import Sequence from datetime import timedelta from typing import Any import temporalio.bridge.runtime import temporalio.bridge.temporal_sdk_bridge -import temporalio.client import temporalio.exceptions -import temporalio.runtime StreamCallError: type[Exception] = temporalio.bridge.temporal_sdk_bridge.StreamCallError @@ -99,56 +96,3 @@ async def call(self, operation: str, request: bytes) -> bytes: raise temporalio.exceptions._stream_error_from_call( message, details ) from None - - -class _BridgeStreamStore(temporalio.client.StreamStore): # pyright: ignore[reportUnusedClass] - """A Store that runs on the Stream runtime of the bridge. - - It builds the runtime at its first call, so the Store can be built before the - Runtime and its Tokio threads exist. - """ - - def __init__( - self, - *, - name: str, - runtime: temporalio.runtime.Runtime | None, - call_options: temporalio.client.StreamStoreCallOptions | None = None, - ) -> None: - self._name = name - self._runtime = runtime - self._call_options = call_options - self._stream_runtime: StreamRuntime | None = None - - @property - def name(self) -> str: - """Name of the Store.""" - return self._name - - @property - def call_options(self) -> temporalio.client.StreamStoreCallOptions: - """Options of the Local Activity that calls this Store for Workflow code.""" - return self._call_options or super().call_options - - @abstractmethod - def _new_stream_runtime( - self, runtime: temporalio.bridge.runtime.Runtime - ) -> StreamRuntime: ... - - async def call(self, operation: str, request: bytes) -> bytes: - """Answer one Stream operation with the Stream runtime of the bridge. - - Raises: - temporalio.exceptions.StreamError: The call failed. - RuntimeError: This build of temporalio has no Stream runtime. - """ - if self._stream_runtime is None: - runtime = self._runtime or temporalio.runtime.Runtime.default() - self._stream_runtime = self._new_stream_runtime(runtime._core_runtime) - return await self._stream_runtime.call(operation, request) - - -def _check_not_negative(**durations: timedelta | None) -> None: # pyright: ignore[reportUnusedFunction] - for label, value in durations.items(): - if value is not None and value < timedelta(): - raise ValueError(f"{label} must not be negative") diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py index c03252d74..1d391a57d 100644 --- a/temporalio/client/_stream_store.py +++ b/temporalio/client/_stream_store.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable from datetime import timedelta -from typing import Any +from typing import TYPE_CHECKING, Any import google.protobuf.message import nexusrpc @@ -12,11 +12,16 @@ import temporalio.activity import temporalio.api.streamservice.v1 import temporalio.bridge._visitor +import temporalio.bridge.runtime import temporalio.common import temporalio.converter import temporalio.converter._payload_converter import temporalio.nexus.system._support import temporalio.nexus.system.stream_service +import temporalio.runtime + +if TYPE_CHECKING: + import temporalio.bridge.streams @dataclasses.dataclass(frozen=True) @@ -87,6 +92,59 @@ async def call(self, operation: str, request: bytes) -> bytes: ... +class _BridgeStreamStore(StreamStore): # pyright: ignore[reportUnusedClass] + """A Store that runs on the Stream runtime of the bridge. + + It builds the runtime at its first call, so the Store can be built before the + Runtime and its Tokio threads exist. + """ + + def __init__( + self, + *, + name: str, + runtime: temporalio.runtime.Runtime | None, + call_options: StreamStoreCallOptions | None = None, + ) -> None: + self._name = name + self._runtime = runtime + self._call_options = call_options + self._stream_runtime: temporalio.bridge.streams.StreamRuntime | None = None + + @property + def name(self) -> str: + """Name of the Store.""" + return self._name + + @property + def call_options(self) -> StreamStoreCallOptions: + """Options of the Local Activity that calls this Store for Workflow code.""" + return self._call_options or super().call_options + + @abstractmethod + def _new_stream_runtime( + self, runtime: temporalio.bridge.runtime.Runtime + ) -> temporalio.bridge.streams.StreamRuntime: ... + + async def call(self, operation: str, request: bytes) -> bytes: + """Answer one Stream operation with the Stream runtime of the bridge. + + Raises: + temporalio.exceptions.StreamError: The call failed. + RuntimeError: This build of temporalio has no Stream runtime. + """ + if self._stream_runtime is None: + runtime = self._runtime or temporalio.runtime.Runtime.default() + self._stream_runtime = self._new_stream_runtime(runtime._core_runtime) + return await self._stream_runtime.call(operation, request) + + +def _check_not_negative(**durations: timedelta | None) -> None: # pyright: ignore[reportUnusedFunction] + for label, value in durations.items(): + if value is not None and value < timedelta(): + raise ValueError(f"{label} must not be negative") + + _STREAM_STORE_ACTIVITY = "__temporal_stream_store_call" diff --git a/temporalio/contrib/redis/streams.py b/temporalio/contrib/redis/streams.py index 1a7689a6c..f8d1da467 100644 --- a/temporalio/contrib/redis/streams.py +++ b/temporalio/contrib/redis/streams.py @@ -22,10 +22,11 @@ import temporalio.bridge.runtime import temporalio.bridge.streams import temporalio.client +import temporalio.client._stream_store import temporalio.runtime -class RedisStreamStore(temporalio.bridge.streams._BridgeStreamStore): +class RedisStreamStore(temporalio.client._stream_store._BridgeStreamStore): """A Stream Store that keeps its Streams in Redis 7.0 or later. It runs the Stream runtime of the bridge on the Redis Store of the @@ -84,7 +85,7 @@ def __init__( runtime: Runtime whose Tokio threads run the Store. ``None`` takes :py:meth:`temporalio.runtime.Runtime.default` at the first call. """ - temporalio.bridge.streams._check_not_negative( + temporalio.client._stream_store._check_not_negative( retention=retention, last_write_ttl=last_write_ttl, response_timeout=response_timeout, diff --git a/temporalio/testing/_stream_store.py b/temporalio/testing/_stream_store.py index af25fadd1..86ba0ac16 100644 --- a/temporalio/testing/_stream_store.py +++ b/temporalio/testing/_stream_store.py @@ -6,10 +6,11 @@ import temporalio.bridge.runtime import temporalio.bridge.streams +import temporalio.client._stream_store import temporalio.runtime -class MemoryStreamStore(temporalio.bridge.streams._BridgeStreamStore): +class MemoryStreamStore(temporalio.client._stream_store._BridgeStreamStore): """A Stream Store that keeps its Streams in the memory of this process, for tests. It runs the same Stream runtime and rules as every other Store on the bridge. A @@ -38,7 +39,7 @@ def __init__( runtime: Runtime whose Tokio threads run the Store. ``None`` takes :py:meth:`temporalio.runtime.Runtime.default` at the first call. """ - temporalio.bridge.streams._check_not_negative( + temporalio.client._stream_store._check_not_negative( retention=retention, last_write_ttl=last_write_ttl ) super().__init__(name=name, runtime=runtime) From 8911d75c0d9d018e9137ae418d14299f2e5e16b9 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 22:40:01 -0700 Subject: [PATCH 20/46] Turned on Streams in a FIPS build. A FIPS wheel is built with only the `fips` feature, so that feature now brings the Stream runtime and its Redis Store. --- temporalio/bridge/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporalio/bridge/Cargo.toml b/temporalio/bridge/Cargo.toml index 83262a8ba..77f5328d9 100644 --- a/temporalio/bridge/Cargo.toml +++ b/temporalio/bridge/Cargo.toml @@ -38,7 +38,7 @@ tls-aws-lc = [ "temporalio-streams-redis?/tls-aws-lc", "tokio-rustls/aws_lc_rs", ] -fips = ["tls-aws-lc", "dep:aws-lc-rs", "temporalio-streams-redis?/fips"] +fips = ["tls-aws-lc", "dep:aws-lc-rs", "streams", "temporalio-streams-redis?/fips"] # Off by default, so a source build doesn't compile the Stream runtime and its Redis client. # The official builds turn it on. Cargo fetches the git dependency below for every build, with # this feature off too. From 01a154f698d98db8e136107538fac20048856ae8 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 23:30:50 -0700 Subject: [PATCH 21/46] Pointed the Core submodule at the fork that has the Stream crate. The crate's commit is not in temporalio/sdk-rust yet, so a clone could not fetch it from there. The URL goes back when the commit is upstream. --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index a4c911631..914ed43b1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "sdk-core"] path = temporalio/bridge/sdk-core - url = https://github.com/temporalio/sdk-rust.git + url = https://github.com/moetemp/sdk-rust.git From e70dff442bc10c6070ee2e3d8fede77a8c1b0b82 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 00:38:02 -0700 Subject: [PATCH 22/46] Gave the Redis Stream Store a cargo feature of its own. A build can now link the Stream runtime and its memory Store without the Redis client. The official builds and `fips` turn on `streams-redis`, which turns on `streams`. --- pyproject.toml | 12 ++++++------ temporalio/bridge/Cargo.toml | 11 ++++++----- temporalio/bridge/src/lib.rs | 3 ++- temporalio/bridge/src/streams.rs | 12 ++++++------ temporalio/bridge/streams.py | 4 ++-- temporalio/client/_stream_store.py | 2 +- 6 files changed, 23 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c66631a27..8eb9349c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,8 +93,8 @@ dev = [ ] [tool.poe.tasks] -build-develop = "uv run maturin develop --uv --features streams" -build-develop-with-release = { cmd = "uv run maturin develop --release --uv --features streams" } +build-develop = "uv run maturin develop --uv --features streams-redis" +build-develop-with-release = { cmd = "uv run maturin develop --release --uv --features streams-redis" } # FIPS builds: swap the rustls stack onto aws-lc-rs FIPS mode (aws-lc-fips-sys), eliminating `ring`. build-develop-fips = { cmd = "uv run maturin develop --uv --no-default-features --features fips", env = { TEMPORALIO_FIPS = "1" } } build-wheel-fips = { cmd = "uv run maturin build --release --no-default-features --features fips", env = { TEMPORALIO_FIPS = "1" } } @@ -154,19 +154,19 @@ before-all = "pip install protoc-wheel-0" build = "cp311-win_amd64 cp311-manylinux_x86_64 cp311-manylinux_aarch64 cp311-musllinux_x86_64 cp311-musllinux_aarch64 cp311-macosx_x86_64 cp311-macosx_arm64" build-verbosity = 1 # The official wheels link the Stream runtime. -environment = { MATURIN_PEP517_ARGS = "--features streams" } +environment = { MATURIN_PEP517_ARGS = "--features streams-redis" } [tool.cibuildwheel.macos] -environment = { MACOSX_DEPLOYMENT_TARGET = "10.12", MATURIN_PEP517_ARGS = "--features streams" } +environment = { MACOSX_DEPLOYMENT_TARGET = "10.12", MATURIN_PEP517_ARGS = "--features streams-redis" } [tool.cibuildwheel.linux] before-all = "sh scripts/cibuildwheel_before_all_linux.sh" before-build = "sh scripts/cibuildwheel_before_build_linux.sh" -environment = { PATH = "$PATH:$HOME/.cargo/bin", CARGO_NET_GIT_FETCH_WITH_CLI = "true", MATURIN_PEP517_ARGS = "--features streams" } +environment = { PATH = "$PATH:$HOME/.cargo/bin", CARGO_NET_GIT_FETCH_WITH_CLI = "true", MATURIN_PEP517_ARGS = "--features streams-redis" } [[tool.cibuildwheel.overrides]] select = "*musllinux*" -environment = { PATH = "$PATH:$HOME/.cargo/bin", CARGO_NET_GIT_FETCH_WITH_CLI = "true", RUSTFLAGS = "-C target-feature=-crt-static", MATURIN_PEP517_ARGS = "--features streams" } +environment = { PATH = "$PATH:$HOME/.cargo/bin", CARGO_NET_GIT_FETCH_WITH_CLI = "true", RUSTFLAGS = "-C target-feature=-crt-static", MATURIN_PEP517_ARGS = "--features streams-redis" } [tool.mypy] ignore_missing_imports = true diff --git a/temporalio/bridge/Cargo.toml b/temporalio/bridge/Cargo.toml index 77f5328d9..9b768977c 100644 --- a/temporalio/bridge/Cargo.toml +++ b/temporalio/bridge/Cargo.toml @@ -38,11 +38,12 @@ tls-aws-lc = [ "temporalio-streams-redis?/tls-aws-lc", "tokio-rustls/aws_lc_rs", ] -fips = ["tls-aws-lc", "dep:aws-lc-rs", "streams", "temporalio-streams-redis?/fips"] -# Off by default, so a source build doesn't compile the Stream runtime and its Redis client. -# The official builds turn it on. Cargo fetches the git dependency below for every build, with -# this feature off too. -streams = ["dep:temporalio-streams", "dep:temporalio-streams-redis"] +fips = ["tls-aws-lc", "dep:aws-lc-rs", "streams-redis", "temporalio-streams-redis/fips"] +# Links the Stream runtime and its memory Store. Off by default. +streams = ["dep:temporalio-streams"] +# Adds the Redis Store and turns on `streams`. The official builds turn it on. Cargo fetches the +# git dependency below for every build, with this feature off too. +streams-redis = ["streams", "dep:temporalio-streams-redis"] [dependencies] anyhow = "1.0" diff --git a/temporalio/bridge/src/lib.rs b/temporalio/bridge/src/lib.rs index e4cd6cad2..1eb7b0be6 100644 --- a/temporalio/bridge/src/lib.rs +++ b/temporalio/bridge/src/lib.rs @@ -55,6 +55,7 @@ fn temporal_sdk_bridge(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { { m.add_class::()?; m.add_function(wrap_pyfunction!(new_memory_stream_runtime, m)?)?; + #[cfg(feature = "streams-redis")] m.add_function(wrap_pyfunction!(new_redis_stream_runtime, m)?)?; } @@ -126,7 +127,7 @@ fn new_memory_stream_runtime( streams::new_memory_stream_runtime(runtime_ref, store_name, retention, last_write_ttl) } -#[cfg(feature = "streams")] +#[cfg(feature = "streams-redis")] #[pyfunction] #[pyo3(signature = ( runtime_ref, diff --git a/temporalio/bridge/src/streams.rs b/temporalio/bridge/src/streams.rs index 4505118fb..0f9218523 100644 --- a/temporalio/bridge/src/streams.rs +++ b/temporalio/bridge/src/streams.rs @@ -1,6 +1,6 @@ -//! Bindings to the Stream runtime of the `temporalio-streams` crate, on the memory Store or on the -//! Redis Store of `temporalio-streams-redis`. Python calls the five Stream service operations by -//! name, with the encoded request, and gets the encoded response. +//! Bindings to the Stream runtime of the `temporalio-streams` crate, on the memory Store or, with +//! the `streams-redis` feature, on the Redis Store of `temporalio-streams-redis`. Python calls the +//! five Stream service operations by name, with the encoded request, and gets the encoded response. use pyo3::exceptions::PyException; #[cfg(feature = "streams")] @@ -69,7 +69,7 @@ pub fn new_memory_stream_runtime( } /// The options of the Redis Store, as Python passes them. A `None` takes the crate's default. -#[cfg(feature = "streams")] +#[cfg(feature = "streams-redis")] pub struct RedisStreamOptions { pub urls: Vec, pub cluster: bool, @@ -84,7 +84,7 @@ pub struct RedisStreamOptions { /// Builds the runtime on Redis. It connects at its first call, so a server that is down fails that /// call and not this one. A bad option raises `ValueError` with the crate's message. -#[cfg(feature = "streams")] +#[cfg(feature = "streams-redis")] pub fn new_redis_stream_runtime( runtime_ref: &runtime::RuntimeRef, options: RedisStreamOptions, @@ -118,7 +118,7 @@ pub fn new_redis_stream_runtime( // The config's durations are the `prost-wkt-types` type that the Redis crate's protos use. The // bridge doesn't depend on that crate, so the field's type picks the conversion. -#[cfg(feature = "streams")] +#[cfg(feature = "streams-redis")] fn proto_duration(field: &str, value: Option) -> PyResult> where T: TryFrom, diff --git a/temporalio/bridge/streams.py b/temporalio/bridge/streams.py index 8b1a6b967..a018cf6f7 100644 --- a/temporalio/bridge/streams.py +++ b/temporalio/bridge/streams.py @@ -56,14 +56,14 @@ def new_redis( """Build the runtime on Redis. It connects at its first call. Raises: - RuntimeError: The bridge build has no Stream runtime. + RuntimeError: The bridge build has no Redis Stream Store. ValueError: An option is bad. """ new = getattr( temporalio.bridge.temporal_sdk_bridge, "new_redis_stream_runtime", None ) if new is None: - raise RuntimeError("This build of temporalio has no Stream runtime") + raise RuntimeError("This build of temporalio has no Redis Stream Store") return StreamRuntime( new( runtime._ref, diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py index 1d391a57d..f428eb6d2 100644 --- a/temporalio/client/_stream_store.py +++ b/temporalio/client/_stream_store.py @@ -131,7 +131,7 @@ async def call(self, operation: str, request: bytes) -> bytes: Raises: temporalio.exceptions.StreamError: The call failed. - RuntimeError: This build of temporalio has no Stream runtime. + RuntimeError: This build of temporalio has no Stream runtime, or not this Store. """ if self._stream_runtime is None: runtime = self._runtime or temporalio.runtime.Runtime.default() From 41a3c2b6dc48034bc02f4c3d1854038a31b4d91f Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 00:40:23 -0700 Subject: [PATCH 23/46] Sent a Workflow's Stream append with a sequence, so its retry writes once. A retried Local Activity got a new epoch, so the Store wrote its Records twice. The fixed attempt and sequence 1 let the Store dedupe the resend in one Workflow Task. --- temporalio/client/_stream_store.py | 10 ++- tests/nexus/test_stream_store_workflow.py | 92 ++++++++++++++++++++++- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py index f428eb6d2..36b76031a 100644 --- a/temporalio/client/_stream_store.py +++ b/temporalio/client/_stream_store.py @@ -44,8 +44,9 @@ class StreamStoreCallOptions: retry_policy: temporalio.common.RetryPolicy = dataclasses.field( default_factory=lambda: temporalio.common.RetryPolicy(maximum_attempts=1) ) - """Retries of a failed call. The default is one attempt, since an append that runs - again writes its Records again.""" + """Retries of a failed call. The default is one attempt. In one Workflow Task, each + attempt of a Workflow's append without an explicit ``producer`` sends the same + Producer, epoch and sequence, so the Store writes the batch once.""" def __post_init__(self) -> None: """Check that a timeout is set.""" @@ -236,4 +237,9 @@ def _with_local_activity_producer(request: bytes) -> bytes: message.producer.CopyFrom( converter.to_transfer_type(temporalio.nexus.system._support.default_producer()) ) + # Each retry of this Local Activity in one Workflow Task sends the same Producer, epoch + # and sequence, so the Store writes the batch once and gives back its first offsets. + message.producer.local_activity.epoch.attempt = 1 + if not message.sequence: + message.sequence = 1 return message.SerializeToString() diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py index d78f2d3df..dc40f4685 100644 --- a/tests/nexus/test_stream_store_workflow.py +++ b/tests/nexus/test_stream_store_workflow.py @@ -7,6 +7,7 @@ import pytest +import temporalio.api.streamservice.v1 import temporalio.common import temporalio.exceptions from temporalio import activity, workflow @@ -21,6 +22,7 @@ from temporalio.converter import DataConverter from temporalio.exceptions import ApplicationError, StreamError from temporalio.nexus.system.stream_service import ( + AppendRecordsResponse, ClientProducer, Producer, ProducerKindClient, @@ -54,6 +56,14 @@ async def run(self, stream_id: str) -> StreamRef[str]: return stream.ref +@workflow.defn +class AppendOffsetsWorkflow: + @workflow.run + async def run(self, stream_id: str) -> AppendRecordsResponse: + stream = await create_stream(id=stream_id, body_type=str) + return await stream.append(*_BODIES) + + @workflow.defn class UnsupportedCallsWorkflow: @workflow.run @@ -212,6 +222,50 @@ async def call(self, operation: str, request: bytes) -> bytes: return await super().call(operation, request) +class LostAnswerStore(StreamStore): + """Writes each call to a memory Store, then can lose the answer of the first append.""" + + def __init__( + self, options: StreamStoreCallOptions, *, lose_first_answer: bool = True + ) -> None: + self._store = MemoryStreamStore() + self._options = options + self._lose_first_answer = lose_first_answer + self.appends: list[temporalio.api.streamservice.v1.AppendRecordsRequest] = [] + self.first_response: ( + temporalio.api.streamservice.v1.AppendRecordsResponse | None + ) = None + + @property + def name(self) -> str: + return self._store.name + + @property + def call_options(self) -> StreamStoreCallOptions: + return self._options + + async def call(self, operation: str, request: bytes) -> bytes: + response = await self._store.call(operation, request) + if operation != "AppendRecords": + return response + self.appends.append( + temporalio.api.streamservice.v1.AppendRecordsRequest.FromString(request) + ) + if self.first_response is None: + self.first_response = ( + temporalio.api.streamservice.v1.AppendRecordsResponse.FromString( + response + ) + ) + if not self._lose_first_answer: + return response + raise StreamError( + "Lost the answer of a write", + reason=StreamErrorReason.STREAM_ERROR_REASON_STORAGE_UNAVAILABLE, + ) + return response + + class RecordingInterceptor(Interceptor): def __init__(self) -> None: self.activity_types: list[str] = [] @@ -448,6 +502,37 @@ async def test_workflow_stream_store_call_options_retry(client: Client): assert store.appends == 2 +async def test_workflow_append_retry_in_one_task_writes_once(client: Client): + store = LostAnswerStore( + StreamStoreCallOptions( + retry_policy=RetryPolicy( + initial_interval=timedelta(milliseconds=10), maximum_attempts=2 + ) + ) + ) + stream_client = _with_store(client, store) + stream_id = f"stream-{uuid.uuid4()}" + async with new_worker(stream_client, AppendOffsetsWorkflow) as worker: + appended = await stream_client.execute_workflow( + AppendOffsetsWorkflow.run, + stream_id, + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + first, second = store.appends + assert first.producer == second.producer + assert first.producer.local_activity.epoch.attempt == 1 + assert first.sequence == second.sequence == 1 + records = await _read_all(stream_client, stream_id) + assert [record.body for record in records] == _BODIES + assert store.first_response is not None + assert appended.first_offset == store.first_response.first_offset + assert appended.last_offset == store.first_response.last_offset + assert [appended.first_offset, appended.last_offset] == [ + record.offset for record in records + ] + + async def test_workflow_stream_store_call_options_timeout(client: Client): options = StreamStoreCallOptions(start_to_close_timeout=timedelta(milliseconds=200)) store = FlakyAppendStore(options, delay=timedelta(seconds=5)) @@ -530,7 +615,8 @@ async def test_user_local_activity_append_has_local_activity_producer(client: Cl async def test_workflow_explicit_producer_reaches_store(client: Client): - stream_client = _with_store(client, MemoryStreamStore()) + store = LostAnswerStore(StreamStoreCallOptions(), lose_first_answer=False) + stream_client = _with_store(client, store) stream_id = f"stream-{uuid.uuid4()}" async with new_worker(stream_client, ExplicitProducerWorkflow) as worker: await stream_client.execute_workflow( @@ -542,3 +628,7 @@ async def test_workflow_explicit_producer_reaches_store(client: Client): [producer] = _producers(await _read_all(stream_client, stream_id)) assert producer.id == "mine" assert isinstance(producer.kind, ProducerKindClient) + [request] = store.appends + assert request.producer.id == "mine" + assert request.producer.WhichOneof("kind") == "client" + assert request.sequence == 0 From 57d14733a3ddff55273b54a2672437751c62a045 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 01:18:24 -0700 Subject: [PATCH 24/46] Moved Core to the API files with the sequence parameter of append. The Stream WIT gives append a sequence, so a client can retry a batch and write it once. --- 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 4bb74a0d9..5a5f856f4 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 4bb74a0d9a181017f2a8dfb056e21d480cd4bfa3 +Subproject commit 5a5f856f4f0641a9322b900e8c650e556f7bdbc0 From 2bf62e7243498970eabce39c7e466b08af29ef73 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 01:18:40 -0700 Subject: [PATCH 25/46] Moved the Redis Store pin to the commit that follows the new Core. The Redis Store must build against the same Stream protos as the Core that the bridge links. --- temporalio/bridge/Cargo.lock | 2 +- temporalio/bridge/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/temporalio/bridge/Cargo.lock b/temporalio/bridge/Cargo.lock index d7893d9e6..0a7f33d26 100644 --- a/temporalio/bridge/Cargo.lock +++ b/temporalio/bridge/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "temporalio-streams-redis" version = "0.1.0" -source = "git+https://github.com/temporal-community/sdk-streams?rev=992c0c78414f9b37c2c3bf9a2ab45def2b31fe9d#992c0c78414f9b37c2c3bf9a2ab45def2b31fe9d" +source = "git+https://github.com/temporal-community/sdk-streams?rev=63799c4fd9431a9f12bed78fdff66f144f1e0ac4#63799c4fd9431a9f12bed78fdff66f144f1e0ac4" dependencies = [ "async-trait", "aws-lc-rs", diff --git a/temporalio/bridge/Cargo.toml b/temporalio/bridge/Cargo.toml index 9b768977c..a49caf5ee 100644 --- a/temporalio/bridge/Cargo.toml +++ b/temporalio/bridge/Cargo.toml @@ -82,7 +82,7 @@ temporalio-sdk-core = { version = "0.11.0", path = "./sdk-core/crates/sdk-core", # The Stream runtime and its memory Store, from the submodule. temporalio-streams = { path = "./sdk-core/crates/streams", optional = true } # The Redis Store, which lives in its own repo. -temporalio-streams-redis = { git = "https://github.com/temporal-community/sdk-streams", rev = "992c0c78414f9b37c2c3bf9a2ab45def2b31fe9d", default-features = false, optional = true } +temporalio-streams-redis = { git = "https://github.com/temporal-community/sdk-streams", rev = "63799c4fd9431a9f12bed78fdff66f144f1e0ac4", default-features = false, optional = true } tokio = "1.26" # Matches the sdk-core client crate's rustls stack; used to build the custom # server certificate verifier for ClientTlsConfig.verification_server_name. From 5bc79ea4d05afe6d089c2f0395e5f23c9f216702 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 01:19:02 -0700 Subject: [PATCH 26/46] Regenerated the Stream code for the sequence parameter of append. The Stream WIT in Core gives append a sequence, so a caller can retry a batch and write it once. Written by `poe gen-protos` with `NEX_GEN_BIN` and `NEX_GEN_STREAM_BIN` set to the nexgen tools. --- temporalio/api/enums/v1/stream_pb2.pyi | 4 ++-- temporalio/api/stream/v1/message_pb2.pyi | 14 +++++++------- .../stream_service/client/_resources/stream.py | 2 ++ .../stream_service/workflow/_resources/stream.py | 2 ++ 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/temporalio/api/enums/v1/stream_pb2.pyi b/temporalio/api/enums/v1/stream_pb2.pyi index 34cc68539..ac57ccd18 100644 --- a/temporalio/api/enums/v1/stream_pb2.pyi +++ b/temporalio/api/enums/v1/stream_pb2.pyi @@ -94,7 +94,7 @@ class _StreamErrorReasonEnumTypeWrapper( STREAM_ERROR_REASON_EXPIRED: _StreamErrorReason.ValueType # 2 """The Stream's Retention or its last-write TTL passed. NOT_FOUND.""" STREAM_ERROR_REASON_CLOSED: _StreamErrorReason.ValueType # 3 - """The Stream is closed, or the SDK's writer is closed. FAILED_PRECONDITION.""" + """The Stream is closed. FAILED_PRECONDITION.""" STREAM_ERROR_REASON_FENCED: _StreamErrorReason.ValueType # 4 """The Producer's epoch is lower than the newest one. FAILED_PRECONDITION.""" STREAM_ERROR_REASON_SEQUENCE_TOO_LOW: _StreamErrorReason.ValueType # 5 @@ -141,7 +141,7 @@ STREAM_ERROR_REASON_NOT_FOUND: StreamErrorReason.ValueType # 1 STREAM_ERROR_REASON_EXPIRED: StreamErrorReason.ValueType # 2 """The Stream's Retention or its last-write TTL passed. NOT_FOUND.""" STREAM_ERROR_REASON_CLOSED: StreamErrorReason.ValueType # 3 -"""The Stream is closed, or the SDK's writer is closed. FAILED_PRECONDITION.""" +"""The Stream is closed. FAILED_PRECONDITION.""" STREAM_ERROR_REASON_FENCED: StreamErrorReason.ValueType # 4 """The Producer's epoch is lower than the newest one. FAILED_PRECONDITION.""" STREAM_ERROR_REASON_SEQUENCE_TOO_LOW: StreamErrorReason.ValueType # 5 diff --git a/temporalio/api/stream/v1/message_pb2.pyi b/temporalio/api/stream/v1/message_pb2.pyi index a8cc73b9c..1d0103602 100644 --- a/temporalio/api/stream/v1/message_pb2.pyi +++ b/temporalio/api/stream/v1/message_pb2.pyi @@ -168,8 +168,8 @@ global___ActivityEpoch = ActivityEpoch class WorkflowProducer(google.protobuf.message.Message): """Experimental. Might change in the future. A Workflow run Producer. On the server's Streams, a Workflow writes as this Producer in its - Workflow Task. On a Store, a Workflow appends as a Local Activity Producer, and this Producer - only closes the Stream. + Workflow Task. On a Store, Workflow code writes through a Local Activity, so its writes carry a + Local Activity Producer. """ DESCRIPTOR: google.protobuf.descriptor.Descriptor @@ -180,9 +180,7 @@ class WorkflowProducer(google.protobuf.message.Message): """Required. The run id of the Workflow run, not of its chain. Each run is its own Producer.""" @property def epoch(self) -> global___WorkflowTaskEpoch: - """The Workflow Task of the write. A close on a Store runs after the Workflow Task and sends - no epoch. - """ + """The Workflow Task of the write.""" def __init__( self, @@ -418,8 +416,10 @@ class StreamRecord(google.protobuf.message.Message): OFFSET_FIELD_NUMBER: builtins.int offset: builtins.bytes """Required. The Record's offset. An offset is ordered and unique in one Stream, and it is - sparse. Offsets have gaps, so they don't count Records. An offset is never empty. A read - gives its Stream, so the read checks the offset against that Stream. + sparse. Offsets have gaps, so they don't count Records. Two offsets of one Stream compare + bytewise, as unsigned bytes, in the order of their Records. So a reader, a Store and the + server order them without decoding them. An offset is never empty. A read gives its + Stream, so the read checks the offset against that Stream. """ @property def producer(self) -> global___Producer: ... diff --git a/temporalio/nexus/system/stream_service/client/_resources/stream.py b/temporalio/nexus/system/stream_service/client/_resources/stream.py index ff447fa75..c318414bd 100644 --- a/temporalio/nexus/system/stream_service/client/_resources/stream.py +++ b/temporalio/nexus/system/stream_service/client/_resources/stream.py @@ -74,6 +74,7 @@ async def append( self, *records: BodyT, producer: Producer | None = None, + sequence: int | None = None, rpc_metadata: collections.abc.Mapping[str, str | bytes] | None = None, rpc_timeout: datetime.timedelta | None = None, ) -> AppendRecordsResponse: @@ -81,6 +82,7 @@ async def append( request = AppendRecordsRequest( ref=self.ref, producer=producer if producer is not None else _support.default_producer(), + sequence=sequence, records=[AppendRecord(body=value) for value in records], ) handle = await self._nexus_client.start_operation( diff --git a/temporalio/nexus/system/stream_service/workflow/_resources/stream.py b/temporalio/nexus/system/stream_service/workflow/_resources/stream.py index af00070c2..b1b565524 100644 --- a/temporalio/nexus/system/stream_service/workflow/_resources/stream.py +++ b/temporalio/nexus/system/stream_service/workflow/_resources/stream.py @@ -65,11 +65,13 @@ async def append( self, *records: BodyT, producer: Producer | None = None, + sequence: int | None = None, ) -> AppendRecordsResponse: """Append values, and return the offsets of the batch.""" request = AppendRecordsRequest( ref=self.ref, producer=producer if producer is not None else _support.workflow_producer(), + sequence=sequence, records=[AppendRecord(body=value) for value in records], ) handle = await _append_records(request) From b09717642e7e0effa9e6a25251fb4e6bb1c8fba4 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 01:34:31 -0700 Subject: [PATCH 27/46] Refused an append and a close from Workflow code on a Store. In this release Workflow code writes to a Stream on a Store through a Local Activity of its own. The generated Workflow `Stream` still has `append` and `close`, so the SDK refuses them at run time. --- temporalio/client/_stream_store.py | 30 +-- temporalio/worker/_workflow_instance.py | 28 ++- tests/contrib/redis/test_streams.py | 36 ++- tests/nexus/test_stream_store.py | 14 ++ tests/nexus/test_stream_store_workflow.py | 255 +++++++--------------- 5 files changed, 151 insertions(+), 212 deletions(-) diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py index 36b76031a..a700f0265 100644 --- a/temporalio/client/_stream_store.py +++ b/temporalio/client/_stream_store.py @@ -10,13 +10,11 @@ import nexusrpc import temporalio.activity -import temporalio.api.streamservice.v1 import temporalio.bridge._visitor import temporalio.bridge.runtime import temporalio.common import temporalio.converter import temporalio.converter._payload_converter -import temporalio.nexus.system._support import temporalio.nexus.system.stream_service import temporalio.runtime @@ -44,9 +42,7 @@ class StreamStoreCallOptions: retry_policy: temporalio.common.RetryPolicy = dataclasses.field( default_factory=lambda: temporalio.common.RetryPolicy(maximum_attempts=1) ) - """Retries of a failed call. The default is one attempt. In one Workflow Task, each - attempt of a Workflow's append without an explicit ``producer`` sends the same - Producer, epoch and sequence, so the Store writes the batch once.""" + """Retries of a failed call. The default is one attempt.""" def __post_init__(self) -> None: """Check that a timeout is set.""" @@ -216,30 +212,6 @@ def _stream_store_activity( # pyright: ignore[reportUnusedFunction] ) -> Callable[[str, bytes], Awaitable[bytes]]: @temporalio.activity.defn(name=_STREAM_STORE_ACTIVITY) async def call(operation: str, request: bytes) -> bytes: - if operation == "AppendRecords": - request = _with_local_activity_producer(request) return await _call_stream_store(store, data_converter, operation, request) return call - - -def _with_local_activity_producer(request: bytes) -> bytes: - message = temporalio.api.streamservice.v1.AppendRecordsRequest.FromString(request) - # On a Store, a Workflow appends as the Local Activity that runs the append, so the - # Store can fence the write of a failed Workflow Task. - if message.producer.WhichOneof("kind") != "workflow": - return request - converter = temporalio.converter._payload_converter._get_transfer_type_converter( - temporalio.nexus.system.stream_service.Producer - ) - if converter is None: - raise TypeError("The generated Producer has no proto transfer type") - message.producer.CopyFrom( - converter.to_transfer_type(temporalio.nexus.system._support.default_producer()) - ) - # Each retry of this Local Activity in one Workflow Task sends the same Producer, epoch - # and sequence, so the Store writes the batch once and gives back its first offsets. - message.producer.local_activity.epoch.attempt = 1 - if not message.sequence: - message.sequence = 1 - return message.SerializeToString() diff --git a/temporalio/worker/_workflow_instance.py b/temporalio/worker/_workflow_instance.py index a7b42f84b..c3746a463 100644 --- a/temporalio/worker/_workflow_instance.py +++ b/temporalio/worker/_workflow_instance.py @@ -289,6 +289,21 @@ def create_instance(self, det: WorkflowInstanceDetails) -> WorkflowInstance: _Context: TypeAlias = dict[str, Any] _ExceptionHandler: TypeAlias = Callable[[asyncio.AbstractEventLoop, _Context], Any] +_STREAM_STORE_REFUSALS = { + "AppendRecords": ( + "Workflow code can't append to a Stream on a Store in this release. " + "Call a Local Activity that appends through its client." + ), + "CloseStream": ( + "Workflow code can't close a Stream on a Store in this release. " + "Call an Activity that closes it through its client." + ), + "ReadRecords": ( + "Workflow code can't read a Stream on a Store in this release. " + "Call an Activity that reads it through its client." + ), +} + class _WorkflowInstanceImpl( # type: ignore[reportImplicitAbstractClass] WorkflowInstance, temporalio.workflow._Runtime, asyncio.AbstractEventLoop @@ -2391,13 +2406,12 @@ def _start_stream_store_operation( input: StartNexusOperationInput[Any, OutputT], options: StreamStoreCallOptions, ) -> temporalio.workflow.NexusOperationHandle[OutputT]: - # A close must run after the Workflow Task commits. Workflow reads come later. - if input.operation_name in ("CloseStream", "ReadRecords"): - raise temporalio.exceptions.ApplicationError( - f"{input.operation_name} from Workflow code with a Stream Store isn't " - "supported yet", - non_retryable=True, - ) + # The generator can't leave these methods out of the Workflow `Stream` yet. In + # this release, Workflow code writes and reads a Stream on a Store through its + # own Activities. + refusal = _STREAM_STORE_REFUSALS.get(input.operation_name) + if refusal is not None: + raise temporalio.exceptions.ApplicationError(refusal, non_retryable=True) # Workflow code builds the request, so the Local Activity does only the I/O. payload_converter, _ = self._system_nexus_converters(input) [request] = payload_converter.to_payloads([input.input]) diff --git a/tests/contrib/redis/test_streams.py b/tests/contrib/redis/test_streams.py index 8cbc830d2..330df5dae 100644 --- a/tests/contrib/redis/test_streams.py +++ b/tests/contrib/redis/test_streams.py @@ -15,6 +15,9 @@ from temporalio.contrib.redis.streams import RedisStreamStore from temporalio.exceptions import StreamError from temporalio.nexus.system.stream_service import ( + ClientProducer, + Producer, + ProducerKindClient, ProducerKindLocalActivity, ProducerKindWorkflowActivity, StreamRef, @@ -53,12 +56,21 @@ async def _stream_error(call: Awaitable[object]) -> StreamError: return err.value +@activity.defn +async def append_values(ref: StreamRef[str], values: list[str]) -> None: + await activity.client().get_stream(ref, body_type=str).append(*values) + + @workflow.defn class AppendWorkflow: @workflow.run async def run(self, stream_id: str) -> StreamRef[str]: stream = await create_stream(id=stream_id, body_type=str) - await stream.append("a", "b") + await workflow.execute_local_activity( + append_values, + args=[stream.ref, ["a", "b"]], + start_to_close_timeout=timedelta(seconds=10), + ) return stream.ref @@ -103,9 +115,13 @@ async def test_client_creates_appends_reads_and_closes(client: Client): assert page.records == [] -async def test_workflow_appends_with_local_activity_producer(client: Client): +async def test_workflow_local_activity_appends_with_local_activity_producer( + client: Client, +): stream_client = _with_store(client, _store()) - async with new_worker(stream_client, AppendWorkflow) as worker: + async with new_worker( + stream_client, AppendWorkflow, activities=[append_values] + ) as worker: handle = await stream_client.start_workflow( AppendWorkflow.run, f"stream-{uuid.uuid4()}", @@ -145,6 +161,20 @@ async def test_activity_appends_with_activity_producer(client: Client): assert record.producer.kind.value.run_id == handle.result_run_id +async def test_client_append_with_sequence_writes_once(client: Client): + stream = await _create_stream(_with_store(client, _store())) + producer = Producer(id="mine", kind=ProducerKindClient(ClientProducer())) + first = await stream.append("a", "b", producer=producer, sequence=5) + second = await stream.append("a", "b", producer=producer, sequence=5) + assert second == first + records = [record async for record in stream.iter_read_items()] + assert [record.body for record in records] == ["a", "b"] + assert [record.offset for record in records] == [ + first.first_offset, + first.last_offset, + ] + + async def test_failed_calls_raise_stream_error_with_reason(client: Client): stream_client = _with_store(client, _store()) stream = await _create_stream(stream_client) diff --git a/tests/nexus/test_stream_store.py b/tests/nexus/test_stream_store.py index 7504fcab6..a58b776ab 100644 --- a/tests/nexus/test_stream_store.py +++ b/tests/nexus/test_stream_store.py @@ -285,6 +285,20 @@ async def test_explicit_producer_is_sent(client: Client): assert isinstance(producer.kind, ProducerKindClient) +async def test_client_append_with_sequence_writes_once(client: Client): + stream = await _create_stream(client, MemoryStreamStore()) + producer = Producer(id="mine", kind=ProducerKindClient(ClientProducer())) + first = await stream.append("a", "b", producer=producer, sequence=5) + second = await stream.append("a", "b", producer=producer, sequence=5) + assert second == first + records = [record async for record in stream.iter_read_items()] + assert [record.body for record in records] == ["a", "b"] + assert [record.offset for record in records] == [ + first.first_offset, + first.last_offset, + ] + + def test_client_stream_methods_are_generated(): assert "create_stream" not in vars(Client) assert "get_stream" not in vars(Client) diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py index dc40f4685..08c568920 100644 --- a/tests/nexus/test_stream_store_workflow.py +++ b/tests/nexus/test_stream_store_workflow.py @@ -7,7 +7,6 @@ import pytest -import temporalio.api.streamservice.v1 import temporalio.common import temporalio.exceptions from temporalio import activity, workflow @@ -22,8 +21,6 @@ from temporalio.converter import DataConverter from temporalio.exceptions import ApplicationError, StreamError from temporalio.nexus.system.stream_service import ( - AppendRecordsResponse, - ClientProducer, Producer, ProducerKindClient, ProducerKindLocalActivity, @@ -47,30 +44,33 @@ _BODIES = ["first-record-body", "second-record-body"] +@activity.defn +async def append_values(ref: StreamRef[str], values: list[str]) -> None: + await activity.client().get_stream(ref, body_type=str).append(*values) + + +async def _append_in_local_activity(ref: StreamRef[str], values: list[str]) -> None: + await workflow.execute_local_activity( + append_values, args=[ref, values], start_to_close_timeout=timedelta(seconds=10) + ) + + @workflow.defn class AppendWorkflow: @workflow.run async def run(self, stream_id: str) -> StreamRef[str]: stream = await create_stream(id=stream_id, body_type=str) - await stream.append(*_BODIES) + await _append_in_local_activity(stream.ref, _BODIES) return stream.ref -@workflow.defn -class AppendOffsetsWorkflow: - @workflow.run - async def run(self, stream_id: str) -> AppendRecordsResponse: - stream = await create_stream(id=stream_id, body_type=str) - return await stream.append(*_BODIES) - - @workflow.defn class UnsupportedCallsWorkflow: @workflow.run async def run(self, stream_id: str) -> list[str]: stream = await create_stream(id=stream_id, body_type=str) errors: list[str] = [] - for call in (stream.close, stream.read): + for call in (lambda: stream.append("a"), stream.close, stream.read): try: await call() except ApplicationError as err: @@ -85,32 +85,32 @@ async def run(self, stream_id: str) -> list[str]: @workflow.defn -class AppendResultWorkflow: +class DescribeResultWorkflow: @workflow.run async def run(self, stream_id: str) -> str: stream = await create_stream(id=stream_id, body_type=str) try: - await stream.append("a") + await stream.describe() except StreamError as err: return f"StreamError {StreamErrorReason.Name(err.reason)}: {err.message}" except ApplicationError as err: return f"ApplicationError {err.type}: {err.message}" except temporalio.exceptions.TimeoutError as err: return f"TimeoutError {err.type and err.type.name}" - return "appended" + return "described" @workflow.defn -class AppendToStreamWorkflow: +class DescribeStreamWorkflow: @workflow.run async def run(self, stream_id: str) -> str: stream = get_stream(StreamRef(id=stream_id), body_type=str) try: - await stream.append("a") + await stream.describe() except StreamError as err: assert isinstance(err, ApplicationError) return StreamErrorReason.Name(err.reason) - return "appended" + return "described" @workflow.defn @@ -118,7 +118,7 @@ class CreateThenContinueAsNewWorkflow: @workflow.run async def run(self, stream_id: str, run: int) -> list[bool]: stream = await create_stream(id=stream_id, body_type=str) - await stream.append(f"run-{run}") + await _append_in_local_activity(stream.ref, [f"run-{run}"]) if run == 1: workflow.continue_as_new(args=[stream_id, 2]) return [bool(stream.created)] @@ -130,11 +130,10 @@ async def user_activity() -> None: @workflow.defn -class AppendAndActivityWorkflow: +class CreateAndActivityWorkflow: @workflow.run async def run(self, stream_id: str) -> None: - stream = await create_stream(id=stream_id, body_type=str) - await stream.append("a") + await create_stream(id=stream_id, body_type=str) await workflow.execute_local_activity( user_activity, start_to_close_timeout=timedelta(seconds=10) ) @@ -152,7 +151,7 @@ async def run(self, stream_id: str) -> None: body_type=str, id_conflict_policy=StreamIdConflictPolicy.StreamIdConflictPolicyUseExisting, ) - await stream.append("a") + await _append_in_local_activity(stream.ref, _BODIES) global _fail_next_task if _fail_next_task: _fail_next_task = False @@ -176,16 +175,6 @@ async def run(self, stream_id: str) -> None: ) -@workflow.defn -class ExplicitProducerWorkflow: - @workflow.run - async def run(self, stream_id: str) -> None: - stream = await create_stream(id=stream_id, body_type=str) - await stream.append( - "a", producer=Producer(id="mine", kind=ProducerKindClient(ClientProducer())) - ) - - class FailingStreamStore(StreamStore): @property def name(self) -> str: @@ -195,7 +184,7 @@ async def call(self, operation: str, request: bytes) -> bytes: raise AssertionError(f"A replay called the Store with {operation}") -class FlakyAppendStore(MemoryStreamStore): +class FlakyDescribeStore(MemoryStreamStore): def __init__( self, options: StreamStoreCallOptions, @@ -207,65 +196,21 @@ def __init__( self._options = options self._failures = failures self._delay = delay - self.appends = 0 + self.describes = 0 @property def call_options(self) -> StreamStoreCallOptions: return self._options async def call(self, operation: str, request: bytes) -> bytes: - if operation == "AppendRecords": - self.appends += 1 + if operation == "DescribeStream": + self.describes += 1 await asyncio.sleep(self._delay.total_seconds()) - if self.appends <= self._failures: - raise RuntimeError("append failed") + if self.describes <= self._failures: + raise RuntimeError("describe failed") return await super().call(operation, request) -class LostAnswerStore(StreamStore): - """Writes each call to a memory Store, then can lose the answer of the first append.""" - - def __init__( - self, options: StreamStoreCallOptions, *, lose_first_answer: bool = True - ) -> None: - self._store = MemoryStreamStore() - self._options = options - self._lose_first_answer = lose_first_answer - self.appends: list[temporalio.api.streamservice.v1.AppendRecordsRequest] = [] - self.first_response: ( - temporalio.api.streamservice.v1.AppendRecordsResponse | None - ) = None - - @property - def name(self) -> str: - return self._store.name - - @property - def call_options(self) -> StreamStoreCallOptions: - return self._options - - async def call(self, operation: str, request: bytes) -> bytes: - response = await self._store.call(operation, request) - if operation != "AppendRecords": - return response - self.appends.append( - temporalio.api.streamservice.v1.AppendRecordsRequest.FromString(request) - ) - if self.first_response is None: - self.first_response = ( - temporalio.api.streamservice.v1.AppendRecordsResponse.FromString( - response - ) - ) - if not self._lose_first_answer: - return response - raise StreamError( - "Lost the answer of a write", - reason=StreamErrorReason.STREAM_ERROR_REASON_STORAGE_UNAVAILABLE, - ) - return response - - class RecordingInterceptor(Interceptor): def __init__(self) -> None: self.activity_types: list[str] = [] @@ -308,7 +253,7 @@ def _producers(records: list[StreamRecord[str]]) -> list[Producer]: async def _run_append_workflow( client: Client, ) -> tuple[WorkflowHandle[AppendWorkflow, StreamRef[str]], StreamRef[str]]: - async with new_worker(client, AppendWorkflow) as worker: + async with new_worker(client, AppendWorkflow, activities=[append_values]) as worker: handle = await client.start_workflow( AppendWorkflow.run, f"stream-{uuid.uuid4()}", @@ -318,11 +263,11 @@ async def _run_append_workflow( return handle, await handle.result() -async def _append_result(client: Client, store: StreamStore) -> str: +async def _describe_result(client: Client, store: StreamStore) -> str: stream_client = _with_store(client, store) - async with new_worker(stream_client, AppendResultWorkflow) as worker: + async with new_worker(stream_client, DescribeResultWorkflow) as worker: return await stream_client.execute_workflow( - AppendResultWorkflow.run, + DescribeResultWorkflow.run, f"stream-{uuid.uuid4()}", id=f"workflow-{uuid.uuid4()}", task_queue=worker.task_queue, @@ -338,8 +283,7 @@ async def test_workflow_stream_uses_store(client: Client): assert [record.body for record in records] == _BODIES run_id = handle.result_run_id assert run_id - first, second = (record.producer for record in records) - assert first is not None + first, second = _producers(records) assert first == second # The create is the run's first Local Activity, so the append is its second. assert first.id == f"la:{run_id}:2" @@ -376,6 +320,7 @@ async def test_workflow_stream_history_has_no_bodies(client: Client): for event in events if event.event_type == EventType.EVENT_TYPE_MARKER_RECORDED ] + # One marker for the create and one for the Local Activity that appends. assert markers == ["core_local_activity", "core_local_activity"] for event in events: for body in _BODIES: @@ -407,7 +352,7 @@ async def test_workflow_stream_without_store_starts_nexus_operation(client: Clie await handle.terminate() -async def test_workflow_stream_close_and_read_fail_with_store(client: Client): +async def test_workflow_stream_append_close_and_read_fail_with_store(client: Client): stream_client = _with_store(client, MemoryStreamStore()) async with new_worker(stream_client, UnsupportedCallsWorkflow) as worker: errors = await stream_client.execute_workflow( @@ -416,40 +361,46 @@ async def test_workflow_stream_close_and_read_fail_with_store(client: Client): id=f"workflow-{uuid.uuid4()}", task_queue=worker.task_queue, ) - assert errors == [ - "CloseStream from Workflow code with a Stream Store isn't supported yet", - "ReadRecords from Workflow code with a Stream Store isn't supported yet", - "CloseStream from Workflow code with a Stream Store isn't supported yet", - ] + append_error = ( + "Workflow code can't append to a Stream on a Store in this release. " + "Call a Local Activity that appends through its client." + ) + close_error = ( + "Workflow code can't close a Stream on a Store in this release. " + "Call an Activity that closes it through its client." + ) + read_error = ( + "Workflow code can't read a Stream on a Store in this release. " + "Call an Activity that reads it through its client." + ) + assert errors == [append_error, close_error, read_error, close_error] async def test_workflow_stream_store_error_reaches_workflow_code(client: Client): - store = FlakyAppendStore(StreamStoreCallOptions(), failures=1) - result = await _append_result(client, store) - assert result == "ApplicationError RuntimeError: append failed" - assert store.appends == 1 + store = FlakyDescribeStore(StreamStoreCallOptions(), failures=1) + result = await _describe_result(client, store) + assert result == "ApplicationError RuntimeError: describe failed" + assert store.describes == 1 -async def test_workflow_append_to_closed_stream_raises_stream_error(client: Client): +async def test_workflow_describe_of_unknown_stream_raises_stream_error(client: Client): stream_client = _with_store(client, MemoryStreamStore()) - stream = await stream_client.create_stream( - id=f"stream-{uuid.uuid4()}", body_type=str - ) - await stream.close() - async with new_worker(stream_client, AppendToStreamWorkflow) as worker: + async with new_worker(stream_client, DescribeStreamWorkflow) as worker: reason = await stream_client.execute_workflow( - AppendToStreamWorkflow.run, - stream.ref.id, + DescribeStreamWorkflow.run, + f"stream-{uuid.uuid4()}", id=f"workflow-{uuid.uuid4()}", task_queue=worker.task_queue, ) - assert reason == "STREAM_ERROR_REASON_CLOSED" + assert reason == "STREAM_ERROR_REASON_NOT_FOUND" async def test_strict_create_in_each_run_of_a_chain_succeeds(client: Client): stream_client = _with_store(client, MemoryStreamStore()) stream_id = f"stream-{uuid.uuid4()}" - async with new_worker(stream_client, CreateThenContinueAsNewWorkflow) as worker: + async with new_worker( + stream_client, CreateThenContinueAsNewWorkflow, activities=[append_values] + ) as worker: handle = await stream_client.start_workflow( CreateThenContinueAsNewWorkflow.run, args=[stream_id, 1], @@ -470,7 +421,7 @@ async def test_strict_create_in_each_run_of_a_chain_succeeds(client: Client): assert second.kind.value.run_id == last_run.run_id -async def test_client_append_after_workflow_append_keeps_offset_order( +async def test_client_append_after_local_activity_append_keeps_offset_order( client: Client, ): stream_client = _with_store(client, MemoryStreamStore()) @@ -497,46 +448,15 @@ async def test_workflow_stream_store_call_options_retry(client: Client): initial_interval=timedelta(milliseconds=10), maximum_attempts=2 ) ) - store = FlakyAppendStore(options, failures=1) - assert await _append_result(client, store) == "appended" - assert store.appends == 2 - - -async def test_workflow_append_retry_in_one_task_writes_once(client: Client): - store = LostAnswerStore( - StreamStoreCallOptions( - retry_policy=RetryPolicy( - initial_interval=timedelta(milliseconds=10), maximum_attempts=2 - ) - ) - ) - stream_client = _with_store(client, store) - stream_id = f"stream-{uuid.uuid4()}" - async with new_worker(stream_client, AppendOffsetsWorkflow) as worker: - appended = await stream_client.execute_workflow( - AppendOffsetsWorkflow.run, - stream_id, - id=f"workflow-{uuid.uuid4()}", - task_queue=worker.task_queue, - ) - first, second = store.appends - assert first.producer == second.producer - assert first.producer.local_activity.epoch.attempt == 1 - assert first.sequence == second.sequence == 1 - records = await _read_all(stream_client, stream_id) - assert [record.body for record in records] == _BODIES - assert store.first_response is not None - assert appended.first_offset == store.first_response.first_offset - assert appended.last_offset == store.first_response.last_offset - assert [appended.first_offset, appended.last_offset] == [ - record.offset for record in records - ] + store = FlakyDescribeStore(options, failures=1) + assert await _describe_result(client, store) == "described" + assert store.describes == 2 async def test_workflow_stream_store_call_options_timeout(client: Client): options = StreamStoreCallOptions(start_to_close_timeout=timedelta(milliseconds=200)) - store = FlakyAppendStore(options, delay=timedelta(seconds=5)) - assert await _append_result(client, store) == "TimeoutError START_TO_CLOSE" + store = FlakyDescribeStore(options, delay=timedelta(seconds=5)) + assert await _describe_result(client, store) == "TimeoutError START_TO_CLOSE" async def test_worker_keeps_its_stream_stores(client: Client): @@ -552,12 +472,12 @@ async def test_activity_interceptors_skip_stream_store_activity(client: Client): stream_client = _with_store(client, MemoryStreamStore()) async with new_worker( stream_client, - AppendAndActivityWorkflow, + CreateAndActivityWorkflow, activities=[user_activity], interceptors=[interceptor], ) as worker: await stream_client.execute_workflow( - AppendAndActivityWorkflow.run, + CreateAndActivityWorkflow.run, f"stream-{uuid.uuid4()}", id=f"workflow-{uuid.uuid4()}", task_queue=worker.task_queue, @@ -570,22 +490,31 @@ async def test_workflow_task_retry_appends_with_higher_epoch(client: Client): _fail_next_task = True stream_client = _with_store(client, MemoryStreamStore()) stream_id = f"stream-{uuid.uuid4()}" - async with new_worker(stream_client, AppendThenFailTaskOnceWorkflow) as worker: + async with new_worker( + stream_client, AppendThenFailTaskOnceWorkflow, activities=[append_values] + ) as worker: await stream_client.execute_workflow( AppendThenFailTaskOnceWorkflow.run, stream_id, id=f"workflow-{uuid.uuid4()}", task_queue=worker.task_queue, ) - first, second = _producers(await _read_all(stream_client, stream_id)) - assert first.id == second.id + records = await _read_all(stream_client, stream_id) + # The failed Workflow Task records no marker, so its retry runs the Local Activity + # again and writes a second copy. + assert [record.body for record in records] == [*_BODIES, *_BODIES] + producers = _producers(records) + assert len({producer.id for producer in producers}) == 1 def epoch(producer: Producer) -> tuple[int, int, int]: assert isinstance(producer.kind, ProducerKindLocalActivity) local = producer.kind.value.epoch return (local.task.started_event_id, local.task.attempt, local.attempt) - assert epoch(second) > epoch(first) + first, second, third, fourth = (epoch(producer) for producer in producers) + assert first == second + assert third == fourth + assert third > first async def test_user_local_activity_append_has_local_activity_producer(client: Client): @@ -612,23 +541,3 @@ async def test_user_local_activity_append_has_local_activity_producer(client: Cl assert producer.kind.value.run_id == run_id assert producer.kind.value.epoch.task.started_event_id > 0 assert producer.kind.value.epoch.attempt == 1 - - -async def test_workflow_explicit_producer_reaches_store(client: Client): - store = LostAnswerStore(StreamStoreCallOptions(), lose_first_answer=False) - stream_client = _with_store(client, store) - stream_id = f"stream-{uuid.uuid4()}" - async with new_worker(stream_client, ExplicitProducerWorkflow) as worker: - await stream_client.execute_workflow( - ExplicitProducerWorkflow.run, - stream_id, - id=f"workflow-{uuid.uuid4()}", - task_queue=worker.task_queue, - ) - [producer] = _producers(await _read_all(stream_client, stream_id)) - assert producer.id == "mine" - assert isinstance(producer.kind, ProducerKindClient) - [request] = store.appends - assert request.producer.id == "mine" - assert request.producer.WhichOneof("kind") == "client" - assert request.sequence == 0 From 1c836c82832f326dbc9f91ad91d764b58d5fc70c Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 12:52:45 -0700 Subject: [PATCH 28/46] Moved the Worker side of a Stream Store into one module. The Activity, the Local Activity call and its handle were spread over generic Worker files. One module now holds all that a Worker does for the Stream Store of its client. --- temporalio/client/_stream_store.py | 17 +-- temporalio/worker/_activity.py | 8 +- temporalio/worker/_replayer.py | 8 +- temporalio/worker/_stream_store.py | 152 ++++++++++++++++++++++++ temporalio/worker/_worker.py | 6 +- temporalio/worker/_workflow.py | 8 +- temporalio/worker/_workflow_instance.py | 121 ++----------------- 7 files changed, 179 insertions(+), 141 deletions(-) create mode 100644 temporalio/worker/_stream_store.py diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py index a700f0265..27cd026cf 100644 --- a/temporalio/client/_stream_store.py +++ b/temporalio/client/_stream_store.py @@ -2,14 +2,12 @@ import dataclasses from abc import ABC, abstractmethod -from collections.abc import Awaitable, Callable from datetime import timedelta from typing import TYPE_CHECKING, Any import google.protobuf.message import nexusrpc -import temporalio.activity import temporalio.bridge._visitor import temporalio.bridge.runtime import temporalio.common @@ -142,9 +140,6 @@ def _check_not_negative(**durations: timedelta | None) -> None: # pyright: igno raise ValueError(f"{label} must not be negative") -_STREAM_STORE_ACTIVITY = "__temporal_stream_store_call" - - def _stream_service() -> nexusrpc.ServiceDefinition: service = nexusrpc.get_service_definition( temporalio.nexus.system.stream_service.StreamService @@ -166,7 +161,7 @@ def _proto_type(model_type: Any) -> type[google.protobuf.message.Message]: return converter.transfer_type -async def _call_stream_store( +async def _call_stream_store( # pyright: ignore[reportUnusedFunction] store: StreamStore, data_converter: temporalio.converter.DataConverter, operation: str, @@ -205,13 +200,3 @@ def _stream_response( # pyright: ignore[reportUnusedFunction] [payload], [result_type] if result_type else None ) return result - - -def _stream_store_activity( # pyright: ignore[reportUnusedFunction] - store: StreamStore, data_converter: temporalio.converter.DataConverter -) -> Callable[[str, bytes], Awaitable[bytes]]: - @temporalio.activity.defn(name=_STREAM_STORE_ACTIVITY) - async def call(operation: str, request: bytes) -> bytes: - return await _call_stream_store(store, data_converter, operation, request) - - return call diff --git a/temporalio/worker/_activity.py b/temporalio/worker/_activity.py index 04846db1b..5ac5f68af 100644 --- a/temporalio/worker/_activity.py +++ b/temporalio/worker/_activity.py @@ -34,7 +34,6 @@ import temporalio.converter import temporalio.converter._serialization_context import temporalio.exceptions -from temporalio.client._stream_store import _STREAM_STORE_ACTIVITY from temporalio.converter import ( StorageDriverActivityInfo, StorageDriverStoreContext, @@ -47,6 +46,7 @@ ExecuteActivityInput, Interceptor, ) +from ._stream_store import _STREAM_STORE_ACTIVITY logger = logging.getLogger(__name__) @@ -742,8 +742,10 @@ async def _execute_activity( # now even though the info() can't be intercepted and heartbeat() will # fail. The interceptors may want to use the info() during init. impl: ActivityInboundInterceptor = _ActivityInboundImpl(self, running_activity) - # Users see a Stream call through the Workflow interceptors, so this internal - # Activity skips theirs. + # A Workflow's Stream call already goes through the user's Workflow interceptors, as one + # system Nexus operation. This Activity is how the Worker runs that call. The user's + # Activity interceptors would see an Activity that no user code scheduled, so they don't + # run for it. if info.activity_type != _STREAM_STORE_ACTIVITY: for interceptor in reversed(list(self._interceptors)): impl = interceptor.intercept_activity(impl) diff --git a/temporalio/worker/_replayer.py b/temporalio/worker/_replayer.py index 66649613e..9b2554709 100644 --- a/temporalio/worker/_replayer.py +++ b/temporalio/worker/_replayer.py @@ -22,6 +22,7 @@ from ..common import HeaderCodecBehavior from ._interceptor import Interceptor +from ._stream_store import _WorkflowStreamStore from ._worker import load_default_build_id from ._workflow import _WorkflowWorker from ._workflow_instance import ( @@ -257,7 +258,6 @@ def on_eviction_hook( self._config.get("data_converter") or temporalio.converter.DataConverter.default ) - stream_stores = self._config.get("stream_stores") workflow_worker = _WorkflowWorker( bridge_worker=lambda: bridge_worker, namespace=self._config.get("namespace", "ReplayNamespace"), @@ -294,9 +294,9 @@ def on_eviction_hook( default_workflow_logic_flags=frozenset( self._default_workflow_logic_flags ), - stream_store_call_options=stream_stores[0].call_options - if stream_stores - else None, + stream_store=_WorkflowStreamStore.from_stores( + self._config.get("stream_stores", ()) + ), ) external_storage = data_converter.external_storage storage_driver_types = ( diff --git a/temporalio/worker/_stream_store.py b/temporalio/worker/_stream_store.py new file mode 100644 index 000000000..09f3f1066 --- /dev/null +++ b/temporalio/worker/_stream_store.py @@ -0,0 +1,152 @@ +"""What a Worker does for the Stream Store of its client.""" + +from __future__ import annotations + +import asyncio +import dataclasses +from collections.abc import Awaitable, Callable, Generator, Sequence +from typing import TYPE_CHECKING, Any + +from nexusrpc import OutputT + +import temporalio.activity +import temporalio.converter +import temporalio.exceptions +import temporalio.workflow +from temporalio.client._stream_store import ( + StreamStore, + StreamStoreCallOptions, + _call_stream_store, + _stream_response, +) + +from ._interceptor import StartLocalActivityInput, StartNexusOperationInput + +if TYPE_CHECKING: + from ._workflow_instance import _WorkflowInstanceImpl + +_STREAM_STORE_ACTIVITY = "__temporal_stream_store_call" + +_STREAM_STORE_REFUSALS = { + "AppendRecords": ( + "Workflow code can't append to a Stream on a Store in this release. " + "Call a Local Activity that appends through its client." + ), + "CloseStream": ( + "Workflow code can't close a Stream on a Store in this release. " + "Call an Activity that closes it through its client." + ), + "ReadRecords": ( + "Workflow code can't read a Stream on a Store in this release. " + "Call an Activity that reads it through its client." + ), +} + + +def _stream_store_activity( # pyright: ignore[reportUnusedFunction] + store: StreamStore, data_converter: temporalio.converter.DataConverter +) -> Callable[[str, bytes], Awaitable[bytes]]: + @temporalio.activity.defn(name=_STREAM_STORE_ACTIVITY) + async def call(operation: str, request: bytes) -> bytes: + return await _call_stream_store(store, data_converter, operation, request) + + return call + + +@dataclasses.dataclass(frozen=True) +class _WorkflowStreamStore: + """The Worker's Stream Store as Workflow code reaches it.""" + + call_options: StreamStoreCallOptions + """Options of the Local Activity that calls the Store.""" + + @classmethod + def from_stores(cls, stores: Sequence[StreamStore]) -> _WorkflowStreamStore | None: + """Build it from the Stream Stores of a client, or give ``None`` when it has none.""" + if not stores: + return None + return cls(call_options=stores[0].call_options) + + def start_operation( + self, + instance: _WorkflowInstanceImpl, + input: StartNexusOperationInput[Any, OutputT], + ) -> temporalio.workflow.NexusOperationHandle[OutputT]: + """Answer a Stream call of Workflow code with a Local Activity that calls the Store.""" + options = self.call_options + # The generator can't leave these methods out of the Workflow `Stream` yet. In + # this release, Workflow code writes and reads a Stream on a Store through its + # own Activities. + refusal = _STREAM_STORE_REFUSALS.get(input.operation_name) + if refusal is not None: + raise temporalio.exceptions.ApplicationError(refusal, non_retryable=True) + # Workflow code builds the request, so the Local Activity does only the I/O. + payload_converter, _ = instance._system_nexus_converters(input) + [request] = payload_converter.to_payloads([input.input]) + activity = instance._outbound_schedule_activity( + StartLocalActivityInput( + activity=_STREAM_STORE_ACTIVITY, + args=(input.operation_name, request.data), + activity_id=None, + schedule_to_close_timeout=options.schedule_to_close_timeout, + schedule_to_start_timeout=None, + start_to_close_timeout=options.start_to_close_timeout, + retry_policy=options.retry_policy, + local_retry_threshold=None, + cancellation_type=temporalio.workflow.ActivityCancellationType.TRY_CANCEL, + headers={}, + summary=input.summary, + event_groups=input.event_groups, + arg_types=[str, bytes], + ret_type=bytes, + ) + ) + + async def result() -> OutputT: + try: + response = await activity + except temporalio.exceptions.ApplicationError as err: + # Core hands back a failed Local Activity's error without a wrapper. + stream_error = temporalio.exceptions._stream_error_from_failure(err) + if stream_error is not None: + raise stream_error from None + raise + except temporalio.exceptions.ActivityError as err: + # Workflow code gets the Store's error, not the Local Activity's wrapper. + if err.cause is not None: + stream_error = temporalio.exceptions._stream_error_from_failure( + err.cause + ) + if stream_error is not None: + raise stream_error from None + if isinstance( + err.cause, + ( + temporalio.exceptions.ApplicationError, + temporalio.exceptions.TimeoutError, + ), + ): + raise err.cause + raise + return _stream_response( + payload_converter, input.operation_name, input.output_type, response + ) + + return _StreamStoreOperationHandle( + instance.create_task(result(), name=f"stream store: {input.operation_name}") + ) + + +class _StreamStoreOperationHandle(temporalio.workflow.NexusOperationHandle[OutputT]): + def __init__(self, task: asyncio.Task[OutputT]) -> None: + self._task = task + + def cancel(self) -> bool: + return self._task.cancel() + + def __await__(self) -> Generator[Any, Any, OutputT]: + return self._task.__await__() + + @property + def operation_token(self) -> str | None: + return None diff --git a/temporalio/worker/_worker.py b/temporalio/worker/_worker.py index ef41e1dff..6def5a597 100644 --- a/temporalio/worker/_worker.py +++ b/temporalio/worker/_worker.py @@ -24,7 +24,6 @@ import temporalio.common import temporalio.runtime import temporalio.service -from temporalio.client._stream_store import _stream_store_activity from temporalio.common import ( HeaderCodecBehavior, VersioningBehavior, @@ -35,6 +34,7 @@ from ._interceptor import Interceptor from ._nexus import _NexusWorker from ._plugin import Plugin +from ._stream_store import _stream_store_activity, _WorkflowStreamStore from ._tuning import WorkerTuner from ._workflow import ( _DEFAULT_WORKFLOW_TASK_EXTERNAL_STORAGE_CONCURRENCY, @@ -586,9 +586,7 @@ def check_activity(activity: str): encode_headers=client_config["header_codec_behavior"] != HeaderCodecBehavior.NO_CODEC, max_workflow_task_external_storage_concurrency=max_workflow_task_external_storage_concurrency, - stream_store_call_options=stream_stores[0].call_options - if stream_stores - else None, + stream_store=_WorkflowStreamStore.from_stores(stream_stores), ) tuner = config.get("tuner") diff --git a/temporalio/worker/_workflow.py b/temporalio/worker/_workflow.py index 5dfc64c1b..3e20e6adb 100644 --- a/temporalio/worker/_workflow.py +++ b/temporalio/worker/_workflow.py @@ -27,7 +27,6 @@ import temporalio.exceptions import temporalio.workflow from temporalio.bridge.worker import PollShutdownError -from temporalio.client._stream_store import StreamStoreCallOptions from temporalio.converter import StorageDriverStoreContext, StorageDriverWorkflowInfo from temporalio.worker.workflow_sandbox._runner import SandboxedWorkflowRunner @@ -41,6 +40,7 @@ WorkflowInboundInterceptor, WorkflowInterceptorClassInput, ) +from ._stream_store import _WorkflowStreamStore from ._workflow_instance import ( _DEFAULT_ENABLED_WORKFLOW_LOGIC_FLAGS, PatchActivationInput, @@ -129,7 +129,7 @@ def __init__( encode_headers: bool, max_workflow_task_external_storage_concurrency: int, default_workflow_logic_flags: frozenset[_WorkflowLogicFlag] | None = None, - stream_store_call_options: StreamStoreCallOptions | None = None, + stream_store: _WorkflowStreamStore | 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")) @@ -199,7 +199,7 @@ def __init__( self._max_workflow_task_external_storage_concurrency = ( max_workflow_task_external_storage_concurrency ) - self._stream_store_call_options = stream_store_call_options + self._stream_store = stream_store self._throw_after_activation: Exception | None = None # If debug mode is enabled, disable deadlock detection @@ -822,7 +822,7 @@ def _create_workflow_instance( nexus_serialization_context=temporalio.converter._serialization_context._propagated_nexus_serialization_context( init ), - stream_store_call_options=self._stream_store_call_options, + stream_store=self._stream_store, ) 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 c3746a463..620ffe8bb 100644 --- a/temporalio/worker/_workflow_instance.py +++ b/temporalio/worker/_workflow_instance.py @@ -59,12 +59,7 @@ import temporalio.exceptions import temporalio.nexus.system import temporalio.workflow -from temporalio.client._stream_store import ( - _STREAM_SERVICE, - _STREAM_STORE_ACTIVITY, - StreamStoreCallOptions, - _stream_response, -) +from temporalio.client._stream_store import _STREAM_SERVICE from temporalio.converter import StorageDriverStoreContext, StorageDriverWorkflowInfo from temporalio.nexus.system.stream_service.workflow._system_nexus_interceptor import ( _start_system_nexus_operation as _start_stream_system_nexus_operation, @@ -100,6 +95,7 @@ WorkflowInboundInterceptor, WorkflowOutboundInterceptor, ) +from ._stream_store import _WorkflowStreamStore logger = logging.getLogger(__name__) @@ -197,9 +193,9 @@ class WorkflowInstanceDetails: temporalio.converter.NexusSerializationContext | None ) = None """Serialization context of the Nexus operation handler that started the workflow, if any.""" - stream_store_call_options: StreamStoreCallOptions | None = None - """Options of the Local Activity that calls the worker's Stream Store, or None when - the worker has no Stream Store.""" + stream_store: _WorkflowStreamStore | None = None + """The Worker's Stream Store as Workflow code reaches it, or ``None`` when the Worker's + client has none.""" class WorkflowInstance(ABC): @@ -289,21 +285,6 @@ def create_instance(self, det: WorkflowInstanceDetails) -> WorkflowInstance: _Context: TypeAlias = dict[str, Any] _ExceptionHandler: TypeAlias = Callable[[asyncio.AbstractEventLoop, _Context], Any] -_STREAM_STORE_REFUSALS = { - "AppendRecords": ( - "Workflow code can't append to a Stream on a Store in this release. " - "Call a Local Activity that appends through its client." - ), - "CloseStream": ( - "Workflow code can't close a Stream on a Store in this release. " - "Call an Activity that closes it through its client." - ), - "ReadRecords": ( - "Workflow code can't read a Stream on a Store in this release. " - "Call an Activity that reads it through its client." - ), -} - class _WorkflowInstanceImpl( # type: ignore[reportImplicitAbstractClass] WorkflowInstance, temporalio.workflow._Runtime, asyncio.AbstractEventLoop @@ -331,7 +312,7 @@ def __init__(self, det: WorkflowInstanceDetails) -> None: # the Nexus caller, which decodes them with the handler's Nexus context. A retried or cron run # reads the previous run's result and failure with the same context. self._nexus_serialization_context = det.nexus_serialization_context - self._stream_store_call_options = det.stream_store_call_options + self._stream_store = det.stream_store if self._nexus_serialization_context: self._result_payload_converter = self._payload_converter_with_context( self._nexus_serialization_context @@ -2371,9 +2352,11 @@ async def _intercept_system_nexus_operation( async def _schedule_system_nexus_operation( self, input: StartNexusOperationInput[Any, OutputT] ) -> temporalio.workflow.NexusOperationHandle[OutputT]: - options = self._stream_store_call_options - if options is not None and input.service == _STREAM_SERVICE.name: - return self._start_stream_store_operation(input, options) + # A Worker whose client has a Stream Store answers a Stream call with that Store. Any + # other Worker sends it to the server, as it sends every system Nexus operation. + store = self._stream_store + if store is not None and input.service == _STREAM_SERVICE.name: + return store.start_operation(self, input) return await self._outbound_start_nexus_operation(input) def _system_nexus_converters( @@ -2401,73 +2384,6 @@ def _system_nexus_converters( ) return payload_converter, user_failure_converter - def _start_stream_store_operation( - self, - input: StartNexusOperationInput[Any, OutputT], - options: StreamStoreCallOptions, - ) -> temporalio.workflow.NexusOperationHandle[OutputT]: - # The generator can't leave these methods out of the Workflow `Stream` yet. In - # this release, Workflow code writes and reads a Stream on a Store through its - # own Activities. - refusal = _STREAM_STORE_REFUSALS.get(input.operation_name) - if refusal is not None: - raise temporalio.exceptions.ApplicationError(refusal, non_retryable=True) - # Workflow code builds the request, so the Local Activity does only the I/O. - payload_converter, _ = self._system_nexus_converters(input) - [request] = payload_converter.to_payloads([input.input]) - activity = self._outbound_schedule_activity( - StartLocalActivityInput( - activity=_STREAM_STORE_ACTIVITY, - args=(input.operation_name, request.data), - activity_id=None, - schedule_to_close_timeout=options.schedule_to_close_timeout, - schedule_to_start_timeout=None, - start_to_close_timeout=options.start_to_close_timeout, - retry_policy=options.retry_policy, - local_retry_threshold=None, - cancellation_type=temporalio.workflow.ActivityCancellationType.TRY_CANCEL, - headers={}, - summary=input.summary, - event_groups=input.event_groups, - arg_types=[str, bytes], - ret_type=bytes, - ) - ) - - async def result() -> OutputT: - try: - response = await activity - except temporalio.exceptions.ApplicationError as err: - # Core hands back a failed Local Activity's error without a wrapper. - stream_error = temporalio.exceptions._stream_error_from_failure(err) - if stream_error is not None: - raise stream_error from None - raise - except temporalio.exceptions.ActivityError as err: - # Workflow code gets the Store's error, not the Local Activity's wrapper. - if err.cause is not None: - stream_error = temporalio.exceptions._stream_error_from_failure( - err.cause - ) - if stream_error is not None: - raise stream_error from None - if isinstance( - err.cause, - ( - temporalio.exceptions.ApplicationError, - temporalio.exceptions.TimeoutError, - ), - ): - raise err.cause - raise - return _stream_response( - payload_converter, input.operation_name, input.output_type, response - ) - - return _StreamStoreOperationHandle( - self.create_task(result(), name=f"stream store: {input.operation_name}") - ) - #### Miscellaneous helpers #### # These are in alphabetical order. @@ -3966,21 +3882,6 @@ async def cancel( await self._instance._cancel_external_workflow(command) -class _StreamStoreOperationHandle(temporalio.workflow.NexusOperationHandle[OutputT]): - def __init__(self, task: asyncio.Task[OutputT]) -> None: - self._task = task - - def cancel(self) -> bool: - return self._task.cancel() - - def __await__(self) -> Generator[Any, Any, OutputT]: - return self._task.__await__() - - @property - def operation_token(self) -> str | None: - return None - - class _NexusOperationHandle(temporalio.workflow.NexusOperationHandle[OutputT]): def __init__( self, From f1fdc702b6b63795379b0258ba371caec83ba861 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 12:53:31 -0700 Subject: [PATCH 29/46] Made the call of a Stream Store private. A Store is the Rust runtime behind the bridge, so no user writes one in Python. Its call takes the bridge's operation name and bytes, which is not an API for users. --- temporalio/client/_stream_store.py | 15 +++++++++------ tests/nexus/test_stream_store_workflow.py | 6 +++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py index 27cd026cf..1f8a3e393 100644 --- a/temporalio/client/_stream_store.py +++ b/temporalio/client/_stream_store.py @@ -53,7 +53,10 @@ def __post_init__(self) -> None: class StreamStore(ABC): """A Store that answers the Stream operations of a client. - A client with a Store sends each Stream operation to it instead of the server. + A client with a Store sends each Stream operation to it instead of the server. A + Store is configuration. Use + :py:class:`temporalio.contrib.redis.streams.RedisStreamStore`, or + :py:class:`temporalio.testing.MemoryStreamStore` in tests. .. warning:: This API is experimental and subject to change. @@ -71,8 +74,8 @@ def call_options(self) -> StreamStoreCallOptions: return StreamStoreCallOptions() @abstractmethod - async def call(self, operation: str, request: bytes) -> bytes: - """Answer one Stream operation. + async def _call(self, operation: str, request: bytes) -> bytes: + """Answer one Stream operation, in the form that the bridge defines. Args: operation: The Nexus operation name, such as ``CreateStream``, @@ -121,7 +124,7 @@ def _new_stream_runtime( self, runtime: temporalio.bridge.runtime.Runtime ) -> temporalio.bridge.streams.StreamRuntime: ... - async def call(self, operation: str, request: bytes) -> bytes: + async def _call(self, operation: str, request: bytes) -> bytes: """Answer one Stream operation with the Stream runtime of the bridge. Raises: @@ -168,7 +171,7 @@ async def _call_stream_store( # pyright: ignore[reportUnusedFunction] request: bytes, ) -> bytes: if not data_converter.payload_codec: - return await store.call(operation, request) + return await store._call(operation, request) # The worker package imports the client, so a module level import would cycle. from temporalio.bridge.worker import _Visitor @@ -178,7 +181,7 @@ async def _call_stream_store( # pyright: ignore[reportUnusedFunction] await visitor.visit( _Visitor(data_converter._encode_payload_sequence), request_message ) - response = await store.call(operation, request_message.SerializeToString()) + response = await store._call(operation, request_message.SerializeToString()) response_message = _proto_type(definition.output_type).FromString(response) await visitor.visit( _Visitor(data_converter._decode_payload_sequence), response_message diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py index 08c568920..76360f62b 100644 --- a/tests/nexus/test_stream_store_workflow.py +++ b/tests/nexus/test_stream_store_workflow.py @@ -180,7 +180,7 @@ class FailingStreamStore(StreamStore): def name(self) -> str: return "failing" - async def call(self, operation: str, request: bytes) -> bytes: + async def _call(self, operation: str, request: bytes) -> bytes: raise AssertionError(f"A replay called the Store with {operation}") @@ -202,13 +202,13 @@ def __init__( def call_options(self) -> StreamStoreCallOptions: return self._options - async def call(self, operation: str, request: bytes) -> bytes: + async def _call(self, operation: str, request: bytes) -> bytes: if operation == "DescribeStream": self.describes += 1 await asyncio.sleep(self._delay.total_seconds()) if self.describes <= self._failures: raise RuntimeError("describe failed") - return await super().call(operation, request) + return await super()._call(operation, request) class RecordingInterceptor(Interceptor): From ed82555b1a618d86ac9dc253207d64fbb0d4b225 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 12:55:39 -0700 Subject: [PATCH 30/46] Said why stream_stores is a list, and named the Redis step in CI. A reader asked why a list takes one Store. A named CI step shows why Redis is installed. --- .github/workflows/ci.yml | 3 ++- temporalio/client/_client.py | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2ab0e624..bea2c19be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,8 @@ jobs: if: ${{ matrix.clippyLinter }} - run: poe build-develop - run: poe lint - - uses: ./.github/actions/install-redis + - name: Install and start Redis for the Redis Stream Store tests + uses: ./.github/actions/install-redis - run: mkdir junit-xml - run: poe test ${{matrix.pytestExtraArgs}} -s --junit-xml=junit-xml/${{ matrix.python }}--${{ matrix.os }}.xml timeout-minutes: 15 diff --git a/temporalio/client/_client.py b/temporalio/client/_client.py index c58d8f926..3ca1fb1ac 100644 --- a/temporalio/client/_client.py +++ b/temporalio/client/_client.py @@ -231,7 +231,10 @@ async def connect( 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_stores: Stores that answer the Stream operations of this - client instead of the server. At most one is supported. + client instead of the server. It is a list, as + :py:attr:`temporalio.converter.ExternalStorage.drivers` is, so that a + later release can route between several Stores. This release takes + one Store. .. warning:: This API is experimental and subject to change. @@ -311,6 +314,8 @@ def __init__( self._init_from_config(config) def _init_from_config(self, config: ClientConfig): + # Two Stores need a rule that picks one for a new Stream. That selector isn't here yet, + # so a second Store is refused and not ignored. if len(config["stream_stores"]) > 1: raise ValueError( "More than one Stream Store needs a selector, which isn't supported yet" From 9fa615e109717a41eee897c11db2515e886c3cef Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 13:15:15 -0700 Subject: [PATCH 31/46] Took the Stream Stores of a client as one config with a selector. The agreed API holds the Stores and the rule that picks one in a single config, as `ExternalStorage` holds drivers and a selector. A ref names its Store, so a call on a known Stream reaches the right Store with several of them. --- temporalio/bridge/streams.py | 14 +- temporalio/client/__init__.py | 9 +- temporalio/client/_client.py | 32 ++--- temporalio/client/_impl.py | 14 +- temporalio/client/_stream_store.py | 162 +++++++++++++++++++++- temporalio/contrib/redis/streams.py | 6 +- temporalio/exceptions.py | 21 +++ temporalio/worker/_replayer.py | 14 +- temporalio/worker/_stream_store.py | 46 +++++- temporalio/worker/_worker.py | 28 ++-- tests/contrib/redis/test_streams.py | 4 +- tests/nexus/test_stream_store.py | 122 ++++++++++++++-- tests/nexus/test_stream_store_workflow.py | 51 ++++++- 13 files changed, 454 insertions(+), 69 deletions(-) diff --git a/temporalio/bridge/streams.py b/temporalio/bridge/streams.py index a018cf6f7..b189517c2 100644 --- a/temporalio/bridge/streams.py +++ b/temporalio/bridge/streams.py @@ -30,13 +30,16 @@ def new_memory( """Build the runtime on a new memory Store. Raises: - RuntimeError: The bridge build has no Stream runtime. + temporalio.exceptions.StreamConfigError: The bridge build has no Stream + runtime. """ new = getattr( temporalio.bridge.temporal_sdk_bridge, "new_memory_stream_runtime", None ) if new is None: - raise RuntimeError("This build of temporalio has no Stream runtime") + raise temporalio.exceptions.StreamConfigError( + "This build of temporalio has no Stream runtime" + ) return StreamRuntime(new(runtime._ref, name, retention, last_write_ttl)) @staticmethod @@ -56,14 +59,17 @@ def new_redis( """Build the runtime on Redis. It connects at its first call. Raises: - RuntimeError: The bridge build has no Redis Stream Store. + temporalio.exceptions.StreamConfigError: The bridge build has no Redis + Stream Store. ValueError: An option is bad. """ new = getattr( temporalio.bridge.temporal_sdk_bridge, "new_redis_stream_runtime", None ) if new is None: - raise RuntimeError("This build of temporalio has no Redis Stream Store") + raise temporalio.exceptions.StreamConfigError( + "This build of temporalio has no Redis Stream Store" + ) return StreamRuntime( new( runtime._ref, diff --git a/temporalio/client/__init__.py b/temporalio/client/__init__.py index 2473af8aa..6fa6dd710 100644 --- a/temporalio/client/__init__.py +++ b/temporalio/client/__init__.py @@ -185,7 +185,12 @@ ScheduleUpdate, ScheduleUpdateInput, ) -from ._stream_store import StreamStore, StreamStoreCallOptions +from ._stream_store import ( + StreamConfig, + StreamSelectContext, + StreamStore, + StreamStoreCallOptions, +) from ._worker_versioning import ( BuildIdOp, BuildIdOpAddNewCompatible, @@ -351,6 +356,8 @@ "TaskReachabilityType", "CloudOperationsClient", "Plugin", + "StreamConfig", + "StreamSelectContext", "StreamStore", "StreamStoreCallOptions", "Callback", diff --git a/temporalio/client/_client.py b/temporalio/client/_client.py index 3ca1fb1ac..4dcfe6b94 100644 --- a/temporalio/client/_client.py +++ b/temporalio/client/_client.py @@ -95,7 +95,7 @@ ScheduleBackfill, ScheduleHandle, ) -from ._stream_store import StreamStore +from ._stream_store import StreamConfig, _refuse_store_plugins from ._worker_versioning import ( BuildIdOp, TaskReachabilityType, @@ -160,7 +160,7 @@ async def connect( grpc_compression: GrpcCompression = GrpcCompression.GZIP, payload_limits: PayloadLimitsConfig = PayloadLimitsConfig(), header_codec_behavior: HeaderCodecBehavior = HeaderCodecBehavior.NO_CODEC, - stream_stores: Sequence[StreamStore] = (), + stream_config: StreamConfig | None = None, ) -> Self: """Connect to a Temporal server. @@ -230,15 +230,16 @@ 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_stores: Stores that answer the Stream operations of this - client instead of the server. It is a list, as - :py:attr:`temporalio.converter.ExternalStorage.drivers` is, so that a - later release can route between several Stores. This release takes - one Store. + stream_config: The Stream Stores of this client and the selector that + picks one for a new Stream, as + :py:class:`temporalio.converter.ExternalStorage` holds drivers and a + selector. A client with it sends each Stream operation to one of its + Stores instead of the server. ``None`` sends them to the server. .. warning:: This API is experimental and subject to change. """ + _refuse_store_plugins(plugins) connect_config = temporalio.service.ConnectConfig( target_host=target_host, api_key=api_key, @@ -275,7 +276,7 @@ def make_lambda( default_workflow_query_reject_condition=default_workflow_query_reject_condition, header_codec_behavior=header_codec_behavior, plugins=plugins, - stream_stores=stream_stores, + stream_config=stream_config, ) def __init__( @@ -289,12 +290,13 @@ def __init__( default_workflow_query_reject_condition: None | (temporalio.common.QueryRejectCondition) = None, header_codec_behavior: HeaderCodecBehavior = HeaderCodecBehavior.NO_CODEC, - stream_stores: Sequence[StreamStore] = (), + stream_config: StreamConfig | None = None, ): """Create a Temporal client from a service client. See :py:meth:`connect` for details on the parameters. """ + _refuse_store_plugins(plugins) # Store the config for tracking config = ClientConfig( service_client=service_client, @@ -304,7 +306,7 @@ def __init__( interceptors=interceptors, default_workflow_query_reject_condition=default_workflow_query_reject_condition, header_codec_behavior=header_codec_behavior, - stream_stores=stream_stores, + stream_config=stream_config, ) self._initial_config = config.copy() @@ -314,12 +316,6 @@ def __init__( self._init_from_config(config) def _init_from_config(self, config: ClientConfig): - # Two Stores need a rule that picks one for a new Stream. That selector isn't here yet, - # so a second Store is refused and not ignored. - if len(config["stream_stores"]) > 1: - raise ValueError( - "More than one Stream Store needs a selector, which isn't supported yet" - ) self._config = config # Iterate over interceptors in reverse building the impl @@ -3051,7 +3047,7 @@ class ClientConnectConfig(TypedDict, total=False): grpc_compression: GrpcCompression payload_limits: PayloadLimitsConfig header_codec_behavior: HeaderCodecBehavior - stream_stores: Sequence[StreamStore] + stream_config: StreamConfig | None class ClientConfig(TypedDict, total=False): @@ -3066,4 +3062,4 @@ class ClientConfig(TypedDict, total=False): temporalio.common.QueryRejectCondition | None ] header_codec_behavior: Required[HeaderCodecBehavior] - stream_stores: Required[Sequence[StreamStore]] + stream_config: Required[StreamConfig | None] diff --git a/temporalio/client/_impl.py b/temporalio/client/_impl.py index bc490f729..6740416ea 100644 --- a/temporalio/client/_impl.py +++ b/temporalio/client/_impl.py @@ -128,7 +128,8 @@ ) from ._stream_store import ( _STREAM_SERVICE, - StreamStore, + StreamConfig, + _activity_target, _call_stream_store, _stream_response, ) @@ -1572,13 +1573,13 @@ async def start_nexus_operation( self, input: StartNexusOperationInput ) -> NexusOperationHandle[Any]: """Start a nexus operation and return a handle to it.""" - stream_stores = self._client._config["stream_stores"] + stream_config = self._client._config.get("stream_config") if ( - stream_stores + stream_config is not None and temporalio.nexus.system.is_system_endpoint(input.endpoint) and input.service == _STREAM_SERVICE.name ): - return await self._start_stream_store_operation(stream_stores[0], input) + return await self._start_stream_store_operation(stream_config, input) nexus_context = temporalio.converter.NexusSerializationContext( endpoint=input.endpoint, service=input.service, @@ -1663,7 +1664,7 @@ async def start_nexus_operation( ) async def _start_stream_store_operation( - self, store: StreamStore, input: StartNexusOperationInput + self, stream_config: StreamConfig, input: StartNexusOperationInput ) -> NexusOperationHandle[Any]: data_converter = self._client.data_converter serialization_context = temporalio.nexus.system._get_serialization_context( @@ -1675,6 +1676,9 @@ async def _start_stream_store_operation( data_converter.payload_converter, data_converter.failure_converter ) [request] = payload_converter.to_payloads([input.arg]) + store = stream_config._store_for( + input.operation, request.data, _activity_target() + ) response = await _call_stream_store( store, data_converter, input.operation, request.data ) diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py index 1f8a3e393..49c87e4a3 100644 --- a/temporalio/client/_stream_store.py +++ b/temporalio/client/_stream_store.py @@ -2,12 +2,15 @@ import dataclasses from abc import ABC, abstractmethod +from collections.abc import Callable, Sequence from datetime import timedelta from typing import TYPE_CHECKING, Any import google.protobuf.message import nexusrpc +import temporalio.activity +import temporalio.api.stream.v1 import temporalio.bridge._visitor import temporalio.bridge.runtime import temporalio.common @@ -15,6 +18,8 @@ import temporalio.converter._payload_converter import temporalio.nexus.system.stream_service import temporalio.runtime +from temporalio.converter import StorageDriverActivityInfo, StorageDriverWorkflowInfo +from temporalio.exceptions import StreamConfigError if TYPE_CHECKING: import temporalio.bridge.streams @@ -129,7 +134,8 @@ async def _call(self, operation: str, request: bytes) -> bytes: Raises: temporalio.exceptions.StreamError: The call failed. - RuntimeError: This build of temporalio has no Stream runtime, or not this Store. + temporalio.exceptions.StreamConfigError: This build of temporalio has no + Stream runtime, or not this Store. """ if self._stream_runtime is None: runtime = self._runtime or temporalio.runtime.Runtime.default() @@ -137,6 +143,160 @@ async def _call(self, operation: str, request: bytes) -> bytes: return await self._stream_runtime.call(operation, request) +@dataclasses.dataclass(frozen=True) +class StreamSelectContext: + """Context passed to :py:attr:`StreamConfig.store_selector` calls. + + .. warning:: + This API is experimental and subject to change. + """ + + id: str + """The id of the Stream.""" + + target: StorageDriverActivityInfo | StorageDriverWorkflowInfo | None = None + """The Workflow or Activity that makes the call, or ``None`` for a plain client. + + A call in an Activity has that Activity here. Workflow code never calls the + selector, since a Worker serves one Store. + """ + + +@dataclasses.dataclass(frozen=True) +class StreamConfig: + """The Stream Stores of a client, and the rule that picks one for a new Stream. + + A client with this config sends each Stream operation to one of its Stores + instead of the server. It holds Stores and a selector, as + :py:class:`temporalio.converter.ExternalStorage` holds drivers and a selector. + + A ref carries the name of its Store, so a call on a known Stream goes to the + Store of that name. A call whose ref names no Store, such as a create, goes to + the one Store, or to the Store that :py:attr:`store_selector` returns. + + .. warning:: + This API is experimental and subject to change. + """ + + stores: Sequence[StreamStore] + """At least one Store. Each Store has a unique name. + + A ref carries the :py:attr:`StreamStore.name` of its Store, and a call looks + the Store up by that name. With more than one Store, :py:attr:`store_selector` + must also be set. A Worker serves one Store, so a Worker whose client has more + than one raises :py:class:`temporalio.exceptions.StreamConfigError`. + """ + + store_selector: Callable[[StreamSelectContext], StreamStore] | None = None + """Picks the Store of a new Stream. Required with more than one Store. + + It gets a :py:class:`StreamSelectContext` for each call whose ref names no + Store, and returns one of the instances in :py:attr:`stores`. When it is + ``None`` and there is one Store, every new Stream goes to that Store. + + A replayed create must reach the same Store, so a selector uses facts that are + fixed for the run, such as the Stream id. The time, a random value or the load + of a Store can change between two runs of one create. + """ + + _store_map: dict[str, StreamStore] = dataclasses.field( + init=False, repr=False, compare=False + ) + """Name-keyed index of :py:attr:`stores`, built at construction time.""" + + def __post_init__(self) -> None: + """Check the Stores and build the name-keyed map of them. + + Raises :py:class:`temporalio.exceptions.StreamConfigError` if there is no + Store, if two Stores share a name, or if more than one Store is given + without a :py:attr:`store_selector`. + """ + if not self.stores: + raise StreamConfigError("StreamConfig.stores must have at least one Store") + store_map: dict[str, StreamStore] = {} + for store in self.stores: + if store.name in store_map: + raise StreamConfigError( + f"StreamConfig.stores has more than one Store named '{store.name}'. " + "Each Store needs a unique name." + ) + store_map[store.name] = store + if len(self.stores) > 1 and self.store_selector is None: + raise StreamConfigError( + "StreamConfig.store_selector must be set when there is more than one Store" + ) + object.__setattr__(self, "_store_map", store_map) + + def _select_store(self, context: StreamSelectContext) -> StreamStore: + selector = self.store_selector + if selector is None: + return self.stores[0] + store = selector(context) + if ( + not isinstance(store, StreamStore) + or self._store_map.get(store.name) is not store + ): + raise StreamConfigError( + f"The store_selector returned {store!r}, which isn't one of " + "StreamConfig.stores" + ) + return store + + def _get_store_by_name(self, name: str) -> StreamStore: + store = self._store_map.get(name) + if store is None: + raise StreamConfigError( + f"The ref names the Stream Store '{name}', which this client doesn't have" + ) + return store + + def _store_for( + self, + operation: str, + request: bytes, + target: StorageDriverActivityInfo | StorageDriverWorkflowInfo | None, + ) -> StreamStore: + ref = _request_ref(operation, request) + if ref.store: + return self._get_store_by_name(ref.store) + return self._select_store(StreamSelectContext(id=ref.id, target=target)) + + +def _request_ref(operation: str, request: bytes) -> temporalio.api.stream.v1.StreamRef: + definition = _STREAM_SERVICE.operation_definitions[operation] + message = _proto_type(definition.input_type).FromString(request) + # A create calls its ref `stream_ref` and every other request calls it `ref`, so + # the field is found by its type. + for field in message.DESCRIPTOR.fields: + if field.message_type is temporalio.api.stream.v1.StreamRef.DESCRIPTOR: + return getattr(message, field.name) + raise TypeError(f"The {operation} request has no StreamRef") + + +def _activity_target() -> StorageDriverActivityInfo | None: # pyright: ignore[reportUnusedFunction] + # External storage builds the same value from the running Activity. + if not temporalio.activity.in_activity(): + return None + info = temporalio.activity.info() + return StorageDriverActivityInfo( + id=info.activity_id, + type=info.activity_type, + run_id=info.activity_run_id, + namespace=info.namespace, + ) + + +def _refuse_store_plugins( # pyright: ignore[reportUnusedFunction] + plugins: Sequence[object], +) -> None: + for plugin in plugins: + if isinstance(plugin, StreamStore): + raise StreamConfigError( + f"The Stream Store '{plugin.name}' is in plugins. Give it in " + "stream_config=StreamConfig(stores=[...]) instead." + ) + + def _check_not_negative(**durations: timedelta | None) -> None: # pyright: ignore[reportUnusedFunction] for label, value in durations.items(): if value is not None and value < timedelta(): diff --git a/temporalio/contrib/redis/streams.py b/temporalio/contrib/redis/streams.py index f8d1da467..98893dc39 100644 --- a/temporalio/contrib/redis/streams.py +++ b/temporalio/contrib/redis/streams.py @@ -1,13 +1,13 @@ """The Redis Stream Store. -A client takes it in ``stream_stores``, and its Workers use the same Store:: +A client takes it in ``stream_config``, and its Workers use the same Store:: - from temporalio.client import Client + from temporalio.client import Client, StreamConfig from temporalio.contrib.redis.streams import RedisStreamStore client = await Client.connect( "localhost:7233", - stream_stores=[RedisStreamStore("redis://localhost:6379")], + stream_config=StreamConfig(stores=[RedisStreamStore("redis://localhost:6379")]), ) .. warning:: diff --git a/temporalio/exceptions.py b/temporalio/exceptions.py index 95cebb6e1..d3252c13a 100644 --- a/temporalio/exceptions.py +++ b/temporalio/exceptions.py @@ -283,6 +283,27 @@ def _stream_error_from_failure( # pyright: ignore[reportUnusedFunction] return _stream_error_from_reason(reason, err.message) +class StreamConfigError(RuntimeError): + """A Stream call can't find its Store, because of how the Stores are set up. + + It is not a :py:class:`StreamError`. A Store didn't fail. The setup of the Stores + is wrong, and a retry won't help. These are its cases. + + * A :py:class:`temporalio.client.StreamConfig` has no Store, has two Stores with + one name, or has more than one Store and no selector. + * A ref names a Store that the config of the client doesn't have. + * The selector returns a Store that isn't one of the config's Stores. + * A :py:class:`temporalio.client.StreamStore` is given in ``plugins``. + * A Worker's client has more than one Store, since a Worker serves one Store. + * A Workflow's Stream call names another Store than the Worker's. This fails the + Workflow Task. + * This build of temporalio has no Stream runtime, or no Redis Stream Store. + + .. warning:: + This API is experimental and subject to change. + """ + + class CancelledError(FailureError): """Error raised on workflow/activity cancellation.""" diff --git a/temporalio/worker/_replayer.py b/temporalio/worker/_replayer.py index 9b2554709..6cce5b292 100644 --- a/temporalio/worker/_replayer.py +++ b/temporalio/worker/_replayer.py @@ -57,7 +57,7 @@ def __init__( runtime: temporalio.runtime.Runtime | None = None, disable_safe_workflow_eviction: bool = False, header_codec_behavior: HeaderCodecBehavior = HeaderCodecBehavior.NO_CODEC, - stream_stores: Sequence[temporalio.client.StreamStore] = (), + stream_config: temporalio.client.StreamConfig | None = None, ) -> None: """Create a replayer to replay workflows from history. @@ -66,9 +66,9 @@ def __init__( the replayer that were passed to the worker when the workflow originally ran. - ``stream_stores`` takes the Stream Stores of the client that the + ``stream_config`` takes the Stream config of the client that the worker had, so a replay makes the same commands. A replay never calls - them. + its Stores. 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 @@ -92,7 +92,7 @@ def __init__( runtime=runtime, disable_safe_workflow_eviction=disable_safe_workflow_eviction, header_codec_behavior=header_codec_behavior, - stream_stores=stream_stores, + stream_config=stream_config, ) self._initial_config = self._config.copy() self._default_workflow_logic_flags = set(_DEFAULT_ENABLED_WORKFLOW_LOGIC_FLAGS) @@ -294,8 +294,8 @@ def on_eviction_hook( default_workflow_logic_flags=frozenset( self._default_workflow_logic_flags ), - stream_store=_WorkflowStreamStore.from_stores( - self._config.get("stream_stores", ()) + stream_store=_WorkflowStreamStore.from_config( + self._config.get("stream_config") ), ) external_storage = data_converter.external_storage @@ -445,7 +445,7 @@ class ReplayerConfig(TypedDict, total=False): runtime: temporalio.runtime.Runtime | None disable_safe_workflow_eviction: bool header_codec_behavior: HeaderCodecBehavior - stream_stores: Sequence[temporalio.client.StreamStore] + stream_config: temporalio.client.StreamConfig | None @dataclass(frozen=True) diff --git a/temporalio/worker/_stream_store.py b/temporalio/worker/_stream_store.py index 09f3f1066..3a3bff0b1 100644 --- a/temporalio/worker/_stream_store.py +++ b/temporalio/worker/_stream_store.py @@ -4,7 +4,7 @@ import asyncio import dataclasses -from collections.abc import Awaitable, Callable, Generator, Sequence +from collections.abc import Awaitable, Callable, Generator from typing import TYPE_CHECKING, Any from nexusrpc import OutputT @@ -14,11 +14,14 @@ import temporalio.exceptions import temporalio.workflow from temporalio.client._stream_store import ( + StreamConfig, StreamStore, StreamStoreCallOptions, _call_stream_store, + _request_ref, _stream_response, ) +from temporalio.exceptions import StreamConfigError from ._interceptor import StartLocalActivityInput, StartNexusOperationInput @@ -53,19 +56,44 @@ async def call(operation: str, request: bytes) -> bytes: return call +def _worker_stream_store(config: StreamConfig | None) -> StreamStore | None: + """Give the one Store that a Worker serves, or ``None`` when its client has none. + + Raises: + temporalio.exceptions.StreamConfigError: The client has more than one Store. + """ + if config is None: + return None + # Workflow code never calls the selector, so a Workflow's new Stream needs a single + # Store that the Worker knows without one. + if len(config.stores) > 1: + raise StreamConfigError( + f"A Worker serves one Stream Store, and its client has {len(config.stores)}" + ) + return config.stores[0] + + @dataclasses.dataclass(frozen=True) class _WorkflowStreamStore: """The Worker's Stream Store as Workflow code reaches it.""" + name: str + """Name of the Store.""" + call_options: StreamStoreCallOptions """Options of the Local Activity that calls the Store.""" @classmethod - def from_stores(cls, stores: Sequence[StreamStore]) -> _WorkflowStreamStore | None: - """Build it from the Stream Stores of a client, or give ``None`` when it has none.""" - if not stores: + def from_config(cls, config: StreamConfig | None) -> _WorkflowStreamStore | None: + """Build it from the Stream config of a client, or give ``None`` when it has none. + + Raises: + temporalio.exceptions.StreamConfigError: The client has more than one Store. + """ + store = _worker_stream_store(config) + if store is None: return None - return cls(call_options=stores[0].call_options) + return cls(name=store.name, call_options=store.call_options) def start_operation( self, @@ -83,6 +111,14 @@ def start_operation( # Workflow code builds the request, so the Local Activity does only the I/O. payload_converter, _ = instance._system_nexus_converters(input) [request] = payload_converter.to_payloads([input.input]) + # The Local Activity calls the Worker's Store, so a ref of another Store can't be + # answered here. The error fails the Workflow Task, so that a fixed Worker can retry it. + ref_store = _request_ref(input.operation_name, request.data).store + if ref_store and ref_store != self.name: + raise StreamConfigError( + f"The ref names the Stream Store '{ref_store}', and this Worker serves " + f"'{self.name}'" + ) activity = instance._outbound_schedule_activity( StartLocalActivityInput( activity=_STREAM_STORE_ACTIVITY, diff --git a/temporalio/worker/_worker.py b/temporalio/worker/_worker.py index 6def5a597..ec537efd1 100644 --- a/temporalio/worker/_worker.py +++ b/temporalio/worker/_worker.py @@ -34,7 +34,11 @@ from ._interceptor import Interceptor from ._nexus import _NexusWorker from ._plugin import Plugin -from ._stream_store import _stream_store_activity, _WorkflowStreamStore +from ._stream_store import ( + _stream_store_activity, + _worker_stream_store, + _WorkflowStreamStore, +) from ._tuning import WorkerTuner from ._workflow import ( _DEFAULT_WORKFLOW_TASK_EXTERNAL_STORAGE_CONCURRENCY, @@ -500,12 +504,11 @@ def _init_from_config(self, client: temporalio.client.Client, config: WorkerConf or temporalio.runtime.Runtime.default() ) activities = list(config.get("activities") or []) - stream_stores = client_config["stream_stores"] - if stream_stores and config.get("workflows"): + stream_config = client_config.get("stream_config") + stream_store = _worker_stream_store(stream_config) + if stream_store is not None and config.get("workflows"): activities.append( - _stream_store_activity( - stream_stores[0], client_config["data_converter"] - ) + _stream_store_activity(stream_store, client_config["data_converter"]) ) if activities: _warn_if_activity_executor_max_workers_is_inconsistent(config) @@ -586,7 +589,7 @@ def check_activity(activity: str): encode_headers=client_config["header_codec_behavior"] != HeaderCodecBehavior.NO_CODEC, max_workflow_task_external_storage_concurrency=max_workflow_task_external_storage_concurrency, - stream_store=_WorkflowStreamStore.from_stores(stream_stores), + stream_store=_WorkflowStreamStore.from_config(stream_config), ) tuner = config.get("tuner") @@ -771,9 +774,7 @@ def client(self, value: temporalio.client.Client) -> None: ) # Running Workflows route their Stream calls by the Store that the worker # started with, so a new client can't change it. - if list(value.config(active_config=True)["stream_stores"]) != list( - self.client.config(active_config=True)["stream_stores"] - ): + if _stream_config_stores(value) != _stream_config_stores(self.client): raise ValueError( "New client doesn't have the Stream Stores of the existing client" ) @@ -1032,6 +1033,13 @@ async def __aexit__(self, exc_type: type[BaseException] | None, *args: Any) -> N raise self._async_context_run_exception +def _stream_config_stores( + client: temporalio.client.Client, +) -> list[temporalio.client.StreamStore]: + config = client.config(active_config=True).get("stream_config") + return list(config.stores) if config is not None else [] + + class WorkerConfig(TypedDict, total=False): """TypedDict of config originally passed to :py:class:`Worker`.""" diff --git a/tests/contrib/redis/test_streams.py b/tests/contrib/redis/test_streams.py index 330df5dae..0bae0921f 100644 --- a/tests/contrib/redis/test_streams.py +++ b/tests/contrib/redis/test_streams.py @@ -11,7 +11,7 @@ from temporalio import activity, workflow from temporalio.api.enums.v1 import StreamErrorReason -from temporalio.client import Client, StreamStore +from temporalio.client import Client, StreamConfig, StreamStore from temporalio.contrib.redis.streams import RedisStreamStore from temporalio.exceptions import StreamError from temporalio.nexus.system.stream_service import ( @@ -42,7 +42,7 @@ def _store() -> RedisStreamStore: def _with_store(client: Client, store: StreamStore) -> Client: config = client.config() - config["stream_stores"] = [store] + config["stream_config"] = StreamConfig(stores=[store]) return Client(**config) diff --git a/tests/nexus/test_stream_store.py b/tests/nexus/test_stream_store.py index a58b776ab..476f6b720 100644 --- a/tests/nexus/test_stream_store.py +++ b/tests/nexus/test_stream_store.py @@ -3,7 +3,7 @@ import ast import dataclasses import uuid -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from datetime import datetime, timedelta, timezone from pathlib import Path from unittest import mock @@ -21,9 +21,14 @@ import temporalio.nexus.system._support import temporalio.workflow from temporalio.api.enums.v1 import StreamErrorReason -from temporalio.client import Client -from temporalio.converter import DataConverter -from temporalio.exceptions import ApplicationError, StreamError +from temporalio.client import ( + Client, + StreamConfig, + StreamSelectContext, + StreamStore, +) +from temporalio.converter import DataConverter, StorageDriverActivityInfo +from temporalio.exceptions import ApplicationError, StreamConfigError, StreamError from temporalio.nexus.system.stream_service import ( ClientProducer, DescribeStreamRequest, @@ -47,11 +52,23 @@ def _with_store(client: Client, store: MemoryStreamStore) -> Client: + return _with_config(client, StreamConfig(stores=[store])) + + +def _with_config(client: Client, stream_config: StreamConfig) -> Client: config = client.config() - config["stream_stores"] = [store] + config["stream_config"] = stream_config return Client(**config) +def _two_stores( + selector: Callable[[StreamSelectContext], StreamStore], +) -> tuple[MemoryStreamStore, MemoryStreamStore, StreamConfig]: + first = MemoryStreamStore(name="first") + second = MemoryStreamStore(name="second") + return first, second, StreamConfig(stores=[first, second], store_selector=selector) + + async def _create_stream(client: Client, store: MemoryStreamStore) -> Stream[str]: return await _with_store(client, store).create_stream( id=f"stream-{uuid.uuid4()}", body_type=str @@ -162,7 +179,7 @@ async def test_client_stream_store_uses_payload_codec(client: Client): store = MemoryStreamStore() config = client.config() config["data_converter"] = DataConverter(payload_codec=SimpleCodec()) - config["stream_stores"] = [store] + config["stream_config"] = StreamConfig(stores=[store]) stream_client = Client(**config) stream = await stream_client.create_stream( @@ -362,13 +379,100 @@ async def start_nexus_operation_execution( assert request.operation == "DescribeStream" -async def test_client_rejects_two_stream_stores(client: Client): +def test_stream_config_refuses_a_bad_set_of_stores(): + with pytest.raises(StreamConfigError, match="at least one Store"): + StreamConfig(stores=[]) + same_name = [MemoryStreamStore(name="a"), MemoryStreamStore(name="a")] + with pytest.raises(StreamConfigError, match="more than one Store named 'a'"): + StreamConfig(stores=same_name, store_selector=lambda _: same_name[0]) + with pytest.raises(StreamConfigError, match="store_selector must be set"): + StreamConfig(stores=[MemoryStreamStore(name="a"), MemoryStreamStore(name="b")]) + assert not issubclass(StreamConfigError, StreamError) + + +def test_client_refuses_a_store_in_plugins(client: Client): config = client.config() - config["stream_stores"] = [MemoryStreamStore(), MemoryStreamStore()] - with pytest.raises(ValueError, match="selector"): + config["plugins"] = [MemoryStreamStore()] # type: ignore[list-item] + with pytest.raises(StreamConfigError, match="plugins"): Client(**config) +async def test_selector_picks_the_store_of_a_new_stream(client: Client): + contexts: list[StreamSelectContext] = [] + + def select(context: StreamSelectContext) -> StreamStore: + contexts.append(context) + return second + + first, second, stream_config = _two_stores(select) + stream_client = _with_config(client, stream_config) + stream_id = f"stream-{uuid.uuid4()}" + stream = await stream_client.create_stream(id=stream_id, body_type=str) + assert stream.ref.store == "second" + assert contexts == [StreamSelectContext(id=stream_id, target=None)] + + await stream_client.get_stream(stream.ref, body_type=str).append("a") + # A call through a ref that names its Store doesn't ask the selector. + assert len(contexts) == 1 + in_second = _with_store(client, second).get_stream(stream.ref, body_type=str) + assert [record.body async for record in in_second.iter_read_items()] == ["a"] + in_first = _with_store(client, first).get_stream( + StreamRef(id=stream_id), body_type=str + ) + err = await _stream_error(in_first.describe()) + assert err.reason == StreamErrorReason.STREAM_ERROR_REASON_NOT_FOUND + + +async def test_selector_gets_the_activity_that_makes_the_call(client: Client): + contexts: list[StreamSelectContext] = [] + + def select(context: StreamSelectContext) -> StreamStore: + contexts.append(context) + return first + + first, _, stream_config = _two_stores(select) + stream_client = _with_config(client, stream_config) + stream_id = f"stream-{uuid.uuid4()}" + env = ActivityEnvironment() + env.info = dataclasses.replace( + ActivityEnvironment.default_info(), + activity_id="job", + activity_type="make_stream", + activity_run_id="job-run", + namespace="streams", + ) + + async def create() -> Stream[str]: + return await stream_client.create_stream(id=stream_id, body_type=str) + + stream = await env.run(create) + assert stream.ref.store == "first" + assert contexts == [ + StreamSelectContext( + id=stream_id, + target=StorageDriverActivityInfo( + id="job", type="make_stream", run_id="job-run", namespace="streams" + ), + ) + ] + + +async def test_selector_that_returns_another_store_fails(client: Client): + _, _, stream_config = _two_stores(lambda _: MemoryStreamStore(name="first")) + stream_client = _with_config(client, stream_config) + with pytest.raises(StreamConfigError, match="isn't one of"): + await stream_client.create_stream(id=f"stream-{uuid.uuid4()}", body_type=str) + + +async def test_ref_of_a_store_that_the_client_lacks_fails(client: Client): + stream_client = _with_store(client, MemoryStreamStore(name="first")) + stream = stream_client.get_stream( + StreamRef(id=f"stream-{uuid.uuid4()}", store="other"), body_type=str + ) + with pytest.raises(StreamConfigError, match="'other'"): + await stream.describe() + + def test_stream_error_carries_its_reason(): err = StreamError( "the Stream is closed", reason=StreamErrorReason.STREAM_ERROR_REASON_CLOSED diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py index 76360f62b..e7e05a9e5 100644 --- a/tests/nexus/test_stream_store_workflow.py +++ b/tests/nexus/test_stream_store_workflow.py @@ -13,13 +13,14 @@ from temporalio.api.enums.v1 import EventType, StreamErrorReason from temporalio.client import ( Client, + StreamConfig, StreamStore, StreamStoreCallOptions, WorkflowHandle, ) from temporalio.common import RetryPolicy from temporalio.converter import DataConverter -from temporalio.exceptions import ApplicationError, StreamError +from temporalio.exceptions import ApplicationError, StreamConfigError, StreamError from temporalio.nexus.system.stream_service import ( Producer, ProducerKindClient, @@ -35,6 +36,7 @@ ExecuteActivityInput, Interceptor, Replayer, + Worker, ) from tests.helpers import assert_task_fail_eventually, new_worker from tests.test_converter import SimpleCodec @@ -124,6 +126,15 @@ async def run(self, stream_id: str, run: int) -> list[bool]: return [bool(stream.created)] +@workflow.defn +class DescribeOtherStoreWorkflow: + @workflow.run + async def run(self, stream_id: str) -> None: + await get_stream( + StreamRef(id=stream_id, store="other"), body_type=str + ).describe() + + @activity.defn async def user_activity() -> None: pass @@ -235,7 +246,7 @@ async def execute_activity(self, input: ExecuteActivityInput) -> Any: def _with_store(client: Client, store: StreamStore) -> Client: config = client.config() - config["stream_stores"] = [store] + config["stream_config"] = StreamConfig(stores=[store]) return Client(**config) @@ -297,7 +308,7 @@ async def test_workflow_stream_store_uses_payload_codec(client: Client): store = MemoryStreamStore() config = client.config() config["data_converter"] = DataConverter(payload_codec=SimpleCodec()) - config["stream_stores"] = [store] + config["stream_config"] = StreamConfig(stores=[store]) stream_client = Client(**config) _, ref = await _run_append_workflow(stream_client) @@ -331,7 +342,8 @@ async def test_workflow_stream_replay_does_not_call_store(client: Client): stream_client = _with_store(client, MemoryStreamStore()) handle, _ = await _run_append_workflow(stream_client) await Replayer( - workflows=[AppendWorkflow], stream_stores=[FailingStreamStore()] + workflows=[AppendWorkflow], + stream_config=StreamConfig(stores=[FailingStreamStore()]), ).replay_workflow(await handle.fetch_history()) @@ -541,3 +553,34 @@ async def test_user_local_activity_append_has_local_activity_producer(client: Cl assert producer.kind.value.run_id == run_id assert producer.kind.value.epoch.task.started_event_id > 0 assert producer.kind.value.epoch.attempt == 1 + + +async def test_worker_refuses_a_client_with_two_stores(client: Client): + config = client.config() + first = MemoryStreamStore(name="first") + config["stream_config"] = StreamConfig( + stores=[first, MemoryStreamStore(name="second")], + store_selector=lambda _: first, + ) + with pytest.raises(StreamConfigError, match="one Stream Store"): + Worker( + Client(**config), + task_queue=f"tq-{uuid.uuid4()}", + workflows=[AppendWorkflow], + ) + + +async def test_workflow_ref_of_another_store_fails_the_task(client: Client): + stream_client = _with_store(client, MemoryStreamStore()) + async with new_worker(stream_client, DescribeOtherStoreWorkflow) as worker: + handle = await stream_client.start_workflow( + DescribeOtherStoreWorkflow.run, + f"stream-{uuid.uuid4()}", + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=30), + ) + await assert_task_fail_eventually( + handle, message_contains="names the Stream Store 'other'" + ) + await handle.terminate() From fa62ab27efae3f1c12562f9ec44883a73c5b3c9b Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 13:15:24 -0700 Subject: [PATCH 32/46] Let WorkflowEnvironment take a Stream config for its client. A test that starts its own server needs a client with Stores, as a test with `Client.connect` has. `start_local` and `start_time_skipping` give the config to the client that they build. --- temporalio/testing/_workflow.py | 12 ++++++++++++ tests/nexus/test_stream_store.py | 20 +++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/temporalio/testing/_workflow.py b/temporalio/testing/_workflow.py index 6c8354162..54add4e36 100644 --- a/temporalio/testing/_workflow.py +++ b/temporalio/testing/_workflow.py @@ -102,6 +102,7 @@ async def start_local( dev_server_download_ttl: timedelta | None = None, dev_server_startup_timeout: timedelta | None = None, ui_port: int | None = None, + stream_config: temporalio.client.StreamConfig | None = None, ) -> WorkflowEnvironment: """Start a full Temporal server locally, downloading if necessary. @@ -165,6 +166,8 @@ async def start_local( become ready after spawning. Defaults to 5 seconds and does not include download time. ui_port: UI port to use if UI is enabled. + stream_config: See parameter of the same name on + :py:meth:`temporalio.client.Client.connect`. Returns: The started CLI dev server workflow environment. @@ -194,6 +197,7 @@ async def start_local( dev_server_download_ttl=dev_server_download_ttl, dev_server_startup_timeout=dev_server_startup_timeout, ui_port=ui_port, + stream_config=stream_config, ) @classmethod @@ -216,6 +220,7 @@ async def start_time_skipping( test_server_extra_args: Sequence[str] = [], test_server_download_ttl: timedelta | None = None, test_server_startup_timeout: timedelta | None = None, + stream_config: temporalio.client.StreamConfig | None = None, ) -> WorkflowEnvironment: """Start a V1 time skipping workflow environment. @@ -276,6 +281,8 @@ async def start_time_skipping( test_server_startup_timeout: Maximum time to wait for the test server to become ready after spawning. Defaults to 5 seconds and does not include download time. + stream_config: See parameter of the same name on + :py:meth:`temporalio.client.Client.connect`. Returns: The started workflow environment with time skipping. @@ -296,6 +303,7 @@ async def start_time_skipping( test_server_extra_args=test_server_extra_args, test_server_download_ttl=test_server_download_ttl, test_server_startup_timeout=test_server_startup_timeout, + stream_config=stream_config, ) @classmethod @@ -582,6 +590,7 @@ async def _bootstrap_dev_server( dev_server_download_ttl: timedelta | None = None, dev_server_startup_timeout: timedelta | None = None, ui_port: int | None = None, + stream_config: temporalio.client.StreamConfig | None = None, ) -> tuple[ temporalio.bridge.testing.EphemeralServer, temporalio.client.Client, @@ -649,6 +658,7 @@ async def _bootstrap_dev_server( rpc_metadata=rpc_metadata, identity=identity, runtime=runtime, + stream_config=stream_config, ) except: try: @@ -719,6 +729,7 @@ async def _create( test_server_extra_args: Sequence[str], test_server_download_ttl: timedelta | None, test_server_startup_timeout: timedelta | None, + stream_config: temporalio.client.StreamConfig | None, ) -> _V1WorkflowEnvironment: runtime = runtime or temporalio.runtime.Runtime.default() download_ttl_ms = None @@ -753,6 +764,7 @@ async def _create( rpc_metadata=rpc_metadata, identity=identity, runtime=runtime, + stream_config=stream_config, ) except: try: diff --git a/tests/nexus/test_stream_store.py b/tests/nexus/test_stream_store.py index 476f6b720..598f3b135 100644 --- a/tests/nexus/test_stream_store.py +++ b/tests/nexus/test_stream_store.py @@ -47,7 +47,12 @@ Stream, StreamServiceClientExtension, ) -from temporalio.testing import ActivityEnvironment, MemoryStreamStore +from temporalio.testing import ( + ActivityEnvironment, + MemoryStreamStore, + WorkflowEnvironment, +) +from tests import DEV_SERVER_DOWNLOAD_VERSION from tests.test_converter import SimpleCodec @@ -473,6 +478,19 @@ async def test_ref_of_a_store_that_the_client_lacks_fails(client: Client): await stream.describe() +async def test_start_local_gives_its_client_the_stream_config(): + stream_config = StreamConfig(stores=[MemoryStreamStore()]) + async with await WorkflowEnvironment.start_local( + stream_config=stream_config, + dev_server_download_version=DEV_SERVER_DOWNLOAD_VERSION, + ) as env: + assert env.client.config()["stream_config"] is stream_config + stream = await env.client.create_stream( + id=f"stream-{uuid.uuid4()}", body_type=str + ) + assert stream.ref.store == "memory" + + def test_stream_error_carries_its_reason(): err = StreamError( "the Stream is closed", reason=StreamErrorReason.STREAM_ERROR_REASON_CLOSED From f1629bbb8cff0563977d5db9b147e34b10775edb Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 13:20:54 -0700 Subject: [PATCH 33/46] Let a Worker serve every Stream Store of its client. A Workflow's Stream call runs in the SDK's Local Activity, so the Store can be picked there by the client's rules. History keeps the ref with the Store's name, so a replay calls no selector. --- temporalio/client/_stream_store.py | 19 ++-- temporalio/exceptions.py | 5 +- temporalio/worker/_stream_store.py | 78 +++++++-------- temporalio/worker/_worker.py | 11 +-- temporalio/worker/_workflow_instance.py | 4 +- tests/nexus/test_stream_store_workflow.py | 111 ++++++++++++++++++---- 6 files changed, 145 insertions(+), 83 deletions(-) diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py index 49c87e4a3..ee48905ae 100644 --- a/temporalio/client/_stream_store.py +++ b/temporalio/client/_stream_store.py @@ -32,6 +32,11 @@ class StreamStoreCallOptions: Workflow code can't do I/O, so a worker calls its Stream Store from a Local Activity. + A Workflow's call whose ref names a Store takes the options of that Store. A call + whose ref names no Store, such as a create, takes the options of the only Store. + With several Stores, such a call takes the defaults of this class, since the Local + Activity picks the Store only when it runs. + .. warning:: This API is experimental and subject to change. """ @@ -157,8 +162,8 @@ class StreamSelectContext: target: StorageDriverActivityInfo | StorageDriverWorkflowInfo | None = None """The Workflow or Activity that makes the call, or ``None`` for a plain client. - A call in an Activity has that Activity here. Workflow code never calls the - selector, since a Worker serves one Store. + A call in an Activity has that Activity here. A Workflow's call runs the selector + in the SDK's Local Activity, and has that Workflow here. """ @@ -183,8 +188,7 @@ class StreamConfig: A ref carries the :py:attr:`StreamStore.name` of its Store, and a call looks the Store up by that name. With more than one Store, :py:attr:`store_selector` - must also be set. A Worker serves one Store, so a Worker whose client has more - than one raises :py:class:`temporalio.exceptions.StreamConfigError`. + must also be set. A Worker serves every Store of its client. """ store_selector: Callable[[StreamSelectContext], StreamStore] | None = None @@ -194,9 +198,10 @@ class StreamConfig: Store, and returns one of the instances in :py:attr:`stores`. When it is ``None`` and there is one Store, every new Stream goes to that Store. - A replayed create must reach the same Store, so a selector uses facts that are - fixed for the run, such as the Stream id. The time, a random value or the load - of a Store can change between two runs of one create. + A Workflow's create runs the selector in a Local Activity. History keeps the + ref with the Store's name, so a replay doesn't call the selector again. A Stream + that code builds from an id only goes through the selector on each call, so the + selector must give the same Store for the same id. """ _store_map: dict[str, StreamStore] = dataclasses.field( diff --git a/temporalio/exceptions.py b/temporalio/exceptions.py index d3252c13a..aeecf486c 100644 --- a/temporalio/exceptions.py +++ b/temporalio/exceptions.py @@ -294,9 +294,8 @@ class StreamConfigError(RuntimeError): * A ref names a Store that the config of the client doesn't have. * The selector returns a Store that isn't one of the config's Stores. * A :py:class:`temporalio.client.StreamStore` is given in ``plugins``. - * A Worker's client has more than one Store, since a Worker serves one Store. - * A Workflow's Stream call names another Store than the Worker's. This fails the - Workflow Task. + * A Workflow's Stream call has a ref that names a Store that the config of the + Worker's client doesn't have. This fails the Workflow Task. * This build of temporalio has no Stream runtime, or no Redis Stream Store. .. warning:: diff --git a/temporalio/worker/_stream_store.py b/temporalio/worker/_stream_store.py index 3a3bff0b1..58b7f9706 100644 --- a/temporalio/worker/_stream_store.py +++ b/temporalio/worker/_stream_store.py @@ -1,4 +1,4 @@ -"""What a Worker does for the Stream Store of its client.""" +"""What a Worker does for the Stream Stores of its client.""" from __future__ import annotations @@ -15,13 +15,12 @@ import temporalio.workflow from temporalio.client._stream_store import ( StreamConfig, - StreamStore, StreamStoreCallOptions, _call_stream_store, _request_ref, _stream_response, ) -from temporalio.exceptions import StreamConfigError +from temporalio.converter import StorageDriverWorkflowInfo from ._interceptor import StartLocalActivityInput, StartNexusOperationInput @@ -47,61 +46,55 @@ def _stream_store_activity( # pyright: ignore[reportUnusedFunction] - store: StreamStore, data_converter: temporalio.converter.DataConverter + config: StreamConfig, data_converter: temporalio.converter.DataConverter ) -> Callable[[str, bytes], Awaitable[bytes]]: @temporalio.activity.defn(name=_STREAM_STORE_ACTIVITY) async def call(operation: str, request: bytes) -> bytes: + store = config._store_for(operation, request, _workflow_target()) return await _call_stream_store(store, data_converter, operation, request) return call -def _worker_stream_store(config: StreamConfig | None) -> StreamStore | None: - """Give the one Store that a Worker serves, or ``None`` when its client has none. - - Raises: - temporalio.exceptions.StreamConfigError: The client has more than one Store. - """ - if config is None: - return None - # Workflow code never calls the selector, so a Workflow's new Stream needs a single - # Store that the Worker knows without one. - if len(config.stores) > 1: - raise StreamConfigError( - f"A Worker serves one Stream Store, and its client has {len(config.stores)}" - ) - return config.stores[0] +def _workflow_target() -> StorageDriverWorkflowInfo: + # The selector sees the Workflow that made the Stream call, not the SDK's Local + # Activity. External storage builds the same value for an Activity of a Workflow. + info = temporalio.activity.info() + return StorageDriverWorkflowInfo( + id=info.workflow_id or None, + type=info.workflow_type or None, + run_id=info.workflow_run_id or None, + namespace=info.workflow_namespace or info.namespace, + ) @dataclasses.dataclass(frozen=True) class _WorkflowStreamStore: - """The Worker's Stream Store as Workflow code reaches it.""" - - name: str - """Name of the Store.""" + """The Worker's Stream Stores as Workflow code reaches them.""" - call_options: StreamStoreCallOptions - """Options of the Local Activity that calls the Store.""" + config: StreamConfig + """The Stream config of the Worker's client.""" @classmethod def from_config(cls, config: StreamConfig | None) -> _WorkflowStreamStore | None: - """Build it from the Stream config of a client, or give ``None`` when it has none. - - Raises: - temporalio.exceptions.StreamConfigError: The client has more than one Store. - """ - store = _worker_stream_store(config) - if store is None: - return None - return cls(name=store.name, call_options=store.call_options) + """Build it from the Stream config of a client, or give ``None`` when it has none.""" + return cls(config=config) if config is not None else None + + def _call_options(self, ref_store: str) -> StreamStoreCallOptions: + # The Local Activity picks the Store of a ref that names none, so with several + # Stores its options can't come from that Store. + if ref_store: + return self.config._get_store_by_name(ref_store).call_options + if len(self.config.stores) == 1: + return self.config.stores[0].call_options + return StreamStoreCallOptions() def start_operation( self, instance: _WorkflowInstanceImpl, input: StartNexusOperationInput[Any, OutputT], ) -> temporalio.workflow.NexusOperationHandle[OutputT]: - """Answer a Stream call of Workflow code with a Local Activity that calls the Store.""" - options = self.call_options + """Answer a Stream call of Workflow code with a Local Activity that calls a Store.""" # The generator can't leave these methods out of the Workflow `Stream` yet. In # this release, Workflow code writes and reads a Stream on a Store through its # own Activities. @@ -111,14 +104,11 @@ def start_operation( # Workflow code builds the request, so the Local Activity does only the I/O. payload_converter, _ = instance._system_nexus_converters(input) [request] = payload_converter.to_payloads([input.input]) - # The Local Activity calls the Worker's Store, so a ref of another Store can't be - # answered here. The error fails the Workflow Task, so that a fixed Worker can retry it. - ref_store = _request_ref(input.operation_name, request.data).store - if ref_store and ref_store != self.name: - raise StreamConfigError( - f"The ref names the Stream Store '{ref_store}', and this Worker serves " - f"'{self.name}'" - ) + # A ref of a Store that the config doesn't have fails here, with no I/O. Its + # `StreamConfigError` fails the Workflow Task. + options = self._call_options( + _request_ref(input.operation_name, request.data).store + ) activity = instance._outbound_schedule_activity( StartLocalActivityInput( activity=_STREAM_STORE_ACTIVITY, diff --git a/temporalio/worker/_worker.py b/temporalio/worker/_worker.py index ec537efd1..3626b03b7 100644 --- a/temporalio/worker/_worker.py +++ b/temporalio/worker/_worker.py @@ -34,11 +34,7 @@ from ._interceptor import Interceptor from ._nexus import _NexusWorker from ._plugin import Plugin -from ._stream_store import ( - _stream_store_activity, - _worker_stream_store, - _WorkflowStreamStore, -) +from ._stream_store import _stream_store_activity, _WorkflowStreamStore from ._tuning import WorkerTuner from ._workflow import ( _DEFAULT_WORKFLOW_TASK_EXTERNAL_STORAGE_CONCURRENCY, @@ -505,10 +501,9 @@ def _init_from_config(self, client: temporalio.client.Client, config: WorkerConf ) activities = list(config.get("activities") or []) stream_config = client_config.get("stream_config") - stream_store = _worker_stream_store(stream_config) - if stream_store is not None and config.get("workflows"): + if stream_config is not None and config.get("workflows"): activities.append( - _stream_store_activity(stream_store, client_config["data_converter"]) + _stream_store_activity(stream_config, client_config["data_converter"]) ) if activities: _warn_if_activity_executor_max_workers_is_inconsistent(config) diff --git a/temporalio/worker/_workflow_instance.py b/temporalio/worker/_workflow_instance.py index 620ffe8bb..4969294b7 100644 --- a/temporalio/worker/_workflow_instance.py +++ b/temporalio/worker/_workflow_instance.py @@ -194,8 +194,8 @@ class WorkflowInstanceDetails: ) = None """Serialization context of the Nexus operation handler that started the workflow, if any.""" stream_store: _WorkflowStreamStore | None = None - """The Worker's Stream Store as Workflow code reaches it, or ``None`` when the Worker's - client has none.""" + """The Worker's Stream Stores as Workflow code reaches them, or ``None`` when the + Worker's client has none.""" class WorkflowInstance(ABC): diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py index e7e05a9e5..7c5832c8c 100644 --- a/tests/nexus/test_stream_store_workflow.py +++ b/tests/nexus/test_stream_store_workflow.py @@ -14,13 +14,14 @@ from temporalio.client import ( Client, StreamConfig, + StreamSelectContext, StreamStore, StreamStoreCallOptions, WorkflowHandle, ) from temporalio.common import RetryPolicy -from temporalio.converter import DataConverter -from temporalio.exceptions import ApplicationError, StreamConfigError, StreamError +from temporalio.converter import DataConverter, StorageDriverWorkflowInfo +from temporalio.exceptions import ApplicationError, StreamError from temporalio.nexus.system.stream_service import ( Producer, ProducerKindClient, @@ -36,7 +37,6 @@ ExecuteActivityInput, Interceptor, Replayer, - Worker, ) from tests.helpers import assert_task_fail_eventually, new_worker from tests.test_converter import SimpleCodec @@ -135,6 +135,25 @@ async def run(self, stream_id: str) -> None: ).describe() +@workflow.defn +class CreateAndDescribeWorkflow: + @workflow.run + async def run(self, stream_id: str) -> StreamRef[str]: + stream = await create_stream(id=stream_id, body_type=str) + await stream.describe() + return stream.ref + + +class CountingStore(MemoryStreamStore): + def __init__(self, name: str) -> None: + super().__init__(name=name) + self.operations: list[str] = [] + + async def _call(self, operation: str, request: bytes) -> bytes: + self.operations.append(operation) + return await super()._call(operation, request) + + @activity.defn async def user_activity() -> None: pass @@ -245,11 +264,41 @@ async def execute_activity(self, input: ExecuteActivityInput) -> Any: def _with_store(client: Client, store: StreamStore) -> Client: + return _with_config(client, StreamConfig(stores=[store])) + + +def _with_config(client: Client, stream_config: StreamConfig) -> Client: config = client.config() - config["stream_config"] = StreamConfig(stores=[store]) + config["stream_config"] = stream_config return Client(**config) +def _two_counting_stores( + contexts: list[StreamSelectContext], +) -> tuple[CountingStore, CountingStore, StreamConfig]: + first = CountingStore("first") + second = CountingStore("second") + + def select(context: StreamSelectContext) -> StreamStore: + contexts.append(context) + return second + + return first, second, StreamConfig(stores=[first, second], store_selector=select) + + +async def _run_create_and_describe( + client: Client, stream_id: str +) -> tuple[WorkflowHandle[CreateAndDescribeWorkflow, StreamRef[str]], StreamRef[str]]: + async with new_worker(client, CreateAndDescribeWorkflow) as worker: + handle = await client.start_workflow( + CreateAndDescribeWorkflow.run, + stream_id, + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + return handle, await handle.result() + + async def _read_all(client: Client, stream_id: str) -> list[StreamRecord[str]]: stream = client.get_stream(StreamRef(id=stream_id), body_type=str) return [record async for record in stream.iter_read_items()] @@ -555,21 +604,6 @@ async def test_user_local_activity_append_has_local_activity_producer(client: Cl assert producer.kind.value.epoch.attempt == 1 -async def test_worker_refuses_a_client_with_two_stores(client: Client): - config = client.config() - first = MemoryStreamStore(name="first") - config["stream_config"] = StreamConfig( - stores=[first, MemoryStreamStore(name="second")], - store_selector=lambda _: first, - ) - with pytest.raises(StreamConfigError, match="one Stream Store"): - Worker( - Client(**config), - task_queue=f"tq-{uuid.uuid4()}", - workflows=[AppendWorkflow], - ) - - async def test_workflow_ref_of_another_store_fails_the_task(client: Client): stream_client = _with_store(client, MemoryStreamStore()) async with new_worker(stream_client, DescribeOtherStoreWorkflow) as worker: @@ -584,3 +618,42 @@ async def test_workflow_ref_of_another_store_fails_the_task(client: Client): handle, message_contains="names the Stream Store 'other'" ) await handle.terminate() + + +async def test_workflow_create_goes_to_the_selected_store(client: Client): + contexts: list[StreamSelectContext] = [] + first, second, stream_config = _two_counting_stores(contexts) + stream_id = f"stream-{uuid.uuid4()}" + handle, ref = await _run_create_and_describe( + _with_config(client, stream_config), stream_id + ) + assert ref.store == "second" + assert first.operations == [] + assert second.operations == ["CreateStream", "DescribeStream"] + # The describe goes by the Store that the ref names, so the selector runs once. + assert contexts == [ + StreamSelectContext( + id=stream_id, + target=StorageDriverWorkflowInfo( + id=handle.id, + type="CreateAndDescribeWorkflow", + run_id=handle.result_run_id, + namespace=client.namespace, + ), + ) + ] + + +async def test_workflow_replay_calls_no_selector_and_no_store(client: Client): + _, _, stream_config = _two_counting_stores([]) + handle, _ = await _run_create_and_describe( + _with_config(client, stream_config), f"stream-{uuid.uuid4()}" + ) + contexts: list[StreamSelectContext] = [] + first, second, replay_config = _two_counting_stores(contexts) + await Replayer( + workflows=[CreateAndDescribeWorkflow], stream_config=replay_config + ).replay_workflow(await handle.fetch_history()) + assert contexts == [] + assert first.operations == [] + assert second.operations == [] From dfa007b3003c2572605ad739ff67285c00925d60 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 14:06:12 -0700 Subject: [PATCH 34/46] Moved the options of the Stream Local Activity to the Stream config. A Workflow's create picks its Store only in the Local Activity, so with several Stores it could not use the options of any one Store. The config now holds one set of options for every Store. --- temporalio/client/_stream_store.py | 28 ++++----- temporalio/contrib/redis/streams.py | 7 +-- temporalio/worker/_stream_store.py | 17 ++---- tests/nexus/test_stream_store_workflow.py | 72 +++++++++++++++++------ 4 files changed, 70 insertions(+), 54 deletions(-) diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py index ee48905ae..6322341c2 100644 --- a/temporalio/client/_stream_store.py +++ b/temporalio/client/_stream_store.py @@ -32,10 +32,9 @@ class StreamStoreCallOptions: Workflow code can't do I/O, so a worker calls its Stream Store from a Local Activity. - A Workflow's call whose ref names a Store takes the options of that Store. A call - whose ref names no Store, such as a create, takes the options of the only Store. - With several Stores, such a call takes the defaults of this class, since the Local - Activity picks the Store only when it runs. + A :py:class:`StreamConfig` holds them in + :py:attr:`StreamConfig.workflow_call_options`, and every Store of that config + gets the same options. .. warning:: This API is experimental and subject to change. @@ -78,11 +77,6 @@ def name(self) -> str: """Name of the Store.""" ... - @property - def call_options(self) -> StreamStoreCallOptions: - """Options of the Local Activity that calls this Store for Workflow code.""" - return StreamStoreCallOptions() - @abstractmethod async def _call(self, operation: str, request: bytes) -> bytes: """Answer one Stream operation, in the form that the bridge defines. @@ -112,11 +106,9 @@ def __init__( *, name: str, runtime: temporalio.runtime.Runtime | None, - call_options: StreamStoreCallOptions | None = None, ) -> None: self._name = name self._runtime = runtime - self._call_options = call_options self._stream_runtime: temporalio.bridge.streams.StreamRuntime | None = None @property @@ -124,11 +116,6 @@ def name(self) -> str: """Name of the Store.""" return self._name - @property - def call_options(self) -> StreamStoreCallOptions: - """Options of the Local Activity that calls this Store for Workflow code.""" - return self._call_options or super().call_options - @abstractmethod def _new_stream_runtime( self, runtime: temporalio.bridge.runtime.Runtime @@ -204,6 +191,15 @@ class StreamConfig: selector must give the same Store for the same id. """ + workflow_call_options: StreamStoreCallOptions = dataclasses.field( + default_factory=StreamStoreCallOptions + ) + """Options of the Local Activity that calls a Store for Workflow code. + + They are the same for every Store of this config. The Local Activity of a create + picks its Store only when it runs, so its options can't come from that Store. + """ + _store_map: dict[str, StreamStore] = dataclasses.field( init=False, repr=False, compare=False ) diff --git a/temporalio/contrib/redis/streams.py b/temporalio/contrib/redis/streams.py index 98893dc39..389601406 100644 --- a/temporalio/contrib/redis/streams.py +++ b/temporalio/contrib/redis/streams.py @@ -21,7 +21,6 @@ import temporalio.bridge.runtime import temporalio.bridge.streams -import temporalio.client import temporalio.client._stream_store import temporalio.runtime @@ -53,7 +52,6 @@ def __init__( wait_replicas: int = 0, wait_timeout: timedelta | None = None, name: str = "redis", - call_options: temporalio.client.StreamStoreCallOptions | None = None, runtime: temporalio.runtime.Runtime | None = None, ) -> None: """Create a Redis Store. It builds its Stream runtime and connects at its first call. @@ -79,9 +77,6 @@ def __init__( ``response_timeout``. ``None`` takes 1 second. name: Name of the Store, which the refs of its Streams carry, so that every process can route a ref that it gets. - call_options: Options of the Local Activity that calls this Store for - Workflow code. ``None`` takes the defaults of - :py:class:`temporalio.client.StreamStoreCallOptions`. runtime: Runtime whose Tokio threads run the Store. ``None`` takes :py:meth:`temporalio.runtime.Runtime.default` at the first call. """ @@ -91,7 +86,7 @@ def __init__( response_timeout=response_timeout, wait_timeout=wait_timeout, ) - super().__init__(name=name, runtime=runtime, call_options=call_options) + super().__init__(name=name, runtime=runtime) self._urls = [url] if isinstance(url, str) else list(url) self._cluster = cluster self._key_prefix = key_prefix diff --git a/temporalio/worker/_stream_store.py b/temporalio/worker/_stream_store.py index 58b7f9706..a4646b2f4 100644 --- a/temporalio/worker/_stream_store.py +++ b/temporalio/worker/_stream_store.py @@ -15,7 +15,6 @@ import temporalio.workflow from temporalio.client._stream_store import ( StreamConfig, - StreamStoreCallOptions, _call_stream_store, _request_ref, _stream_response, @@ -80,15 +79,6 @@ def from_config(cls, config: StreamConfig | None) -> _WorkflowStreamStore | None """Build it from the Stream config of a client, or give ``None`` when it has none.""" return cls(config=config) if config is not None else None - def _call_options(self, ref_store: str) -> StreamStoreCallOptions: - # The Local Activity picks the Store of a ref that names none, so with several - # Stores its options can't come from that Store. - if ref_store: - return self.config._get_store_by_name(ref_store).call_options - if len(self.config.stores) == 1: - return self.config.stores[0].call_options - return StreamStoreCallOptions() - def start_operation( self, instance: _WorkflowInstanceImpl, @@ -106,9 +96,10 @@ def start_operation( [request] = payload_converter.to_payloads([input.input]) # A ref of a Store that the config doesn't have fails here, with no I/O. Its # `StreamConfigError` fails the Workflow Task. - options = self._call_options( - _request_ref(input.operation_name, request.data).store - ) + ref_store = _request_ref(input.operation_name, request.data).store + if ref_store: + self.config._get_store_by_name(ref_store) + options = self.config.workflow_call_options activity = instance._outbound_schedule_activity( StartLocalActivityInput( activity=_STREAM_STORE_ACTIVITY, diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py index 7c5832c8c..4c57eae4b 100644 --- a/tests/nexus/test_stream_store_workflow.py +++ b/tests/nexus/test_stream_store_workflow.py @@ -215,23 +215,12 @@ async def _call(self, operation: str, request: bytes) -> bytes: class FlakyDescribeStore(MemoryStreamStore): - def __init__( - self, - options: StreamStoreCallOptions, - *, - failures: int = 0, - delay: timedelta = timedelta(), - ) -> None: + def __init__(self, *, failures: int = 0, delay: timedelta = timedelta()) -> None: super().__init__() - self._options = options self._failures = failures self._delay = delay self.describes = 0 - @property - def call_options(self) -> StreamStoreCallOptions: - return self._options - async def _call(self, operation: str, request: bytes) -> bytes: if operation == "DescribeStream": self.describes += 1 @@ -241,6 +230,19 @@ async def _call(self, operation: str, request: bytes) -> bytes: return await super()._call(operation, request) +class FlakyCreateStore(CountingStore): + def __init__(self, name: str) -> None: + super().__init__(name) + self.failed_creates = 0 + + async def _call(self, operation: str, request: bytes) -> bytes: + if operation == "CreateStream" and not self.failed_creates: + self.failed_creates += 1 + self.operations.append(operation) + raise RuntimeError("create failed") + return await super()._call(operation, request) + + class RecordingInterceptor(Interceptor): def __init__(self) -> None: self.activity_types: list[str] = [] @@ -323,8 +325,15 @@ async def _run_append_workflow( return handle, await handle.result() -async def _describe_result(client: Client, store: StreamStore) -> str: - stream_client = _with_store(client, store) +async def _describe_result( + client: Client, + store: StreamStore, + options: StreamStoreCallOptions | None = None, +) -> str: + stream_config = StreamConfig( + stores=[store], workflow_call_options=options or StreamStoreCallOptions() + ) + stream_client = _with_config(client, stream_config) async with new_worker(stream_client, DescribeResultWorkflow) as worker: return await stream_client.execute_workflow( DescribeResultWorkflow.run, @@ -438,7 +447,7 @@ async def test_workflow_stream_append_close_and_read_fail_with_store(client: Cli async def test_workflow_stream_store_error_reaches_workflow_code(client: Client): - store = FlakyDescribeStore(StreamStoreCallOptions(), failures=1) + store = FlakyDescribeStore(failures=1) result = await _describe_result(client, store) assert result == "ApplicationError RuntimeError: describe failed" assert store.describes == 1 @@ -509,15 +518,16 @@ async def test_workflow_stream_store_call_options_retry(client: Client): initial_interval=timedelta(milliseconds=10), maximum_attempts=2 ) ) - store = FlakyDescribeStore(options, failures=1) - assert await _describe_result(client, store) == "described" + store = FlakyDescribeStore(failures=1) + assert await _describe_result(client, store, options) == "described" assert store.describes == 2 async def test_workflow_stream_store_call_options_timeout(client: Client): options = StreamStoreCallOptions(start_to_close_timeout=timedelta(milliseconds=200)) - store = FlakyDescribeStore(options, delay=timedelta(seconds=5)) - assert await _describe_result(client, store) == "TimeoutError START_TO_CLOSE" + store = FlakyDescribeStore(delay=timedelta(seconds=5)) + result = await _describe_result(client, store, options) + assert result == "TimeoutError START_TO_CLOSE" async def test_worker_keeps_its_stream_stores(client: Client): @@ -657,3 +667,27 @@ async def test_workflow_replay_calls_no_selector_and_no_store(client: Client): assert contexts == [] assert first.operations == [] assert second.operations == [] + + +async def test_workflow_create_with_several_stores_takes_the_config_options( + client: Client, +): + first = CountingStore("first") + second = FlakyCreateStore("second") + stream_config = StreamConfig( + stores=[first, second], + store_selector=lambda _: second, + workflow_call_options=StreamStoreCallOptions( + retry_policy=RetryPolicy( + initial_interval=timedelta(milliseconds=10), maximum_attempts=2 + ) + ), + ) + # The default options make one attempt, so only the config's retry policy lets + # the create pass after its first attempt fails. + _, ref = await _run_create_and_describe( + _with_config(client, stream_config), f"stream-{uuid.uuid4()}" + ) + assert ref.store == "second" + assert first.operations == [] + assert second.operations == ["CreateStream", "CreateStream", "DescribeStream"] From 2d86f5b1945908813e67c85d8150c0dda7c0e1e3 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 14:10:16 -0700 Subject: [PATCH 35/46] Read the ref of a Stream call from its request object. Decoding the encoded request to find its ref decoded every Record of an append once more. The caller has the request object before it encodes it, and the Local Activity gets the ref as two strings. --- temporalio/client/_impl.py | 5 ++--- temporalio/client/_stream_store.py | 21 ++++++++++----------- temporalio/worker/_stream_store.py | 22 ++++++++++++++-------- tests/nexus/test_stream_store.py | 7 +++++++ 4 files changed, 33 insertions(+), 22 deletions(-) diff --git a/temporalio/client/_impl.py b/temporalio/client/_impl.py index 6740416ea..6e8a0ea89 100644 --- a/temporalio/client/_impl.py +++ b/temporalio/client/_impl.py @@ -131,6 +131,7 @@ StreamConfig, _activity_target, _call_stream_store, + _request_ref, _stream_response, ) from ._worker_versioning import WorkerBuildIdVersionSets, WorkerTaskReachability @@ -1676,9 +1677,7 @@ async def _start_stream_store_operation( data_converter.payload_converter, data_converter.failure_converter ) [request] = payload_converter.to_payloads([input.arg]) - store = stream_config._store_for( - input.operation, request.data, _activity_target() - ) + store = stream_config._store_for(_request_ref(input.arg), _activity_target()) response = await _call_stream_store( store, data_converter, input.operation, request.data ) diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py index 6322341c2..8d9afc07d 100644 --- a/temporalio/client/_stream_store.py +++ b/temporalio/client/_stream_store.py @@ -10,7 +10,6 @@ import nexusrpc import temporalio.activity -import temporalio.api.stream.v1 import temporalio.bridge._visitor import temporalio.bridge.runtime import temporalio.common @@ -253,25 +252,25 @@ def _get_store_by_name(self, name: str) -> StreamStore: def _store_for( self, - operation: str, - request: bytes, + ref: temporalio.nexus.system.stream_service.StreamRef[Any], target: StorageDriverActivityInfo | StorageDriverWorkflowInfo | None, ) -> StreamStore: - ref = _request_ref(operation, request) if ref.store: return self._get_store_by_name(ref.store) return self._select_store(StreamSelectContext(id=ref.id, target=target)) -def _request_ref(operation: str, request: bytes) -> temporalio.api.stream.v1.StreamRef: - definition = _STREAM_SERVICE.operation_definitions[operation] - message = _proto_type(definition.input_type).FromString(request) +def _request_ref( # pyright: ignore[reportUnusedFunction] + request: Any, +) -> temporalio.nexus.system.stream_service.StreamRef[Any]: # A create calls its ref `stream_ref` and every other request calls it `ref`, so # the field is found by its type. - for field in message.DESCRIPTOR.fields: - if field.message_type is temporalio.api.stream.v1.StreamRef.DESCRIPTOR: - return getattr(message, field.name) - raise TypeError(f"The {operation} request has no StreamRef") + if dataclasses.is_dataclass(request) and not isinstance(request, type): + for field in dataclasses.fields(request): + value = getattr(request, field.name) + if isinstance(value, temporalio.nexus.system.stream_service.StreamRef): + return value + raise TypeError(f"The request {type(request).__name__} has no StreamRef") def _activity_target() -> StorageDriverActivityInfo | None: # pyright: ignore[reportUnusedFunction] diff --git a/temporalio/worker/_stream_store.py b/temporalio/worker/_stream_store.py index a4646b2f4..f9016ba77 100644 --- a/temporalio/worker/_stream_store.py +++ b/temporalio/worker/_stream_store.py @@ -20,6 +20,7 @@ _stream_response, ) from temporalio.converter import StorageDriverWorkflowInfo +from temporalio.nexus.system.stream_service import StreamRef from ._interceptor import StartLocalActivityInput, StartNexusOperationInput @@ -46,10 +47,15 @@ def _stream_store_activity( # pyright: ignore[reportUnusedFunction] config: StreamConfig, data_converter: temporalio.converter.DataConverter -) -> Callable[[str, bytes], Awaitable[bytes]]: +) -> Callable[[str, bytes, str, str], Awaitable[bytes]]: @temporalio.activity.defn(name=_STREAM_STORE_ACTIVITY) - async def call(operation: str, request: bytes) -> bytes: - store = config._store_for(operation, request, _workflow_target()) + async def call( + operation: str, request: bytes, stream_id: str, store_name: str + ) -> bytes: + # The Workflow passes the ref beside the encoded request, so that the request + # isn't decoded again to route it. An empty `store_name` means no Store. + ref = StreamRef[Any](id=stream_id, store=store_name or None) + store = config._store_for(ref, _workflow_target()) return await _call_stream_store(store, data_converter, operation, request) return call @@ -96,14 +102,14 @@ def start_operation( [request] = payload_converter.to_payloads([input.input]) # A ref of a Store that the config doesn't have fails here, with no I/O. Its # `StreamConfigError` fails the Workflow Task. - ref_store = _request_ref(input.operation_name, request.data).store - if ref_store: - self.config._get_store_by_name(ref_store) + ref = _request_ref(input.input) + if ref.store: + self.config._get_store_by_name(ref.store) options = self.config.workflow_call_options activity = instance._outbound_schedule_activity( StartLocalActivityInput( activity=_STREAM_STORE_ACTIVITY, - args=(input.operation_name, request.data), + args=(input.operation_name, request.data, ref.id, ref.store or ""), activity_id=None, schedule_to_close_timeout=options.schedule_to_close_timeout, schedule_to_start_timeout=None, @@ -114,7 +120,7 @@ def start_operation( headers={}, summary=input.summary, event_groups=input.event_groups, - arg_types=[str, bytes], + arg_types=[str, bytes, str, str], ret_type=bytes, ) ) diff --git a/tests/nexus/test_stream_store.py b/tests/nexus/test_stream_store.py index 598f3b135..f18004d1b 100644 --- a/tests/nexus/test_stream_store.py +++ b/tests/nexus/test_stream_store.py @@ -27,6 +27,7 @@ StreamSelectContext, StreamStore, ) +from temporalio.client._stream_store import _request_ref from temporalio.converter import DataConverter, StorageDriverActivityInfo from temporalio.exceptions import ApplicationError, StreamConfigError, StreamError from temporalio.nexus.system.stream_service import ( @@ -478,6 +479,12 @@ async def test_ref_of_a_store_that_the_client_lacks_fails(client: Client): await stream.describe() +def test_request_without_a_stream_ref_fails(): + request = Producer(id="mine", kind=ProducerKindClient(ClientProducer())) + with pytest.raises(TypeError, match="Producer has no StreamRef"): + _request_ref(request) + + async def test_start_local_gives_its_client_the_stream_config(): stream_config = StreamConfig(stores=[MemoryStreamStore()]) async with await WorkflowEnvironment.start_local( From d0fba105aea52d4a4b22084a7a12ef32b739f769 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 14:16:07 -0700 Subject: [PATCH 36/46] Added BufferedAppender, which batches the appends of one Stream. An Activity that streams model tokens appends a few characters many times a second, and one Store call for each is too many. The appender writes them in batches of at most 1,000 Records. --- temporalio/client/__init__.py | 2 + temporalio/client/_stream_appender.py | 193 ++++++++++++++++++++++++++ tests/nexus/test_stream_appender.py | 161 +++++++++++++++++++++ 3 files changed, 356 insertions(+) create mode 100644 temporalio/client/_stream_appender.py create mode 100644 tests/nexus/test_stream_appender.py diff --git a/temporalio/client/__init__.py b/temporalio/client/__init__.py index 6fa6dd710..7305b4be5 100644 --- a/temporalio/client/__init__.py +++ b/temporalio/client/__init__.py @@ -185,6 +185,7 @@ ScheduleUpdate, ScheduleUpdateInput, ) +from ._stream_appender import BufferedAppender from ._stream_store import ( StreamConfig, StreamSelectContext, @@ -356,6 +357,7 @@ "TaskReachabilityType", "CloudOperationsClient", "Plugin", + "BufferedAppender", "StreamConfig", "StreamSelectContext", "StreamStore", diff --git a/temporalio/client/_stream_appender.py b/temporalio/client/_stream_appender.py new file mode 100644 index 000000000..ec8d3d49d --- /dev/null +++ b/temporalio/client/_stream_appender.py @@ -0,0 +1,193 @@ +"""A helper that batches the appends of one Stream.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +from types import TracebackType +from typing import Generic, TypeVar + +from typing_extensions import Self + +import temporalio.nexus.system.stream_service.client + +from ._stream_store import _check_not_negative + +BodyT = TypeVar("BodyT") + +# The Store refuses an append of more Records than this. Many small Records fit under its byte +# limit, but they keep the Store busy for long. +_MAX_BATCH_RECORDS = 1000 + + +class BufferedAppender(Generic[BodyT]): + """Batches the appends of one Stream. + + Use it in an Activity or a client that appends often, such as one that streams model + tokens. :py:meth:`append` returns at once and does no I/O. The appender writes the + buffered Records as one batch at most ``max_delay`` after the first of them, or at once + when they fill a batch of 1,000 Records. One write is in flight at a time. Records + appended during a write wait for the next batch, and they keep their order. + + Use it in ``async with``, which writes the remaining Records at the end of the block, or + await :py:meth:`flush`. Without ``async with`` or a :py:meth:`flush`, the Records still in + the buffer are lost. The end of an Activity attempt doesn't write them. + + ``await flush()`` is the backpressure point. A caller that appends faster than the Store + accepts Records must await it from time to time, so the buffer doesn't grow without bound. + + After a batch fails, the appender writes nothing more, so a reader sees no gap. Each later + :py:meth:`append` and :py:meth:`flush` raises the error of that batch. + + The appender doesn't count bytes, so a batch over the size limit of the Store fails with + the Store's error. + + It takes a client Stream, so Workflow code has no use for it. + + .. warning:: + This API is experimental and subject to change. + """ + + def __init__( + self, + stream: temporalio.nexus.system.stream_service.client.Stream[BodyT], + *, + max_delay: timedelta = timedelta(milliseconds=100), + ) -> None: + """Create an appender. It starts no write until the first append. + + Args: + stream: The Stream to append to. + max_delay: The longest time that a Record waits in the buffer before its batch + starts to write. It must not be negative. + """ + _check_not_negative(max_delay=max_delay) + self._stream = stream + self._max_delay = max_delay + self._buffer: list[BodyT] = [] + # When the oldest buffered Record arrived, so its batch starts at most max_delay later. + self._first_at = 0.0 + self._timer: asyncio.TimerHandle | None = None + self._writing: asyncio.Task[None] | None = None + self._appended = 0 + # Count of the Records that a write took or that a failed batch left, written or not. + self._settled = 0 + self._error: BaseException | None = None + self._last_offset: bytes | None = None + self._unwritten = 0 + + def append(self, *records: BodyT) -> None: + """Add one Record for each value to the buffer, and return at once. + + A list is one value. + + Raises: + TypeError: No value was given. + temporalio.exceptions.StreamError: A batch failed, so the appender takes no more + Records. + """ + self._raise_error() + if not records: + raise TypeError("append takes one or more values") + loop = asyncio.get_running_loop() + if not self._buffer: + self._first_at = loop.time() + self._buffer.extend(records) + self._appended += len(records) + self._schedule() + + async def flush(self) -> bytes | None: + """Write every Record appended before the call. + + Returns: + The ``last_offset`` of the last written batch, or ``None`` if the appender has + written nothing. + + Raises: + temporalio.exceptions.StreamError: A batch failed. + """ + target = self._appended + while self._settled < target: + if self._writing is None: + self._start() + assert self._writing is not None + # The write goes on when the caller is cancelled, so no batch is left half done. + await asyncio.shield(self._writing) + self._raise_error() + return self._last_offset + + @property + def unwritten(self) -> int: + """How many Records the failed batch and the ones after it left unwritten.""" + return self._unwritten + + async def __aenter__(self) -> Self: + """Use the appender in ``async with``.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + """Write the buffered Records. A write error doesn't hide an error of the block.""" + try: + await self.flush() + except Exception: + if exc is None: + raise + + def _raise_error(self) -> None: + if self._error is not None: + raise self._error + + def _schedule(self) -> None: + if self._writing is not None or not self._buffer: + return + if len(self._buffer) >= _MAX_BATCH_RECORDS: + self._start() + elif self._timer is None: + self._timer = asyncio.get_running_loop().call_at( + self._first_at + self._max_delay.total_seconds(), self._start + ) + + def _start(self) -> None: + if self._timer is not None: + self._timer.cancel() + self._timer = None + if self._writing is not None or not self._buffer: + return + batch = self._buffer[:_MAX_BATCH_RECORDS] + del self._buffer[:_MAX_BATCH_RECORDS] + self._writing = asyncio.get_running_loop().create_task(self._write(batch)) + + async def _write(self, batch: list[BodyT]) -> None: + try: + # The Store numbers each batch, since the appender sends no `sequence` and never + # sends a batch twice. A `sequence` counts for a Producer on a Stream, and two + # appenders of one Activity attempt would each start at 1. + response = await self._stream.append(*batch) + self._last_offset = response.last_offset + except asyncio.CancelledError: + # A later call must not report a cancel that its caller didn't ask for. + self._fail(RuntimeError("The write of a batch was cancelled"), len(batch)) + raise + except Exception as err: + self._fail(err, len(batch)) + finally: + self._settled += len(batch) + self._writing = None + # Schedule the Records appended during the write, for when their delay ends or + # they fill a batch. + self._schedule() + + def _fail(self, error: BaseException, count: int) -> None: + # The Records after a failed batch are never written, so a reader sees no gap. + self._error = error + self._unwritten += count + len(self._buffer) + self._settled += len(self._buffer) + self._buffer.clear() + if self._timer is not None: + self._timer.cancel() + self._timer = None diff --git a/tests/nexus/test_stream_appender.py b/tests/nexus/test_stream_appender.py new file mode 100644 index 000000000..54e8cb3d5 --- /dev/null +++ b/tests/nexus/test_stream_appender.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import asyncio +import uuid +from datetime import timedelta + +import pytest + +import temporalio.api.streamservice.v1 +from temporalio.api.enums.v1 import StreamErrorReason +from temporalio.client import BufferedAppender, Client +from temporalio.nexus.system.stream_service import StreamRef +from temporalio.nexus.system.stream_service.client import Stream +from temporalio.testing import MemoryStreamStore +from tests.nexus.test_stream_store import _create_stream, _stream_error, _with_store + +# Long enough that only a flush, a full batch or the end of `async with` starts a write. +_NEVER = timedelta(hours=1) + + +class RecordingStore(MemoryStreamStore): + def __init__(self) -> None: + super().__init__() + self.batches: list[int] = [] + self.append_started = asyncio.Event() + self.release: asyncio.Event | None = None + + async def _call(self, operation: str, request: bytes) -> bytes: + if operation == "AppendRecords": + message = temporalio.api.streamservice.v1.AppendRecordsRequest.FromString( + request + ) + self.batches.append(len(message.records)) + self.append_started.set() + if self.release is not None: + await self.release.wait() + return await super()._call(operation, request) + + +async def _bodies(stream: Stream[str]) -> list[str]: + return [record.body async for record in stream.iter_read_items()] + + +async def test_appends_inside_one_delay_go_as_one_batch(client: Client): + store = RecordingStore() + stream = await _create_stream(client, store) + appender = BufferedAppender(stream, max_delay=_NEVER) + appender.append("a") + appender.append("b", "c") + appender.append("d") + await appender.flush() + assert store.batches == [4] + assert await _bodies(stream) == ["a", "b", "c", "d"] + + +async def test_flush_returns_the_last_offset(client: Client): + stream = await _create_stream(client, MemoryStreamStore()) + appender = BufferedAppender(stream) + assert await appender.flush() is None + appender.append("a", "b") + offset = await appender.flush() + assert offset is not None + assert offset == (await stream.describe()).last_offset + + +async def test_appends_during_a_write_go_in_the_next_batch(client: Client): + store = RecordingStore() + stream = await _create_stream(client, store) + store.release = asyncio.Event() + appender = BufferedAppender(stream, max_delay=_NEVER) + appender.append("a") + first = asyncio.create_task(appender.flush()) + await store.append_started.wait() + appender.append("b") + appender.append("c") + assert store.batches == [1] + store.release.set() + await first + await appender.flush() + assert store.batches == [1, 2] + assert await _bodies(stream) == ["a", "b", "c"] + + +async def test_a_large_buffer_goes_as_batches_of_at_most_1000(client: Client): + store = RecordingStore() + stream = await _create_stream(client, store) + appender = BufferedAppender(stream, max_delay=_NEVER) + values = [str(index) for index in range(2500)] + appender.append(*values) + # A full batch doesn't wait for the delay or a flush. + await store.append_started.wait() + await appender.flush() + assert store.batches == [1000, 1000, 500] + assert await _bodies(stream) == values + + +async def test_the_timer_alone_writes_the_batch(client: Client): + store = RecordingStore() + stream = await _create_stream(client, store) + appender = BufferedAppender(stream, max_delay=timedelta(milliseconds=10)) + appender.append("a", "b") + page = await stream.read(wait=timedelta(seconds=10)) + assert [record.body for record in page.records] == ["a", "b"] + assert store.batches == [2] + + +async def test_a_failed_batch_stops_the_appender(client: Client): + store = RecordingStore() + stream = await _create_stream(client, store) + await stream.close() + store.release = asyncio.Event() + appender = BufferedAppender(stream, max_delay=_NEVER) + appender.append("a") + first = asyncio.create_task(appender.flush()) + await store.append_started.wait() + appender.append("b", "c") + store.release.set() + err = await _stream_error(first) + assert err.reason == StreamErrorReason.STREAM_ERROR_REASON_CLOSED + assert await _stream_error(appender.flush()) is err + with pytest.raises(type(err)) as later: + appender.append("d") + assert later.value is err + assert appender.unwritten == 3 + assert store.batches == [1] + + +async def test_async_with_flushes_at_its_end(client: Client): + store = RecordingStore() + stream = await _create_stream(client, store) + async with BufferedAppender(stream, max_delay=_NEVER) as appender: + appender.append("a", "b") + assert store.batches == [2] + assert await _bodies(stream) == ["a", "b"] + + +async def test_async_with_keeps_the_error_of_its_block(client: Client): + store = RecordingStore() + stream = await _create_stream(client, store) + await stream.close() + appender = BufferedAppender(stream, max_delay=_NEVER) + with pytest.raises(RuntimeError, match="the block failed"): + async with appender: + appender.append("a") + raise RuntimeError("the block failed") + # The flush at the end of the block ran and failed. + assert store.batches == [1] + assert appender.unwritten == 1 + err = await _stream_error(appender.flush()) + assert err.reason == StreamErrorReason.STREAM_ERROR_REASON_CLOSED + + +async def test_append_without_a_value_fails(client: Client): + stream = _with_store(client, MemoryStreamStore()).get_stream( + StreamRef(id=f"stream-{uuid.uuid4()}"), body_type=str + ) + appender = BufferedAppender(stream) + with pytest.raises(TypeError, match="one or more values"): + appender.append() + with pytest.raises(ValueError, match="max_delay must not be negative"): + BufferedAppender(stream, max_delay=timedelta(seconds=-1)) From 51a7d0bffb930fea466d53b94c66089b677f738b Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 14:17:01 -0700 Subject: [PATCH 37/46] Moved Core to the API files with the Workflow Stream marks. The new Stream WIT leaves `append`, `close` and `read` out of the Workflow target and gives the Producer parameters their default. Its Rust code is the same, so the bridge needs no rebuild. --- 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 5a5f856f4..d242a65c3 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 5a5f856f4f0641a9322b900e8c650e556f7bdbc0 +Subproject commit d242a65c36b417823ac58947e62340a70e1d2fb2 From 2c8b0498076f7c2cd097239ff65e458d34f2bcdf Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 14:21:16 -0700 Subject: [PATCH 38/46] Followed the Workflow Stream that has no append, close or read. The regenerated Workflow `Stream` lost the three methods, but Workflow code can still start their operations through a Nexus client, so the Worker keeps its refusal and the tests start them that way. The generator script names the nexgen commit that made the code. --- scripts/gen_nexus_system_api.py | 6 +- temporalio/worker/_stream_store.py | 7 +- tests/nexus/test_stream_store_workflow.py | 96 +++++++++++++++-------- 3 files changed, 71 insertions(+), 38 deletions(-) diff --git a/scripts/gen_nexus_system_api.py b/scripts/gen_nexus_system_api.py index b8a4b8ca9..48998fc7c 100644 --- a/scripts/gen_nexus_system_api.py +++ b/scripts/gen_nexus_system_api.py @@ -28,8 +28,8 @@ workflow_init_path = base_dir / "temporalio" / "workflow" / "__init__.py" NEX_GEN_VERSION = "0.2.8" # The Stream package needs a newer nexgen, which rejects the other WITs. One pin is enough -# again once the api WITs and a nexgen release agree. No release has nexgen commit bf3c49c9 -# yet, so NEX_GEN_STREAM_BIN points at a build of it. +# again once the api WITs and a nexgen release agree. No release has nexgen commit 9653a0f5, +# the `main` of nexgen, yet, so NEX_GEN_STREAM_BIN points at a build of it. NEX_GEN_STREAM_VERSION = "0.2.12" proto_files = [ gen_protos.api_proto_dir @@ -87,7 +87,7 @@ def nex_gen_stream_command() -> list[str]: bin_path = os.environ.get("NEX_GEN_STREAM_BIN") if not bin_path: raise RuntimeError( - "NEX_GEN_STREAM_BIN must point at a nexgen build of commit bf3c49c9" + "NEX_GEN_STREAM_BIN must point at a nexgen build of commit 9653a0f5" ) version = subprocess.check_output([bin_path, "--version"], text=True).strip() if version != f"nexgen {NEX_GEN_STREAM_VERSION}": diff --git a/temporalio/worker/_stream_store.py b/temporalio/worker/_stream_store.py index f9016ba77..b96f2886f 100644 --- a/temporalio/worker/_stream_store.py +++ b/temporalio/worker/_stream_store.py @@ -91,9 +91,10 @@ def start_operation( input: StartNexusOperationInput[Any, OutputT], ) -> temporalio.workflow.NexusOperationHandle[OutputT]: """Answer a Stream call of Workflow code with a Local Activity that calls a Store.""" - # The generator can't leave these methods out of the Workflow `Stream` yet. In - # this release, Workflow code writes and reads a Stream on a Store through its - # own Activities. + # The Workflow `Stream` has no `append`, `close` or `read`, but the generated + # Workflow service still has their operations, so Workflow code can start one + # through a Nexus client. In this release, Workflow code writes and reads a Stream + # on a Store through its own Activities. refusal = _STREAM_STORE_REFUSALS.get(input.operation_name) if refusal is not None: raise temporalio.exceptions.ApplicationError(refusal, non_retryable=True) diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py index 4c57eae4b..1e6798405 100644 --- a/tests/nexus/test_stream_store_workflow.py +++ b/tests/nexus/test_stream_store_workflow.py @@ -22,15 +22,25 @@ from temporalio.common import RetryPolicy from temporalio.converter import DataConverter, StorageDriverWorkflowInfo from temporalio.exceptions import ApplicationError, StreamError +from temporalio.nexus.system import TEMPORAL_SYSTEM_ENDPOINT from temporalio.nexus.system.stream_service import ( + AppendRecord, + AppendRecordsRequest, + ClientProducer, + CloseStreamRequest, Producer, ProducerKindClient, ProducerKindLocalActivity, + ReadRecordsRequest, StreamIdConflictPolicy, StreamRecord, StreamRef, + StreamService, ) from temporalio.nexus.system.stream_service.workflow import create_stream, get_stream +from temporalio.nexus.system.stream_service.workflow._resources.stream import ( + Stream as WorkflowStream, +) from temporalio.testing import MemoryStreamStore from temporalio.worker import ( ActivityInboundInterceptor, @@ -67,23 +77,29 @@ async def run(self, stream_id: str) -> StreamRef[str]: @workflow.defn -class UnsupportedCallsWorkflow: +class RefusedOperationWorkflow: @workflow.run - async def run(self, stream_id: str) -> list[str]: - stream = await create_stream(id=stream_id, body_type=str) - errors: list[str] = [] - for call in (lambda: stream.append("a"), stream.close, stream.read): - try: - await call() - except ApplicationError as err: - assert err.non_retryable - errors.append(err.message) + async def run(self, operation: str) -> str: + # The Workflow `Stream` has no method for these operations, so the test starts + # them through a Nexus client, as Workflow code still can. + ref = StreamRef[str](id="stream") + producer = Producer(id="workflow", kind=ProducerKindClient(ClientProducer())) + requests: dict[str, Any] = { + "AppendRecords": AppendRecordsRequest( + ref=ref, records=[AppendRecord(body="a")], producer=producer + ), + "CloseStream": CloseStreamRequest(ref=ref, producer=producer), + "ReadRecords": ReadRecordsRequest(ref=ref), + } + nexus_client = workflow.create_nexus_client( + service=StreamService, endpoint=TEMPORAL_SYSTEM_ENDPOINT + ) try: - async with get_stream(stream.ref): - pass + await nexus_client.start_operation(operation, requests[operation]) except ApplicationError as err: - errors.append(err.message) - return errors + assert err.non_retryable + return err.message + return "started" @workflow.defn @@ -422,28 +438,44 @@ async def test_workflow_stream_without_store_starts_nexus_operation(client: Clie await handle.terminate() -async def test_workflow_stream_append_close_and_read_fail_with_store(client: Client): +@pytest.mark.parametrize( + "operation, message", + [ + ( + "AppendRecords", + "Workflow code can't append to a Stream on a Store in this release. " + "Call a Local Activity that appends through its client.", + ), + ( + "CloseStream", + "Workflow code can't close a Stream on a Store in this release. " + "Call an Activity that closes it through its client.", + ), + ( + "ReadRecords", + "Workflow code can't read a Stream on a Store in this release. " + "Call an Activity that reads it through its client.", + ), + ], +) +async def test_workflow_stream_operation_fails_with_store( + client: Client, operation: str, message: str +): stream_client = _with_store(client, MemoryStreamStore()) - async with new_worker(stream_client, UnsupportedCallsWorkflow) as worker: - errors = await stream_client.execute_workflow( - UnsupportedCallsWorkflow.run, - f"stream-{uuid.uuid4()}", + async with new_worker(stream_client, RefusedOperationWorkflow) as worker: + result = await stream_client.execute_workflow( + RefusedOperationWorkflow.run, + operation, id=f"workflow-{uuid.uuid4()}", task_queue=worker.task_queue, ) - append_error = ( - "Workflow code can't append to a Stream on a Store in this release. " - "Call a Local Activity that appends through its client." - ) - close_error = ( - "Workflow code can't close a Stream on a Store in this release. " - "Call an Activity that closes it through its client." - ) - read_error = ( - "Workflow code can't read a Stream on a Store in this release. " - "Call an Activity that reads it through its client." - ) - assert errors == [append_error, close_error, read_error, close_error] + assert result == message + + +def test_workflow_stream_has_no_append_close_or_read(): + for name in ("append", "close", "read"): + assert not hasattr(WorkflowStream, name) + assert hasattr(WorkflowStream, "describe") async def test_workflow_stream_store_error_reaches_workflow_code(client: Client): From 336dbedbe31a32a84af6821b51afa8b243585e38 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 14:27:09 -0700 Subject: [PATCH 39/46] Let a module of the Stream Workflow package be the first import. The generated Workflow modules import `temporalio.workflow` at their top, and it imports them back, so a new interpreter that started with one of them failed. The parent package now loads `temporalio.workflow` last, which completes it before a generated Workflow module starts. --- temporalio/nexus/system/__init__.py | 7 +++++++ tests/nexus/test_stream_store.py | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/temporalio/nexus/system/__init__.py b/temporalio/nexus/system/__init__.py index 4b6910185..91b967606 100644 --- a/temporalio/nexus/system/__init__.py +++ b/temporalio/nexus/system/__init__.py @@ -199,3 +199,10 @@ def _get_serialization_context( # pyright: ignore[reportUnusedFunction] "TEMPORAL_SYSTEM_ENDPOINT", "is_system_endpoint", ] + +# The generated Workflow modules of `stream_service` import `temporalio.workflow` at their top, +# and `temporalio.workflow` imports `create_stream` and `get_stream` from them. So this package +# loads `temporalio.workflow` as its last statement, when every name above exists. Then +# `temporalio.workflow` is complete before a generated Workflow module starts, whichever module +# is the first import. +import temporalio.workflow # noqa: E402 # pyright: ignore[reportUnusedImport] diff --git a/tests/nexus/test_stream_store.py b/tests/nexus/test_stream_store.py index f18004d1b..74ed95dd7 100644 --- a/tests/nexus/test_stream_store.py +++ b/tests/nexus/test_stream_store.py @@ -2,6 +2,8 @@ import ast import dataclasses +import subprocess +import sys import uuid from collections.abc import Awaitable, Callable from datetime import datetime, timedelta, timezone @@ -351,6 +353,31 @@ def test_no_hand_written_stream_classes(): ) +def test_each_stream_module_can_be_the_first_import(): + modules = [ + "temporalio.nexus.system.stream_service.workflow", + "temporalio.nexus.system.stream_service.client", + "temporalio.nexus.system.stream_service", + "temporalio.nexus.system", + "temporalio.workflow", + "temporalio.client", + "temporalio.worker", + "temporalio.contrib.redis.streams", + ] + failures: dict[str, str] = {} + for module in modules: + # Only a new interpreter has no module loaded, so the import order is real. + result = subprocess.run( + [sys.executable, "-c", f"import {module}"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + failures[module] = result.stderr.strip().splitlines()[-1] + assert failures == {} + + async def test_client_without_stream_store_starts_nexus_operation(client: Client): requests: list[ temporalio.api.workflowservice.v1.StartNexusOperationExecutionRequest From 56c99375ae7d4f04968c5be5ce0cac927f5edd2b Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 14:55:20 -0700 Subject: [PATCH 40/46] Let the user's Activity interceptors see the Stream Local Activity. An Activity interceptor sees every Activity that runs on its Worker, also one that the SDK schedules. A tracing interceptor then shows the Store call under its Workflow, which is correct. --- temporalio/worker/_activity.py | 10 ++-------- tests/nexus/test_stream_store_workflow.py | 8 ++++++-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/temporalio/worker/_activity.py b/temporalio/worker/_activity.py index 5ac5f68af..dbd967fed 100644 --- a/temporalio/worker/_activity.py +++ b/temporalio/worker/_activity.py @@ -46,7 +46,6 @@ ExecuteActivityInput, Interceptor, ) -from ._stream_store import _STREAM_STORE_ACTIVITY logger = logging.getLogger(__name__) @@ -742,13 +741,8 @@ async def _execute_activity( # now even though the info() can't be intercepted and heartbeat() will # fail. The interceptors may want to use the info() during init. impl: ActivityInboundInterceptor = _ActivityInboundImpl(self, running_activity) - # A Workflow's Stream call already goes through the user's Workflow interceptors, as one - # system Nexus operation. This Activity is how the Worker runs that call. The user's - # Activity interceptors would see an Activity that no user code scheduled, so they don't - # run for it. - if info.activity_type != _STREAM_STORE_ACTIVITY: - for interceptor in reversed(list(self._interceptors)): - impl = interceptor.intercept_activity(impl) + for interceptor in reversed(list(self._interceptors)): + impl = interceptor.intercept_activity(impl) impl.init(_ActivityOutboundImpl(self, running_activity.info)) return await impl.execute_activity(input) diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py index 1e6798405..84fe9e0e8 100644 --- a/tests/nexus/test_stream_store_workflow.py +++ b/tests/nexus/test_stream_store_workflow.py @@ -570,7 +570,7 @@ async def test_worker_keeps_its_stream_stores(client: Client): worker.client = stream_client -async def test_activity_interceptors_skip_stream_store_activity(client: Client): +async def test_activity_interceptors_see_stream_store_activity(client: Client): interceptor = RecordingInterceptor() stream_client = _with_store(client, MemoryStreamStore()) async with new_worker( @@ -585,7 +585,11 @@ async def test_activity_interceptors_skip_stream_store_activity(client: Client): id=f"workflow-{uuid.uuid4()}", task_queue=worker.task_queue, ) - assert interceptor.activity_types == ["user_activity"] + # The create runs as the SDK's Local Activity, before the Workflow's own Activity. + assert interceptor.activity_types == [ + "__temporal_stream_store_call", + "user_activity", + ] async def test_workflow_task_retry_appends_with_higher_epoch(client: Client): From 63180a323e4bbe2ffce438ac5e84715c0a87fa79 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 14:58:14 -0700 Subject: [PATCH 41/46] Raised an error for a Stream call when the client has no Store. This release has no Streams on the server, so such a call could only fail late, with a message that didn't say why. It now fails at once with a `StreamConfigError` that names the fix. --- temporalio/client/_client.py | 4 +++- temporalio/client/_impl.py | 11 +++++++--- temporalio/client/_stream_store.py | 9 +++++--- temporalio/exceptions.py | 4 ++++ temporalio/worker/_workflow_instance.py | 17 ++++++++++----- tests/nexus/test_stream_store.py | 26 +++++++++++++---------- tests/nexus/test_stream_store_workflow.py | 14 ++++++------ 7 files changed, 56 insertions(+), 29 deletions(-) diff --git a/temporalio/client/_client.py b/temporalio/client/_client.py index 4dcfe6b94..d42d7d9b0 100644 --- a/temporalio/client/_client.py +++ b/temporalio/client/_client.py @@ -234,7 +234,9 @@ async def connect( picks one for a new Stream, as :py:class:`temporalio.converter.ExternalStorage` holds drivers and a selector. A client with it sends each Stream operation to one of its - Stores instead of the server. ``None`` sends them to the server. + Stores. This release has no Streams on the server, so with ``None`` a + Stream operation raises + :py:class:`temporalio.exceptions.StreamConfigError`. .. warning:: This API is experimental and subject to change. diff --git a/temporalio/client/_impl.py b/temporalio/client/_impl.py index 6e8a0ea89..0baba6151 100644 --- a/temporalio/client/_impl.py +++ b/temporalio/client/_impl.py @@ -1574,12 +1574,17 @@ async def start_nexus_operation( self, input: StartNexusOperationInput ) -> NexusOperationHandle[Any]: """Start a nexus operation and return a handle to it.""" - stream_config = self._client._config.get("stream_config") if ( - stream_config is not None - and temporalio.nexus.system.is_system_endpoint(input.endpoint) + temporalio.nexus.system.is_system_endpoint(input.endpoint) and input.service == _STREAM_SERVICE.name ): + # This release has no Streams on the server, so a Stream call needs a Store. + stream_config = self._client._config.get("stream_config") + if stream_config is None: + raise temporalio.exceptions.StreamConfigError( + "This client has no Stream Store. Give one in " + "stream_config=StreamConfig(stores=[...])." + ) return await self._start_stream_store_operation(stream_config, input) nexus_context = temporalio.converter.NexusSerializationContext( endpoint=input.endpoint, diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py index 8d9afc07d..634f727a2 100644 --- a/temporalio/client/_stream_store.py +++ b/temporalio/client/_stream_store.py @@ -61,7 +61,8 @@ def __post_init__(self) -> None: class StreamStore(ABC): """A Store that answers the Stream operations of a client. - A client with a Store sends each Stream operation to it instead of the server. A + A client with a Store sends each Stream operation to it. This release has no + Streams on the server, so a client needs a Store for any Stream operation. A Store is configuration. Use :py:class:`temporalio.contrib.redis.streams.RedisStreamStore`, or :py:class:`temporalio.testing.MemoryStreamStore` in tests. @@ -157,8 +158,10 @@ class StreamSelectContext: class StreamConfig: """The Stream Stores of a client, and the rule that picks one for a new Stream. - A client with this config sends each Stream operation to one of its Stores - instead of the server. It holds Stores and a selector, as + A client with this config sends each Stream operation to one of its Stores. + This release has no Streams on the server, so a client without this config + raises :py:class:`temporalio.exceptions.StreamConfigError` for a Stream + operation. It holds Stores and a selector, as :py:class:`temporalio.converter.ExternalStorage` holds drivers and a selector. A ref carries the name of its Store, so a call on a known Stream goes to the diff --git a/temporalio/exceptions.py b/temporalio/exceptions.py index aeecf486c..63135cd9f 100644 --- a/temporalio/exceptions.py +++ b/temporalio/exceptions.py @@ -289,6 +289,10 @@ class StreamConfigError(RuntimeError): It is not a :py:class:`StreamError`. A Store didn't fail. The setup of the Stores is wrong, and a retry won't help. These are its cases. + * A Stream call is made on a client with no Stream config. This release has no + Streams on the server, so every Stream call needs a Store. + * A Workflow's Stream call is made on a Worker whose client has no Stream config. + This fails the Workflow Task. * A :py:class:`temporalio.client.StreamConfig` has no Store, has two Stores with one name, or has more than one Store and no selector. * A ref names a Store that the config of the client doesn't have. diff --git a/temporalio/worker/_workflow_instance.py b/temporalio/worker/_workflow_instance.py index 4969294b7..c19bb109b 100644 --- a/temporalio/worker/_workflow_instance.py +++ b/temporalio/worker/_workflow_instance.py @@ -195,7 +195,8 @@ class WorkflowInstanceDetails: """Serialization context of the Nexus operation handler that started the workflow, if any.""" stream_store: _WorkflowStreamStore | None = None """The Worker's Stream Stores as Workflow code reaches them, or ``None`` when the - Worker's client has none.""" + Worker's client has none. This release has no Streams on the server, so with ``None`` + a Stream call fails the Workflow Task.""" class WorkflowInstance(ABC): @@ -2352,10 +2353,16 @@ async def _intercept_system_nexus_operation( async def _schedule_system_nexus_operation( self, input: StartNexusOperationInput[Any, OutputT] ) -> temporalio.workflow.NexusOperationHandle[OutputT]: - # A Worker whose client has a Stream Store answers a Stream call with that Store. Any - # other Worker sends it to the server, as it sends every system Nexus operation. - store = self._stream_store - if store is not None and input.service == _STREAM_SERVICE.name: + # This release has no Streams on the server, so a Worker answers a Stream call with + # a Store of its client. On a Worker whose client has none, the `StreamConfigError` + # fails the Workflow Task. Every other system Nexus operation goes to the server. + if input.service == _STREAM_SERVICE.name: + store = self._stream_store + if store is None: + raise temporalio.exceptions.StreamConfigError( + "The client of this Worker has no Stream Store. Give one in " + "stream_config=StreamConfig(stores=[...])." + ) return store.start_operation(self, input) return await self._outbound_start_nexus_operation(input) diff --git a/tests/nexus/test_stream_store.py b/tests/nexus/test_stream_store.py index 74ed95dd7..49627cb85 100644 --- a/tests/nexus/test_stream_store.py +++ b/tests/nexus/test_stream_store.py @@ -378,7 +378,7 @@ def test_each_stream_module_can_be_the_first_import(): assert failures == {} -async def test_client_without_stream_store_starts_nexus_operation(client: Client): +async def test_client_without_stream_store_raises_and_sends_nothing(client: Client): requests: list[ temporalio.api.workflowservice.v1.StartNexusOperationExecutionRequest ] = [] @@ -400,16 +400,20 @@ async def start_nexus_operation_execution( "start_nexus_operation_execution", start_nexus_operation_execution, ): - handle = await nexus_client.start_operation( - StreamService.describe_stream, - DescribeStreamRequest(ref=StreamRef(id="stream")), - id="operation", - ) - assert handle.run_id == "run" - [request] = requests - assert request.endpoint == temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT - assert request.service == "temporal.api.streamservice.v1.StreamService" - assert request.operation == "DescribeStream" + with pytest.raises(StreamConfigError) as err: + await nexus_client.start_operation( + StreamService.describe_stream, + DescribeStreamRequest(ref=StreamRef(id="stream")), + id="operation", + ) + # The generated client method goes the same way. + with pytest.raises(StreamConfigError): + await client.create_stream(id="stream", body_type=str) + assert str(err.value) == ( + "This client has no Stream Store. Give one in " + "stream_config=StreamConfig(stores=[...])." + ) + assert requests == [] def test_stream_config_refuses_a_bad_set_of_stores(): diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py index 84fe9e0e8..9374fa73c 100644 --- a/tests/nexus/test_stream_store_workflow.py +++ b/tests/nexus/test_stream_store_workflow.py @@ -421,19 +421,21 @@ async def test_workflow_stream_replay_does_not_call_store(client: Client): ).replay_workflow(await handle.fetch_history()) -async def test_workflow_stream_without_store_starts_nexus_operation(client: Client): - async with new_worker(client, AppendWorkflow) as worker: +async def test_workflow_create_without_store_fails_the_task(client: Client): + async with new_worker(client, CreateAndDescribeWorkflow) as worker: handle = await client.start_workflow( - AppendWorkflow.run, + CreateAndDescribeWorkflow.run, f"stream-{uuid.uuid4()}", id=f"workflow-{uuid.uuid4()}", task_queue=worker.task_queue, execution_timeout=timedelta(seconds=30), ) - # The dev server doesn't serve the Stream service, so it rejects the Nexus - # command that only the system Nexus path makes. await assert_task_fail_eventually( - handle, message_contains="temporal.api.streamservice.v1.StreamService" + handle, + message_contains=( + "The client of this Worker has no Stream Store. Give one in " + "stream_config=StreamConfig(stores=[...])." + ), ) await handle.terminate() From 6427a7f2f48ca7fb02f9d816bc84abfc81a0f29e Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 15:08:56 -0700 Subject: [PATCH 42/46] Gave StreamStore the five Stream operations as methods. A person who writes a Store for another system needs typed operations, not an operation name and bytes. Bytes now belong to the Stores on the bridge, and the hooks hand messages to a Store with no extra parse. --- temporalio/client/_impl.py | 10 +- temporalio/client/_stream_store.py | 220 ++++++++++++++++------ temporalio/nexus/system/__init__.py | 24 ++- temporalio/worker/_stream_store.py | 43 ++--- temporalio/worker/_workflow_instance.py | 4 +- tests/helpers/streams.py | 186 ++++++++++++++++++ tests/nexus/test_stream_appender.py | 16 +- tests/nexus/test_stream_store.py | 58 +++++- tests/nexus/test_stream_store_workflow.py | 48 ++--- 9 files changed, 487 insertions(+), 122 deletions(-) create mode 100644 tests/helpers/streams.py diff --git a/temporalio/client/_impl.py b/temporalio/client/_impl.py index 0baba6151..4766126ae 100644 --- a/temporalio/client/_impl.py +++ b/temporalio/client/_impl.py @@ -132,7 +132,6 @@ _activity_target, _call_stream_store, _request_ref, - _stream_response, ) from ._worker_versioning import WorkerBuildIdVersionSets, WorkerTaskReachability from ._workflow import ( @@ -1681,10 +1680,11 @@ async def _start_stream_store_operation( payload_converter = temporalio.nexus.system._get_payload_converter( data_converter.payload_converter, data_converter.failure_converter ) - [request] = payload_converter.to_payloads([input.arg]) + # The generated model converts to its proto message with no payload in between. + request = payload_converter._to_transfer_value(input.arg) store = stream_config._store_for(_request_ref(input.arg), _activity_target()) response = await _call_stream_store( - store, data_converter, input.operation, request.data + store, data_converter, input.operation, request ) handle: NexusOperationHandle[Any] = NexusOperationHandle( self._client, @@ -1694,8 +1694,8 @@ async def _start_stream_store_operation( service=input.service, operation=input.operation, ) - handle._known_outcome = _stream_response( - payload_converter, input.operation, input.result_type, response + handle._known_outcome = payload_converter._from_transfer_value( + response, input.result_type ) return handle diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py index 634f727a2..f7b109bb6 100644 --- a/temporalio/client/_stream_store.py +++ b/temporalio/client/_stream_store.py @@ -10,11 +10,11 @@ import nexusrpc import temporalio.activity +import temporalio.api.streamservice.v1 import temporalio.bridge._visitor import temporalio.bridge.runtime import temporalio.common import temporalio.converter -import temporalio.converter._payload_converter import temporalio.nexus.system.stream_service import temporalio.runtime from temporalio.converter import StorageDriverActivityInfo, StorageDriverWorkflowInfo @@ -67,6 +67,13 @@ class StreamStore(ABC): :py:class:`temporalio.contrib.redis.streams.RedisStreamStore`, or :py:class:`temporalio.testing.MemoryStreamStore` in tests. + To keep Streams in another system, write a Store that implements :py:attr:`name` + and the five operations. Each operation takes and gives a message of + ``temporalio.api.streamservice.v1``. The bodies of the Records arrive as payloads + that the SDK already encoded with the data converter and the payload codec, so a + Store keeps payloads and not the user's types. A failed call raises + :py:class:`temporalio.exceptions.StreamError` with a reason. + .. warning:: This API is experimental and subject to change. """ @@ -74,25 +81,54 @@ class StreamStore(ABC): @property @abstractmethod def name(self) -> str: - """Name of the Store.""" + """Name of the Store, which the refs of its Streams carry.""" + ... + + @abstractmethod + async def create_stream( + self, request: temporalio.api.streamservice.v1.CreateStreamRequest + ) -> temporalio.api.streamservice.v1.CreateStreamResponse: + """Create a Stream, or get the open one when the conflict policy allows it. + + The ref of the response carries the name of this Store. + """ ... @abstractmethod - async def _call(self, operation: str, request: bytes) -> bytes: - """Answer one Stream operation, in the form that the bridge defines. - - Args: - operation: The Nexus operation name, such as ``CreateStream``, - ``AppendRecords``, ``ReadRecords``, ``CloseStream`` or - ``DescribeStream``. - request: The serialized request message of - ``temporal.api.streamservice.v1``. - - Returns: - The serialized response message of ``temporal.api.streamservice.v1``. + async def append_records( + self, request: temporalio.api.streamservice.v1.AppendRecordsRequest + ) -> temporalio.api.streamservice.v1.AppendRecordsResponse: + """Append one batch of Records as one Producer. + + A batch that is sent again with the same Producer, epoch and sequence is + written once. """ ... + @abstractmethod + async def read_records( + self, request: temporalio.api.streamservice.v1.ReadRecordsRequest + ) -> temporalio.api.streamservice.v1.ReadRecordsResponse: + """Read one page of Records after an offset. + + At the end of the Stream it waits up to the request's ``wait`` for a new Record. + """ + ... + + @abstractmethod + async def close_stream( + self, request: temporalio.api.streamservice.v1.CloseStreamRequest + ) -> temporalio.api.streamservice.v1.CloseStreamResponse: + """Close a Stream. Readers drain what is left, and later appends fail.""" + ... + + @abstractmethod + async def describe_stream( + self, request: temporalio.api.streamservice.v1.DescribeStreamRequest + ) -> temporalio.api.streamservice.v1.DescribeStreamResponse: + """Give the status and the newest offset of a Stream.""" + ... + class _BridgeStreamStore(StreamStore): # pyright: ignore[reportUnusedClass] """A Store that runs on the Stream runtime of the bridge. @@ -121,7 +157,39 @@ def _new_stream_runtime( self, runtime: temporalio.bridge.runtime.Runtime ) -> temporalio.bridge.streams.StreamRuntime: ... - async def _call(self, operation: str, request: bytes) -> bytes: + async def create_stream( + self, request: temporalio.api.streamservice.v1.CreateStreamRequest + ) -> temporalio.api.streamservice.v1.CreateStreamResponse: + """See base class.""" + return await self._call_bridge("CreateStream", request) + + async def append_records( + self, request: temporalio.api.streamservice.v1.AppendRecordsRequest + ) -> temporalio.api.streamservice.v1.AppendRecordsResponse: + """See base class.""" + return await self._call_bridge("AppendRecords", request) + + async def read_records( + self, request: temporalio.api.streamservice.v1.ReadRecordsRequest + ) -> temporalio.api.streamservice.v1.ReadRecordsResponse: + """See base class.""" + return await self._call_bridge("ReadRecords", request) + + async def close_stream( + self, request: temporalio.api.streamservice.v1.CloseStreamRequest + ) -> temporalio.api.streamservice.v1.CloseStreamResponse: + """See base class.""" + return await self._call_bridge("CloseStream", request) + + async def describe_stream( + self, request: temporalio.api.streamservice.v1.DescribeStreamRequest + ) -> temporalio.api.streamservice.v1.DescribeStreamResponse: + """See base class.""" + return await self._call_bridge("DescribeStream", request) + + async def _call_bridge( + self, operation: str, request: google.protobuf.message.Message + ) -> Any: """Answer one Stream operation with the Stream runtime of the bridge. Raises: @@ -132,7 +200,80 @@ async def _call(self, operation: str, request: bytes) -> bytes: if self._stream_runtime is None: runtime = self._runtime or temporalio.runtime.Runtime.default() self._stream_runtime = self._new_stream_runtime(runtime._core_runtime) - return await self._stream_runtime.call(operation, request) + # The bridge takes and gives the messages in their wire form. + response = await self._stream_runtime.call( + operation, request.SerializeToString() + ) + return _STREAM_OPERATIONS[operation].response_type.FromString(response) + + +@dataclasses.dataclass(frozen=True) +class _StreamOperation: + """How a Store answers one operation of the Stream service.""" + + store_method: str + """The name of the :py:class:`StreamStore` method that answers it.""" + + request_type: type[google.protobuf.message.Message] + + response_type: type[google.protobuf.message.Message] + + workflow_refusal: str | None = None + """Why Workflow code can't start it in this release, or ``None`` when it can.""" + + async def call( + self, store: StreamStore, request: google.protobuf.message.Message + ) -> google.protobuf.message.Message: + """Call the method of ``store`` for this operation and check its answer.""" + response = await getattr(store, self.store_method)(request) + if not isinstance(response, self.response_type): + raise TypeError( + f"The Stream Store '{store.name}' answered {self.store_method} with " + f"{type(response).__name__}, not {self.response_type.__name__}" + ) + return response + + +_STREAM_OPERATIONS: dict[str, _StreamOperation] = { + "CreateStream": _StreamOperation( + "create_stream", + temporalio.api.streamservice.v1.CreateStreamRequest, + temporalio.api.streamservice.v1.CreateStreamResponse, + ), + "AppendRecords": _StreamOperation( + "append_records", + temporalio.api.streamservice.v1.AppendRecordsRequest, + temporalio.api.streamservice.v1.AppendRecordsResponse, + workflow_refusal=( + "Workflow code can't append to a Stream on a Store in this release. " + "Call a Local Activity that appends through its client." + ), + ), + "ReadRecords": _StreamOperation( + "read_records", + temporalio.api.streamservice.v1.ReadRecordsRequest, + temporalio.api.streamservice.v1.ReadRecordsResponse, + workflow_refusal=( + "Workflow code can't read a Stream on a Store in this release. " + "Call an Activity that reads it through its client." + ), + ), + "CloseStream": _StreamOperation( + "close_stream", + temporalio.api.streamservice.v1.CloseStreamRequest, + temporalio.api.streamservice.v1.CloseStreamResponse, + workflow_refusal=( + "Workflow code can't close a Stream on a Store in this release. " + "Call an Activity that closes it through its client." + ), + ), + "DescribeStream": _StreamOperation( + "describe_stream", + temporalio.api.streamservice.v1.DescribeStreamRequest, + temporalio.api.streamservice.v1.DescribeStreamResponse, + ), +} +"""The operations of the Stream service, by their Nexus operation name.""" @dataclasses.dataclass(frozen=True) @@ -318,51 +459,20 @@ def _stream_service() -> nexusrpc.ServiceDefinition: _STREAM_SERVICE = _stream_service() -def _proto_type(model_type: Any) -> type[google.protobuf.message.Message]: - converter = temporalio.converter._payload_converter._get_transfer_type_converter( - model_type - ) - if converter is None or converter.transfer_type is None: - raise TypeError(f"{model_type} has no proto transfer type") - return converter.transfer_type - - async def _call_stream_store( # pyright: ignore[reportUnusedFunction] store: StreamStore, data_converter: temporalio.converter.DataConverter, operation: str, - request: bytes, -) -> bytes: + request: google.protobuf.message.Message, +) -> google.protobuf.message.Message: + stream_operation = _STREAM_OPERATIONS[operation] if not data_converter.payload_codec: - return await store._call(operation, request) + return await stream_operation.call(store, request) # The worker package imports the client, so a module level import would cycle. from temporalio.bridge.worker import _Visitor - definition = _STREAM_SERVICE.operation_definitions[operation] visitor = temporalio.bridge._visitor.PayloadVisitor(skip_search_attributes=True) - request_message = _proto_type(definition.input_type).FromString(request) - await visitor.visit( - _Visitor(data_converter._encode_payload_sequence), request_message - ) - response = await store._call(operation, request_message.SerializeToString()) - response_message = _proto_type(definition.output_type).FromString(response) - await visitor.visit( - _Visitor(data_converter._decode_payload_sequence), response_message - ) - return response_message.SerializeToString() - - -def _stream_response( # pyright: ignore[reportUnusedFunction] - payload_converter: temporalio.converter.PayloadConverter, - operation: str, - result_type: type | None, - response: bytes, -) -> Any: - definition = _STREAM_SERVICE.operation_definitions[operation] - [payload] = payload_converter.to_payloads( - [_proto_type(definition.output_type).FromString(response)] - ) - [result] = payload_converter.from_payloads( - [payload], [result_type] if result_type else None - ) - return result + await visitor.visit(_Visitor(data_converter._encode_payload_sequence), request) + response = await stream_operation.call(store, request) + await visitor.visit(_Visitor(data_converter._decode_payload_sequence), response) + return response diff --git a/temporalio/nexus/system/__init__.py b/temporalio/nexus/system/__init__.py index 91b967606..d3096b1a0 100644 --- a/temporalio/nexus/system/__init__.py +++ b/temporalio/nexus/system/__init__.py @@ -19,6 +19,7 @@ from temporalio.bridge._visitor_functions import VisitorFunctions from temporalio.converter import BinaryProtoPayloadConverter, CompositePayloadConverter from temporalio.converter._payload_converter import ( + _get_transfer_type_converter, _TemporalTransferTypePayloadConverter, ) @@ -123,6 +124,27 @@ def from_payloads( with _user_converter_context(self._user_converters): return self._outer_payload_converter.from_payloads(payloads, type_hints) + def _to_transfer_value(self, value: Any) -> Any: + """Convert a system Nexus model to its proto message, with no payload in between.""" + converter = _get_transfer_type_converter(type(value)) + if converter is None: + raise TypeError(f"{type(value).__name__} has no transfer type") + with _user_converter_context(self._user_converters): + return converter.to_transfer_type(value) + + def _from_transfer_value(self, value: Any, type_hint: type | None) -> Any: + """Convert a proto message to the system Nexus model of ``type_hint``. + + With no ``type_hint``, or one that has no transfer type, it gives the message. + """ + converter = ( + _get_transfer_type_converter(type_hint) if type_hint is not None else None + ) + if converter is None or type_hint is None: + return value + with _user_converter_context(self._user_converters): + return converter.from_transfer_type(value, type_hint) + def is_system_endpoint(endpoint: str) -> bool: """Return whether a Nexus endpoint is the Temporal system endpoint. @@ -171,7 +193,7 @@ async def maybe_visit_payload( def _get_payload_converter( # pyright: ignore[reportUnusedFunction] user_payload_converter: temporalio.converter.PayloadConverter, user_failure_converter: temporalio.converter.FailureConverter, -) -> temporalio.converter.PayloadConverter: +) -> _SystemNexusPayloadConverter: """Return the fixed payload converter for system Nexus outer envelopes.""" return _SystemNexusPayloadConverter(user_payload_converter, user_failure_converter) diff --git a/temporalio/worker/_stream_store.py b/temporalio/worker/_stream_store.py index b96f2886f..c6e664054 100644 --- a/temporalio/worker/_stream_store.py +++ b/temporalio/worker/_stream_store.py @@ -14,10 +14,10 @@ import temporalio.exceptions import temporalio.workflow from temporalio.client._stream_store import ( + _STREAM_OPERATIONS, StreamConfig, _call_stream_store, _request_ref, - _stream_response, ) from temporalio.converter import StorageDriverWorkflowInfo from temporalio.nexus.system.stream_service import StreamRef @@ -29,21 +29,6 @@ _STREAM_STORE_ACTIVITY = "__temporal_stream_store_call" -_STREAM_STORE_REFUSALS = { - "AppendRecords": ( - "Workflow code can't append to a Stream on a Store in this release. " - "Call a Local Activity that appends through its client." - ), - "CloseStream": ( - "Workflow code can't close a Stream on a Store in this release. " - "Call an Activity that closes it through its client." - ), - "ReadRecords": ( - "Workflow code can't read a Stream on a Store in this release. " - "Call an Activity that reads it through its client." - ), -} - def _stream_store_activity( # pyright: ignore[reportUnusedFunction] config: StreamConfig, data_converter: temporalio.converter.DataConverter @@ -56,7 +41,9 @@ async def call( # isn't decoded again to route it. An empty `store_name` means no Store. ref = StreamRef[Any](id=stream_id, store=store_name or None) store = config._store_for(ref, _workflow_target()) - return await _call_stream_store(store, data_converter, operation, request) + message = _STREAM_OPERATIONS[operation].request_type.FromString(request) + response = await _call_stream_store(store, data_converter, operation, message) + return response.SerializeToString() return call @@ -95,12 +82,14 @@ def start_operation( # Workflow service still has their operations, so Workflow code can start one # through a Nexus client. In this release, Workflow code writes and reads a Stream # on a Store through its own Activities. - refusal = _STREAM_STORE_REFUSALS.get(input.operation_name) - if refusal is not None: - raise temporalio.exceptions.ApplicationError(refusal, non_retryable=True) + stream_operation = _STREAM_OPERATIONS[input.operation_name] + if stream_operation.workflow_refusal is not None: + raise temporalio.exceptions.ApplicationError( + stream_operation.workflow_refusal, non_retryable=True + ) # Workflow code builds the request, so the Local Activity does only the I/O. payload_converter, _ = instance._system_nexus_converters(input) - [request] = payload_converter.to_payloads([input.input]) + request = payload_converter._to_transfer_value(input.input) # A ref of a Store that the config doesn't have fails here, with no I/O. Its # `StreamConfigError` fails the Workflow Task. ref = _request_ref(input.input) @@ -110,7 +99,12 @@ def start_operation( activity = instance._outbound_schedule_activity( StartLocalActivityInput( activity=_STREAM_STORE_ACTIVITY, - args=(input.operation_name, request.data, ref.id, ref.store or ""), + args=( + input.operation_name, + request.SerializeToString(), + ref.id, + ref.store or "", + ), activity_id=None, schedule_to_close_timeout=options.schedule_to_close_timeout, schedule_to_start_timeout=None, @@ -152,8 +146,9 @@ async def result() -> OutputT: ): raise err.cause raise - return _stream_response( - payload_converter, input.operation_name, input.output_type, response + return payload_converter._from_transfer_value( + stream_operation.response_type.FromString(response), + input.output_type, ) return _StreamStoreOperationHandle( diff --git a/temporalio/worker/_workflow_instance.py b/temporalio/worker/_workflow_instance.py index c19bb109b..e0c43ed31 100644 --- a/temporalio/worker/_workflow_instance.py +++ b/temporalio/worker/_workflow_instance.py @@ -2303,6 +2303,7 @@ async def _outbound_start_nexus_operation( # examples of the evolution of the resulting handle state machine in the sync and async # Nexus response cases. handle: _NexusOperationHandle[OutputT] + payload_converter: temporalio.converter.PayloadConverter async def operation_handle_fn() -> OutputT: return cast( @@ -2369,7 +2370,8 @@ async def _schedule_system_nexus_operation( def _system_nexus_converters( self, input: StartNexusOperationInput[Any, Any] ) -> tuple[ - temporalio.converter.PayloadConverter, temporalio.converter.FailureConverter + temporalio.nexus.system._SystemNexusPayloadConverter, + temporalio.converter.FailureConverter, ]: serialization_context = temporalio.nexus.system._get_serialization_context( input.service, diff --git a/tests/helpers/streams.py b/tests/helpers/streams.py new file mode 100644 index 000000000..beb4eaaa4 --- /dev/null +++ b/tests/helpers/streams.py @@ -0,0 +1,186 @@ +"""Stream Stores that the Stream tests share.""" + +from __future__ import annotations + +import dataclasses +from collections.abc import Awaitable, Callable +from typing import TypeVar + +from google.protobuf.message import Message + +from temporalio.api.enums.v1 import ( + StreamErrorReason, + StreamIdConflictPolicy, + StreamStatus, +) +from temporalio.api.stream.v1 import StreamRecord, StreamRef +from temporalio.api.streamservice.v1 import ( + AppendRecordsRequest, + AppendRecordsResponse, + CloseStreamRequest, + CloseStreamResponse, + CreateStreamRequest, + CreateStreamResponse, + DescribeStreamRequest, + DescribeStreamResponse, + ReadRecordsRequest, + ReadRecordsResponse, +) +from temporalio.client import StreamStore +from temporalio.exceptions import StreamError + +_RequestT = TypeVar("_RequestT", bound=Message) +_ResponseT = TypeVar("_ResponseT", bound=Message) + + +class RecordingStreamStore(StreamStore): + """A Store that passes each call to another Store and keeps the operation names. + + It has the name of the Store that it wraps, since that Store writes its own name + in the ref of a create. A test overrides :py:meth:`before_call` to fail a call or + to hold it. + """ + + def __init__(self, inner: StreamStore) -> None: + self.inner = inner + self.operations: list[str] = [] + + @property + def name(self) -> str: + return self.inner.name + + async def before_call(self, operation: str, request: Message) -> None: + del operation, request + + async def _forward( + self, + operation: str, + request: _RequestT, + call: Callable[[_RequestT], Awaitable[_ResponseT]], + ) -> _ResponseT: + self.operations.append(operation) + await self.before_call(operation, request) + return await call(request) + + async def create_stream(self, request: CreateStreamRequest) -> CreateStreamResponse: + return await self._forward("CreateStream", request, self.inner.create_stream) + + async def append_records( + self, request: AppendRecordsRequest + ) -> AppendRecordsResponse: + return await self._forward("AppendRecords", request, self.inner.append_records) + + async def read_records(self, request: ReadRecordsRequest) -> ReadRecordsResponse: + return await self._forward("ReadRecords", request, self.inner.read_records) + + async def close_stream(self, request: CloseStreamRequest) -> CloseStreamResponse: + return await self._forward("CloseStream", request, self.inner.close_stream) + + async def describe_stream( + self, request: DescribeStreamRequest + ) -> DescribeStreamResponse: + return await self._forward( + "DescribeStream", request, self.inner.describe_stream + ) + + +@dataclasses.dataclass +class _DictStream: + request_id: str + records: list[StreamRecord] = dataclasses.field(default_factory=list) + closed: bool = False + + +def _offset(index: int) -> bytes: + return index.to_bytes(8, "big") + + +class DictStreamStore(StreamStore): + """A Store written from scratch, with no bridge, that keeps its Streams in a dict. + + It has enough of each operation for the tests, and no dedupe, no wait and no + Retention. + """ + + def __init__(self, name: str = "dict") -> None: + self._name = name + self.streams: dict[str, _DictStream] = {} + + @property + def name(self) -> str: + return self._name + + def _stream(self, ref: StreamRef) -> _DictStream: + stream = self.streams.get(ref.id) + if stream is None: + raise StreamError( + f"No Stream '{ref.id}'", + reason=StreamErrorReason.STREAM_ERROR_REASON_NOT_FOUND, + ) + return stream + + async def create_stream(self, request: CreateStreamRequest) -> CreateStreamResponse: + ref = StreamRef(id=request.stream_ref.id, store=self.name) + stream = self.streams.get(ref.id) + if stream is None: + self.streams[ref.id] = _DictStream(request_id=request.request_id) + return CreateStreamResponse(ref=ref, created=True) + if stream.request_id == request.request_id: + return CreateStreamResponse(ref=ref, created=True) + if ( + request.id_conflict_policy + == StreamIdConflictPolicy.STREAM_ID_CONFLICT_POLICY_USE_EXISTING + ): + return CreateStreamResponse(ref=ref, created=False) + raise StreamError( + f"The Stream '{ref.id}' exists", + reason=StreamErrorReason.STREAM_ERROR_REASON_ALREADY_EXISTS, + ) + + async def append_records( + self, request: AppendRecordsRequest + ) -> AppendRecordsResponse: + stream = self._stream(request.ref) + if stream.closed: + raise StreamError( + f"The Stream '{request.ref.id}' is closed", + reason=StreamErrorReason.STREAM_ERROR_REASON_CLOSED, + ) + first = len(stream.records) + 1 + for record in request.records: + stream.records.append( + StreamRecord( + producer=request.producer, + body=record.body, + offset=_offset(len(stream.records) + 1), + ) + ) + return AppendRecordsResponse( + first_offset=_offset(first), last_offset=_offset(len(stream.records)) + ) + + async def read_records(self, request: ReadRecordsRequest) -> ReadRecordsResponse: + stream = self._stream(request.ref) + start = int.from_bytes(request.from_offset, "big") if request.from_offset else 0 + page = stream.records[start : start + (request.max_records or 1000)] + last_offset = page[-1].offset if page else request.from_offset + return ReadRecordsResponse( + records=page, + last_offset=last_offset, + done=stream.closed and start + len(page) == len(stream.records), + ) + + async def close_stream(self, request: CloseStreamRequest) -> CloseStreamResponse: + self._stream(request.ref).closed = True + return CloseStreamResponse() + + async def describe_stream( + self, request: DescribeStreamRequest + ) -> DescribeStreamResponse: + stream = self._stream(request.ref) + return DescribeStreamResponse( + status=StreamStatus.STREAM_STATUS_CLOSED + if stream.closed + else StreamStatus.STREAM_STATUS_OPEN, + last_offset=stream.records[-1].offset if stream.records else b"", + ) diff --git a/tests/nexus/test_stream_appender.py b/tests/nexus/test_stream_appender.py index 54e8cb3d5..fd320ff47 100644 --- a/tests/nexus/test_stream_appender.py +++ b/tests/nexus/test_stream_appender.py @@ -5,6 +5,7 @@ from datetime import timedelta import pytest +from google.protobuf.message import Message import temporalio.api.streamservice.v1 from temporalio.api.enums.v1 import StreamErrorReason @@ -12,29 +13,26 @@ from temporalio.nexus.system.stream_service import StreamRef from temporalio.nexus.system.stream_service.client import Stream from temporalio.testing import MemoryStreamStore +from tests.helpers.streams import RecordingStreamStore from tests.nexus.test_stream_store import _create_stream, _stream_error, _with_store # Long enough that only a flush, a full batch or the end of `async with` starts a write. _NEVER = timedelta(hours=1) -class RecordingStore(MemoryStreamStore): +class RecordingStore(RecordingStreamStore): def __init__(self) -> None: - super().__init__() + super().__init__(MemoryStreamStore()) self.batches: list[int] = [] self.append_started = asyncio.Event() self.release: asyncio.Event | None = None - async def _call(self, operation: str, request: bytes) -> bytes: - if operation == "AppendRecords": - message = temporalio.api.streamservice.v1.AppendRecordsRequest.FromString( - request - ) - self.batches.append(len(message.records)) + async def before_call(self, operation: str, request: Message) -> None: + if isinstance(request, temporalio.api.streamservice.v1.AppendRecordsRequest): + self.batches.append(len(request.records)) self.append_started.set() if self.release is not None: await self.release.wait() - return await super()._call(operation, request) async def _bodies(stream: Stream[str]) -> list[str]: diff --git a/tests/nexus/test_stream_store.py b/tests/nexus/test_stream_store.py index 49627cb85..db37459f9 100644 --- a/tests/nexus/test_stream_store.py +++ b/tests/nexus/test_stream_store.py @@ -17,7 +17,9 @@ import temporalio.activity import temporalio.api.stream.v1 import temporalio.api.workflowservice.v1 +import temporalio.client._stream_store import temporalio.common +import temporalio.converter._payload_converter import temporalio.exceptions import temporalio.nexus.system import temporalio.nexus.system._support @@ -29,7 +31,7 @@ StreamSelectContext, StreamStore, ) -from temporalio.client._stream_store import _request_ref +from temporalio.client._stream_store import _STREAM_OPERATIONS, _request_ref from temporalio.converter import DataConverter, StorageDriverActivityInfo from temporalio.exceptions import ApplicationError, StreamConfigError, StreamError from temporalio.nexus.system.stream_service import ( @@ -56,10 +58,11 @@ WorkflowEnvironment, ) from tests import DEV_SERVER_DOWNLOAD_VERSION +from tests.helpers.streams import DictStreamStore from tests.test_converter import SimpleCodec -def _with_store(client: Client, store: MemoryStreamStore) -> Client: +def _with_store(client: Client, store: StreamStore) -> Client: return _with_config(client, StreamConfig(stores=[store])) @@ -77,7 +80,7 @@ def _two_stores( return first, second, StreamConfig(stores=[first, second], store_selector=selector) -async def _create_stream(client: Client, store: MemoryStreamStore) -> Stream[str]: +async def _create_stream(client: Client, store: StreamStore) -> Stream[str]: return await _with_store(client, store).create_stream( id=f"stream-{uuid.uuid4()}", body_type=str ) @@ -148,6 +151,55 @@ async def test_client_stream_uses_store(client: Client): assert [record.body for page in pages for record in page.records] == ["a", "b"] +async def test_client_stream_uses_a_store_written_from_scratch(client: Client): + store = DictStreamStore() + config = client.config() + config["data_converter"] = DataConverter(payload_codec=SimpleCodec()) + config["stream_config"] = StreamConfig(stores=[store]) + stream_client = Client(**config) + + stream_id = f"stream-{uuid.uuid4()}" + stream = await stream_client.create_stream(id=stream_id, body_type=str) + assert stream.created + assert stream.ref.store == "dict" + appended = await stream.append("a", "b") + assert [record.body async for record in stream.iter_read_items()] == ["a", "b"] + # The Store gets payloads that the client already encoded with the codec. + stored = store.streams[stream_id].records + assert [record.body.metadata["simple-codec"] for record in stored] == [ + b"true", + b"true", + ] + described = await stream.describe() + assert described.status == StreamStatus.StreamStatusOpen + assert described.last_offset == appended.last_offset + err = await _stream_error(stream_client.create_stream(id=stream_id, body_type=str)) + assert err.reason == StreamErrorReason.STREAM_ERROR_REASON_ALREADY_EXISTS + + await stream.close() + err = await _stream_error(stream.append("c")) + assert err.reason == StreamErrorReason.STREAM_ERROR_REASON_CLOSED + + +def test_stream_operations_match_the_stream_service(): + service = temporalio.client._stream_store._STREAM_SERVICE + assert set(_STREAM_OPERATIONS) == set(service.operation_definitions) + for name, operation in _STREAM_OPERATIONS.items(): + definition = service.operation_definitions[name] + for model, message in ( + (definition.input_type, operation.request_type), + (definition.output_type, operation.response_type), + ): + converter = ( + temporalio.converter._payload_converter._get_transfer_type_converter( + model + ) + ) + assert converter is not None + assert converter.transfer_type is message + assert callable(getattr(StreamStore, operation.store_method)) + + async def test_client_strict_create_of_existing_stream_fails(client: Client): stream_client = _with_store(client, MemoryStreamStore()) stream_id = f"stream-{uuid.uuid4()}" diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py index 9374fa73c..f8abdb031 100644 --- a/tests/nexus/test_stream_store_workflow.py +++ b/tests/nexus/test_stream_store_workflow.py @@ -6,6 +6,7 @@ from typing import Any import pytest +from google.protobuf.message import Message import temporalio.common import temporalio.exceptions @@ -49,6 +50,7 @@ Replayer, ) from tests.helpers import assert_task_fail_eventually, new_worker +from tests.helpers.streams import DictStreamStore, RecordingStreamStore from tests.test_converter import SimpleCodec # Workflow code writes these values, so the History shows only whether a Record body @@ -160,14 +162,9 @@ async def run(self, stream_id: str) -> StreamRef[str]: return stream.ref -class CountingStore(MemoryStreamStore): +class CountingStore(RecordingStreamStore): def __init__(self, name: str) -> None: - super().__init__(name=name) - self.operations: list[str] = [] - - async def _call(self, operation: str, request: bytes) -> bytes: - self.operations.append(operation) - return await super()._call(operation, request) + super().__init__(MemoryStreamStore(name=name)) @activity.defn @@ -221,29 +218,19 @@ async def run(self, stream_id: str) -> None: ) -class FailingStreamStore(StreamStore): - @property - def name(self) -> str: - return "failing" - - async def _call(self, operation: str, request: bytes) -> bytes: - raise AssertionError(f"A replay called the Store with {operation}") - - -class FlakyDescribeStore(MemoryStreamStore): +class FlakyDescribeStore(RecordingStreamStore): def __init__(self, *, failures: int = 0, delay: timedelta = timedelta()) -> None: - super().__init__() + super().__init__(MemoryStreamStore()) self._failures = failures self._delay = delay self.describes = 0 - async def _call(self, operation: str, request: bytes) -> bytes: + async def before_call(self, operation: str, request: Message) -> None: if operation == "DescribeStream": self.describes += 1 await asyncio.sleep(self._delay.total_seconds()) if self.describes <= self._failures: raise RuntimeError("describe failed") - return await super()._call(operation, request) class FlakyCreateStore(CountingStore): @@ -251,12 +238,10 @@ def __init__(self, name: str) -> None: super().__init__(name) self.failed_creates = 0 - async def _call(self, operation: str, request: bytes) -> bytes: + async def before_call(self, operation: str, request: Message) -> None: if operation == "CreateStream" and not self.failed_creates: self.failed_creates += 1 - self.operations.append(operation) raise RuntimeError("create failed") - return await super()._call(operation, request) class RecordingInterceptor(Interceptor): @@ -378,6 +363,19 @@ async def test_workflow_stream_uses_store(client: Client): assert first.kind.value.epoch.attempt == 1 +async def test_workflow_stream_uses_a_store_written_from_scratch(client: Client): + store = DictStreamStore() + stream_client = _with_store(client, store) + stream_id = f"stream-{uuid.uuid4()}" + _, ref = await _run_create_and_describe(stream_client, stream_id) + assert ref == StreamRef(id=stream_id, store="dict") + assert list(store.streams) == [stream_id] + + stream = stream_client.get_stream(ref, body_type=str) + await stream.append("a") + assert [record.body async for record in stream.iter_read_items()] == ["a"] + + async def test_workflow_stream_store_uses_payload_codec(client: Client): store = MemoryStreamStore() config = client.config() @@ -415,10 +413,12 @@ async def test_workflow_stream_history_has_no_bodies(client: Client): async def test_workflow_stream_replay_does_not_call_store(client: Client): stream_client = _with_store(client, MemoryStreamStore()) handle, _ = await _run_append_workflow(stream_client) + replay_store = CountingStore("memory") await Replayer( workflows=[AppendWorkflow], - stream_config=StreamConfig(stores=[FailingStreamStore()]), + stream_config=StreamConfig(stores=[replay_store]), ).replay_workflow(await handle.fetch_history()) + assert replay_store.operations == [] async def test_workflow_create_without_store_fails_the_task(client: Client): From e2d3bf8ffa65d2f71e372edf00e84c7f1279fce4 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 15:20:08 -0700 Subject: [PATCH 43/46] Moved the Worker side of a Stream call into the Store. Worker code should only find a Store and hand it the call, not know how the Store serves Workflow code. Each Store now brings its own Activities and its own call from Workflow code, with a default Local Activity that carries the Store's name. --- temporalio/client/_stream_store.py | 101 +++++++++++++++++++--- temporalio/worker/_replayer.py | 5 +- temporalio/worker/_stream_store.py | 86 ++++-------------- temporalio/worker/_worker.py | 11 ++- tests/nexus/test_stream_store_workflow.py | 83 +++++++++++++++++- 5 files changed, 194 insertions(+), 92 deletions(-) diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py index f7b109bb6..6c4ced7b0 100644 --- a/temporalio/client/_stream_store.py +++ b/temporalio/client/_stream_store.py @@ -2,7 +2,7 @@ import dataclasses from abc import ABC, abstractmethod -from collections.abc import Callable, Sequence +from collections.abc import Awaitable, Callable, Sequence from datetime import timedelta from typing import TYPE_CHECKING, Any @@ -17,6 +17,7 @@ import temporalio.converter import temporalio.nexus.system.stream_service import temporalio.runtime +import temporalio.workflow from temporalio.converter import StorageDriverActivityInfo, StorageDriverWorkflowInfo from temporalio.exceptions import StreamConfigError @@ -28,8 +29,8 @@ class StreamStoreCallOptions: """Options of the Local Activity that calls a Stream Store for Workflow code. - Workflow code can't do I/O, so a worker calls its Stream Store from a Local - Activity. + Workflow code can't do I/O, so the default + :py:meth:`StreamStore.call_from_workflow` calls its Store from a Local Activity. A :py:class:`StreamConfig` holds them in :py:attr:`StreamConfig.workflow_call_options`, and every Store of that config @@ -129,6 +130,50 @@ async def describe_stream( """Give the status and the newest offset of a Stream.""" ... + def worker_activities(self) -> Sequence[Callable[..., Any]]: + """The Activities that a Worker registers for this Store when it runs Workflows. + + The default is one Activity, named for this Store, that + :py:meth:`call_from_workflow` runs as a Local Activity. A Store that overrides + one of the two methods overrides the other to match. + """ + return [_store_call_activity(self)] + + async def call_from_workflow( + self, + operation: str, + request: google.protobuf.message.Message, + options: StreamStoreCallOptions, + ) -> google.protobuf.message.Message: + """Answer one Stream operation for Workflow code. + + It runs in Workflow code, so it does no I/O itself. The default runs the + Activity of :py:meth:`worker_activities` as a Local Activity, with the timeouts + and the retry policy of ``options``. A replay takes the result from History and + calls no method of this Store. + + Args: + operation: The Nexus operation name, ``CreateStream`` or ``DescribeStream`` + in this release. + request: The request message of ``temporalio.api.streamservice.v1``. + options: The :py:attr:`StreamConfig.workflow_call_options` of the config. + + Returns: + The response message of the operation. + """ + stream_operation = _STREAM_OPERATIONS[operation] + # Workflow code can start only a create and a describe in this release, and their + # messages carry no payload. So the Local Activity has no payload codec step. + response = await temporalio.workflow.execute_local_activity( + _store_call_activity_name(self.name), + args=[operation, request.SerializeToString()], + result_type=bytes, + schedule_to_close_timeout=options.schedule_to_close_timeout, + start_to_close_timeout=options.start_to_close_timeout, + retry_policy=options.retry_policy, + ) + return stream_operation.response_type.FromString(response) + class _BridgeStreamStore(StreamStore): # pyright: ignore[reportUnusedClass] """A Store that runs on the Stream runtime of the bridge. @@ -225,7 +270,16 @@ async def call( self, store: StreamStore, request: google.protobuf.message.Message ) -> google.protobuf.message.Message: """Call the method of ``store`` for this operation and check its answer.""" - response = await getattr(store, self.store_method)(request) + return self.check_response( + store, await getattr(store, self.store_method)(request) + ) + + def check_response( + self, store: StreamStore, response: object + ) -> google.protobuf.message.Message: + """Give ``response`` back when it has the response type of this operation.""" + # A Store that someone wrote can give another type, and the error names it here + # rather than in the conversion to the user's type. if not isinstance(response, self.response_type): raise TypeError( f"The Stream Store '{store.name}' answered {self.store_method} with " @@ -275,6 +329,27 @@ async def call( } """The operations of the Stream service, by their Nexus operation name.""" +_STORE_CALL_ACTIVITY = "__temporal_stream_store_call" + + +def _store_call_activity_name(store_name: str) -> str: + # The name carries the Store's name, so two Stores on one Worker don't clash. + return f"{_STORE_CALL_ACTIVITY}:{store_name}" + + +def _store_call_activity( + store: StreamStore, +) -> Callable[[str, bytes], Awaitable[bytes]]: + @temporalio.activity.defn(name=_store_call_activity_name(store.name)) + async def call(operation: str, request: bytes) -> bytes: + stream_operation = _STREAM_OPERATIONS[operation] + response = await stream_operation.call( + store, stream_operation.request_type.FromString(request) + ) + return response.SerializeToString() + + return call + @dataclasses.dataclass(frozen=True) class StreamSelectContext: @@ -291,7 +366,7 @@ class StreamSelectContext: """The Workflow or Activity that makes the call, or ``None`` for a plain client. A call in an Activity has that Activity here. A Workflow's call runs the selector - in the SDK's Local Activity, and has that Workflow here. + in Workflow code, and has that Workflow here. """ @@ -328,19 +403,21 @@ class StreamConfig: Store, and returns one of the instances in :py:attr:`stores`. When it is ``None`` and there is one Store, every new Stream goes to that Store. - A Workflow's create runs the selector in a Local Activity. History keeps the - ref with the Store's name, so a replay doesn't call the selector again. A Stream - that code builds from an id only goes through the selector on each call, so the - selector must give the same Store for the same id. + For a call of Workflow code, the selector runs in Workflow code, also on a + replay. So it must give the same Store for the same context. The Local Activity + of the chosen Store is in History, and another answer on a replay is a + non-determinism error. A Stream that code builds from an id only goes through the + selector on each call, so the selector must also give the same Store for the same + id. """ workflow_call_options: StreamStoreCallOptions = dataclasses.field( default_factory=StreamStoreCallOptions ) - """Options of the Local Activity that calls a Store for Workflow code. + """Options of a Store's call for Workflow code, for every Store of this config. - They are the same for every Store of this config. The Local Activity of a create - picks its Store only when it runs, so its options can't come from that Store. + :py:meth:`StreamStore.call_from_workflow` gets them. The default runs its Local + Activity with their timeouts and retry policy. """ _store_map: dict[str, StreamStore] = dataclasses.field( diff --git a/temporalio/worker/_replayer.py b/temporalio/worker/_replayer.py index 6cce5b292..540e9d405 100644 --- a/temporalio/worker/_replayer.py +++ b/temporalio/worker/_replayer.py @@ -67,8 +67,9 @@ def __init__( ran. ``stream_config`` takes the Stream config of the client that the - worker had, so a replay makes the same commands. A replay never calls - its Stores. + worker had, so a replay makes the same commands. A history with a Stream + call needs it, since a Stream call with no Stream config fails the + Workflow Task. A replay calls no method of its Stores. 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 diff --git a/temporalio/worker/_stream_store.py b/temporalio/worker/_stream_store.py index c6e664054..da9faa689 100644 --- a/temporalio/worker/_stream_store.py +++ b/temporalio/worker/_stream_store.py @@ -1,62 +1,37 @@ -"""What a Worker does for the Stream Stores of its client.""" +"""How Workflow code reaches the Stream Stores of its Worker's client.""" from __future__ import annotations import asyncio import dataclasses -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Generator from typing import TYPE_CHECKING, Any from nexusrpc import OutputT -import temporalio.activity -import temporalio.converter import temporalio.exceptions import temporalio.workflow from temporalio.client._stream_store import ( _STREAM_OPERATIONS, StreamConfig, - _call_stream_store, _request_ref, ) from temporalio.converter import StorageDriverWorkflowInfo -from temporalio.nexus.system.stream_service import StreamRef -from ._interceptor import StartLocalActivityInput, StartNexusOperationInput +from ._interceptor import StartNexusOperationInput if TYPE_CHECKING: from ._workflow_instance import _WorkflowInstanceImpl -_STREAM_STORE_ACTIVITY = "__temporal_stream_store_call" - - -def _stream_store_activity( # pyright: ignore[reportUnusedFunction] - config: StreamConfig, data_converter: temporalio.converter.DataConverter -) -> Callable[[str, bytes, str, str], Awaitable[bytes]]: - @temporalio.activity.defn(name=_STREAM_STORE_ACTIVITY) - async def call( - operation: str, request: bytes, stream_id: str, store_name: str - ) -> bytes: - # The Workflow passes the ref beside the encoded request, so that the request - # isn't decoded again to route it. An empty `store_name` means no Store. - ref = StreamRef[Any](id=stream_id, store=store_name or None) - store = config._store_for(ref, _workflow_target()) - message = _STREAM_OPERATIONS[operation].request_type.FromString(request) - response = await _call_stream_store(store, data_converter, operation, message) - return response.SerializeToString() - - return call - def _workflow_target() -> StorageDriverWorkflowInfo: - # The selector sees the Workflow that made the Stream call, not the SDK's Local - # Activity. External storage builds the same value for an Activity of a Workflow. - info = temporalio.activity.info() + # External storage builds the same value for a Workflow. + info = temporalio.workflow.info() return StorageDriverWorkflowInfo( - id=info.workflow_id or None, - type=info.workflow_type or None, - run_id=info.workflow_run_id or None, - namespace=info.workflow_namespace or info.namespace, + id=info.workflow_id, + type=info.workflow_type, + run_id=info.run_id, + namespace=info.namespace, ) @@ -77,52 +52,28 @@ def start_operation( instance: _WorkflowInstanceImpl, input: StartNexusOperationInput[Any, OutputT], ) -> temporalio.workflow.NexusOperationHandle[OutputT]: - """Answer a Stream call of Workflow code with a Local Activity that calls a Store.""" + """Answer a Stream call of Workflow code with the Store that the call goes to.""" + stream_operation = _STREAM_OPERATIONS[input.operation_name] # The Workflow `Stream` has no `append`, `close` or `read`, but the generated # Workflow service still has their operations, so Workflow code can start one # through a Nexus client. In this release, Workflow code writes and reads a Stream # on a Store through its own Activities. - stream_operation = _STREAM_OPERATIONS[input.operation_name] if stream_operation.workflow_refusal is not None: raise temporalio.exceptions.ApplicationError( stream_operation.workflow_refusal, non_retryable=True ) - # Workflow code builds the request, so the Local Activity does only the I/O. payload_converter, _ = instance._system_nexus_converters(input) request = payload_converter._to_transfer_value(input.input) - # A ref of a Store that the config doesn't have fails here, with no I/O. Its - # `StreamConfigError` fails the Workflow Task. - ref = _request_ref(input.input) - if ref.store: - self.config._get_store_by_name(ref.store) + # The choice runs in Workflow code, so a ref of a Store that the config doesn't + # have raises `StreamConfigError` here, with no I/O, and fails the Workflow Task. + store = self.config._store_for(_request_ref(input.input), _workflow_target()) options = self.config.workflow_call_options - activity = instance._outbound_schedule_activity( - StartLocalActivityInput( - activity=_STREAM_STORE_ACTIVITY, - args=( - input.operation_name, - request.SerializeToString(), - ref.id, - ref.store or "", - ), - activity_id=None, - schedule_to_close_timeout=options.schedule_to_close_timeout, - schedule_to_start_timeout=None, - start_to_close_timeout=options.start_to_close_timeout, - retry_policy=options.retry_policy, - local_retry_threshold=None, - cancellation_type=temporalio.workflow.ActivityCancellationType.TRY_CANCEL, - headers={}, - summary=input.summary, - event_groups=input.event_groups, - arg_types=[str, bytes, str, str], - ret_type=bytes, - ) - ) async def result() -> OutputT: try: - response = await activity + response = await store.call_from_workflow( + input.operation_name, request, options + ) except temporalio.exceptions.ApplicationError as err: # Core hands back a failed Local Activity's error without a wrapper. stream_error = temporalio.exceptions._stream_error_from_failure(err) @@ -147,8 +98,7 @@ async def result() -> OutputT: raise err.cause raise return payload_converter._from_transfer_value( - stream_operation.response_type.FromString(response), - input.output_type, + stream_operation.check_response(store, response), input.output_type ) return _StreamStoreOperationHandle( diff --git a/temporalio/worker/_worker.py b/temporalio/worker/_worker.py index 3626b03b7..bbf68e2bf 100644 --- a/temporalio/worker/_worker.py +++ b/temporalio/worker/_worker.py @@ -34,7 +34,7 @@ from ._interceptor import Interceptor from ._nexus import _NexusWorker from ._plugin import Plugin -from ._stream_store import _stream_store_activity, _WorkflowStreamStore +from ._stream_store import _WorkflowStreamStore from ._tuning import WorkerTuner from ._workflow import ( _DEFAULT_WORKFLOW_TASK_EXTERNAL_STORAGE_CONCURRENCY, @@ -502,9 +502,8 @@ def _init_from_config(self, client: temporalio.client.Client, config: WorkerConf activities = list(config.get("activities") or []) stream_config = client_config.get("stream_config") if stream_config is not None and config.get("workflows"): - activities.append( - _stream_store_activity(stream_config, client_config["data_converter"]) - ) + for store in stream_config.stores: + activities.extend(store.worker_activities()) if activities: _warn_if_activity_executor_max_workers_is_inconsistent(config) self._activity_worker = _ActivityWorker( @@ -674,8 +673,8 @@ def check_activity(activity: str): enable_workflows=self._workflow_worker is not None, enable_local_activities=self._activity_worker is not None and self._workflow_worker is not None, - # The Stream Store Local Activity alone doesn't make this worker - # poll for Activity Tasks. + # The Activities of the Stream Stores run as Local Activities, so + # alone they don't make this worker poll for Activity Tasks. enable_remote_activities=bool(config.get("activities")) and not config.get("no_remote_activities"), enable_nexus=self._nexus_worker is not None, diff --git a/tests/nexus/test_stream_store_workflow.py b/tests/nexus/test_stream_store_workflow.py index f8abdb031..7913a6049 100644 --- a/tests/nexus/test_stream_store_workflow.py +++ b/tests/nexus/test_stream_store_workflow.py @@ -2,6 +2,7 @@ import asyncio import uuid +from collections.abc import Callable, Sequence from datetime import timedelta from typing import Any @@ -12,6 +13,18 @@ import temporalio.exceptions from temporalio import activity, workflow from temporalio.api.enums.v1 import EventType, StreamErrorReason +from temporalio.api.streamservice.v1 import ( + CreateStreamRequest as CreateStreamRequestMessage, +) +from temporalio.api.streamservice.v1 import ( + CreateStreamResponse as CreateStreamResponseMessage, +) +from temporalio.api.streamservice.v1 import ( + DescribeStreamRequest as DescribeStreamRequestMessage, +) +from temporalio.api.streamservice.v1 import ( + DescribeStreamResponse as DescribeStreamResponseMessage, +) from temporalio.client import ( Client, StreamConfig, @@ -589,7 +602,7 @@ async def test_activity_interceptors_see_stream_store_activity(client: Client): ) # The create runs as the SDK's Local Activity, before the Workflow's own Activity. assert interceptor.activity_types == [ - "__temporal_stream_store_call", + "__temporal_stream_store_call:memory", "user_activity", ] @@ -692,17 +705,22 @@ async def test_workflow_create_goes_to_the_selected_store(client: Client): ] -async def test_workflow_replay_calls_no_selector_and_no_store(client: Client): +async def test_workflow_replay_calls_no_store(client: Client): _, _, stream_config = _two_counting_stores([]) + stream_id = f"stream-{uuid.uuid4()}" handle, _ = await _run_create_and_describe( - _with_config(client, stream_config), f"stream-{uuid.uuid4()}" + _with_config(client, stream_config), stream_id ) contexts: list[StreamSelectContext] = [] first, second, replay_config = _two_counting_stores(contexts) await Replayer( workflows=[CreateAndDescribeWorkflow], stream_config=replay_config ).replay_workflow(await handle.fetch_history()) - assert contexts == [] + # The selector runs in Workflow code, so a replay runs it again with the Workflow. + assert [context.id for context in contexts] == [stream_id] + target = contexts[0].target + assert isinstance(target, StorageDriverWorkflowInfo) + assert (target.id, target.run_id) == (handle.id, handle.result_run_id) assert first.operations == [] assert second.operations == [] @@ -729,3 +747,60 @@ async def test_workflow_create_with_several_stores_takes_the_config_options( assert ref.store == "second" assert first.operations == [] assert second.operations == ["CreateStream", "CreateStream", "DescribeStream"] + + +class OwnActivityStore(CountingStore): + """A Store that brings its own Activity for the calls of Workflow code.""" + + def __init__(self) -> None: + super().__init__("own") + self.workflow_calls: list[str] = [] + + def worker_activities(self) -> Sequence[Callable[..., Any]]: + @activity.defn(name="own_store_call") + async def own_store_call(operation: str, request: bytes) -> bytes: + if operation == "CreateStream": + created = await self.create_stream( + CreateStreamRequestMessage.FromString(request) + ) + return created.SerializeToString() + described = await self.describe_stream( + DescribeStreamRequestMessage.FromString(request) + ) + return described.SerializeToString() + + return [own_store_call] + + async def call_from_workflow( + self, operation: str, request: Message, options: StreamStoreCallOptions + ) -> Message: + self.workflow_calls.append(operation) + response = await workflow.execute_local_activity( + "own_store_call", + args=[operation, request.SerializeToString()], + result_type=bytes, + start_to_close_timeout=options.start_to_close_timeout, + ) + if operation == "CreateStream": + return CreateStreamResponseMessage.FromString(response) + return DescribeStreamResponseMessage.FromString(response) + + +async def test_workflow_call_goes_through_the_store_own_activity(client: Client): + store = OwnActivityStore() + interceptor = RecordingInterceptor() + stream_client = _with_store(client, store) + stream_id = f"stream-{uuid.uuid4()}" + async with new_worker( + stream_client, CreateAndDescribeWorkflow, interceptors=[interceptor] + ) as worker: + ref = await stream_client.execute_workflow( + CreateAndDescribeWorkflow.run, + stream_id, + id=f"workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + assert ref == StreamRef(id=stream_id, store="own") + assert store.workflow_calls == ["CreateStream", "DescribeStream"] + assert store.operations == ["CreateStream", "DescribeStream"] + assert interceptor.activity_types == ["own_store_call", "own_store_call"] From 419fc751b8b6a585ddc592d9fa31835b1b058935 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 15:21:51 -0700 Subject: [PATCH 44/46] Moved Core to the Stream crate that has no Python script. The crate writes its test vectors with a Rust program, so the submodule has no Python file. Its Rust code is the same, and the bridge needs no rebuild. --- 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 d242a65c3..1addba05b 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit d242a65c36b417823ac58947e62340a70e1d2fb2 +Subproject commit 1addba05ba0f6bb6640e23bcb9185a88221c6d10 From a69aef69c43ea3ca0fd14e31bb8ddaa8f8c75d24 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 15:21:52 -0700 Subject: [PATCH 45/46] Dropped the lint excludes for the Core submodule. The submodule has no Python file any more, so pyright, pydocstyle and ruff have nothing to skip there. --- pyproject.toml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8eb9349c2..8d427a07a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -176,8 +176,6 @@ exclude = [ 'temporalio/bridge/proto', 'temporalio/nexus/system/workflow_service', 'temporalio/nexus/system/stream_service', - # The Core submodule, which has a script of the crate's test vectors - 'temporalio/bridge/sdk-core', ] [[tool.mypy.overrides]] @@ -191,7 +189,7 @@ ignore_errors = true [tool.pydocstyle] convention = "google" # https://github.com/PyCQA/pydocstyle/issues/363#issuecomment-625563088 -match_dir = "^(?!(docs|scripts|tests|api|proto|system|_support|notifications|sdk-core|\\.)).*" +match_dir = "^(?!(docs|scripts|tests|api|proto|system|_support|notifications|\\.)).*" add_ignore = [ # We like to wrap at a certain number of chars, even long summary sentences. # https://github.com/PyCQA/pydocstyle/issues/184 @@ -253,14 +251,10 @@ exclude = [ "temporalio/bridge/proto", "temporalio/bridge/_visitor.py", "tests/worker/workflow_sandbox/testmodules/proto", - # The Core submodule, which has a script of the crate's test vectors - "temporalio/bridge/sdk-core", ] [tool.ruff] target-version = "py311" -# The Core submodule, which has a script of the crate's test vectors -extend-exclude = ["temporalio/bridge/sdk-core"] [build-system] requires = ["maturin>=1.0,<2.0"] From 138f338f4f07d40ee62e4c5065660ad58e68a794 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 15:21:52 -0700 Subject: [PATCH 46/46] Removed a stale sentence from the StreamStore docstring. A Store is an interface that a person can implement, so the docstring no longer calls it configuration. --- temporalio/client/_stream_store.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/temporalio/client/_stream_store.py b/temporalio/client/_stream_store.py index 6c4ced7b0..622f00bf1 100644 --- a/temporalio/client/_stream_store.py +++ b/temporalio/client/_stream_store.py @@ -63,8 +63,7 @@ class StreamStore(ABC): """A Store that answers the Stream operations of a client. A client with a Store sends each Stream operation to it. This release has no - Streams on the server, so a client needs a Store for any Stream operation. A - Store is configuration. Use + Streams on the server, so a client needs a Store for any Stream operation. Use :py:class:`temporalio.contrib.redis.streams.RedisStreamStore`, or :py:class:`temporalio.testing.MemoryStreamStore` in tests.