From 03c0b1073abddb3139a5a6206043b43530e28fd0 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Sun, 18 Oct 2026 00:00:14 -0700 Subject: [PATCH 1/3] Generated the stream service from the crate's Nexus contract. --- .github/workflows/ci.yml | 18 + .gitignore | 1 + pyproject.toml | 3 +- scripts/gen_bridge_client.py | 88 +- scripts/gen_nexus_streams_api.py | 104 + scripts/gen_protos.py | 4 + temporalio/api/common/v1/message_pb2.py | 78 +- temporalio/api/common/v1/message_pb2.pyi | 15 + temporalio/api/nexus/v1/__init__.py | 2 + temporalio/api/nexus/v1/progress_pb2.py | 71 + temporalio/api/nexus/v1/progress_pb2.pyi | 150 + .../api/notificationservice/v1/__init__.py | 3 + .../notificationservice/v1/progress_pb2.py | 60 + .../notificationservice/v1/progress_pb2.pyi | 110 + .../v1/progress_pb2_grpc.py | 4 + .../v1/progress_pb2_grpc.pyi | 4 + .../v1/request_response_pb2.py | 8 +- .../v1/request_response_pb2.pyi | 8 + temporalio/api/workflowservice/v1/__init__.py | 6 + .../v1/nexus_request_response_pb2.py | 81 + .../v1/nexus_request_response_pb2.pyi | 151 + .../v1/nexus_request_response_pb2_grpc.py | 4 + .../v1/nexus_request_response_pb2_grpc.pyi | 4 + temporalio/bridge/Cargo.lock | 5 +- temporalio/bridge/Cargo.toml | 2 +- temporalio/bridge/proto/streams/__init__.py | 24 + temporalio/bridge/proto/streams/nexus_pb2.py | 213 + temporalio/bridge/proto/streams/nexus_pb2.pyi | 505 ++ .../bridge/proto/streams/streams_pb2.pyi | 8 +- temporalio/bridge/sdk-core | 2 +- temporalio/bridge/src/lib.rs | 1 + temporalio/bridge/src/streams.rs | 31 +- temporalio/bridge/streams.py | 21 + temporalio/bridge/streams_generated.py | 74 +- temporalio/contrib/streams/_plugin.py | 12 +- temporalio/contrib/streams/nexus/__init__.py | 55 + .../streams/nexus/_generated/__init__.py | 61 + .../streams/nexus/_generated/_definitions.py | 581 ++ .../streams/nexus/_generated/client.py | 882 +++ .../streams/nexus/_generated/forwarder.py | 163 + .../streams/nexus/_generated/models.py | 5235 +++++++++++++++++ .../streams/nexus/_generated/services.py | 89 + tests/contrib/streams/_support.py | 5 + .../contrib/streams/test_nexus_service_idl.py | 336 ++ 44 files changed, 9194 insertions(+), 88 deletions(-) create mode 100644 scripts/gen_nexus_streams_api.py create mode 100644 temporalio/api/nexus/v1/progress_pb2.py create mode 100644 temporalio/api/nexus/v1/progress_pb2.pyi create mode 100644 temporalio/api/notificationservice/v1/progress_pb2.py create mode 100644 temporalio/api/notificationservice/v1/progress_pb2.pyi create mode 100644 temporalio/api/notificationservice/v1/progress_pb2_grpc.py create mode 100644 temporalio/api/notificationservice/v1/progress_pb2_grpc.pyi create mode 100644 temporalio/api/workflowservice/v1/nexus_request_response_pb2.py create mode 100644 temporalio/api/workflowservice/v1/nexus_request_response_pb2.pyi create mode 100644 temporalio/api/workflowservice/v1/nexus_request_response_pb2_grpc.py create mode 100644 temporalio/api/workflowservice/v1/nexus_request_response_pb2_grpc.pyi create mode 100644 temporalio/bridge/proto/streams/nexus_pb2.py create mode 100644 temporalio/bridge/proto/streams/nexus_pb2.pyi create mode 100644 temporalio/contrib/streams/nexus/__init__.py create mode 100644 temporalio/contrib/streams/nexus/_generated/__init__.py create mode 100644 temporalio/contrib/streams/nexus/_generated/_definitions.py create mode 100644 temporalio/contrib/streams/nexus/_generated/client.py create mode 100644 temporalio/contrib/streams/nexus/_generated/forwarder.py create mode 100644 temporalio/contrib/streams/nexus/_generated/models.py create mode 100644 temporalio/contrib/streams/nexus/_generated/services.py create mode 100644 tests/contrib/streams/test_nexus_service_idl.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55eb706089..30d17a80ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -244,6 +244,24 @@ jobs: env: TEMPORAL_TEST_PROTO3: 1 + # The stream service's bindings come from the Nexus contract in the + # temporalio-streams crate that the bridge pins. Cargo fetches the crate, so + # this job needs no bridge build. + check-nexus-streams-api: + timeout-minutes: 20 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.10" + - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8 + - run: uv sync --only-dev + - run: uv run --no-sync python scripts/gen_nexus_streams_api.py --check + test-latest-deps: timeout-minutes: 30 runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 8cd439e052..302684ec95 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ temporalio/bridge/temporal_sdk_bridge* tags /.claude tmpclaude-* +/.nexgen diff --git a/pyproject.toml b/pyproject.toml index 87004a15fc..d6bd611f49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,6 +109,7 @@ format = [ ] gen-docs = "uv run scripts/gen_docs.py" gen-nexus-system-api = "uv run scripts/gen_nexus_system_api.py" +gen-nexus-streams-api = "uv run scripts/gen_nexus_streams_api.py" gen-protos = [ { cmd = "uv run scripts/gen_protos.py" }, { ref = "gen-nexus-system-api" }, @@ -194,7 +195,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|\\.)).*" +match_dir = "^(?!(docs|scripts|tests|api|proto|system|_generated|\\.)).*" add_ignore = [ # We like to wrap at a certain number of chars, even long summary sentences. # https://github.com/PyCQA/pydocstyle/issues/184 diff --git a/scripts/gen_bridge_client.py b/scripts/gen_bridge_client.py index 1a69469322..666717dd1e 100644 --- a/scripts/gen_bridge_client.py +++ b/scripts/gen_bridge_client.py @@ -13,6 +13,7 @@ import temporalio.api.testservice.v1.service_pb2 as test_service import temporalio.api.workflowservice.v1.service_pb2 as workflow_service import temporalio.bridge.proto.health.v1.health_pb2 as health_service +import temporalio.bridge.proto.streams.nexus_pb2 as stream_nexus_service import temporalio.bridge.proto.streams.streams_pb2 as stream_service @@ -245,44 +246,71 @@ def python_module_for(file_name: str) -> str: def generate_python_stream_service( - service_descriptor: ServiceDescriptor, + service_descriptors: list[ServiceDescriptor], output_file: str = "temporalio/bridge/streams_generated.py", ): - print("generating python stream service") + print("generating python stream services") def type_name(message) -> str: return f"{python_module_for(message.file.name)}.{message.name}" - methods = sorted( - ( - method - for method in service_descriptor.methods - if not method.client_streaming and not method.server_streaming - ), - key=lambda m: m.name, - ) + def unary(service_descriptor: ServiceDescriptor) -> list: + return sorted( + ( + method + for method in service_descriptor.methods + if not method.client_streaming and not method.server_streaming + ), + key=lambda m: m.name, + ) + modules = sorted( - {python_module_for(service_descriptor.file.name)} - | {python_module_for(m.input_type.file.name) for m in methods} - | {python_module_for(m.output_type.file.name) for m in methods} + {python_module_for(d.file.name) for d in service_descriptors} + | { + python_module_for(m.input_type.file.name) + for d in service_descriptors + for m in unary(d) + } + | { + python_module_for(m.output_type.file.name) + for d in service_descriptors + for m in unary(d) + } ) method_template = Template(''' async def $method_name(self, req: $request_type) -> $response_type: """Invokes the $service_name.$method_name call.""" return await self._store.call("$rpc_name", req, $response_type) ''') - method_calls = [ - method_template.substitute( + # Every service's calls go through the store's one entry, which the stream + # runtime dispatches by name, so each service is a class over the same store. + class_template = Template(''' + +class $service_name: + """Calls for the $service_name, which the stream runtime serves in process.""" + + def __init__(self, store: StreamStore) -> None: + """Initialize service with the provided store.""" + self._store = store +$method_calls''') + classes = [ + class_template.substitute( service_name=service_descriptor.name, - method_name=pascal_to_snake(method.name), - rpc_name=method.name, - request_type=type_name(method.input_type), - response_type=type_name(method.output_type), + method_calls="".join( + method_template.substitute( + service_name=service_descriptor.name, + method_name=pascal_to_snake(method.name), + rpc_name=method.name, + request_type=type_name(method.input_type), + response_type=type_name(method.output_type), + ) + for method in unary(service_descriptor) + ), ) - for method in methods + for service_descriptor in service_descriptors ] file_template = Template('''# Generated file. DO NOT EDIT -"""Generated calls for the stream runtime's in-process $service_name.""" +"""Generated calls for the stream runtime's in-process stream services.""" from __future__ import annotations @@ -292,21 +320,12 @@ async def $method_name(self, req: $request_type) -> $response_type: if TYPE_CHECKING: from temporalio.bridge.streams import StreamStore - - -class $service_name: - """Calls for the $service_name, which the stream runtime serves in process.""" - - def __init__(self, store: StreamStore) -> None: - """Initialize service with the provided store.""" - self._store = store -$method_calls''') +$classes''') with open(output_file, "w") as f: f.write( file_template.substitute( - service_name=service_descriptor.name, imports="\n".join(f"import {module}" for module in modules), - method_calls="".join(method_calls), + classes="".join(classes), ) ) @@ -355,5 +374,8 @@ def sanitize_proto_name(input: str) -> str: ) generate_python_stream_service( - stream_service.DESCRIPTOR.services_by_name["StreamService"] + [ + stream_service.DESCRIPTOR.services_by_name["StreamService"], + stream_nexus_service.DESCRIPTOR.services_by_name["StreamNexusService"], + ] ) diff --git a/scripts/gen_nexus_streams_api.py b/scripts/gen_nexus_streams_api.py new file mode 100644 index 0000000000..286fa270bf --- /dev/null +++ b/scripts/gen_nexus_streams_api.py @@ -0,0 +1,104 @@ +"""Generate the stream service's Python bindings from its Nexus contract. + +The stream runtime serves the contract, so its one copy is the +``crates/streams/nexus/temporal_streams.nexusrpc.yaml`` of the temporalio-streams +crate that the bridge pins, and the bindings land in +``temporalio/contrib/streams/nexus/_generated``. With +``--check`` the script generates into a scratch directory and fails when the +result differs from what is checked in, which is how CI keeps the two in step. + +The contract uses the ``x-nexus-long-poll``, ``x-nexus-cursor``, +``x-nexus-stream-ref``, ``x-nexus-handle`` and ``x-nexus-operation-token`` +annotations, and the bindings include the forwarding handler, which no nexgen +release carries yet. So it uses the nexgen fork revision that +``gen_nexus_system_api.py`` pins, and the same install. +""" + +import argparse +import filecmp +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import gen_protos +from gen_nexus_system_api import nex_gen_command + +base_dir = Path(__file__).parent.parent +package_dir = base_dir / "temporalio" / "contrib" / "streams" / "nexus" +contract_path = ( + gen_protos.streams_proto_dir.parent / "nexus" / "temporal_streams.nexusrpc.yaml" +) +output_dir = package_dir / "_generated" + + +def generate(target: Path) -> None: + """Generate the bindings into ``target``, formatted as the repository formats.""" + subprocess.check_call( + [ + *nex_gen_command(), + "python", + "--client", + "--forwarder", + "--output", + str(target), + str(contract_path), + ] + ) + for fix in (["check", "--select", "I", "--fix"], ["format"]): + subprocess.check_call( + [sys.executable, "-m", "ruff", *fix, "--quiet", str(target)] + ) + + +def differences(left: Path, right: Path) -> list[str]: + """The relative paths whose content differs between two trees.""" + comparison = filecmp.dircmp(left, right, ignore=["__pycache__"]) + found = [ + *comparison.left_only, + *comparison.right_only, + *comparison.diff_files, + *comparison.funny_files, + ] + for name, sub in comparison.subdirs.items(): + found.extend( + f"{name}/{path}" for path in differences(Path(sub.left), Path(sub.right)) + ) + _, mismatch, errors = filecmp.cmpfiles( + left, right, comparison.same_files, shallow=False + ) + return sorted({*found, *mismatch, *errors}) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--check", + action="store_true", + help="fail when regenerating would change the checked-in bindings", + ) + args = parser.parse_args() + if not contract_path.exists(): + raise RuntimeError(f"missing contract: {contract_path}") + + with tempfile.TemporaryDirectory(dir=base_dir) as temp_dir: + scratch = Path(temp_dir) / "_generated" + generate(scratch) + if args.check: + changed = differences(scratch, output_dir) if output_dir.exists() else ["."] + if changed: + print( + "The stream service bindings are stale; run " + "`uv run scripts/gen_nexus_streams_api.py`. Changed: " + + ", ".join(changed), + file=sys.stderr, + ) + sys.exit(1) + return + shutil.rmtree(output_dir, ignore_errors=True) + shutil.copytree(scratch, output_dir) + + +if __name__ == "__main__": + main() diff --git a/scripts/gen_protos.py b/scripts/gen_protos.py index b1924f2fca..76655dc842 100644 --- a/scripts/gen_protos.py +++ b/scripts/gen_protos.py @@ -50,6 +50,8 @@ def find_streams_proto_dir() -> Path: # The stream API that the crate carries until it lands upstream. Core's api_upstream does not have # it, so its modules go into temporalio.api from here. streams_api_proto_dir = streams_proto_dir / "api_stream" +# The Nexus progress and sync call API, which the crate carries for the same reason. +nexus_api_proto_dir = streams_proto_dir / "api_nexus" # Exclude testsrv dependencies protos proto_paths = [ @@ -62,6 +64,7 @@ def find_streams_proto_dir() -> Path: # The crate's api_include copy is left out, since api_upstream generates those modules. proto_paths.extend((streams_proto_dir / "temporal").glob("**/*.proto")) proto_paths.extend(streams_api_proto_dir.glob("**/*.proto")) +proto_paths.extend(nexus_api_proto_dir.glob("**/*.proto")) proto_paths.extend(test_proto_dir.glob("**/*.proto")) proto_paths.extend(additional_proto_dir.glob("**/*.proto")) @@ -221,6 +224,7 @@ def generate_protos(output_dir: Path): "-mgrpc_tools.protoc", f"--proto_path={api_proto_dir}", f"--proto_path={streams_api_proto_dir}", + f"--proto_path={nexus_api_proto_dir}", f"--proto_path={api_cloud_proto_dir}", f"--proto_path={core_proto_dir}", f"--proto_path={testsrv_proto_dir}", diff --git a/temporalio/api/common/v1/message_pb2.py b/temporalio/api/common/v1/message_pb2.py index 4a375947e4..d4205a53c3 100644 --- a/temporalio/api/common/v1/message_pb2.py +++ b/temporalio/api/common/v1/message_pb2.py @@ -29,7 +29,7 @@ ) DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n$temporal/api/common/v1/message.proto\x12\x16temporal.api.common.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a"temporal/api/enums/v1/common.proto\x1a&temporal/api/enums/v1/event_type.proto\x1a!temporal/api/enums/v1/reset.proto"T\n\x08\x44\x61taBlob\x12:\n\rencoding_type\x18\x01 \x01(\x0e\x32#.temporal.api.enums.v1.EncodingType\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c"=\n\x08Payloads\x12\x31\n\x08payloads\x18\x01 \x03(\x0b\x32\x1f.temporal.api.common.v1.Payload"\x8a\x02\n\x07Payload\x12?\n\x08metadata\x18\x01 \x03(\x0b\x32-.temporal.api.common.v1.Payload.MetadataEntry\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12Q\n\x11\x65xternal_payloads\x18\x03 \x03(\x0b\x32\x36.temporal.api.common.v1.Payload.ExternalPayloadDetails\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\x1a,\n\x16\x45xternalPayloadDetails\x12\x12\n\nsize_bytes\x18\x01 \x01(\x03"\xbe\x01\n\x10SearchAttributes\x12S\n\x0eindexed_fields\x18\x01 \x03(\x0b\x32;.temporal.api.common.v1.SearchAttributes.IndexedFieldsEntry\x1aU\n\x12IndexedFieldsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12.\n\x05value\x18\x02 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload:\x02\x38\x01"\x90\x01\n\x04Memo\x12\x38\n\x06\x66ields\x18\x01 \x03(\x0b\x32(.temporal.api.common.v1.Memo.FieldsEntry\x1aN\n\x0b\x46ieldsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12.\n\x05value\x18\x02 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload:\x02\x38\x01"\x94\x01\n\x06Header\x12:\n\x06\x66ields\x18\x01 \x03(\x0b\x32*.temporal.api.common.v1.Header.FieldsEntry\x1aN\n\x0b\x46ieldsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12.\n\x05value\x18\x02 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload:\x02\x38\x01"8\n\x11WorkflowExecution\x12\x13\n\x0bworkflow_id\x18\x01 \x01(\t\x12\x0e\n\x06run_id\x18\x02 \x01(\t"d\n\tExecution\x12\x32\n\x04type\x18\x01 \x01(\x0e\x32$.temporal.api.enums.v1.ExecutionType\x12\x13\n\x0b\x62usiness_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t"\x1c\n\x0cWorkflowType\x12\x0c\n\x04name\x18\x01 \x01(\t"\x1c\n\x0c\x41\x63tivityType\x12\x0c\n\x04name\x18\x01 \x01(\t"\xd1\x01\n\x0bRetryPolicy\x12\x33\n\x10initial_interval\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x1b\n\x13\x62\x61\x63koff_coefficient\x18\x02 \x01(\x01\x12\x33\n\x10maximum_interval\x18\x03 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x18\n\x10maximum_attempts\x18\x04 \x01(\x05\x12!\n\x19non_retryable_error_types\x18\x05 \x03(\t"F\n\x10MeteringMetadata\x12\x32\n*nonfirst_local_activity_execution_attempts\x18\r \x01(\r">\n\x12WorkerVersionStamp\x12\x10\n\x08\x62uild_id\x18\x01 \x01(\t\x12\x16\n\x0euse_versioning\x18\x03 \x01(\x08"e\n\x19WorkerVersionCapabilities\x12\x10\n\x08\x62uild_id\x18\x01 \x01(\t\x12\x16\n\x0euse_versioning\x18\x02 \x01(\x08\x12\x1e\n\x16\x64\x65ployment_series_name\x18\x04 \x01(\t"\xed\x02\n\x0cResetOptions\x12\x35\n\x13\x66irst_workflow_task\x18\x01 \x01(\x0b\x32\x16.google.protobuf.EmptyH\x00\x12\x34\n\x12last_workflow_task\x18\x02 \x01(\x0b\x32\x16.google.protobuf.EmptyH\x00\x12\x1a\n\x10workflow_task_id\x18\x03 \x01(\x03H\x00\x12\x12\n\x08\x62uild_id\x18\x04 \x01(\tH\x00\x12G\n\x12reset_reapply_type\x18\n \x01(\x0e\x32\'.temporal.api.enums.v1.ResetReapplyTypeB\x02\x18\x01\x12\x18\n\x10\x63urrent_run_only\x18\x0b \x01(\x08\x12S\n\x1breset_reapply_exclude_types\x18\x0c \x03(\x0e\x32..temporal.api.enums.v1.ResetReapplyExcludeTypeB\x08\n\x06target"\xb3\x04\n\x08\x43\x61llback\x12\x37\n\x05nexus\x18\x02 \x01(\x0b\x32&.temporal.api.common.v1.Callback.NexusH\x00\x12=\n\x08internal\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.Callback.InternalH\x00\x12\x46\n\rnexus_handler\x18\x04 \x01(\x0b\x32-.temporal.api.common.v1.Callback.NexusHandlerH\x00\x12+\n\x05links\x18\x64 \x03(\x0b\x32\x1c.temporal.api.common.v1.Link\x1a\x87\x01\n\x05Nexus\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x42\n\x06header\x18\x02 \x03(\x0b\x32\x32.temporal.api.common.v1.Callback.Nexus.HeaderEntry\x1a-\n\x0bHeaderEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x18\n\x08Internal\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\x1a\x84\x01\n\x0cNexusHandler\x12\x17\n\x0ftask_queue_name\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\x12\x11\n\toperation\x18\x03 \x01(\t\x12\x37\n\x0esource_context\x18\x04 \x01(\x0b\x32\x1f.temporal.api.common.v1.PayloadB\t\n\x07variantJ\x04\x08\x01\x10\x02"\xc6\t\n\x04Link\x12\x44\n\x0eworkflow_event\x18\x01 \x01(\x0b\x32*.temporal.api.common.v1.Link.WorkflowEventH\x00\x12:\n\tbatch_job\x18\x02 \x01(\x0b\x32%.temporal.api.common.v1.Link.BatchJobH\x00\x12\x39\n\x08\x61\x63tivity\x18\x03 \x01(\x0b\x32%.temporal.api.common.v1.Link.ActivityH\x00\x12\x46\n\x0fnexus_operation\x18\x04 \x01(\x0b\x32+.temporal.api.common.v1.Link.NexusOperationH\x00\x12\x39\n\x08workflow\x18\x05 \x01(\x0b\x32%.temporal.api.common.v1.Link.WorkflowH\x00\x12\x39\n\x08\x63\x61llback\x18\x06 \x01(\x0b\x32%.temporal.api.common.v1.Link.CallbackH\x00\x1a\xb7\x03\n\rWorkflowEvent\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12N\n\tevent_ref\x18\x64 \x01(\x0b\x32\x39.temporal.api.common.v1.Link.WorkflowEvent.EventReferenceH\x00\x12W\n\x0erequest_id_ref\x18\x65 \x01(\x0b\x32=.temporal.api.common.v1.Link.WorkflowEvent.RequestIdReferenceH\x00\x1aX\n\x0e\x45ventReference\x12\x10\n\x08\x65vent_id\x18\x01 \x01(\x03\x12\x34\n\nevent_type\x18\x02 \x01(\x0e\x32 .temporal.api.enums.v1.EventType\x1a^\n\x12RequestIdReference\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x34\n\nevent_type\x18\x02 \x01(\x0e\x32 .temporal.api.enums.v1.EventTypeB\x0b\n\treference\x1a\x1a\n\x08\x42\x61tchJob\x12\x0e\n\x06job_id\x18\x01 \x01(\t\x1a\x42\n\x08\x41\x63tivity\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x1aI\n\x0eNexusOperation\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0coperation_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x1aR\n\x08Workflow\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x0e\n\x06reason\x18\x04 \x01(\t\x1a\x7f\n\x08\x43\x61llback\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x34\n\texecution\x18\x02 \x01(\x0b\x32!.temporal.api.common.v1.Execution\x12\x16\n\x0e\x63omponent_path\x18\x03 \x03(\t\x12\x12\n\nrequest_id\x18\x04 \x01(\tB\t\n\x07variant"\'\n\tPrincipal\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t"O\n\x08Priority\x12\x14\n\x0cpriority_key\x18\x01 \x01(\x05\x12\x14\n\x0c\x66\x61irness_key\x18\x02 \x01(\t\x12\x17\n\x0f\x66\x61irness_weight\x18\x03 \x01(\x02";\n\x0eWorkerSelector\x12\x1d\n\x13worker_instance_key\x18\x01 \x01(\tH\x00\x42\n\n\x08selector"i\n\x11OnConflictOptions\x12\x19\n\x11\x61ttach_request_id\x18\x01 \x01(\x08\x12#\n\x1b\x61ttach_completion_callbacks\x18\x02 \x01(\x08\x12\x14\n\x0c\x61ttach_links\x18\x03 \x01(\x08"\xaa\x01\n\x12TimeSkippingConfig\x12\x0f\n\x07\x65nabled\x18\x01 \x01(\x08\x12\x46\n\x13\x66\x61st_forward_config\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.FastForwardConfig\x12\x1b\n\x13\x64isable_propagation\x18\x03 \x01(\x08\x12\x1e\n\x16max_session_skip_count\x18\x04 \x01(\x05"L\n\x11\x46\x61stForwardConfig\x12\n\n\x02id\x18\x01 \x01(\t\x12+\n\x08\x64uration\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration"\xb5\x01\n\x1cTimeSkippingStatePropagation\x12;\n\x18initial_skipped_duration\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\x12<\n\x18\x66\x61st_forward_target_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1a\n\x12initial_skip_count\x18\x03 \x01(\x05"\xfe\x01\n\x10TimeSkippingInfo\x12\x30\n\x0c\x63urrent_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x44\n\x10\x65\x66\x66\x65\x63tive_config\x18\x02 \x01(\x0b\x32*.temporal.api.common.v1.TimeSkippingConfig\x12N\n\x11\x66\x61st_forward_info\x18\x04 \x01(\x0b\x32\x33.temporal.api.common.v1.TimeSkippingFastForwardInfo\x12"\n\x1a\x63urrent_session_skip_count\x18\x06 \x01(\x05"\xb8\x01\n\x1bTimeSkippingFastForwardInfo\x12\x38\n\x15\x66\x61st_forward_duration\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x17\n\x0f\x66\x61st_forward_id\x18\x02 \x01(\t\x12/\n\x0btarget_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rhas_completed\x18\x04 \x01(\x08\x42\x89\x01\n\x19io.temporal.api.common.v1B\x0cMessageProtoP\x01Z#go.temporal.io/api/common/v1;common\xaa\x02\x18Temporalio.Api.Common.V1\xea\x02\x1bTemporalio::Api::Common::V1b\x06proto3' + b'\n$temporal/api/common/v1/message.proto\x12\x16temporal.api.common.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a"temporal/api/enums/v1/common.proto\x1a&temporal/api/enums/v1/event_type.proto\x1a!temporal/api/enums/v1/reset.proto"T\n\x08\x44\x61taBlob\x12:\n\rencoding_type\x18\x01 \x01(\x0e\x32#.temporal.api.enums.v1.EncodingType\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c"=\n\x08Payloads\x12\x31\n\x08payloads\x18\x01 \x03(\x0b\x32\x1f.temporal.api.common.v1.Payload"\x8a\x02\n\x07Payload\x12?\n\x08metadata\x18\x01 \x03(\x0b\x32-.temporal.api.common.v1.Payload.MetadataEntry\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12Q\n\x11\x65xternal_payloads\x18\x03 \x03(\x0b\x32\x36.temporal.api.common.v1.Payload.ExternalPayloadDetails\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\x1a,\n\x16\x45xternalPayloadDetails\x12\x12\n\nsize_bytes\x18\x01 \x01(\x03"\xbe\x01\n\x10SearchAttributes\x12S\n\x0eindexed_fields\x18\x01 \x03(\x0b\x32;.temporal.api.common.v1.SearchAttributes.IndexedFieldsEntry\x1aU\n\x12IndexedFieldsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12.\n\x05value\x18\x02 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload:\x02\x38\x01"\x90\x01\n\x04Memo\x12\x38\n\x06\x66ields\x18\x01 \x03(\x0b\x32(.temporal.api.common.v1.Memo.FieldsEntry\x1aN\n\x0b\x46ieldsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12.\n\x05value\x18\x02 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload:\x02\x38\x01"\x94\x01\n\x06Header\x12:\n\x06\x66ields\x18\x01 \x03(\x0b\x32*.temporal.api.common.v1.Header.FieldsEntry\x1aN\n\x0b\x46ieldsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12.\n\x05value\x18\x02 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload:\x02\x38\x01"8\n\x11WorkflowExecution\x12\x13\n\x0bworkflow_id\x18\x01 \x01(\t\x12\x0e\n\x06run_id\x18\x02 \x01(\t"d\n\tExecution\x12\x32\n\x04type\x18\x01 \x01(\x0e\x32$.temporal.api.enums.v1.ExecutionType\x12\x13\n\x0b\x62usiness_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t"\x1c\n\x0cWorkflowType\x12\x0c\n\x04name\x18\x01 \x01(\t"\x1c\n\x0c\x41\x63tivityType\x12\x0c\n\x04name\x18\x01 \x01(\t"\xd1\x01\n\x0bRetryPolicy\x12\x33\n\x10initial_interval\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x1b\n\x13\x62\x61\x63koff_coefficient\x18\x02 \x01(\x01\x12\x33\n\x10maximum_interval\x18\x03 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x18\n\x10maximum_attempts\x18\x04 \x01(\x05\x12!\n\x19non_retryable_error_types\x18\x05 \x03(\t"F\n\x10MeteringMetadata\x12\x32\n*nonfirst_local_activity_execution_attempts\x18\r \x01(\r">\n\x12WorkerVersionStamp\x12\x10\n\x08\x62uild_id\x18\x01 \x01(\t\x12\x16\n\x0euse_versioning\x18\x03 \x01(\x08"e\n\x19WorkerVersionCapabilities\x12\x10\n\x08\x62uild_id\x18\x01 \x01(\t\x12\x16\n\x0euse_versioning\x18\x02 \x01(\x08\x12\x1e\n\x16\x64\x65ployment_series_name\x18\x04 \x01(\t"\xed\x02\n\x0cResetOptions\x12\x35\n\x13\x66irst_workflow_task\x18\x01 \x01(\x0b\x32\x16.google.protobuf.EmptyH\x00\x12\x34\n\x12last_workflow_task\x18\x02 \x01(\x0b\x32\x16.google.protobuf.EmptyH\x00\x12\x1a\n\x10workflow_task_id\x18\x03 \x01(\x03H\x00\x12\x12\n\x08\x62uild_id\x18\x04 \x01(\tH\x00\x12G\n\x12reset_reapply_type\x18\n \x01(\x0e\x32\'.temporal.api.enums.v1.ResetReapplyTypeB\x02\x18\x01\x12\x18\n\x10\x63urrent_run_only\x18\x0b \x01(\x08\x12S\n\x1breset_reapply_exclude_types\x18\x0c \x03(\x0e\x32..temporal.api.enums.v1.ResetReapplyExcludeTypeB\x08\n\x06target"\xcf\x04\n\x08\x43\x61llback\x12\x37\n\x05nexus\x18\x02 \x01(\x0b\x32&.temporal.api.common.v1.Callback.NexusH\x00\x12=\n\x08internal\x18\x03 \x01(\x0b\x32).temporal.api.common.v1.Callback.InternalH\x00\x12\x46\n\rnexus_handler\x18\x04 \x01(\x0b\x32-.temporal.api.common.v1.Callback.NexusHandlerH\x00\x12+\n\x05links\x18\x64 \x03(\x0b\x32\x1c.temporal.api.common.v1.Link\x1a\x87\x01\n\x05Nexus\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x42\n\x06header\x18\x02 \x03(\x0b\x32\x32.temporal.api.common.v1.Callback.Nexus.HeaderEntry\x1a-\n\x0bHeaderEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x18\n\x08Internal\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\x1a\xa0\x01\n\x0cNexusHandler\x12\x17\n\x0ftask_queue_name\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\x12\x11\n\toperation\x18\x03 \x01(\t\x12\x37\n\x0esource_context\x18\x04 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x1a\n\x12progress_operation\x18\x05 \x01(\tB\t\n\x07variantJ\x04\x08\x01\x10\x02"\xc6\t\n\x04Link\x12\x44\n\x0eworkflow_event\x18\x01 \x01(\x0b\x32*.temporal.api.common.v1.Link.WorkflowEventH\x00\x12:\n\tbatch_job\x18\x02 \x01(\x0b\x32%.temporal.api.common.v1.Link.BatchJobH\x00\x12\x39\n\x08\x61\x63tivity\x18\x03 \x01(\x0b\x32%.temporal.api.common.v1.Link.ActivityH\x00\x12\x46\n\x0fnexus_operation\x18\x04 \x01(\x0b\x32+.temporal.api.common.v1.Link.NexusOperationH\x00\x12\x39\n\x08workflow\x18\x05 \x01(\x0b\x32%.temporal.api.common.v1.Link.WorkflowH\x00\x12\x39\n\x08\x63\x61llback\x18\x06 \x01(\x0b\x32%.temporal.api.common.v1.Link.CallbackH\x00\x1a\xb7\x03\n\rWorkflowEvent\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12N\n\tevent_ref\x18\x64 \x01(\x0b\x32\x39.temporal.api.common.v1.Link.WorkflowEvent.EventReferenceH\x00\x12W\n\x0erequest_id_ref\x18\x65 \x01(\x0b\x32=.temporal.api.common.v1.Link.WorkflowEvent.RequestIdReferenceH\x00\x1aX\n\x0e\x45ventReference\x12\x10\n\x08\x65vent_id\x18\x01 \x01(\x03\x12\x34\n\nevent_type\x18\x02 \x01(\x0e\x32 .temporal.api.enums.v1.EventType\x1a^\n\x12RequestIdReference\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x34\n\nevent_type\x18\x02 \x01(\x0e\x32 .temporal.api.enums.v1.EventTypeB\x0b\n\treference\x1a\x1a\n\x08\x42\x61tchJob\x12\x0e\n\x06job_id\x18\x01 \x01(\t\x1a\x42\n\x08\x41\x63tivity\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0b\x61\x63tivity_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x1aI\n\x0eNexusOperation\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x14\n\x0coperation_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x1aR\n\x08Workflow\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x02 \x01(\t\x12\x0e\n\x06run_id\x18\x03 \x01(\t\x12\x0e\n\x06reason\x18\x04 \x01(\t\x1a\x7f\n\x08\x43\x61llback\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x34\n\texecution\x18\x02 \x01(\x0b\x32!.temporal.api.common.v1.Execution\x12\x16\n\x0e\x63omponent_path\x18\x03 \x03(\t\x12\x12\n\nrequest_id\x18\x04 \x01(\tB\t\n\x07variant"\'\n\tPrincipal\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t"O\n\x08Priority\x12\x14\n\x0cpriority_key\x18\x01 \x01(\x05\x12\x14\n\x0c\x66\x61irness_key\x18\x02 \x01(\t\x12\x17\n\x0f\x66\x61irness_weight\x18\x03 \x01(\x02";\n\x0eWorkerSelector\x12\x1d\n\x13worker_instance_key\x18\x01 \x01(\tH\x00\x42\n\n\x08selector"i\n\x11OnConflictOptions\x12\x19\n\x11\x61ttach_request_id\x18\x01 \x01(\x08\x12#\n\x1b\x61ttach_completion_callbacks\x18\x02 \x01(\x08\x12\x14\n\x0c\x61ttach_links\x18\x03 \x01(\x08"\xaa\x01\n\x12TimeSkippingConfig\x12\x0f\n\x07\x65nabled\x18\x01 \x01(\x08\x12\x46\n\x13\x66\x61st_forward_config\x18\x02 \x01(\x0b\x32).temporal.api.common.v1.FastForwardConfig\x12\x1b\n\x13\x64isable_propagation\x18\x03 \x01(\x08\x12\x1e\n\x16max_session_skip_count\x18\x04 \x01(\x05"L\n\x11\x46\x61stForwardConfig\x12\n\n\x02id\x18\x01 \x01(\t\x12+\n\x08\x64uration\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration"\xb5\x01\n\x1cTimeSkippingStatePropagation\x12;\n\x18initial_skipped_duration\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\x12<\n\x18\x66\x61st_forward_target_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1a\n\x12initial_skip_count\x18\x03 \x01(\x05"\xfe\x01\n\x10TimeSkippingInfo\x12\x30\n\x0c\x63urrent_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x44\n\x10\x65\x66\x66\x65\x63tive_config\x18\x02 \x01(\x0b\x32*.temporal.api.common.v1.TimeSkippingConfig\x12N\n\x11\x66\x61st_forward_info\x18\x04 \x01(\x0b\x32\x33.temporal.api.common.v1.TimeSkippingFastForwardInfo\x12"\n\x1a\x63urrent_session_skip_count\x18\x06 \x01(\x05"\xb8\x01\n\x1bTimeSkippingFastForwardInfo\x12\x38\n\x15\x66\x61st_forward_duration\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x17\n\x0f\x66\x61st_forward_id\x18\x02 \x01(\t\x12/\n\x0btarget_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rhas_completed\x18\x04 \x01(\x08\x42\x89\x01\n\x19io.temporal.api.common.v1B\x0cMessageProtoP\x01Z#go.temporal.io/api/common/v1;common\xaa\x02\x18Temporalio.Api.Common.V1\xea\x02\x1bTemporalio::Api::Common::V1b\x06proto3' ) @@ -602,7 +602,7 @@ _RESETOPTIONS._serialized_start = 1850 _RESETOPTIONS._serialized_end = 2215 _CALLBACK._serialized_start = 2218 - _CALLBACK._serialized_end = 2781 + _CALLBACK._serialized_end = 2809 _CALLBACK_NEXUS._serialized_start = 2468 _CALLBACK_NEXUS._serialized_end = 2603 _CALLBACK_NEXUS_HEADERENTRY._serialized_start = 2558 @@ -610,41 +610,41 @@ _CALLBACK_INTERNAL._serialized_start = 2605 _CALLBACK_INTERNAL._serialized_end = 2629 _CALLBACK_NEXUSHANDLER._serialized_start = 2632 - _CALLBACK_NEXUSHANDLER._serialized_end = 2764 - _LINK._serialized_start = 2784 - _LINK._serialized_end = 4006 - _LINK_WORKFLOWEVENT._serialized_start = 3172 - _LINK_WORKFLOWEVENT._serialized_end = 3611 - _LINK_WORKFLOWEVENT_EVENTREFERENCE._serialized_start = 3414 - _LINK_WORKFLOWEVENT_EVENTREFERENCE._serialized_end = 3502 - _LINK_WORKFLOWEVENT_REQUESTIDREFERENCE._serialized_start = 3504 - _LINK_WORKFLOWEVENT_REQUESTIDREFERENCE._serialized_end = 3598 - _LINK_BATCHJOB._serialized_start = 3613 - _LINK_BATCHJOB._serialized_end = 3639 - _LINK_ACTIVITY._serialized_start = 3641 - _LINK_ACTIVITY._serialized_end = 3707 - _LINK_NEXUSOPERATION._serialized_start = 3709 - _LINK_NEXUSOPERATION._serialized_end = 3782 - _LINK_WORKFLOW._serialized_start = 3784 - _LINK_WORKFLOW._serialized_end = 3866 - _LINK_CALLBACK._serialized_start = 3868 - _LINK_CALLBACK._serialized_end = 3995 - _PRINCIPAL._serialized_start = 4008 - _PRINCIPAL._serialized_end = 4047 - _PRIORITY._serialized_start = 4049 - _PRIORITY._serialized_end = 4128 - _WORKERSELECTOR._serialized_start = 4130 - _WORKERSELECTOR._serialized_end = 4189 - _ONCONFLICTOPTIONS._serialized_start = 4191 - _ONCONFLICTOPTIONS._serialized_end = 4296 - _TIMESKIPPINGCONFIG._serialized_start = 4299 - _TIMESKIPPINGCONFIG._serialized_end = 4469 - _FASTFORWARDCONFIG._serialized_start = 4471 - _FASTFORWARDCONFIG._serialized_end = 4547 - _TIMESKIPPINGSTATEPROPAGATION._serialized_start = 4550 - _TIMESKIPPINGSTATEPROPAGATION._serialized_end = 4731 - _TIMESKIPPINGINFO._serialized_start = 4734 - _TIMESKIPPINGINFO._serialized_end = 4988 - _TIMESKIPPINGFASTFORWARDINFO._serialized_start = 4991 - _TIMESKIPPINGFASTFORWARDINFO._serialized_end = 5175 + _CALLBACK_NEXUSHANDLER._serialized_end = 2792 + _LINK._serialized_start = 2812 + _LINK._serialized_end = 4034 + _LINK_WORKFLOWEVENT._serialized_start = 3200 + _LINK_WORKFLOWEVENT._serialized_end = 3639 + _LINK_WORKFLOWEVENT_EVENTREFERENCE._serialized_start = 3442 + _LINK_WORKFLOWEVENT_EVENTREFERENCE._serialized_end = 3530 + _LINK_WORKFLOWEVENT_REQUESTIDREFERENCE._serialized_start = 3532 + _LINK_WORKFLOWEVENT_REQUESTIDREFERENCE._serialized_end = 3626 + _LINK_BATCHJOB._serialized_start = 3641 + _LINK_BATCHJOB._serialized_end = 3667 + _LINK_ACTIVITY._serialized_start = 3669 + _LINK_ACTIVITY._serialized_end = 3735 + _LINK_NEXUSOPERATION._serialized_start = 3737 + _LINK_NEXUSOPERATION._serialized_end = 3810 + _LINK_WORKFLOW._serialized_start = 3812 + _LINK_WORKFLOW._serialized_end = 3894 + _LINK_CALLBACK._serialized_start = 3896 + _LINK_CALLBACK._serialized_end = 4023 + _PRINCIPAL._serialized_start = 4036 + _PRINCIPAL._serialized_end = 4075 + _PRIORITY._serialized_start = 4077 + _PRIORITY._serialized_end = 4156 + _WORKERSELECTOR._serialized_start = 4158 + _WORKERSELECTOR._serialized_end = 4217 + _ONCONFLICTOPTIONS._serialized_start = 4219 + _ONCONFLICTOPTIONS._serialized_end = 4324 + _TIMESKIPPINGCONFIG._serialized_start = 4327 + _TIMESKIPPINGCONFIG._serialized_end = 4497 + _FASTFORWARDCONFIG._serialized_start = 4499 + _FASTFORWARDCONFIG._serialized_end = 4575 + _TIMESKIPPINGSTATEPROPAGATION._serialized_start = 4578 + _TIMESKIPPINGSTATEPROPAGATION._serialized_end = 4759 + _TIMESKIPPINGINFO._serialized_start = 4762 + _TIMESKIPPINGINFO._serialized_end = 5016 + _TIMESKIPPINGFASTFORWARDINFO._serialized_start = 5019 + _TIMESKIPPINGFASTFORWARDINFO._serialized_end = 5203 # @@protoc_insertion_point(module_scope) diff --git a/temporalio/api/common/v1/message_pb2.pyi b/temporalio/api/common/v1/message_pb2.pyi index 441f1cdf10..1c67d81aea 100644 --- a/temporalio/api/common/v1/message_pb2.pyi +++ b/temporalio/api/common/v1/message_pb2.pyi @@ -745,6 +745,13 @@ class Callback(google.protobuf.message.Message): - Input: temporalio.api.notificationservice.v1.OnCompleteRequest - Output: temporalio.api.notificationservice.v1.OnCompleteResponse + When `progress_operation` is set, the server also invokes that operation of the same service + for the source operation's progress. That operation must have the following: + - Input: temporalio.api.notificationservice.v1.OnProgressRequest + - Output: temporalio.api.notificationservice.v1.OnProgressResponse + It may be invoked any number of times before, during or after the single completion. It + must complete synchronously. + The targeted Nexus service must be registered within the same namespace as the source operation the callback is attached to. (While Nexus allows for cross-namespace operations, NexusHandler callbacks are strictly caller-side.) @@ -760,6 +767,7 @@ class Callback(google.protobuf.message.Message): SERVICE_FIELD_NUMBER: builtins.int OPERATION_FIELD_NUMBER: builtins.int SOURCE_CONTEXT_FIELD_NUMBER: builtins.int + PROGRESS_OPERATION_FIELD_NUMBER: builtins.int task_queue_name: builtins.str """Nexus task queue the Temporal worker is listening on. @@ -778,6 +786,10 @@ class Callback(google.protobuf.message.Message): total sum of all source context payloads attached to an execution. See dynamic configuration: "callback.nexusHandler.sourceContext.maxSize", "callback.nexusHandler.sourceContext.aggregateMaxSize". """ + progress_operation: builtins.str + """Target operation for progress of the source operation, e.g. "OnProgress". Empty means the + callback takes the completion only. + """ def __init__( self, *, @@ -785,6 +797,7 @@ class Callback(google.protobuf.message.Message): service: builtins.str = ..., operation: builtins.str = ..., source_context: global___Payload | None = ..., + progress_operation: builtins.str = ..., ) -> None: ... def HasField( self, @@ -795,6 +808,8 @@ class Callback(google.protobuf.message.Message): field_name: typing_extensions.Literal[ "operation", b"operation", + "progress_operation", + b"progress_operation", "service", b"service", "source_context", diff --git a/temporalio/api/nexus/v1/__init__.py b/temporalio/api/nexus/v1/__init__.py index b85ea67f40..4f7e5f9058 100644 --- a/temporalio/api/nexus/v1/__init__.py +++ b/temporalio/api/nexus/v1/__init__.py @@ -16,6 +16,7 @@ StartOperationResponse, UnsuccessfulOperationError, ) +from .progress_pb2 import NexusOperationProgress __all__ = [ "CancelOperationRequest", @@ -29,6 +30,7 @@ "NexusOperationExecutionCancellationInfo", "NexusOperationExecutionInfo", "NexusOperationExecutionListInfo", + "NexusOperationProgress", "Request", "Response", "StartOperationRequest", diff --git a/temporalio/api/nexus/v1/progress_pb2.py b/temporalio/api/nexus/v1/progress_pb2.py new file mode 100644 index 0000000000..3b247bb72f --- /dev/null +++ b/temporalio/api/nexus/v1/progress_pb2.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: temporal/api/nexus/v1/progress.proto +"""Generated protocol buffer code.""" + +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n$temporal/api/nexus/v1/progress.proto\x12\x15temporal.api.nexus.v1"\x96\x03\n\x16NexusOperationProgress\x12S\n\roperation_key\x18\x05 \x01(\x0b\x32:.temporal.api.nexus.v1.NexusOperationProgress.OperationKeyH\x00\x12\x10\n\x08position\x18\x02 \x01(\t\x12\x0f\n\x07\x63ounter\x18\x03 \x01(\x03\x12M\n\x08metadata\x18\x04 \x03(\x0b\x32;.temporal.api.nexus.v1.NexusOperationProgress.MetadataEntry\x1a]\n\x0cOperationKey\x12\x10\n\x08\x65ndpoint\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\x12\x11\n\toperation\x18\x03 \x01(\t\x12\x17\n\x0foperation_token\x18\x04 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x0b\n\toperationJ\x04\x08\x01\x10\x02R\x12scheduled_event_idB\x85\x01\n\x18io.temporal.api.nexus.v1B\rProgressProtoP\x01Z!go.temporal.io/api/nexus/v1;nexus\xaa\x02\x17Temporalio.Api.Nexus.V1\xea\x02\x1aTemporalio::Api::Nexus::V1b\x06proto3' +) + + +_NEXUSOPERATIONPROGRESS = DESCRIPTOR.message_types_by_name["NexusOperationProgress"] +_NEXUSOPERATIONPROGRESS_OPERATIONKEY = _NEXUSOPERATIONPROGRESS.nested_types_by_name[ + "OperationKey" +] +_NEXUSOPERATIONPROGRESS_METADATAENTRY = _NEXUSOPERATIONPROGRESS.nested_types_by_name[ + "MetadataEntry" +] +NexusOperationProgress = _reflection.GeneratedProtocolMessageType( + "NexusOperationProgress", + (_message.Message,), + { + "OperationKey": _reflection.GeneratedProtocolMessageType( + "OperationKey", + (_message.Message,), + { + "DESCRIPTOR": _NEXUSOPERATIONPROGRESS_OPERATIONKEY, + "__module__": "temporalio.api.nexus.v1.progress_pb2", + # @@protoc_insertion_point(class_scope:temporal.api.nexus.v1.NexusOperationProgress.OperationKey) + }, + ), + "MetadataEntry": _reflection.GeneratedProtocolMessageType( + "MetadataEntry", + (_message.Message,), + { + "DESCRIPTOR": _NEXUSOPERATIONPROGRESS_METADATAENTRY, + "__module__": "temporalio.api.nexus.v1.progress_pb2", + # @@protoc_insertion_point(class_scope:temporal.api.nexus.v1.NexusOperationProgress.MetadataEntry) + }, + ), + "DESCRIPTOR": _NEXUSOPERATIONPROGRESS, + "__module__": "temporalio.api.nexus.v1.progress_pb2", + # @@protoc_insertion_point(class_scope:temporal.api.nexus.v1.NexusOperationProgress) + }, +) +_sym_db.RegisterMessage(NexusOperationProgress) +_sym_db.RegisterMessage(NexusOperationProgress.OperationKey) +_sym_db.RegisterMessage(NexusOperationProgress.MetadataEntry) + +if _descriptor._USE_C_DESCRIPTORS == False: + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b"\n\030io.temporal.api.nexus.v1B\rProgressProtoP\001Z!go.temporal.io/api/nexus/v1;nexus\252\002\027Temporalio.Api.Nexus.V1\352\002\032Temporalio::Api::Nexus::V1" + _NEXUSOPERATIONPROGRESS_METADATAENTRY._options = None + _NEXUSOPERATIONPROGRESS_METADATAENTRY._serialized_options = b"8\001" + _NEXUSOPERATIONPROGRESS._serialized_start = 64 + _NEXUSOPERATIONPROGRESS._serialized_end = 470 + _NEXUSOPERATIONPROGRESS_OPERATIONKEY._serialized_start = 289 + _NEXUSOPERATIONPROGRESS_OPERATIONKEY._serialized_end = 382 + _NEXUSOPERATIONPROGRESS_METADATAENTRY._serialized_start = 384 + _NEXUSOPERATIONPROGRESS_METADATAENTRY._serialized_end = 431 +# @@protoc_insertion_point(module_scope) diff --git a/temporalio/api/nexus/v1/progress_pb2.pyi b/temporalio/api/nexus/v1/progress_pb2.pyi new file mode 100644 index 0000000000..0e1bf4ab78 --- /dev/null +++ b/temporalio/api/nexus/v1/progress_pb2.pyi @@ -0,0 +1,150 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" + +import builtins +import collections.abc +import sys + +import google.protobuf.descriptor +import google.protobuf.internal.containers +import google.protobuf.message + +if sys.version_info >= (3, 8): + import typing as typing_extensions +else: + import typing_extensions + +DESCRIPTOR: google.protobuf.descriptor.FileDescriptor + +class NexusOperationProgress(google.protobuf.message.Message): + """Non-terminal progress of an asynchronous Nexus operation, delivered to the operation's callback + with the `running` state before its completion. + + Progress says that the operation's output moved, not what the output is: the receiver reads the + output itself, starting from `position`. Deliveries may arrive out of order or be dropped, so a + receiver keeps the one with the highest `counter` and drops any lower one. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + class OperationKey(google.protobuf.message.Message): + """Names a caller Workflow's operation by the values the Workflow already knows. The Workflow + gives the endpoint, service and operation in its schedule command, and gets the token when + the operation starts. A token is unique only within one handler, so the token alone is not + a key. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + ENDPOINT_FIELD_NUMBER: builtins.int + SERVICE_FIELD_NUMBER: builtins.int + OPERATION_FIELD_NUMBER: builtins.int + OPERATION_TOKEN_FIELD_NUMBER: builtins.int + endpoint: builtins.str + service: builtins.str + operation: builtins.str + operation_token: builtins.str + """The token from the server's own record of the operation start, not from the handler's + progress request. + """ + def __init__( + self, + *, + endpoint: builtins.str = ..., + service: builtins.str = ..., + operation: builtins.str = ..., + operation_token: builtins.str = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "endpoint", + b"endpoint", + "operation", + b"operation", + "operation_token", + b"operation_token", + "service", + b"service", + ], + ) -> None: ... + + class MetadataEntry(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + KEY_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + key: builtins.str + value: builtins.str + def __init__( + self, + *, + key: builtins.str = ..., + value: builtins.str = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing_extensions.Literal["key", b"key", "value", b"value"], + ) -> None: ... + + OPERATION_KEY_FIELD_NUMBER: builtins.int + POSITION_FIELD_NUMBER: builtins.int + COUNTER_FIELD_NUMBER: builtins.int + METADATA_FIELD_NUMBER: builtins.int + @property + def operation_key(self) -> global___NexusOperationProgress.OperationKey: + """The caller Workflow's operation. Set on the progress that the server attaches to a + Workflow Task. + """ + position: builtins.str + """Where the operation's output stands after the change this progress reports, in the + handler's terms, such as a stream cursor. Opaque to the server, which rejects a position + longer than 1 KiB of UTF-8. + """ + counter: builtins.int + """Orders the progress of one operation. Among progress folded together, the highest counter + is kept. Must be positive. + """ + @property + def metadata( + self, + ) -> google.protobuf.internal.containers.ScalarMap[builtins.str, builtins.str]: + """Small details for the caller. The server rejects progress whose metadata keys and values + exceed 2 KiB in total, counted as UTF-8 bytes. + """ + def __init__( + self, + *, + operation_key: global___NexusOperationProgress.OperationKey | None = ..., + position: builtins.str = ..., + counter: builtins.int = ..., + metadata: collections.abc.Mapping[builtins.str, builtins.str] | None = ..., + ) -> None: ... + def HasField( + self, + field_name: typing_extensions.Literal[ + "operation", b"operation", "operation_key", b"operation_key" + ], + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "counter", + b"counter", + "metadata", + b"metadata", + "operation", + b"operation", + "operation_key", + b"operation_key", + "position", + b"position", + ], + ) -> None: ... + def WhichOneof( + self, oneof_group: typing_extensions.Literal["operation", b"operation"] + ) -> typing_extensions.Literal["operation_key"] | None: ... + +global___NexusOperationProgress = NexusOperationProgress diff --git a/temporalio/api/notificationservice/v1/__init__.py b/temporalio/api/notificationservice/v1/__init__.py index 7b51b0a83a..dcb048ddf5 100644 --- a/temporalio/api/notificationservice/v1/__init__.py +++ b/temporalio/api/notificationservice/v1/__init__.py @@ -1,6 +1,9 @@ +from .progress_pb2 import OnProgressRequest, OnProgressResponse from .request_response_pb2 import OnCompleteRequest, OnCompleteResponse __all__ = [ "OnCompleteRequest", "OnCompleteResponse", + "OnProgressRequest", + "OnProgressResponse", ] diff --git a/temporalio/api/notificationservice/v1/progress_pb2.py b/temporalio/api/notificationservice/v1/progress_pb2.py new file mode 100644 index 0000000000..c022d2421f --- /dev/null +++ b/temporalio/api/notificationservice/v1/progress_pb2.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: temporal/api/notificationservice/v1/progress.proto +"""Generated protocol buffer code.""" + +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from temporalio.api.common.v1 import ( + message_pb2 as temporal_dot_api_dot_common_dot_v1_dot_message__pb2, +) +from temporalio.api.nexus.v1 import ( + progress_pb2 as temporal_dot_api_dot_nexus_dot_v1_dot_progress__pb2, +) + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n2temporal/api/notificationservice/v1/progress.proto\x12#temporal.api.notificationservice.v1\x1a$temporal/api/common/v1/message.proto\x1a$temporal/api/nexus/v1/progress.proto"\xbe\x01\n\x11OnProgressRequest\x12?\n\x08progress\x18\x01 \x01(\x0b\x32-.temporal.api.nexus.v1.NexusOperationProgress\x12\x37\n\x0esource_context\x18\x02 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x17\n\x0foperation_token\x18\x03 \x01(\t\x12\x16\n\x0ereceiver_state\x18\x04 \x01(\x0c"<\n\x12OnProgressResponse\x12\x16\n\x0ereceiver_state\x18\x01 \x01(\x0c\x12\x0e\n\x06\x62\x65hind\x18\x02 \x01(\x08\x42\xcb\x01\n&io.temporal.api.notificationservice.v1B\rProgressProtoP\x01Z=go.temporal.io/api/notificationservice/v1;notificationservice\xaa\x02%Temporalio.Api.NotificationService.V1\xea\x02(Temporalio::Api::NotificationService::V1b\x06proto3' +) + + +_ONPROGRESSREQUEST = DESCRIPTOR.message_types_by_name["OnProgressRequest"] +_ONPROGRESSRESPONSE = DESCRIPTOR.message_types_by_name["OnProgressResponse"] +OnProgressRequest = _reflection.GeneratedProtocolMessageType( + "OnProgressRequest", + (_message.Message,), + { + "DESCRIPTOR": _ONPROGRESSREQUEST, + "__module__": "temporalio.api.notificationservice.v1.progress_pb2", + # @@protoc_insertion_point(class_scope:temporal.api.notificationservice.v1.OnProgressRequest) + }, +) +_sym_db.RegisterMessage(OnProgressRequest) + +OnProgressResponse = _reflection.GeneratedProtocolMessageType( + "OnProgressResponse", + (_message.Message,), + { + "DESCRIPTOR": _ONPROGRESSRESPONSE, + "__module__": "temporalio.api.notificationservice.v1.progress_pb2", + # @@protoc_insertion_point(class_scope:temporal.api.notificationservice.v1.OnProgressResponse) + }, +) +_sym_db.RegisterMessage(OnProgressResponse) + +if _descriptor._USE_C_DESCRIPTORS == False: + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b"\n&io.temporal.api.notificationservice.v1B\rProgressProtoP\001Z=go.temporal.io/api/notificationservice/v1;notificationservice\252\002%Temporalio.Api.NotificationService.V1\352\002(Temporalio::Api::NotificationService::V1" + _ONPROGRESSREQUEST._serialized_start = 168 + _ONPROGRESSREQUEST._serialized_end = 358 + _ONPROGRESSRESPONSE._serialized_start = 360 + _ONPROGRESSRESPONSE._serialized_end = 420 +# @@protoc_insertion_point(module_scope) diff --git a/temporalio/api/notificationservice/v1/progress_pb2.pyi b/temporalio/api/notificationservice/v1/progress_pb2.pyi new file mode 100644 index 0000000000..2580c5fe2d --- /dev/null +++ b/temporalio/api/notificationservice/v1/progress_pb2.pyi @@ -0,0 +1,110 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" + +import builtins +import sys + +import google.protobuf.descriptor +import google.protobuf.message + +import temporalio.api.common.v1.message_pb2 +import temporalio.api.nexus.v1.progress_pb2 + +if sys.version_info >= (3, 8): + import typing as typing_extensions +else: + import typing_extensions + +DESCRIPTOR: google.protobuf.descriptor.FileDescriptor + +class OnProgressRequest(google.protobuf.message.Message): + """OnProgressRequest is the request type to the NotificationService's OnProgress operation, which + receives the non-terminal progress of the source operation. It may be delivered any number of + times before the single OnComplete, in any order, so a handler keeps the highest counter. + + Information about the source operation is available the same way as for OnCompleteRequest. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + PROGRESS_FIELD_NUMBER: builtins.int + SOURCE_CONTEXT_FIELD_NUMBER: builtins.int + OPERATION_TOKEN_FIELD_NUMBER: builtins.int + RECEIVER_STATE_FIELD_NUMBER: builtins.int + @property + def progress(self) -> temporalio.api.nexus.v1.progress_pb2.NexusOperationProgress: + """The progress of the source operation. `progress.operation` is not set.""" + @property + def source_context(self) -> temporalio.api.common.v1.message_pb2.Payload: + """User-supplied data which was added to the source invocation. (As applicable.)""" + operation_token: builtins.str + """The handler token of the source operation, when the source is a standalone Nexus operation + that has started. Empty otherwise. + """ + receiver_state: builtins.bytes + """The `receiver_state` of the last OnProgressResponse that the server accepted for this + callback. Empty when no progress was accepted. + """ + def __init__( + self, + *, + progress: temporalio.api.nexus.v1.progress_pb2.NexusOperationProgress + | None = ..., + source_context: temporalio.api.common.v1.message_pb2.Payload | None = ..., + operation_token: builtins.str = ..., + receiver_state: builtins.bytes = ..., + ) -> None: ... + def HasField( + self, + field_name: typing_extensions.Literal[ + "progress", b"progress", "source_context", b"source_context" + ], + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "operation_token", + b"operation_token", + "progress", + b"progress", + "receiver_state", + b"receiver_state", + "source_context", + b"source_context", + ], + ) -> None: ... + +global___OnProgressRequest = OnProgressRequest + +class OnProgressResponse(google.protobuf.message.Message): + """OnProgressResponse is the return type of the OnProgress operation.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + RECEIVER_STATE_FIELD_NUMBER: builtins.int + BEHIND_FIELD_NUMBER: builtins.int + receiver_state: builtins.bytes + """Opaque state for the handler, such as a read cursor. The server stores it on the callback + and sends it in the next OnProgressRequest and in the OnCompleteRequest. The server refuses + further progress for the callback when the state is larger than its configured limit. + """ + behind: builtins.bool + """Set when the handler did not reach `progress.position`. The server then sends the same + progress again at once, unless newer progress replaced it. + """ + def __init__( + self, + *, + receiver_state: builtins.bytes = ..., + behind: builtins.bool = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "behind", b"behind", "receiver_state", b"receiver_state" + ], + ) -> None: ... + +global___OnProgressResponse = OnProgressResponse diff --git a/temporalio/api/notificationservice/v1/progress_pb2_grpc.py b/temporalio/api/notificationservice/v1/progress_pb2_grpc.py new file mode 100644 index 0000000000..bf947056a2 --- /dev/null +++ b/temporalio/api/notificationservice/v1/progress_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/notificationservice/v1/progress_pb2_grpc.pyi b/temporalio/api/notificationservice/v1/progress_pb2_grpc.pyi new file mode 100644 index 0000000000..f3a5a087ef --- /dev/null +++ b/temporalio/api/notificationservice/v1/progress_pb2_grpc.pyi @@ -0,0 +1,4 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" diff --git a/temporalio/api/notificationservice/v1/request_response_pb2.py b/temporalio/api/notificationservice/v1/request_response_pb2.py index 0f7296d1bb..b973039b43 100644 --- a/temporalio/api/notificationservice/v1/request_response_pb2.py +++ b/temporalio/api/notificationservice/v1/request_response_pb2.py @@ -22,7 +22,7 @@ ) DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n:temporal/api/notificationservice/v1/request_response.proto\x12#temporal.api.notificationservice.v1\x1a$temporal/api/common/v1/message.proto\x1a%temporal/api/failure/v1/message.proto"\xbf\x01\n\x11OnCompleteRequest\x12\x32\n\x07success\x18\x01 \x01(\x0b\x32\x1f.temporal.api.common.v1.PayloadH\x00\x12\x33\n\x07\x66\x61ilure\x18\x02 \x01(\x0b\x32 .temporal.api.failure.v1.FailureH\x00\x12\x37\n\x0esource_context\x18\x03 \x01(\x0b\x32\x1f.temporal.api.common.v1.PayloadB\x08\n\x06result"\x14\n\x12OnCompleteResponseB\xd2\x01\n&io.temporal.api.notificationservice.v1B\x14RequestResponseProtoP\x01Z=go.temporal.io/api/notificationservice/v1;notificationservice\xaa\x02%Temporalio.Api.NotificationService.V1\xea\x02(Temporalio::Api::NotificationService::V1b\x06proto3' + b'\n:temporal/api/notificationservice/v1/request_response.proto\x12#temporal.api.notificationservice.v1\x1a$temporal/api/common/v1/message.proto\x1a%temporal/api/failure/v1/message.proto"\xd7\x01\n\x11OnCompleteRequest\x12\x32\n\x07success\x18\x01 \x01(\x0b\x32\x1f.temporal.api.common.v1.PayloadH\x00\x12\x33\n\x07\x66\x61ilure\x18\x02 \x01(\x0b\x32 .temporal.api.failure.v1.FailureH\x00\x12\x37\n\x0esource_context\x18\x03 \x01(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x16\n\x0ereceiver_state\x18\x04 \x01(\x0c\x42\x08\n\x06result"\x14\n\x12OnCompleteResponseB\xd2\x01\n&io.temporal.api.notificationservice.v1B\x14RequestResponseProtoP\x01Z=go.temporal.io/api/notificationservice/v1;notificationservice\xaa\x02%Temporalio.Api.NotificationService.V1\xea\x02(Temporalio::Api::NotificationService::V1b\x06proto3' ) @@ -54,7 +54,7 @@ DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b"\n&io.temporal.api.notificationservice.v1B\024RequestResponseProtoP\001Z=go.temporal.io/api/notificationservice/v1;notificationservice\252\002%Temporalio.Api.NotificationService.V1\352\002(Temporalio::Api::NotificationService::V1" _ONCOMPLETEREQUEST._serialized_start = 177 - _ONCOMPLETEREQUEST._serialized_end = 368 - _ONCOMPLETERESPONSE._serialized_start = 370 - _ONCOMPLETERESPONSE._serialized_end = 390 + _ONCOMPLETEREQUEST._serialized_end = 392 + _ONCOMPLETERESPONSE._serialized_start = 394 + _ONCOMPLETERESPONSE._serialized_end = 414 # @@protoc_insertion_point(module_scope) diff --git a/temporalio/api/notificationservice/v1/request_response_pb2.pyi b/temporalio/api/notificationservice/v1/request_response_pb2.pyi index f83768a3cb..fa4dba45e1 100644 --- a/temporalio/api/notificationservice/v1/request_response_pb2.pyi +++ b/temporalio/api/notificationservice/v1/request_response_pb2.pyi @@ -34,6 +34,7 @@ class OnCompleteRequest(google.protobuf.message.Message): SUCCESS_FIELD_NUMBER: builtins.int FAILURE_FIELD_NUMBER: builtins.int SOURCE_CONTEXT_FIELD_NUMBER: builtins.int + RECEIVER_STATE_FIELD_NUMBER: builtins.int @property def success(self) -> temporalio.api.common.v1.message_pb2.Payload: """The operation was successful, and resulted in the given payload.""" @@ -43,12 +44,17 @@ class OnCompleteRequest(google.protobuf.message.Message): @property def source_context(self) -> temporalio.api.common.v1.message_pb2.Payload: """User-supplied data which was added to the source invocation. (As applicable.)""" + receiver_state: builtins.bytes + """The `receiver_state` of the last OnProgressResponse that the server accepted for this + callback. Empty when no progress was accepted. + """ def __init__( self, *, success: temporalio.api.common.v1.message_pb2.Payload | None = ..., failure: temporalio.api.failure.v1.message_pb2.Failure | None = ..., source_context: temporalio.api.common.v1.message_pb2.Payload | None = ..., + receiver_state: builtins.bytes = ..., ) -> None: ... def HasField( self, @@ -68,6 +74,8 @@ class OnCompleteRequest(google.protobuf.message.Message): field_name: typing_extensions.Literal[ "failure", b"failure", + "receiver_state", + b"receiver_state", "result", b"result", "source_context", diff --git a/temporalio/api/workflowservice/v1/__init__.py b/temporalio/api/workflowservice/v1/__init__.py index 66fc27a4be..9503bffe8a 100644 --- a/temporalio/api/workflowservice/v1/__init__.py +++ b/temporalio/api/workflowservice/v1/__init__.py @@ -1,3 +1,7 @@ +from .nexus_request_response_pb2 import ( + ExecuteSyncNexusOperationRequest, + ExecuteSyncNexusOperationResponse, +) from .request_response_pb2 import ( CountActivityExecutionsRequest, CountActivityExecutionsResponse, @@ -330,6 +334,8 @@ "DetachStreamCallbackResponse", "ExecuteMultiOperationRequest", "ExecuteMultiOperationResponse", + "ExecuteSyncNexusOperationRequest", + "ExecuteSyncNexusOperationResponse", "FetchWorkerConfigRequest", "FetchWorkerConfigResponse", "GetClusterInfoRequest", diff --git a/temporalio/api/workflowservice/v1/nexus_request_response_pb2.py b/temporalio/api/workflowservice/v1/nexus_request_response_pb2.py new file mode 100644 index 0000000000..996f1e722f --- /dev/null +++ b/temporalio/api/workflowservice/v1/nexus_request_response_pb2.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: temporal/api/workflowservice/v1/nexus_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 message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from temporalio.api.common.v1 import ( + message_pb2 as temporal_dot_api_dot_common_dot_v1_dot_message__pb2, +) +from temporalio.api.failure.v1 import ( + message_pb2 as temporal_dot_api_dot_failure_dot_v1_dot_message__pb2, +) + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n= (3, 8): + import typing as typing_extensions +else: + import typing_extensions + +DESCRIPTOR: google.protobuf.descriptor.FileDescriptor + +class ExecuteSyncNexusOperationRequest(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + class NexusHeaderEntry(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + KEY_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + key: builtins.str + value: builtins.str + def __init__( + self, + *, + key: builtins.str = ..., + value: builtins.str = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing_extensions.Literal["key", b"key", "value", b"value"], + ) -> None: ... + + NAMESPACE_FIELD_NUMBER: builtins.int + IDENTITY_FIELD_NUMBER: builtins.int + REQUEST_ID_FIELD_NUMBER: builtins.int + ENDPOINT_FIELD_NUMBER: builtins.int + SERVICE_FIELD_NUMBER: builtins.int + OPERATION_FIELD_NUMBER: builtins.int + INPUT_FIELD_NUMBER: builtins.int + NEXUS_HEADER_FIELD_NUMBER: builtins.int + namespace: builtins.str + """The caller namespace. Authorization and the endpoint lookup run against it.""" + identity: builtins.str + """The identity of the client who sent this request.""" + request_id: builtins.str + """A unique identifier for this call. Typically UUIDv4. The handler gets it as the Nexus + request ID. + """ + endpoint: builtins.str + """Endpoint name, resolved through the cluster's endpoint registry for the caller namespace.""" + service: builtins.str + """Service name.""" + operation: builtins.str + """Operation name.""" + @property + def input(self) -> temporalio.api.common.v1.message_pb2.Payload: + """Serialized input to the operation. Passed as the request payload.""" + @property + def nexus_header( + self, + ) -> google.protobuf.internal.containers.ScalarMap[builtins.str, builtins.str]: + """Header to attach to the Nexus request. Keys are lowercased. The server owns the + `temporal-caller-namespace` key and refuses a request that sets it. + """ + def __init__( + self, + *, + namespace: builtins.str = ..., + identity: builtins.str = ..., + request_id: builtins.str = ..., + endpoint: builtins.str = ..., + service: builtins.str = ..., + operation: builtins.str = ..., + input: temporalio.api.common.v1.message_pb2.Payload | None = ..., + nexus_header: collections.abc.Mapping[builtins.str, builtins.str] | None = ..., + ) -> None: ... + def HasField( + self, field_name: typing_extensions.Literal["input", b"input"] + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "endpoint", + b"endpoint", + "identity", + b"identity", + "input", + b"input", + "namespace", + b"namespace", + "nexus_header", + b"nexus_header", + "operation", + b"operation", + "request_id", + b"request_id", + "service", + b"service", + ], + ) -> None: ... + +global___ExecuteSyncNexusOperationRequest = ExecuteSyncNexusOperationRequest + +class ExecuteSyncNexusOperationResponse(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + RESULT_FIELD_NUMBER: builtins.int + FAILURE_FIELD_NUMBER: builtins.int + @property + def result(self) -> temporalio.api.common.v1.message_pb2.Payload: + """The result of an operation that completed.""" + @property + def failure(self) -> temporalio.api.failure.v1.message_pb2.Failure: + """The failure of the call. A handler error has `nexus_handler_failure_info`. An operation + that failed or was canceled has `application_failure_info` or `canceled_failure_info`. + """ + def __init__( + self, + *, + result: temporalio.api.common.v1.message_pb2.Payload | None = ..., + failure: temporalio.api.failure.v1.message_pb2.Failure | None = ..., + ) -> None: ... + def HasField( + self, + field_name: typing_extensions.Literal[ + "failure", b"failure", "outcome", b"outcome", "result", b"result" + ], + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "failure", b"failure", "outcome", b"outcome", "result", b"result" + ], + ) -> None: ... + def WhichOneof( + self, oneof_group: typing_extensions.Literal["outcome", b"outcome"] + ) -> typing_extensions.Literal["result", "failure"] | None: ... + +global___ExecuteSyncNexusOperationResponse = ExecuteSyncNexusOperationResponse diff --git a/temporalio/api/workflowservice/v1/nexus_request_response_pb2_grpc.py b/temporalio/api/workflowservice/v1/nexus_request_response_pb2_grpc.py new file mode 100644 index 0000000000..bf947056a2 --- /dev/null +++ b/temporalio/api/workflowservice/v1/nexus_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/workflowservice/v1/nexus_request_response_pb2_grpc.pyi b/temporalio/api/workflowservice/v1/nexus_request_response_pb2_grpc.pyi new file mode 100644 index 0000000000..f3a5a087ef --- /dev/null +++ b/temporalio/api/workflowservice/v1/nexus_request_response_pb2_grpc.pyi @@ -0,0 +1,4 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" diff --git a/temporalio/bridge/Cargo.lock b/temporalio/bridge/Cargo.lock index eee40156db..8a624a0490 100644 --- a/temporalio/bridge/Cargo.lock +++ b/temporalio/bridge/Cargo.lock @@ -2926,10 +2926,11 @@ dependencies = [ [[package]] name = "temporalio-streams" version = "0.1.0" -source = "git+https://github.com/moetemp/sdk-streams?rev=9250d6c7003b6deedf8222972ccbebcfc8efa1c7#9250d6c7003b6deedf8222972ccbebcfc8efa1c7" +source = "git+https://github.com/moetemp/sdk-streams?rev=472e700f2ed59e599d62c6572252978767c6f858#472e700f2ed59e599d62c6572252978767c6f858" dependencies = [ "async-trait", "aws-lc-rs", + "base64 0.23.1", "bytes", "crc16", "gethostname", @@ -2938,6 +2939,8 @@ dependencies = [ "prost", "prost-wkt-types", "redis", + "serde", + "serde_json", "sha2", "temporalio-client", "temporalio-common", diff --git a/temporalio/bridge/Cargo.toml b/temporalio/bridge/Cargo.toml index 9b53c2c814..e8f63b5075 100644 --- a/temporalio/bridge/Cargo.toml +++ b/temporalio/bridge/Cargo.toml @@ -91,7 +91,7 @@ url = "2.2" # makes it use the submodule's crates, so the bridge sees one `Connection` type. [dependencies.temporalio-streams] git = "https://github.com/moetemp/sdk-streams" -rev = "9250d6c7003b6deedf8222972ccbebcfc8efa1c7" +rev = "472e700f2ed59e599d62c6572252978767c6f858" default-features = false features = ["envconfig", "redis"] diff --git a/temporalio/bridge/proto/streams/__init__.py b/temporalio/bridge/proto/streams/__init__.py index 1e7e59ff6b..c3b7b79d2f 100644 --- a/temporalio/bridge/proto/streams/__init__.py +++ b/temporalio/bridge/proto/streams/__init__.py @@ -1,3 +1,16 @@ +from .nexus_pb2 import ( + AttachCallerRequest, + AttachCallerResponse, + CodecResult, + CodecWork, + DetachCallerRequest, + DetachCallerResponse, + NexusCall, + NexusFailure, + NexusLimits, + NexusResumeRequest, + NexusStep, +) from .streams_pb2 import ( ActivityProducer, AppendRecord, @@ -73,13 +86,19 @@ "AppendRecord", "AppendRequest", "AppendResponse", + "AttachCallerRequest", + "AttachCallerResponse", "ChainId", "CloseRequest", "CloseResponse", + "CodecResult", + "CodecWork", "DeleteOwnerRequest", "DeleteOwnerResponse", "DescribeStoreRequest", "DescribeStoreResponse", + "DetachCallerRequest", + "DetachCallerResponse", "FlushNotificationsRequest", "FlushNotificationsResponse", "FollowRangeRequest", @@ -93,6 +112,11 @@ "LinkState", "MemoryStoreConfig", "NamedProducer", + "NexusCall", + "NexusFailure", + "NexusLimits", + "NexusResumeRequest", + "NexusStep", "OutputRecord", "PublishWorkflowTaskRequest", "PublishWorkflowTaskResponse", diff --git a/temporalio/bridge/proto/streams/nexus_pb2.py b/temporalio/bridge/proto/streams/nexus_pb2.py new file mode 100644 index 0000000000..64266709ef --- /dev/null +++ b/temporalio/bridge/proto/streams/nexus_pb2.py @@ -0,0 +1,213 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: temporal/sdk/core/streams/nexus.proto +"""Generated protocol buffer code.""" + +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database + +# @@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.failure.v1 import ( + message_pb2 as temporal_dot_api_dot_failure_dot_v1_dot_message__pb2, +) +from temporalio.bridge.proto.streams import ( + streams_pb2 as temporal_dot_sdk_dot_core_dot_streams_dot_streams__pb2, +) + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n%temporal/sdk/core/streams/nexus.proto\x12\x0f\x63oresdk.streams\x1a\x1fgoogle/protobuf/timestamp.proto\x1a$temporal/api/common/v1/message.proto\x1a%temporal/api/failure/v1/message.proto\x1a\'temporal/sdk/core/streams/streams.proto"\x89\x01\n\tNexusCall\x12\r\n\x05input\x18\x01 \x01(\x0c\x12,\n\x08\x64\x65\x61\x64line\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12,\n\x06limits\x18\x03 \x01(\x0b\x32\x1c.coresdk.streams.NexusLimits\x12\x11\n\tnamespace\x18\x04 \x01(\t"A\n\x0bNexusLimits\x12\x18\n\x10max_answer_bytes\x18\x01 \x01(\x04\x12\x18\n\x10max_record_bytes\x18\x02 \x01(\x04"\x84\x01\n\tNexusStep\x12\x10\n\x06output\x18\x01 \x01(\x0cH\x00\x12+\n\x05\x63odec\x18\x02 \x01(\x0b\x32\x1a.coresdk.streams.CodecWorkH\x00\x12\x30\n\x07\x66\x61ilure\x18\x03 \x01(\x0b\x32\x1d.coresdk.streams.NexusFailureH\x00\x42\x06\n\x04step"\xd7\x01\n\tCodecWork\x12\x0e\n\x06\x65ncode\x18\x01 \x01(\x08\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12\x13\n\x0bworkflow_id\x18\x03 \x01(\t\x12\x31\n\x08payloads\x18\x04 \x03(\x0b\x32\x1f.temporal.api.common.v1.Payload\x12\x14\n\x0c\x63ontinuation\x18\x05 \x01(\x04\x12\x34\n\nowner_kind\x18\x06 \x01(\x0e\x32 .coresdk.streams.StreamOwnerKind\x12\x13\n\x0b\x61\x63tivity_id\x18\x07 \x01(\t"Y\n\x12NexusResumeRequest\x12\x14\n\x0c\x63ontinuation\x18\x01 \x01(\x04\x12-\n\x07results\x18\x02 \x03(\x0b\x32\x1c.coresdk.streams.CodecResult"\\\n\x0b\x43odecResult\x12\x32\n\x07payload\x18\x01 \x01(\x0b\x32\x1f.temporal.api.common.v1.PayloadH\x00\x12\x0f\n\x05\x65rror\x18\x02 \x01(\tH\x00\x42\x08\n\x06result"\x81\x01\n\x0cNexusFailure\x12\x1a\n\x12handler_error_type\x18\x01 \x01(\t\x12\x11\n\tretryable\x18\x02 \x01(\x08\x12\x0f\n\x07message\x18\x03 \x01(\t\x12\x31\n\x07\x66\x61ilure\x18\x04 \x01(\x0b\x32 .temporal.api.failure.v1.Failure"\xb4\x02\n\x13\x41ttachCallerRequest\x12.\n\x06stream\x18\x01 \x01(\x0b\x32\x1e.coresdk.streams.StreamAddress\x12\x12\n\nrequest_id\x18\x02 \x01(\t\x12\x14\n\x0c\x63\x61llback_url\x18\x03 \x01(\t\x12S\n\x10\x63\x61llback_headers\x18\x04 \x03(\x0b\x32\x39.coresdk.streams.AttachCallerRequest.CallbackHeadersEntry\x12\x1f\n\x12wake_after_counter\x18\x05 \x01(\x03H\x00\x88\x01\x01\x1a\x36\n\x14\x43\x61llbackHeadersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x15\n\x13_wake_after_counter"_\n\x14\x41ttachCallerResponse\x12\x17\n\x0foperation_token\x18\x01 \x01(\t\x12.\n\x07\x66\x61ilure\x18\x02 \x01(\x0b\x32\x1d.coresdk.streams.NexusFailure"A\n\x13\x44\x65tachCallerRequest\x12\x11\n\tnamespace\x18\x01 \x01(\t\x12\x17\n\x0foperation_token\x18\x02 \x01(\t"F\n\x14\x44\x65tachCallerResponse\x12.\n\x07\x66\x61ilure\x18\x01 \x01(\x0b\x32\x1d.coresdk.streams.NexusFailure2\x87\x05\n\x12StreamNexusService\x12\x45\n\x0bNexusAppend\x12\x1a.coresdk.streams.NexusCall\x1a\x1a.coresdk.streams.NexusStep\x12\x43\n\tNexusRead\x12\x1a.coresdk.streams.NexusCall\x1a\x1a.coresdk.streams.NexusStep\x12H\n\x0eNexusReadRange\x12\x1a.coresdk.streams.NexusCall\x1a\x1a.coresdk.streams.NexusStep\x12\x45\n\x0bNexusFollow\x12\x1a.coresdk.streams.NexusCall\x1a\x1a.coresdk.streams.NexusStep\x12J\n\x10NexusFollowRange\x12\x1a.coresdk.streams.NexusCall\x1a\x1a.coresdk.streams.NexusStep\x12N\n\x0bNexusResume\x12#.coresdk.streams.NexusResumeRequest\x1a\x1a.coresdk.streams.NexusStep\x12[\n\x0c\x41ttachCaller\x12$.coresdk.streams.AttachCallerRequest\x1a%.coresdk.streams.AttachCallerResponse\x12[\n\x0c\x44\x65tachCaller\x12$.coresdk.streams.DetachCallerRequest\x1a%.coresdk.streams.DetachCallerResponseB-\xea\x02*Temporalio::Internal::Bridge::Api::Streamsb\x06proto3' +) + + +_NEXUSCALL = DESCRIPTOR.message_types_by_name["NexusCall"] +_NEXUSLIMITS = DESCRIPTOR.message_types_by_name["NexusLimits"] +_NEXUSSTEP = DESCRIPTOR.message_types_by_name["NexusStep"] +_CODECWORK = DESCRIPTOR.message_types_by_name["CodecWork"] +_NEXUSRESUMEREQUEST = DESCRIPTOR.message_types_by_name["NexusResumeRequest"] +_CODECRESULT = DESCRIPTOR.message_types_by_name["CodecResult"] +_NEXUSFAILURE = DESCRIPTOR.message_types_by_name["NexusFailure"] +_ATTACHCALLERREQUEST = DESCRIPTOR.message_types_by_name["AttachCallerRequest"] +_ATTACHCALLERREQUEST_CALLBACKHEADERSENTRY = _ATTACHCALLERREQUEST.nested_types_by_name[ + "CallbackHeadersEntry" +] +_ATTACHCALLERRESPONSE = DESCRIPTOR.message_types_by_name["AttachCallerResponse"] +_DETACHCALLERREQUEST = DESCRIPTOR.message_types_by_name["DetachCallerRequest"] +_DETACHCALLERRESPONSE = DESCRIPTOR.message_types_by_name["DetachCallerResponse"] +NexusCall = _reflection.GeneratedProtocolMessageType( + "NexusCall", + (_message.Message,), + { + "DESCRIPTOR": _NEXUSCALL, + "__module__": "temporal.sdk.core.streams.nexus_pb2", + # @@protoc_insertion_point(class_scope:coresdk.streams.NexusCall) + }, +) +_sym_db.RegisterMessage(NexusCall) + +NexusLimits = _reflection.GeneratedProtocolMessageType( + "NexusLimits", + (_message.Message,), + { + "DESCRIPTOR": _NEXUSLIMITS, + "__module__": "temporal.sdk.core.streams.nexus_pb2", + # @@protoc_insertion_point(class_scope:coresdk.streams.NexusLimits) + }, +) +_sym_db.RegisterMessage(NexusLimits) + +NexusStep = _reflection.GeneratedProtocolMessageType( + "NexusStep", + (_message.Message,), + { + "DESCRIPTOR": _NEXUSSTEP, + "__module__": "temporal.sdk.core.streams.nexus_pb2", + # @@protoc_insertion_point(class_scope:coresdk.streams.NexusStep) + }, +) +_sym_db.RegisterMessage(NexusStep) + +CodecWork = _reflection.GeneratedProtocolMessageType( + "CodecWork", + (_message.Message,), + { + "DESCRIPTOR": _CODECWORK, + "__module__": "temporal.sdk.core.streams.nexus_pb2", + # @@protoc_insertion_point(class_scope:coresdk.streams.CodecWork) + }, +) +_sym_db.RegisterMessage(CodecWork) + +NexusResumeRequest = _reflection.GeneratedProtocolMessageType( + "NexusResumeRequest", + (_message.Message,), + { + "DESCRIPTOR": _NEXUSRESUMEREQUEST, + "__module__": "temporal.sdk.core.streams.nexus_pb2", + # @@protoc_insertion_point(class_scope:coresdk.streams.NexusResumeRequest) + }, +) +_sym_db.RegisterMessage(NexusResumeRequest) + +CodecResult = _reflection.GeneratedProtocolMessageType( + "CodecResult", + (_message.Message,), + { + "DESCRIPTOR": _CODECRESULT, + "__module__": "temporal.sdk.core.streams.nexus_pb2", + # @@protoc_insertion_point(class_scope:coresdk.streams.CodecResult) + }, +) +_sym_db.RegisterMessage(CodecResult) + +NexusFailure = _reflection.GeneratedProtocolMessageType( + "NexusFailure", + (_message.Message,), + { + "DESCRIPTOR": _NEXUSFAILURE, + "__module__": "temporal.sdk.core.streams.nexus_pb2", + # @@protoc_insertion_point(class_scope:coresdk.streams.NexusFailure) + }, +) +_sym_db.RegisterMessage(NexusFailure) + +AttachCallerRequest = _reflection.GeneratedProtocolMessageType( + "AttachCallerRequest", + (_message.Message,), + { + "CallbackHeadersEntry": _reflection.GeneratedProtocolMessageType( + "CallbackHeadersEntry", + (_message.Message,), + { + "DESCRIPTOR": _ATTACHCALLERREQUEST_CALLBACKHEADERSENTRY, + "__module__": "temporal.sdk.core.streams.nexus_pb2", + # @@protoc_insertion_point(class_scope:coresdk.streams.AttachCallerRequest.CallbackHeadersEntry) + }, + ), + "DESCRIPTOR": _ATTACHCALLERREQUEST, + "__module__": "temporal.sdk.core.streams.nexus_pb2", + # @@protoc_insertion_point(class_scope:coresdk.streams.AttachCallerRequest) + }, +) +_sym_db.RegisterMessage(AttachCallerRequest) +_sym_db.RegisterMessage(AttachCallerRequest.CallbackHeadersEntry) + +AttachCallerResponse = _reflection.GeneratedProtocolMessageType( + "AttachCallerResponse", + (_message.Message,), + { + "DESCRIPTOR": _ATTACHCALLERRESPONSE, + "__module__": "temporal.sdk.core.streams.nexus_pb2", + # @@protoc_insertion_point(class_scope:coresdk.streams.AttachCallerResponse) + }, +) +_sym_db.RegisterMessage(AttachCallerResponse) + +DetachCallerRequest = _reflection.GeneratedProtocolMessageType( + "DetachCallerRequest", + (_message.Message,), + { + "DESCRIPTOR": _DETACHCALLERREQUEST, + "__module__": "temporal.sdk.core.streams.nexus_pb2", + # @@protoc_insertion_point(class_scope:coresdk.streams.DetachCallerRequest) + }, +) +_sym_db.RegisterMessage(DetachCallerRequest) + +DetachCallerResponse = _reflection.GeneratedProtocolMessageType( + "DetachCallerResponse", + (_message.Message,), + { + "DESCRIPTOR": _DETACHCALLERRESPONSE, + "__module__": "temporal.sdk.core.streams.nexus_pb2", + # @@protoc_insertion_point(class_scope:coresdk.streams.DetachCallerResponse) + }, +) +_sym_db.RegisterMessage(DetachCallerResponse) + +_STREAMNEXUSSERVICE = DESCRIPTOR.services_by_name["StreamNexusService"] +if _descriptor._USE_C_DESCRIPTORS == False: + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = ( + b"\352\002*Temporalio::Internal::Bridge::Api::Streams" + ) + _ATTACHCALLERREQUEST_CALLBACKHEADERSENTRY._options = None + _ATTACHCALLERREQUEST_CALLBACKHEADERSENTRY._serialized_options = b"8\001" + _NEXUSCALL._serialized_start = 210 + _NEXUSCALL._serialized_end = 347 + _NEXUSLIMITS._serialized_start = 349 + _NEXUSLIMITS._serialized_end = 414 + _NEXUSSTEP._serialized_start = 417 + _NEXUSSTEP._serialized_end = 549 + _CODECWORK._serialized_start = 552 + _CODECWORK._serialized_end = 767 + _NEXUSRESUMEREQUEST._serialized_start = 769 + _NEXUSRESUMEREQUEST._serialized_end = 858 + _CODECRESULT._serialized_start = 860 + _CODECRESULT._serialized_end = 952 + _NEXUSFAILURE._serialized_start = 955 + _NEXUSFAILURE._serialized_end = 1084 + _ATTACHCALLERREQUEST._serialized_start = 1087 + _ATTACHCALLERREQUEST._serialized_end = 1395 + _ATTACHCALLERREQUEST_CALLBACKHEADERSENTRY._serialized_start = 1318 + _ATTACHCALLERREQUEST_CALLBACKHEADERSENTRY._serialized_end = 1372 + _ATTACHCALLERRESPONSE._serialized_start = 1397 + _ATTACHCALLERRESPONSE._serialized_end = 1492 + _DETACHCALLERREQUEST._serialized_start = 1494 + _DETACHCALLERREQUEST._serialized_end = 1559 + _DETACHCALLERRESPONSE._serialized_start = 1561 + _DETACHCALLERRESPONSE._serialized_end = 1631 + _STREAMNEXUSSERVICE._serialized_start = 1634 + _STREAMNEXUSSERVICE._serialized_end = 2281 +# @@protoc_insertion_point(module_scope) diff --git a/temporalio/bridge/proto/streams/nexus_pb2.pyi b/temporalio/bridge/proto/streams/nexus_pb2.pyi new file mode 100644 index 0000000000..1426c6b943 --- /dev/null +++ b/temporalio/bridge/proto/streams/nexus_pb2.pyi @@ -0,0 +1,505 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" + +import builtins +import collections.abc +import sys + +import google.protobuf.descriptor +import google.protobuf.internal.containers +import google.protobuf.message +import google.protobuf.timestamp_pb2 + +import temporalio.api.common.v1.message_pb2 +import temporalio.api.failure.v1.message_pb2 +import temporalio.bridge.proto.streams.streams_pb2 + +if sys.version_info >= (3, 8): + import typing as typing_extensions +else: + import typing_extensions + +DESCRIPTOR: google.protobuf.descriptor.FileDescriptor + +class NexusCall(google.protobuf.message.Message): + """One Nexus operation call.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + INPUT_FIELD_NUMBER: builtins.int + DEADLINE_FIELD_NUMBER: builtins.int + LIMITS_FIELD_NUMBER: builtins.int + NAMESPACE_FIELD_NUMBER: builtins.int + input: builtins.bytes + """The operation's input, the contract's model in its JSON transfer form.""" + @property + def deadline(self) -> google.protobuf.timestamp_pb2.Timestamp: + """When the Nexus request times out. The service answers a read in time for its answer to travel + back. + """ + @property + def limits(self) -> global___NexusLimits: ... + namespace: builtins.str + """The namespace of the lang Worker's client, which streams the call names live in.""" + def __init__( + self, + *, + input: builtins.bytes = ..., + deadline: google.protobuf.timestamp_pb2.Timestamp | None = ..., + limits: global___NexusLimits | None = ..., + namespace: builtins.str = ..., + ) -> None: ... + def HasField( + self, + field_name: typing_extensions.Literal[ + "deadline", b"deadline", "limits", b"limits" + ], + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "deadline", + b"deadline", + "input", + b"input", + "limits", + b"limits", + "namespace", + b"namespace", + ], + ) -> None: ... + +global___NexusCall = NexusCall + +class NexusLimits(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + MAX_ANSWER_BYTES_FIELD_NUMBER: builtins.int + MAX_RECORD_BYTES_FIELD_NUMBER: builtins.int + max_answer_bytes: builtins.int + """The most bytes one read answer takes as JSON past its first record, with its tokens and state. + Zero takes the default of 1 MiB, which keeps an answer below the server's blob size limit. + """ + max_record_bytes: builtins.int + """The most bytes one record may take in an answer. A record above it can never be read, so an + append with one is refused. Zero takes the default of 1.5 MiB. + """ + def __init__( + self, + *, + max_answer_bytes: builtins.int = ..., + max_record_bytes: builtins.int = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "max_answer_bytes", + b"max_answer_bytes", + "max_record_bytes", + b"max_record_bytes", + ], + ) -> None: ... + +global___NexusLimits = NexusLimits + +class NexusStep(google.protobuf.message.Message): + """What the service answers a call or a resume with. A codec step comes before the output on a call + whose bodies need lang's payload codec, and lang resumes it with the results. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + OUTPUT_FIELD_NUMBER: builtins.int + CODEC_FIELD_NUMBER: builtins.int + FAILURE_FIELD_NUMBER: builtins.int + output: builtins.bytes + """The operation's output, the contract's model in its JSON transfer form.""" + @property + def codec(self) -> global___CodecWork: ... + @property + def failure(self) -> global___NexusFailure: ... + def __init__( + self, + *, + output: builtins.bytes = ..., + codec: global___CodecWork | None = ..., + failure: global___NexusFailure | None = ..., + ) -> None: ... + def HasField( + self, + field_name: typing_extensions.Literal[ + "codec", + b"codec", + "failure", + b"failure", + "output", + b"output", + "step", + b"step", + ], + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "codec", + b"codec", + "failure", + b"failure", + "output", + b"output", + "step", + b"step", + ], + ) -> None: ... + def WhichOneof( + self, oneof_group: typing_extensions.Literal["step", b"step"] + ) -> typing_extensions.Literal["output", "codec", "failure"] | None: ... + +global___NexusStep = NexusStep + +class CodecWork(google.protobuf.message.Message): + """Bodies for lang's payload codec. Lang runs the namespace's codec over every payload, in order, + and resumes with one result per payload. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + ENCODE_FIELD_NUMBER: builtins.int + NAMESPACE_FIELD_NUMBER: builtins.int + WORKFLOW_ID_FIELD_NUMBER: builtins.int + PAYLOADS_FIELD_NUMBER: builtins.int + CONTINUATION_FIELD_NUMBER: builtins.int + OWNER_KIND_FIELD_NUMBER: builtins.int + ACTIVITY_ID_FIELD_NUMBER: builtins.int + encode: builtins.bool + """True encodes the bodies of an append, and false decodes the bodies of a read.""" + namespace: builtins.str + """The stream's namespace and owner, for the codec's serialization context.""" + workflow_id: builtins.str + @property + def payloads( + self, + ) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[ + temporalio.api.common.v1.message_pb2.Payload + ]: ... + continuation: builtins.int + """Hand back in `NexusResumeRequest`. The service forgets a continuation 60 s after it asked.""" + owner_kind: temporalio.bridge.proto.streams.streams_pb2.StreamOwnerKind.ValueType + """The kind of the stream's owner. For a standalone Activity owner, `workflow_id` is empty and + `activity_id` names the Activity, and the codec context has the namespace and the Activity id + only. For a Workflow Activity owner, both are set, and the codec context has the namespace, + the Activity id and the Workflow id. + """ + activity_id: builtins.str + def __init__( + self, + *, + encode: builtins.bool = ..., + namespace: builtins.str = ..., + workflow_id: builtins.str = ..., + payloads: collections.abc.Iterable[temporalio.api.common.v1.message_pb2.Payload] + | None = ..., + continuation: builtins.int = ..., + owner_kind: temporalio.bridge.proto.streams.streams_pb2.StreamOwnerKind.ValueType = ..., + activity_id: builtins.str = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "activity_id", + b"activity_id", + "continuation", + b"continuation", + "encode", + b"encode", + "namespace", + b"namespace", + "owner_kind", + b"owner_kind", + "payloads", + b"payloads", + "workflow_id", + b"workflow_id", + ], + ) -> None: ... + +global___CodecWork = CodecWork + +class NexusResumeRequest(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + CONTINUATION_FIELD_NUMBER: builtins.int + RESULTS_FIELD_NUMBER: builtins.int + continuation: builtins.int + @property + def results( + self, + ) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[ + global___CodecResult + ]: + """One per payload of the `CodecWork`, in order.""" + def __init__( + self, + *, + continuation: builtins.int = ..., + results: collections.abc.Iterable[global___CodecResult] | None = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "continuation", b"continuation", "results", b"results" + ], + ) -> None: ... + +global___NexusResumeRequest = NexusResumeRequest + +class CodecResult(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + PAYLOAD_FIELD_NUMBER: builtins.int + ERROR_FIELD_NUMBER: builtins.int + @property + def payload(self) -> temporalio.api.common.v1.message_pb2.Payload: ... + error: builtins.str + """Why the codec failed on this payload. A read hands the record over as an error at its + cursor, and an append fails. + """ + def __init__( + self, + *, + payload: temporalio.api.common.v1.message_pb2.Payload | None = ..., + error: builtins.str = ..., + ) -> None: ... + def HasField( + self, + field_name: typing_extensions.Literal[ + "error", b"error", "payload", b"payload", "result", b"result" + ], + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "error", b"error", "payload", b"payload", "result", b"result" + ], + ) -> None: ... + def WhichOneof( + self, oneof_group: typing_extensions.Literal["result", b"result"] + ) -> typing_extensions.Literal["payload", "error"] | None: ... + +global___CodecResult = CodecResult + +class NexusFailure(google.protobuf.message.Message): + """How a Nexus operation failed, ready for lang's Nexus handler error.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + HANDLER_ERROR_TYPE_FIELD_NUMBER: builtins.int + RETRYABLE_FIELD_NUMBER: builtins.int + MESSAGE_FIELD_NUMBER: builtins.int + FAILURE_FIELD_NUMBER: builtins.int + handler_error_type: builtins.str + """The Nexus `HandlerErrorType` name, such as `BAD_REQUEST`.""" + retryable: builtins.bool + message: builtins.str + @property + def failure(self) -> temporalio.api.failure.v1.message_pb2.Failure: + """The failure that crosses to the caller, with the stream condition's class as its cause's + application error type. + """ + def __init__( + self, + *, + handler_error_type: builtins.str = ..., + retryable: builtins.bool = ..., + message: builtins.str = ..., + failure: temporalio.api.failure.v1.message_pb2.Failure | None = ..., + ) -> None: ... + def HasField( + self, field_name: typing_extensions.Literal["failure", b"failure"] + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "failure", + b"failure", + "handler_error_type", + b"handler_error_type", + "message", + b"message", + "retryable", + b"retryable", + ], + ) -> None: ... + +global___NexusFailure = NexusFailure + +class AttachCallerRequest(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + class CallbackHeadersEntry(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + KEY_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + key: builtins.str + value: builtins.str + def __init__( + self, + *, + key: builtins.str = ..., + value: builtins.str = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing_extensions.Literal["key", b"key", "value", b"value"], + ) -> None: ... + + STREAM_FIELD_NUMBER: builtins.int + REQUEST_ID_FIELD_NUMBER: builtins.int + CALLBACK_URL_FIELD_NUMBER: builtins.int + CALLBACK_HEADERS_FIELD_NUMBER: builtins.int + WAKE_AFTER_COUNTER_FIELD_NUMBER: builtins.int + @property + def stream(self) -> temporalio.bridge.proto.streams.streams_pb2.StreamAddress: + """The stream the operation's start names. An empty run id attaches to the chain that holds the + Workflow id now. + """ + request_id: builtins.str + """The start's request id. A retried start attaches once.""" + callback_url: builtins.str + """The start's callback URL and headers. An empty URL is refused, since the caller could never + hear about the stream. + """ + @property + def callback_headers( + self, + ) -> google.protobuf.internal.containers.ScalarMap[builtins.str, builtins.str]: ... + wake_after_counter: builtins.int + """The callback is idle, so it hears no progress until a wait. Set, this asks for the first + wait right after the attach: one wake once the stream holds a record above this counter, at + once when it already does. A follower that reads only when woken sets it, zero for the + stream's start. A reader that reads first leaves it unset and waits through its reads' + `wake_token`. + """ + def __init__( + self, + *, + stream: temporalio.bridge.proto.streams.streams_pb2.StreamAddress | None = ..., + request_id: builtins.str = ..., + callback_url: builtins.str = ..., + callback_headers: collections.abc.Mapping[builtins.str, builtins.str] + | None = ..., + wake_after_counter: builtins.int | None = ..., + ) -> None: ... + def HasField( + self, + field_name: typing_extensions.Literal[ + "_wake_after_counter", + b"_wake_after_counter", + "stream", + b"stream", + "wake_after_counter", + b"wake_after_counter", + ], + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "_wake_after_counter", + b"_wake_after_counter", + "callback_headers", + b"callback_headers", + "callback_url", + b"callback_url", + "request_id", + b"request_id", + "stream", + b"stream", + "wake_after_counter", + b"wake_after_counter", + ], + ) -> None: ... + def WhichOneof( + self, + oneof_group: typing_extensions.Literal[ + "_wake_after_counter", b"_wake_after_counter" + ], + ) -> typing_extensions.Literal["wake_after_counter"] | None: ... + +global___AttachCallerRequest = AttachCallerRequest + +class AttachCallerResponse(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + OPERATION_TOKEN_FIELD_NUMBER: builtins.int + FAILURE_FIELD_NUMBER: builtins.int + operation_token: builtins.str + """The token the start answers with. It names the stream, the attach and the chain.""" + @property + def failure(self) -> global___NexusFailure: + """Set when the attach failed, as the Nexus calls answer a failure, so lang hands it to its + Nexus handler as it is. The token is then empty. + """ + def __init__( + self, + *, + operation_token: builtins.str = ..., + failure: global___NexusFailure | None = ..., + ) -> None: ... + def HasField( + self, field_name: typing_extensions.Literal["failure", b"failure"] + ) -> builtins.bool: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "failure", b"failure", "operation_token", b"operation_token" + ], + ) -> None: ... + +global___AttachCallerResponse = AttachCallerResponse + +class DetachCallerRequest(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + NAMESPACE_FIELD_NUMBER: builtins.int + OPERATION_TOKEN_FIELD_NUMBER: builtins.int + namespace: builtins.str + operation_token: builtins.str + """The token the start answered with.""" + def __init__( + self, + *, + namespace: builtins.str = ..., + operation_token: builtins.str = ..., + ) -> None: ... + def ClearField( + self, + field_name: typing_extensions.Literal[ + "namespace", b"namespace", "operation_token", b"operation_token" + ], + ) -> None: ... + +global___DetachCallerRequest = DetachCallerRequest + +class DetachCallerResponse(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + FAILURE_FIELD_NUMBER: builtins.int + @property + def failure(self) -> global___NexusFailure: + """Set when the detach failed, as the Nexus calls answer a failure.""" + def __init__( + self, + *, + failure: global___NexusFailure | None = ..., + ) -> None: ... + def HasField( + self, field_name: typing_extensions.Literal["failure", b"failure"] + ) -> builtins.bool: ... + def ClearField( + self, field_name: typing_extensions.Literal["failure", b"failure"] + ) -> None: ... + +global___DetachCallerResponse = DetachCallerResponse diff --git a/temporalio/bridge/proto/streams/streams_pb2.pyi b/temporalio/bridge/proto/streams/streams_pb2.pyi index c9243519ec..e9d910e193 100644 --- a/temporalio/bridge/proto/streams/streams_pb2.pyi +++ b/temporalio/bridge/proto/streams/streams_pb2.pyi @@ -95,8 +95,8 @@ class _StreamFailureKindEnumTypeWrapper( """This release or this store does not offer the capability.""" STREAM_FAILURE_KIND_INVALID_ARGUMENT: _StreamFailureKind.ValueType # 12 """The call's input breaks the contract, such as a missing member or a value out of range, so - the same call fails the same way. A lang built before this kind maps it, as any kind it - doesn't know, to a storage failure. + the same call fails the same way. The Nexus calls answer it, as BAD_REQUEST. A lang built + before this kind maps it, as any kind it doesn't know, to a storage failure. """ STREAM_FAILURE_KIND_REPLAY_MISMATCH: _StreamFailureKind.ValueType # 13 """A replay read found other records than the read it repeats: other content, another count @@ -139,8 +139,8 @@ STREAM_FAILURE_KIND_UNSUPPORTED: StreamFailureKind.ValueType # 11 """This release or this store does not offer the capability.""" STREAM_FAILURE_KIND_INVALID_ARGUMENT: StreamFailureKind.ValueType # 12 """The call's input breaks the contract, such as a missing member or a value out of range, so -the same call fails the same way. A lang built before this kind maps it, as any kind it -doesn't know, to a storage failure. +the same call fails the same way. The Nexus calls answer it, as BAD_REQUEST. A lang built +before this kind maps it, as any kind it doesn't know, to a storage failure. """ STREAM_FAILURE_KIND_REPLAY_MISMATCH: StreamFailureKind.ValueType # 13 """A replay read found other records than the read it repeats: other content, another count diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 869a190ad8..84dc65fdc1 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 869a190ad857754d9f338736007be4932c84e052 +Subproject commit 84dc65fdc137ac3b78db1389bb1bad9ea82b2715 diff --git a/temporalio/bridge/src/lib.rs b/temporalio/bridge/src/lib.rs index 2202a75e5f..6869a0ddb4 100644 --- a/temporalio/bridge/src/lib.rs +++ b/temporalio/bridge/src/lib.rs @@ -66,6 +66,7 @@ fn temporal_sdk_bridge(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { )?; m.add_class::()?; m.add_function(wrap_pyfunction!(streams::connect_stream_store, m)?)?; + m.add_function(wrap_pyfunction!(streams::execute_sync_nexus_operation, m)?)?; m.add_class::()?; m.add_class::()?; diff --git a/temporalio/bridge/src/streams.rs b/temporalio/bridge/src/streams.rs index 53869b09bb..2d4cf0f3ec 100644 --- a/temporalio/bridge/src/streams.rs +++ b/temporalio/bridge/src/streams.rs @@ -4,9 +4,15 @@ use std::sync::Arc; use prost::Message; use pyo3::{create_exception, exceptions::PyException, exceptions::PyValueError, prelude::*}; -use temporalio_streams::{connect_stream_service, proto, StreamService}; +use temporalio_streams::{ + connect_stream_service, execute_sync_nexus_operation as execute_sync_nexus, proto, + protos::temporal::api::workflowservice::v1::ExecuteSyncNexusOperationRequest, StreamService, +}; -use crate::{client::ClientRef, runtime}; +use crate::{ + client::{rpc_resp, ClientRef}, + runtime, +}; // Carries a serialized `coresdk.streams.StreamFailure`, so Python maps every failure the same way // whatever store raised it. @@ -45,6 +51,27 @@ pub fn connect_stream_store<'p>( }) } +/// Runs one synchronous Nexus operation through `client`'s server with a serialized +/// `ExecuteSyncNexusOperationRequest`, answering with the serialized response. Core's client has no +/// stub for the RPC, so the stream runtime calls it by its method path. +#[pyfunction] +pub fn execute_sync_nexus_operation<'p>( + py: Python<'p>, + client: &ClientRef, + request: Vec, +) -> PyResult> { + let request = ExecuteSyncNexusOperationRequest::decode(&*request) + .map_err(|err| PyValueError::new_err(format!("Invalid proto: {err}")))?; + let connection = client.connection.clone(); + client.runtime.future_into_py(py, async move { + rpc_resp( + execute_sync_nexus(&connection, request) + .await + .map(tonic::Response::new), + ) + }) +} + #[pymethods] impl StreamStoreRef { /// Makes one stream service call with a serialized request, answering with the serialized diff --git a/temporalio/bridge/streams.py b/temporalio/bridge/streams.py index 51b21fbe5e..448b7893bd 100644 --- a/temporalio/bridge/streams.py +++ b/temporalio/bridge/streams.py @@ -11,6 +11,7 @@ import google.protobuf.message +import temporalio.api.workflowservice.v1 import temporalio.bridge.client import temporalio.bridge.proto.streams import temporalio.bridge.temporal_sdk_bridge @@ -85,6 +86,26 @@ async def call( return resp +async def execute_sync_nexus_operation( + client: temporalio.bridge.client.Client, + req: temporalio.api.workflowservice.v1.ExecuteSyncNexusOperationRequest, +) -> temporalio.api.workflowservice.v1.ExecuteSyncNexusOperationResponse: + """Run one synchronous Nexus operation through ``client``'s server. + + Core's client has no stub for the RPC, so the stream runtime calls it by + its method path. + + Raises: + temporalio.bridge.client.RPCError: The call failed. + """ + data = await temporalio.bridge.temporal_sdk_bridge.execute_sync_nexus_operation( + client._ref, req.SerializeToString() + ) + resp = temporalio.api.workflowservice.v1.ExecuteSyncNexusOperationResponse() + resp.ParseFromString(data) + return resp + + def _call_failure( error: temporalio.bridge.temporal_sdk_bridge.StreamFailureError, ) -> StreamCallFailure: diff --git a/temporalio/bridge/streams_generated.py b/temporalio/bridge/streams_generated.py index 2a20ca6f2a..995980e7f7 100644 --- a/temporalio/bridge/streams_generated.py +++ b/temporalio/bridge/streams_generated.py @@ -1,5 +1,5 @@ # Generated file. DO NOT EDIT -"""Generated calls for the stream runtime's in-process StreamService.""" +"""Generated calls for the stream runtime's in-process stream services.""" from __future__ import annotations @@ -182,3 +182,75 @@ async def wait_for_notification( req, temporalio.bridge.proto.streams.WaitForNotificationResponse, ) + + +class StreamNexusService: + """Calls for the StreamNexusService, which the stream runtime serves in process.""" + + def __init__(self, store: StreamStore) -> None: + """Initialize service with the provided store.""" + self._store = store + + async def attach_caller( + self, req: temporalio.bridge.proto.streams.AttachCallerRequest + ) -> temporalio.bridge.proto.streams.AttachCallerResponse: + """Invokes the StreamNexusService.attach_caller call.""" + return await self._store.call( + "AttachCaller", req, temporalio.bridge.proto.streams.AttachCallerResponse + ) + + async def detach_caller( + self, req: temporalio.bridge.proto.streams.DetachCallerRequest + ) -> temporalio.bridge.proto.streams.DetachCallerResponse: + """Invokes the StreamNexusService.detach_caller call.""" + return await self._store.call( + "DetachCaller", req, temporalio.bridge.proto.streams.DetachCallerResponse + ) + + async def nexus_append( + self, req: temporalio.bridge.proto.streams.NexusCall + ) -> temporalio.bridge.proto.streams.NexusStep: + """Invokes the StreamNexusService.nexus_append call.""" + return await self._store.call( + "NexusAppend", req, temporalio.bridge.proto.streams.NexusStep + ) + + async def nexus_follow( + self, req: temporalio.bridge.proto.streams.NexusCall + ) -> temporalio.bridge.proto.streams.NexusStep: + """Invokes the StreamNexusService.nexus_follow call.""" + return await self._store.call( + "NexusFollow", req, temporalio.bridge.proto.streams.NexusStep + ) + + async def nexus_follow_range( + self, req: temporalio.bridge.proto.streams.NexusCall + ) -> temporalio.bridge.proto.streams.NexusStep: + """Invokes the StreamNexusService.nexus_follow_range call.""" + return await self._store.call( + "NexusFollowRange", req, temporalio.bridge.proto.streams.NexusStep + ) + + async def nexus_read( + self, req: temporalio.bridge.proto.streams.NexusCall + ) -> temporalio.bridge.proto.streams.NexusStep: + """Invokes the StreamNexusService.nexus_read call.""" + return await self._store.call( + "NexusRead", req, temporalio.bridge.proto.streams.NexusStep + ) + + async def nexus_read_range( + self, req: temporalio.bridge.proto.streams.NexusCall + ) -> temporalio.bridge.proto.streams.NexusStep: + """Invokes the StreamNexusService.nexus_read_range call.""" + return await self._store.call( + "NexusReadRange", req, temporalio.bridge.proto.streams.NexusStep + ) + + async def nexus_resume( + self, req: temporalio.bridge.proto.streams.NexusResumeRequest + ) -> temporalio.bridge.proto.streams.NexusStep: + """Invokes the StreamNexusService.nexus_resume call.""" + return await self._store.call( + "NexusResume", req, temporalio.bridge.proto.streams.NexusStep + ) diff --git a/temporalio/contrib/streams/_plugin.py b/temporalio/contrib/streams/_plugin.py index c0c580ec36..36c43d0f66 100644 --- a/temporalio/contrib/streams/_plugin.py +++ b/temporalio/contrib/streams/_plugin.py @@ -34,7 +34,7 @@ StreamStoreConfig, ) from temporalio.bridge.streams import StreamCallFailure, StreamStore -from temporalio.bridge.streams_generated import StreamService +from temporalio.bridge.streams_generated import StreamNexusService, StreamService from temporalio.contrib.streams._capabilities import StoreCapabilities, describe from temporalio.contrib.streams._errors import error_from_failure from temporalio.converter import DataConverter @@ -389,6 +389,16 @@ async def _service_for(self, client: Client) -> StreamService: assert self._service is not None return self._service + async def _nexus_service_for(self, client: Client) -> StreamNexusService: + """The stream service's Nexus calls, on the store of ``client``. + + Raises: + ValueError: Another client connection already connected the store. + """ + await self._connected(client.service_client) + assert self._store is not None + return StreamNexusService(self._store) + async def _connected(self, service_client: ServiceClient) -> None: if self._service_client is service_client: return diff --git a/temporalio/contrib/streams/nexus/__init__.py b/temporalio/contrib/streams/nexus/__init__.py new file mode 100644 index 0000000000..f3e64d4e46 --- /dev/null +++ b/temporalio/contrib/streams/nexus/__init__.py @@ -0,0 +1,55 @@ +"""The stream service: streams over a Nexus endpoint. + +.. warning:: + This package is experimental and may change in future versions. + +A process that cannot reach a stream's store, or should not, appends to and +reads from it through a Nexus endpoint. The endpoint runs the +``temporal.sdk.streams.v1.TemporalStreams`` service over the store the +endpoint's Worker carries, so callers never learn which store that is. + +The stream runtime serves the service, so it is defined once, in the +``crates/streams/nexus/temporal_streams.nexusrpc.yaml`` of the sdk-streams +repository. Everything in :mod:`temporalio.contrib.streams.nexus._generated` +is generated from the copy in the temporalio-streams crate that this SDK pins, +by ``scripts/gen_nexus_streams_api.py``: + +* :class:`TemporalStreams`, the service definition a handler implements and a + Workflow's Nexus client calls. +* The models its operations take and return. :class:`StreamRef` is + :class:`temporalio.contrib.streams.StreamRef` itself, so a reference a + Workflow hands out is the value the service takes. +* :class:`TemporalStreamsHttpClient`, a caller for a process outside any + Worker, posting to the endpoint over the Nexus HTTP ingress. It depends on + nothing beyond the standard library. +* ``TemporalStreamsForwarder``, which hands each operation's input to the + stream runtime as the contract's JSON transfer form. +""" + +from temporalio.contrib.streams import StreamRef +from temporalio.contrib.streams.nexus._generated import ( + AppendInput, + AppendOutput, + ReadInput, + ReadOutput, + RecordWire, + StreamCursor, + TemporalStreams, +) +from temporalio.contrib.streams.nexus._generated.client import ( + HTTPStatusError, + TemporalStreamsHttpClient, +) + +__all__ = [ + "AppendInput", + "AppendOutput", + "HTTPStatusError", + "ReadInput", + "ReadOutput", + "RecordWire", + "StreamCursor", + "StreamRef", + "TemporalStreams", + "TemporalStreamsHttpClient", +] diff --git a/temporalio/contrib/streams/nexus/_generated/__init__.py b/temporalio/contrib/streams/nexus/_generated/__init__.py new file mode 100644 index 0000000000..70e891759a --- /dev/null +++ b/temporalio/contrib/streams/nexus/_generated/__init__.py @@ -0,0 +1,61 @@ +# Generated by nexgen v0.2.8. DO NOT EDIT! + +from __future__ import annotations + +from ._definitions import ( + StreamCursor, + Violation, +) +from .models import ( + AppendInput, + AppendOutput, + FollowedWire, + FollowedWireLinkState, + FollowedWireRecord, + FollowedWireRecordSuperseded, + FollowInput, + FollowOutput, + FollowOutputReplay, + FollowRangeInput, + FollowReplay, + FollowSourceWire, + LinkStateWire, + ReadInput, + ReadOutput, + ReadOutputReplay, + ReadRangeInput, + RecordWire, + RecordWireSuperseded, + ReplayRange, + StreamOperationToken, + Supersession, +) +from .services import TemporalStreams + +__all__ = [ + "StreamCursor", + "Violation", + "AppendInput", + "AppendOutput", + "FollowInput", + "FollowOutput", + "FollowOutputReplay", + "FollowRangeInput", + "FollowReplay", + "FollowSourceWire", + "FollowedWire", + "FollowedWireLinkState", + "FollowedWireRecord", + "FollowedWireRecordSuperseded", + "LinkStateWire", + "ReadInput", + "ReadOutput", + "ReadOutputReplay", + "ReadRangeInput", + "RecordWire", + "RecordWireSuperseded", + "ReplayRange", + "StreamOperationToken", + "Supersession", + "TemporalStreams", +] diff --git a/temporalio/contrib/streams/nexus/_generated/_definitions.py b/temporalio/contrib/streams/nexus/_generated/_definitions.py new file mode 100644 index 0000000000..9477a0aac7 --- /dev/null +++ b/temporalio/contrib/streams/nexus/_generated/_definitions.py @@ -0,0 +1,581 @@ +# Generated by nexgen v0.2.8. DO NOT EDIT! + +from __future__ import annotations + +import base64 +import collections.abc +import dataclasses +import datetime +import json +import re +import typing + +import temporalio.converter +import temporalio.exceptions + +__all__ = [ + "Violation", + "_binary64", + "_check_contains", + "_check_date_time", + "_check_duration", + "_check_time", + "_check_unique_items", + "_collect", + "_json_values_equal", + "_member_path", + "_format_base64", + "_format_base64url", + "_format_date", + "_format_date_time", + "_format_duration", + "_format_time", + "_parse_base64", + "_parse_base64url", + "_parse_date", + "_parse_date_time", + "_parse_duration", + "_parse_spec_integer", + "_parse_time", + "_quote", + "_transfer_type_convertible", + "StreamCursor", +] + + +@dataclasses.dataclass(frozen=True, slots=True) +class Violation: + """A single constraint failure, located by JSON path.""" + + path: str + reason: str + + +def _quote(value: object) -> str: + """Renders a value in the JSON form every target quotes offending values in.""" + + try: + return json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError): + return repr(value) + + +def _member_path(key: str) -> str: + """Returns one escaped member segment in the public violation-path grammar.""" + + if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key): + return key + return '["' + key.replace("\\", "\\\\").replace('"', '\\"') + '"]' + + +def _collect( + violations: list[Violation], + path: str, + error: temporalio.exceptions.ApplicationError, +) -> None: + """Re-paths a nested model's violations under ``path`` and appends them.""" + + if error.type != "PayloadValidationError" or not error.details: + raise error + # Generated failures retain the original list as their first detail. The + # cast is type-checker-only and performs no serialization. + nested_violations = typing.cast(list[Violation], error.details[0]) + for inner in nested_violations: + # A nested violation about the value *itself* carries no path of its own + # (a union branch's own constraint, an element-level check), so the + # prefix is the whole path -- never a dangling separator (P11). + nested = ( + path + if not inner.path + else f"{path}{inner.path}" + if inner.path.startswith("[") + else f"{path}.{inner.path}" + ) + violations.append(Violation(path=nested, reason=inner.reason)) + + +_ModelT = typing.TypeVar("_ModelT") + + +def _transfer_type_convertible( + converter: type[temporalio.converter.TransferTypeConverter[typing.Any, typing.Any]], +) -> collections.abc.Callable[[type[_ModelT]], type[_ModelT]]: + """Registers a transfer type converter on a model class. + + Wraps ``temporalio.converter.transfer_type_convertible`` to erase the + converter's value-type parameter. Binding it directly on the decorated class + is circular for a static type checker -- the class's type depends on the + decorator, whose value type depends on the class -- which degrades the model + to ``Unknown``. Erasing it here keeps the decorator idiomatic at each model and + resolves the cycle. + """ + + return temporalio.converter.transfer_type_convertible(converter) + + +_INTEGER_CAP = (1 << 53) - 1 + + +def _parse_spec_integer( + value: object, path: str, violations: list[Violation] +) -> int | None: + """Parses a JSON number as a spec integer (``1.0`` accepted, ``1.5`` rejected).""" + + # `bool` is a subclass of `int`, so it must be excluded before the int check. + if isinstance(value, bool) or not isinstance(value, (int, float)): + violations.append(Violation(path=path, reason="expected integer")) + return None + if isinstance(value, float): + if not value.is_integer(): + violations.append(Violation(path=path, reason="expected integer")) + return None + out = int(value) + else: + out = value + if abs(out) > _INTEGER_CAP: + violations.append(Violation(path=path, reason="expected integer")) + return None + return out + + +def _binary64(value: float) -> float: + """Narrows a ``number`` value to the binary64 domain shared by every target.""" + + try: + return float(value) + except OverflowError: + # Past the binary64 range there is nothing to narrow to. The caller's + # finiteness check has already recorded that violation and will raise, so + # hand the value back rather than throwing out of turn and losing the + # other violations collected alongside it. + return value + + +def _json_values_equal(left: typing.Any, right: typing.Any) -> bool: + """Compares JSON values without Python's bool-as-int equality leak.""" + + left_is_number = isinstance(left, (int, float)) and not isinstance(left, bool) + right_is_number = isinstance(right, (int, float)) and not isinstance(right, bool) + if left_is_number or right_is_number: + return left_is_number and right_is_number and left == right + if type(left) is not type(right): + return False + if isinstance(left, list): + left_list = typing.cast("list[typing.Any]", left) + right_list = typing.cast("list[typing.Any]", right) + return len(left_list) == len(right_list) and all( + _json_values_equal(a, b) for a, b in zip(left_list, right_list) + ) + if isinstance(left, dict): + left_dict = typing.cast("dict[typing.Any, typing.Any]", left) + right_dict = typing.cast("dict[typing.Any, typing.Any]", right) + return left_dict.keys() == right_dict.keys() and all( + _json_values_equal(left_dict[key], right_dict[key]) for key in left_dict + ) + return left == right + + +def _check_unique_items( + value: list[typing.Any], path: str, violations: list[Violation] +) -> None: + """Asserts an array's elements are pairwise distinct.""" + + seen: list[typing.Any] = [] + for index, element in enumerate(value): + for earlier, previous in enumerate(seen): + if _json_values_equal(previous, element): + violations.append( + Violation( + path=path, + reason=( + f"duplicate items: element at index {index} " + f"equals index {earlier}" + ), + ) + ) + break + seen.append(element) + + +def _check_contains( + value: list[typing.Any], + matches: typing.Callable[[typing.Any], bool], + min_contains: int, + max_contains: int | None, + bounded_min: bool, + path: str, + violations: list[Violation], +) -> None: + """Asserts how many of an array's elements match the ``contains`` schema.""" + + match_count = sum(1 for element in value if matches(element)) + if match_count < min_contains: + if bounded_min: + violations.append( + Violation( + path=path, + reason=( + f"too few matching items: at least {min_contains}, " + f"got {match_count}" + ), + ) + ) + else: + violations.append( + Violation(path=path, reason="no element matches the required schema") + ) + if max_contains is not None and match_count > max_contains: + violations.append( + Violation( + path=path, + reason=( + f"too many matching items: at most {max_contains}, " + f"got {match_count}" + ), + ) + ) + + +_TEMPORAL_DATE_TIME_RE = re.compile( + r"^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])[Tt]([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\.[0-9]+)?([Zz]|[+-]((0[0-9]|1[0-7]):[0-5][0-9]|18:00))\Z" +) +_TEMPORAL_DATE_RE = re.compile(r"^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])\Z") +_TEMPORAL_TIME_RE = re.compile( + r"^([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\.[0-9]+)?([Zz]|[+-]((0[0-9]|1[0-7]):[0-5][0-9]|18:00))?\Z" +) +_TEMPORAL_DURATION_RE = re.compile( + r"^PT(?:[0-9]+H(?:[0-9]+M(?:[0-9]+S)?)?|[0-9]+M(?:[0-9]+S)?|[0-9]+S)\Z" +) +_TEMPORAL_MAX_DURATION_SECONDS = ((1 << 63) - 1) // 1_000_000_000 +# A duration component with more digits than the cap itself is over the cap +# whatever those digits are, which is how the magnitude is bounded before `int()` +# sees it: CPython refuses to convert a string of more than 4300 digits. +_TEMPORAL_MAX_DURATION_DIGITS = len(str(_TEMPORAL_MAX_DURATION_SECONDS)) +# `datetime` resolves to microseconds, and `fromisoformat` before Python 3.11 +# parses only the fraction widths `isoformat` writes. +_TEMPORAL_FRACTION_DIGITS = 6 + + +def _days_in_month(year: int, month: int) -> int: + if month in (1, 3, 5, 7, 8, 10, 12): + return 31 + if month in (4, 6, 9, 11): + return 30 + if month == 2: + return 29 if (year % 4 == 0 and year % 100 != 0) or year % 400 == 0 else 28 + return 0 + + +def _valid_temporal_calendar(value: str) -> bool: + if len(value) < 10: + return False + try: + year, month, day = int(value[0:4]), int(value[5:7]), int(value[8:10]) + except ValueError: + return False + # `datetime.MINYEAR` is 1, which is also the shared cross-language floor. + # Year 0000 is rejected rather than shifted into range, and + # `_temporal_reason` says so. + if year < datetime.MINYEAR: + return False + maximum = _days_in_month(year, month) + return maximum > 0 and 1 <= day <= maximum + + +def _temporal_reason(name: str, value: str) -> str: + """The reason a rejected temporal string is reported under. + + Year 0000 earns its own clause so the caller sees the shared calendar floor + rather than only a generic malformed-timestamp reason. + """ + + if value[0:4] == "0000": + return ( + f"must be a valid {name}, got {_quote(value)}: year 0000 is not" + f" representable (datetime.MINYEAR is {datetime.MINYEAR})" + ) + return f"must be a valid {name}, got {_quote(value)}" + + +def _temporal_isoformat(value: str) -> str: + """Rewrites a wire temporal into the spelling ``fromisoformat`` accepts. + + ``Z`` becomes ``+00:00``, and the fractional second is padded or truncated to + exactly ``_TEMPORAL_FRACTION_DIGITS``: before Python 3.11 ``fromisoformat`` + parses only what ``isoformat`` writes, so an RFC 3339 ``.1`` or ``.1234567`` -- + which every other target accepts -- would otherwise raise. Digits past the + sixth are dropped, the loss at ``datetime``'s own resolution that P1 allows; + the canonical output re-trims the padding, so ``.1`` still writes as ``.1``. + """ + + normalized = value.upper() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + dot = normalized.find(".") + if dot < 0: + return normalized + end = dot + 1 + while end < len(normalized) and normalized[end].isdigit(): + end += 1 + fraction = normalized[dot + 1 : end].ljust(_TEMPORAL_FRACTION_DIGITS, "0") + return ( + normalized[: dot + 1] + fraction[:_TEMPORAL_FRACTION_DIGITS] + normalized[end:] + ) + + +def _parse_date_time( + value: str, path: str, violations: list[Violation] +) -> datetime.datetime | None: + if _TEMPORAL_DATE_TIME_RE.match(value) is None or not _valid_temporal_calendar( + value + ): + violations.append( + Violation(path=path, reason=_temporal_reason("date-time", value)) + ) + return None + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) + + +def _parse_date( + value: str, path: str, violations: list[Violation] +) -> datetime.date | None: + if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): + violations.append(Violation(path=path, reason=_temporal_reason("date", value))) + return None + return datetime.date.fromisoformat(value) + + +def _parse_time( + value: str, path: str, violations: list[Violation] +) -> datetime.time | None: + if _TEMPORAL_TIME_RE.match(value) is None: + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) + return None + return datetime.time.fromisoformat(_temporal_isoformat(value)) + + +def _parse_duration( + value: str, path: str, violations: list[Violation] +) -> datetime.timedelta | None: + if _TEMPORAL_DURATION_RE.match(value) is None: + violations.append( + Violation(path=path, reason=_temporal_reason("duration", value)) + ) + return None + total = 0 + number = "" + for char in value[2:]: + if char.isdigit(): + number += char + continue + digits = number.lstrip("0") + number = "" + if len(digits) > _TEMPORAL_MAX_DURATION_DIGITS: + # Over the cap by digit count alone (see the constant), so the + # conversion `int()` would refuse is never attempted. + total = _TEMPORAL_MAX_DURATION_SECONDS + 1 + break + total += int(digits or "0") * {"H": 3600, "M": 60, "S": 1}[char] + if total > _TEMPORAL_MAX_DURATION_SECONDS: + break + if total > _TEMPORAL_MAX_DURATION_SECONDS: + violations.append( + Violation(path=path, reason=_temporal_reason("duration", value)) + ) + return None + return datetime.timedelta(seconds=total) + + +def _check_temporal_offset( + name: str, + value: datetime.datetime | datetime.time, + offset: datetime.timedelta, + path: str, + violations: list[Violation], +) -> None: + """Asserts a UTC offset is a whole number of minutes in -18:00..+18:00. + + ``tzinfo`` allows seconds and offsets up to almost 24 hours, both wider than + the materialized wire grammar. + """ + + if offset % datetime.timedelta(minutes=1): + violations.append( + Violation( + path=path, + reason=( + f"must be a valid {name}, got {_quote(str(value))}: " + f"the UTC offset {offset} is not a whole number of minutes" + ), + ) + ) + elif abs(offset) > datetime.timedelta(hours=18): + violations.append( + Violation( + path=path, + reason=( + f"must be a valid {name}, got {_quote(str(value))}: " + "the UTC offset is outside -18:00 through +18:00" + ), + ) + ) + + +def _check_date_time( + value: datetime.datetime, path: str, violations: list[Violation] +) -> None: + """Asserts a datetime is writable as a wire date-time (P12). + + A dataclass is constructed unchecked, so a naive datetime -- with no offset + the required wire form could carry -- reaches serialize; without this it + would emit a value this module's own parser rejects. + """ + + offset = value.utcoffset() + if offset is None: + violations.append( + Violation( + path=path, + reason=( + f"must be a valid date-time, got {_quote(str(value))}: " + "a naive datetime carries no UTC offset" + ), + ) + ) + return + _check_temporal_offset("date-time", value, offset, path, violations) + + +def _check_time(value: datetime.time, path: str, violations: list[Violation]) -> None: + """Asserts a time is writable as a wire time (P12). The offset is optional in + the grammar, so only its precision is held to anything.""" + + offset = value.utcoffset() + if offset is not None: + _check_temporal_offset("time", value, offset, path, violations) + + +def _check_duration( + value: datetime.timedelta, path: str, violations: list[Violation] +) -> None: + """Asserts a timedelta is writable as a wire duration (P12): the grammar is + unsigned, whole-second and capped, and a ``timedelta`` is none of those.""" + + if value < datetime.timedelta(0): + reason = "a duration cannot be negative" + elif value % datetime.timedelta(seconds=1): + reason = "a duration cannot carry a fraction of a second" + elif value.total_seconds() > _TEMPORAL_MAX_DURATION_SECONDS: + reason = f"a duration cannot exceed {_TEMPORAL_MAX_DURATION_SECONDS} seconds" + else: + return + violations.append( + Violation( + path=path, + reason=f"must be a valid duration, got {_quote(str(value))}: {reason}", + ) + ) + + +def _temporal_frac(microsecond: int) -> str: + if microsecond == 0: + return "" + return "." + f"{microsecond:06d}".rstrip("0") + + +def _temporal_offset(value: datetime.datetime | datetime.time) -> str: + offset = value.utcoffset() + if offset is None: + return "" + total = int(offset.total_seconds()) + if total == 0: + return "Z" + sign = "+" if total > 0 else "-" + total = abs(total) + return f"{sign}{total // 3600:02d}:{(total % 3600) // 60:02d}" + + +def _format_date_time(value: datetime.datetime) -> str: + return ( + f"{value.year:04d}-{value.month:02d}-{value.day:02d}" + f"T{value.hour:02d}:{value.minute:02d}:{value.second:02d}" + f"{_temporal_frac(value.microsecond)}{_temporal_offset(value)}" + ) + + +def _format_date(value: datetime.date) -> str: + return f"{value.year:04d}-{value.month:02d}-{value.day:02d}" + + +def _format_time(value: datetime.time) -> str: + return ( + f"{value.hour:02d}:{value.minute:02d}:{value.second:02d}" + f"{_temporal_frac(value.microsecond)}{_temporal_offset(value)}" + ) + + +def _format_duration(value: datetime.timedelta) -> str: + total = int(value.total_seconds()) + if total == 0: + return "PT0S" + hours, remainder = divmod(total, 3600) + minutes, seconds = divmod(remainder, 60) + out = "PT" + if hours: + out += f"{hours}H" + if minutes: + out += f"{minutes}M" + if seconds: + out += f"{seconds}S" + return out + + +_BASE64_RE = re.compile( + "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/][AQgw]==|[A-Za-z0-9+/]{2}[AEIMQUYcgkosw048]=)?\\Z", + re.ASCII, +) +_BASE64URL_RE = re.compile( + "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?\\Z", + re.ASCII, +) + + +def _parse_base64(value: str, path: str, violations: list[Violation]) -> bytes | None: + if _BASE64_RE.match(value) is None: + violations.append( + Violation(path=path, reason=f"must be base64-encoded, got {_quote(value)}") + ) + return None + return base64.b64decode(value, validate=True) + + +def _format_base64(value: bytes) -> str: + return base64.b64encode(value).decode("ascii") + + +def _parse_base64url( + value: str, path: str, violations: list[Violation] +) -> bytes | None: + if _BASE64URL_RE.match(value) is None: + violations.append( + Violation( + path=path, reason=f"must be base64url-encoded, got {_quote(value)}" + ) + ) + return None + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _format_base64url(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +StreamCursor = typing.NewType("StreamCursor", str) +"""StreamCursor is an opaque resume token issued by the endpoint. + +The value is never parsed, compared or constructed by a caller; only the endpoint that +issued it can interpret it. + +Pass one back to resume strictly after the record it names, so that record is never +delivered twice. +""" diff --git a/temporalio/contrib/streams/nexus/_generated/client.py b/temporalio/contrib/streams/nexus/_generated/client.py new file mode 100644 index 0000000000..77797b9049 --- /dev/null +++ b/temporalio/contrib/streams/nexus/_generated/client.py @@ -0,0 +1,882 @@ +# Generated by nexgen v0.2.8. DO NOT EDIT! + +from __future__ import annotations + +import asyncio +import dataclasses +import json +import ssl +import time +import typing +import urllib.error +import urllib.request + +import temporalio.api.common.v1 +import temporalio.converter + +from .models import ( + AppendInput, + AppendOutput, + FollowInput, + FollowOutput, + FollowRangeInput, + ReadInput, + ReadOutput, + ReadRangeInput, + StreamRef, + _AppendInputTransferTypeConverter, + _AppendOutputTransferTypeConverter, + _FollowInputTransferTypeConverter, + _FollowOutputTransferTypeConverter, + _FollowRangeInputTransferTypeConverter, + _ReadInputTransferTypeConverter, + _ReadOutputTransferTypeConverter, + _ReadRangeInputTransferTypeConverter, +) + + +class HTTPStatusError(RuntimeError): + """An answer from the ingress that is not a synchronous result. + + It carries the status so a caller can tell a refusal apart from an answer + another attempt could still get. + """ + + def __init__( + self, url: str, status: int, detail: str, retryable: bool | None = None + ) -> None: + super().__init__(f"{url} failed ({status}): {detail}") + # Annotated explicitly: a target's strict type checker asks for it on a + # class it cannot prove closed. + self.url: str = url + self.status: int = status + self.detail: str = detail + self._retryable: bool | None = retryable + failure_type, failure_message = _application_failure(detail) + # The application failure type the handler raised, such as a stream error + # class, and its message. Both are None when the body names none. + self.failure_type: str | None = failure_type + self.failure_message: str | None = failure_message + + @property + def retryable(self) -> bool: + """Whether the same request could answer differently. + + The handler's ``Nexus-Request-Retryable`` header decides when it is + set. Otherwise too many requests and the server-side failures are the + endpoint's own transient conditions; every other status is about this + request, and repeating it changes nothing. + """ + if self._retryable is not None: + return self._retryable + return self.status == 429 or self.status >= 500 + + +def _application_failure(detail: str) -> tuple[str | None, str | None]: + """The type and message of the first application failure a body holds. + + A Temporal handler's failure crosses as a Nexus failure whose ``details`` is + the Temporal failure in proto JSON, with its message left on the Nexus + failure. The application failure can sit in a ``cause`` at either level, so + both are searched, outermost first. + """ + try: + pending: list[tuple[object, object]] = [(json.loads(detail), None)] + except ValueError: + return None, None + while pending: + failure, outer_message = pending.pop(0) + if not isinstance(failure, dict): + continue + fields = typing.cast("dict[str, object]", failure) + message = fields.get("message", outer_message) + info = fields.get("applicationFailureInfo") + if isinstance(info, dict): + failure_type = typing.cast("dict[str, object]", info).get("type") + if isinstance(failure_type, str): + return failure_type, message if isinstance(message, str) else None + pending.append((fields.get("details"), message)) + pending.append((fields.get("cause"), None)) + return None, None + + +def _retryable_header(headers: typing.Any) -> bool | None: + value = headers.get("Nexus-Request-Retryable") if headers is not None else None + if value is None: + return None + return str(value).strip().lower() == "true" + + +_PAYLOAD_CONTENT_TYPE = "application/x-temporal-payload" + + +def _post( + url: str, + body: bytes, + headers: dict[str, str], + timeout: float, + content_type: str = "application/json", + ssl_context: ssl.SSLContext | None = None, +) -> tuple[bytes, str]: + request = urllib.request.Request( + url, + data=body, + headers={ + "Content-Type": content_type, + # Tells the handler how long this caller waits, so it can cut a + # long poll short instead of being cut off. + "Request-Timeout": f"{int(timeout * 1000)}ms", + **headers, + }, + method="POST", + ) + try: + with urllib.request.urlopen( + request, timeout=timeout, context=ssl_context + ) as response: + status = typing.cast("int", response.status) + answer = typing.cast("bytes", response.read()) + retryable = _retryable_header(response.headers) + answer_type = str(response.headers.get("Content-Type") or "") + except urllib.error.HTTPError as error: + detail = error.read().decode(errors="replace") + raise HTTPStatusError( + url, error.code, detail, _retryable_header(error.headers) + ) from error + # A 201 means the handler started the operation asynchronously and answered + # with a token, which this caller cannot read as the operation's result. + if status not in (200, 204): + raise HTTPStatusError(url, status, answer.decode(errors="replace"), retryable) + return answer, answer_type + + +def _single_payload( + url: str, payloads: list[temporalio.api.common.v1.Payload] +) -> temporalio.api.common.v1.Payload: + if len(payloads) != 1: + raise ValueError( + f"{url}: the payload codec gave {len(payloads)} payloads for one" + ) + return payloads[0] + + +async def _exchange( + url: str, + body: bytes, + headers: dict[str, str], + timeout: float, + ssl_context: ssl.SSLContext | None, + payload_codec: temporalio.converter.PayloadCodec | None, +) -> bytes: + """Posts a JSON body and answers with the JSON the endpoint sent back. + + With a codec, the body crosses as an encoded Temporal payload, which the + handler's own codec decodes. The ingress sends a result that the handler's + codec encoded as a payload, and this caller decodes it before it parses. + """ + content_type = "application/json" + if payload_codec is not None: + plain = temporalio.api.common.v1.Payload( + metadata={"encoding": b"json/plain"}, data=body + ) + encoded = _single_payload(url, await payload_codec.encode([plain])) + body = encoded.SerializeToString() + content_type = _PAYLOAD_CONTENT_TYPE + answer, answer_type = await asyncio.to_thread( + _post, + url, + body, + headers, + timeout, + content_type=content_type, + ssl_context=ssl_context, + ) + if answer_type.split(";")[0].strip().lower() != _PAYLOAD_CONTENT_TYPE: + return answer + payload = temporalio.api.common.v1.Payload.FromString(answer) + if payload_codec is not None: + payload = _single_payload(url, await payload_codec.decode([payload])) + encoding = payload.metadata.get("encoding", b"").decode(errors="replace") + # A void result crosses as a null payload, which has no JSON to parse. + if encoding == "binary/null": + return b"" + if not encoding.startswith("json/"): + hint = ", so pass payload_codec= to decode it" if payload_codec is None else "" + raise ValueError(f"{url} answered with a {encoding!r} payload{hint}") + return payload.data + + +_LONG_POLL_MIN_BACKOFF = 0.05 +_LONG_POLL_MAX_BACKOFF = 2.0 + + +async def _long_poll_pause(backoff: float, end: float) -> None: + """Waits out a backoff without running past the read's own deadline.""" + remaining = end - time.monotonic() + delay = min(backoff, remaining) + if delay > 0: + await asyncio.sleep(delay) + + +def _long_poll_next_backoff(backoff: float) -> float: + return min(backoff * 2, _LONG_POLL_MAX_BACKOFF) + + +class TemporalStreamsHttpClient: + """Appends to and reads from streams through one Nexus endpoint, so the store + behind it stays the endpoint's business and an operator can change it without + touching callers. An append carries one batch and a read answers with a batch, + because a Nexus operation per record costs too much for token streams. A + record crosses as the serialized temporal.sdk.streams.v1.StreamRecord, the + bytes every store keeps, so a caller in any language decodes it with that + proto alone. A Workflow caller goes through its Worker, whose codec applies. + + Every method posts to ``{base_url}/{operation}``, so ``base_url`` is the + service's address on the Nexus HTTP ingress, up to but not including the + operation name. + + ``ssl_context`` sets the TLS for an ``https`` ``base_url``, for example a + private certificate authority or a client certificate for mTLS. + + Give ``payload_codec`` the codec of the namespace that runs the handler. The + input then crosses as an encoded payload, and an encoded result is decoded + before it is parsed. + """ + + def __init__( + self, + base_url: str, + *, + headers: dict[str, str] | None = None, + timeout: float = 30.0, + ssl_context: ssl.SSLContext | None = None, + payload_codec: temporalio.converter.PayloadCodec | None = None, + ) -> None: + self._base_url: str = base_url.rstrip("/") + self._headers: dict[str, str] = dict(headers or {}) + self._timeout: float = timeout + self._ssl_context: ssl.SSLContext | None = ssl_context + self._payload_codec: temporalio.converter.PayloadCodec | None = payload_codec + + def _poll_budget(self, remaining: float) -> float: + """How long one attempt may ask the endpoint to park, in seconds. + + Asking for longer than the transport will wait is a socket timeout + dressed as a long poll, so the ask leaves the answer room to come back + inside this client's own timeout. + """ + return max(0.001, min(remaining, self._timeout * 0.9)) + + async def append( + self, + request: AppendInput, + ) -> AppendOutput: + """Write one batch as one producer attempt, or finish the producer. The store + numbers the batch from sequence and deduplicates a retry: a repeat of the + producer attempt's newest batch with the same content answers with where + the original landed and writes nothing. A sequence already used with other + content, or below the newest one the store holds for the attempt, is + refused. Supersession records are never written: a reader synthesizes them + from the attempts it observes. + """ + wire: typing.Any = _AppendInputTransferTypeConverter().to_transfer_type(request) + body = json.dumps(wire).encode() + raw = await _exchange( + f"{self._base_url}/append", + body, + self._headers, + self._timeout, + self._ssl_context, + self._payload_codec, + ) + if not raw: + raise ValueError("append answered with an empty body") + answer: typing.Any = json.loads(raw) + return _AppendOutputTransferTypeConverter().from_transfer_type( + answer, AppendOutput + ) + + async def read( + self, + request: ReadInput, + ) -> ReadOutput: + """Answer with the records after the caller's cursor. The call waits until it + has max_records records or wait_ms passes, whichever comes first, and + answers with what it collected. It says when the stream has ended for the + reader, so the caller can stop. + """ + wire: typing.Any = _ReadInputTransferTypeConverter().to_transfer_type(request) + body = json.dumps(wire).encode() + raw = await _exchange( + f"{self._base_url}/read", + body, + self._headers, + self._timeout, + self._ssl_context, + self._payload_codec, + ) + if not raw: + raise ValueError("read answered with an empty body") + answer: typing.Any = json.loads(raw) + return _ReadOutputTransferTypeConverter().from_transfer_type(answer, ReadOutput) + + async def read_until_records( + self, + request: ReadInput, + *, + deadline: float, + ) -> ReadOutput: + """Calls ``read`` until it answers with something, or until ``deadline``. + + Each attempt sets ``wait_ms`` from the budget left, so the endpoint parks + for the caller instead of the caller spinning. The first answer whose + ``records`` is non-empty, or whose ``done`` is true, is returned. When the + deadline passes first, the last answer is returned, which still carries + the caller's resume position. + + After an answer with nothing, the next attempt sends the ``state`` that + answer carried, so the endpoint keeps nothing between calls. + + ``deadline`` is the whole budget in seconds. The single-shot method stays + available for a caller that wants one attempt. + """ + end = time.monotonic() + deadline + answer: ReadOutput | None = None + backoff = _LONG_POLL_MIN_BACKOFF + while True: + wait = self._poll_budget(end - time.monotonic()) + wait_ms = int(wait * 1000) + wait_ms = min(wait_ms, 60000) + wait_ms = max(wait_ms, 0) + wait = wait_ms / 1000 + attempt = dataclasses.replace(request, wait_ms=wait_ms) + started = time.monotonic() + try: + answer = await self.read(attempt) + except HTTPStatusError as error: + if not error.retryable or time.monotonic() >= end: + raise + await _long_poll_pause(backoff, end) + backoff = _long_poll_next_backoff(backoff) + continue + if answer.records or answer.done: + return answer + if answer.state is not None: + request = dataclasses.replace(request, state=answer.state) + if time.monotonic() >= end: + return answer + if time.monotonic() - started < wait / 2: + await _long_poll_pause(backoff, end) + backoff = _long_poll_next_backoff(backoff) + else: + backoff = _LONG_POLL_MIN_BACKOFF + + async def read_range( + self, + request: ReadRangeInput, + ) -> ReadOutput: + """Answer again the records of an earlier read that named a floor_holder, for + a Workflow that replays that read. The call reads the range its replay + names with the read's input state, so the superseded and stale marks come + out the same, and checks the records against the read's digest. It waits + for nothing, asks nothing of the owner and has no byte budget, since it + must answer exactly what the read answered. Records with other content are + refused as StreamReplayMismatchError, and records the store no longer + holds as StreamReplayRangeLostError. The answer holds the records and + next_token only, since the caller keeps the read's state, done and replay. + """ + wire: typing.Any = _ReadRangeInputTransferTypeConverter().to_transfer_type( + request + ) + body = json.dumps(wire).encode() + raw = await _exchange( + f"{self._base_url}/read_range", + body, + self._headers, + self._timeout, + self._ssl_context, + self._payload_codec, + ) + if not raw: + raise ValueError("read_range answered with an empty body") + answer: typing.Any = json.loads(raw) + return _ReadOutputTransferTypeConverter().from_transfer_type(answer, ReadOutput) + + async def follow( + self, + request: FollowInput, + ) -> FollowOutput: + """Answer with the records after the caller's position on a stream and on + every stream its link records open, in one order. Order within each stream + is kept. A linked stream's records come after its OPEN link, and the + linking stream's records after a JOIN come after the linked stream reached + the JOIN's point. Each item names its source, and a linked stream's state + comes as a link_state item. A linked stream that can't be read or that + ends never fails the call. Links to another namespace or endpoint are not + followed. The call waits like read, and the state resumes every open + stream. + """ + wire: typing.Any = _FollowInputTransferTypeConverter().to_transfer_type(request) + body = json.dumps(wire).encode() + raw = await _exchange( + f"{self._base_url}/follow", + body, + self._headers, + self._timeout, + self._ssl_context, + self._payload_codec, + ) + if not raw: + raise ValueError("follow answered with an empty body") + answer: typing.Any = json.loads(raw) + return _FollowOutputTransferTypeConverter().from_transfer_type( + answer, FollowOutput + ) + + async def follow_until_records( + self, + request: FollowInput, + *, + deadline: float, + ) -> FollowOutput: + """Calls ``follow`` until it answers with something, or until ``deadline``. + + Each attempt sets ``wait_ms`` from the budget left, so the endpoint parks + for the caller instead of the caller spinning. The first answer whose + ``records`` is non-empty, or whose ``done`` is true, is returned. When the + deadline passes first, the last answer is returned, which still carries + the caller's resume position. + + After an answer with nothing, the next attempt sends the ``state`` that + answer carried, so the endpoint keeps nothing between calls. + + ``deadline`` is the whole budget in seconds. The single-shot method stays + available for a caller that wants one attempt. + """ + end = time.monotonic() + deadline + answer: FollowOutput | None = None + backoff = _LONG_POLL_MIN_BACKOFF + while True: + wait = self._poll_budget(end - time.monotonic()) + wait_ms = int(wait * 1000) + wait_ms = min(wait_ms, 60000) + wait_ms = max(wait_ms, 0) + wait = wait_ms / 1000 + attempt = dataclasses.replace(request, wait_ms=wait_ms) + started = time.monotonic() + try: + answer = await self.follow(attempt) + except HTTPStatusError as error: + if not error.retryable or time.monotonic() >= end: + raise + await _long_poll_pause(backoff, end) + backoff = _long_poll_next_backoff(backoff) + continue + if answer.records or answer.done: + return answer + if answer.state is not None: + request = dataclasses.replace(request, state=answer.state) + if time.monotonic() >= end: + return answer + if time.monotonic() - started < wait / 2: + await _long_poll_pause(backoff, end) + backoff = _long_poll_next_backoff(backoff) + else: + backoff = _LONG_POLL_MIN_BACKOFF + + async def follow_range( + self, + request: FollowRangeInput, + ) -> FollowOutput: + """Answer again the records of an earlier follow that named a floor_holder, + for a Workflow that replays that follow. The call reads each range again + with the follow's input state, checks each against its digest and the + order against the follow's digest, and answers the same records in the + same order. Other records or another order are refused as + StreamReplayMismatchError. + """ + wire: typing.Any = _FollowRangeInputTransferTypeConverter().to_transfer_type( + request + ) + body = json.dumps(wire).encode() + raw = await _exchange( + f"{self._base_url}/follow_range", + body, + self._headers, + self._timeout, + self._ssl_context, + self._payload_codec, + ) + if not raw: + raise ValueError("follow_range answered with an empty body") + answer: typing.Any = json.loads(raw) + return _FollowOutputTransferTypeConverter().from_transfer_type( + answer, FollowOutput + ) + + def stream( + self, + *, + stream: StreamRef, + ) -> "StreamHandle": + """A ``StreamHandle`` bound to ``stream``.""" + return StreamHandle( + self, + stream=stream, + ) + + def stream_producer( + self, + *, + stream: StreamRef, + producer_id: str, + attempt: int, + ) -> "StreamProducer": + """A ``StreamProducer`` bound to ``stream``, ``producer_id``, ``attempt``.""" + return StreamProducer( + self, + stream=stream, + producer_id=producer_id, + attempt=attempt, + ) + + +class StreamHandle: + """Binds ``stream`` for the temporal.sdk.streams.v1.TemporalStreams service. + + Every method posts through the ``TemporalStreamsHttpClient`` it was built from + with the bound members filled in, so a caller states a stream's identity once. + The flat caller stays available for a call that spells every member out. + """ + + def __init__( + self, + client: TemporalStreamsHttpClient, + *, + stream: StreamRef, + ) -> None: + self._client: TemporalStreamsHttpClient = client + self._stream: StreamRef = stream + + async def append( + self, + *, + producer_id: str, + attempt: int, + sequence: int, + payloads: list[bytes] | None = None, + finish: bool | None = None, + ) -> AppendOutput: + """Write one batch as one producer attempt, or finish the producer. The store + numbers the batch from sequence and deduplicates a retry: a repeat of the + producer attempt's newest batch with the same content answers with where + the original landed and writes nothing. A sequence already used with other + content, or below the newest one the store holds for the attempt, is + refused. Supersession records are never written: a reader synthesizes them + from the attempts it observes. + + This handle supplies ``stream``; the flat caller's ``append`` takes it + spelled out. + """ + return await self._client.append( + AppendInput( + stream=self._stream, + producer_id=producer_id, + attempt=attempt, + sequence=sequence, + payloads=payloads, + finish=finish, + ), + ) + + async def read( + self, + *, + after_token: str | None = None, + max_records: int | None = None, + wait_ms: int | None = None, + latest_only: bool | None = None, + state: bytes | None = None, + floor_holder: str | None = None, + wake_token: str | None = None, + ) -> ReadOutput: + """Answer with the records after the caller's cursor. The call waits until it + has max_records records or wait_ms passes, whichever comes first, and + answers with what it collected. It says when the stream has ended for the + reader, so the caller can stop. + + This handle supplies ``stream``; the flat caller's ``read`` takes it + spelled out. + """ + return await self._client.read( + ReadInput( + stream=self._stream, + after_token=after_token, + max_records=max_records, + wait_ms=wait_ms, + latest_only=latest_only, + state=state, + floor_holder=floor_holder, + wake_token=wake_token, + ), + ) + + async def read_until_records( + self, + *, + after_token: str | None = None, + max_records: int | None = None, + latest_only: bool | None = None, + state: bytes | None = None, + floor_holder: str | None = None, + wake_token: str | None = None, + deadline: float, + ) -> ReadOutput: + """Calls ``read`` until it answers with something, or until ``deadline``. + + Each attempt sets ``wait_ms`` from the budget left, so the endpoint parks + for the caller instead of the caller spinning. The first answer whose + ``records`` is non-empty, or whose ``done`` is true, is returned. When the + deadline passes first, the last answer is returned, which still carries + the caller's resume position. + + After an answer with nothing, the next attempt sends the ``state`` that + answer carried, so the endpoint keeps nothing between calls. + + ``deadline`` is the whole budget in seconds. The single-shot method stays + available for a caller that wants one attempt. + + This handle supplies ``stream``; the flat caller's ``read_until_records`` + takes it spelled out. + """ + return await self._client.read_until_records( + ReadInput( + stream=self._stream, + after_token=after_token, + max_records=max_records, + latest_only=latest_only, + state=state, + floor_holder=floor_holder, + wake_token=wake_token, + ), + deadline=deadline, + ) + + async def read_range( + self, + *, + chain_first_run_id: str, + after_token: str, + to_token: str, + stored_records: int, + state: bytes | None = None, + records_digest: bytes, + floor_holder: str | None = None, + ) -> ReadOutput: + """Answer again the records of an earlier read that named a floor_holder, for + a Workflow that replays that read. The call reads the range its replay + names with the read's input state, so the superseded and stale marks come + out the same, and checks the records against the read's digest. It waits + for nothing, asks nothing of the owner and has no byte budget, since it + must answer exactly what the read answered. Records with other content are + refused as StreamReplayMismatchError, and records the store no longer + holds as StreamReplayRangeLostError. The answer holds the records and + next_token only, since the caller keeps the read's state, done and replay. + + This handle supplies ``stream``; the flat caller's ``read_range`` takes it + spelled out. + """ + return await self._client.read_range( + ReadRangeInput( + stream=self._stream, + chain_first_run_id=chain_first_run_id, + after_token=after_token, + to_token=to_token, + stored_records=stored_records, + state=state, + records_digest=records_digest, + floor_holder=floor_holder, + ), + ) + + async def follow( + self, + *, + after_token: str | None = None, + state: bytes | None = None, + max_records: int | None = None, + wait_ms: int | None = None, + max_depth: int | None = None, + max_open: int | None = None, + floor_holder: str | None = None, + wake_token: str | None = None, + ) -> FollowOutput: + """Answer with the records after the caller's position on a stream and on + every stream its link records open, in one order. Order within each stream + is kept. A linked stream's records come after its OPEN link, and the + linking stream's records after a JOIN come after the linked stream reached + the JOIN's point. Each item names its source, and a linked stream's state + comes as a link_state item. A linked stream that can't be read or that + ends never fails the call. Links to another namespace or endpoint are not + followed. The call waits like read, and the state resumes every open + stream. + + This handle supplies ``stream``; the flat caller's ``follow`` takes it + spelled out. + """ + return await self._client.follow( + FollowInput( + stream=self._stream, + after_token=after_token, + state=state, + max_records=max_records, + wait_ms=wait_ms, + max_depth=max_depth, + max_open=max_open, + floor_holder=floor_holder, + wake_token=wake_token, + ), + ) + + async def follow_until_records( + self, + *, + after_token: str | None = None, + state: bytes | None = None, + max_records: int | None = None, + max_depth: int | None = None, + max_open: int | None = None, + floor_holder: str | None = None, + wake_token: str | None = None, + deadline: float, + ) -> FollowOutput: + """Calls ``follow`` until it answers with something, or until ``deadline``. + + Each attempt sets ``wait_ms`` from the budget left, so the endpoint parks + for the caller instead of the caller spinning. The first answer whose + ``records`` is non-empty, or whose ``done`` is true, is returned. When the + deadline passes first, the last answer is returned, which still carries + the caller's resume position. + + After an answer with nothing, the next attempt sends the ``state`` that + answer carried, so the endpoint keeps nothing between calls. + + ``deadline`` is the whole budget in seconds. The single-shot method stays + available for a caller that wants one attempt. + + This handle supplies ``stream``; the flat caller's + ``follow_until_records`` takes it spelled out. + """ + return await self._client.follow_until_records( + FollowInput( + stream=self._stream, + after_token=after_token, + state=state, + max_records=max_records, + max_depth=max_depth, + max_open=max_open, + floor_holder=floor_holder, + wake_token=wake_token, + ), + deadline=deadline, + ) + + async def follow_range( + self, + *, + state: bytes | None = None, + ranges: list[bytes], + follow_digest: bytes, + max_depth: int | None = None, + max_open: int | None = None, + floor_holder: str | None = None, + ) -> FollowOutput: + """Answer again the records of an earlier follow that named a floor_holder, + for a Workflow that replays that follow. The call reads each range again + with the follow's input state, checks each against its digest and the + order against the follow's digest, and answers the same records in the + same order. Other records or another order are refused as + StreamReplayMismatchError. + + This handle supplies ``stream``; the flat caller's ``follow_range`` takes + it spelled out. + """ + return await self._client.follow_range( + FollowRangeInput( + stream=self._stream, + state=state, + ranges=ranges, + follow_digest=follow_digest, + max_depth=max_depth, + max_open=max_open, + floor_holder=floor_holder, + ), + ) + + def producer( + self, + *, + producer_id: str, + attempt: int, + ) -> "StreamProducer": + """A ``StreamProducer`` bound to ``stream``, ``producer_id``, ``attempt``: + this handle's own members and the ones given here. + """ + return StreamProducer( + self._client, + stream=self._stream, + producer_id=producer_id, + attempt=attempt, + ) + + +class StreamProducer: + """Binds ``stream``, ``producer_id``, ``attempt`` for the + temporal.sdk.streams.v1.TemporalStreams service. + + Every method posts through the ``TemporalStreamsHttpClient`` it was built from + with the bound members filled in, so a caller states a stream's identity once. + The flat caller stays available for a call that spells every member out. + """ + + def __init__( + self, + client: TemporalStreamsHttpClient, + *, + stream: StreamRef, + producer_id: str, + attempt: int, + ) -> None: + self._client: TemporalStreamsHttpClient = client + self._stream: StreamRef = stream + self._producer_id: str = producer_id + self._attempt: int = attempt + + async def append( + self, + *, + sequence: int, + payloads: list[bytes] | None = None, + finish: bool | None = None, + ) -> AppendOutput: + """Write one batch as one producer attempt, or finish the producer. The store + numbers the batch from sequence and deduplicates a retry: a repeat of the + producer attempt's newest batch with the same content answers with where + the original landed and writes nothing. A sequence already used with other + content, or below the newest one the store holds for the attempt, is + refused. Supersession records are never written: a reader synthesizes them + from the attempts it observes. + + This handle supplies ``stream``, ``producer_id``, ``attempt``; the flat + caller's ``append`` takes them spelled out. + """ + return await self._client.append( + AppendInput( + stream=self._stream, + producer_id=self._producer_id, + attempt=self._attempt, + sequence=sequence, + payloads=payloads, + finish=finish, + ), + ) diff --git a/temporalio/contrib/streams/nexus/_generated/forwarder.py b/temporalio/contrib/streams/nexus/_generated/forwarder.py new file mode 100644 index 0000000000..0f16c14342 --- /dev/null +++ b/temporalio/contrib/streams/nexus/_generated/forwarder.py @@ -0,0 +1,163 @@ +# Generated by nexgen v0.2.8. DO NOT EDIT! + +from __future__ import annotations + +import abc +import typing + +import nexusrpc +import nexusrpc.handler + +from .models import ( + AppendInput, + AppendOutput, + FollowInput, + FollowOutput, + FollowRangeInput, + ReadInput, + ReadOutput, + ReadRangeInput, + _AppendInputTransferTypeConverter, + _AppendOutputTransferTypeConverter, + _FollowInputTransferTypeConverter, + _FollowOutputTransferTypeConverter, + _FollowRangeInputTransferTypeConverter, + _ReadInputTransferTypeConverter, + _ReadOutputTransferTypeConverter, + _ReadRangeInputTransferTypeConverter, +) + + +class TemporalStreamsForwarder(abc.ABC): + """Serves the temporal.sdk.streams.v1.TemporalStreams service by forwarding each + operation to :meth:`forward`. + + Each operation turns its input into the JSON transfer form, hands it to + ``forward`` with the operation's wire name, and turns the answer back into the + output model. The class holds no protocol rule. Each operation is synchronous. + + A subclass implements ``forward`` and declares itself the service's handler + with ``@nexusrpc.handler.service_handler(service=TemporalStreams)``, from the + services module. nexusrpc reads that declaration from the class itself, never + from a base class. + """ + + @abc.abstractmethod + async def forward( + self, + operation: str, + ctx: nexusrpc.handler.StartOperationContext, + transfer: typing.Any, + ) -> typing.Any: + """Serves one operation from its input's JSON transfer form. + + ``operation`` is the operation's wire name, and ``transfer`` is its input + as JSON values, or ``None`` when the operation takes none. The answer is + the output's JSON transfer form, or ``None`` when the operation has none. + Raise ``nexusrpc.HandlerError`` to fail the operation. + """ + raise NotImplementedError + + @nexusrpc.handler.sync_operation + async def append( + self, + ctx: nexusrpc.handler.StartOperationContext, + input: AppendInput, + ) -> AppendOutput: + """Write one batch as one producer attempt, or finish the producer. The store + numbers the batch from sequence and deduplicates a retry: a repeat of the + producer attempt's newest batch with the same content answers with where + the original landed and writes nothing. A sequence already used with other + content, or below the newest one the store holds for the attempt, is + refused. Supersession records are never written: a reader synthesizes them + from the attempts it observes. + """ + transfer: typing.Any = _AppendInputTransferTypeConverter().to_transfer_type( + input + ) + answer: typing.Any = await self.forward("append", ctx, transfer) + return _AppendOutputTransferTypeConverter().from_transfer_type( + answer, AppendOutput + ) + + @nexusrpc.handler.sync_operation + async def read( + self, + ctx: nexusrpc.handler.StartOperationContext, + input: ReadInput, + ) -> ReadOutput: + """Answer with the records after the caller's cursor. The call waits until it + has max_records records or wait_ms passes, whichever comes first, and + answers with what it collected. It says when the stream has ended for the + reader, so the caller can stop. + """ + transfer: typing.Any = _ReadInputTransferTypeConverter().to_transfer_type(input) + answer: typing.Any = await self.forward("read", ctx, transfer) + return _ReadOutputTransferTypeConverter().from_transfer_type(answer, ReadOutput) + + @nexusrpc.handler.sync_operation + async def read_range( + self, + ctx: nexusrpc.handler.StartOperationContext, + input: ReadRangeInput, + ) -> ReadOutput: + """Answer again the records of an earlier read that named a floor_holder, for + a Workflow that replays that read. The call reads the range its replay + names with the read's input state, so the superseded and stale marks come + out the same, and checks the records against the read's digest. It waits + for nothing, asks nothing of the owner and has no byte budget, since it + must answer exactly what the read answered. Records with other content are + refused as StreamReplayMismatchError, and records the store no longer + holds as StreamReplayRangeLostError. The answer holds the records and + next_token only, since the caller keeps the read's state, done and replay. + """ + transfer: typing.Any = _ReadRangeInputTransferTypeConverter().to_transfer_type( + input + ) + answer: typing.Any = await self.forward("read_range", ctx, transfer) + return _ReadOutputTransferTypeConverter().from_transfer_type(answer, ReadOutput) + + @nexusrpc.handler.sync_operation + async def follow( + self, + ctx: nexusrpc.handler.StartOperationContext, + input: FollowInput, + ) -> FollowOutput: + """Answer with the records after the caller's position on a stream and on + every stream its link records open, in one order. Order within each stream + is kept. A linked stream's records come after its OPEN link, and the + linking stream's records after a JOIN come after the linked stream reached + the JOIN's point. Each item names its source, and a linked stream's state + comes as a link_state item. A linked stream that can't be read or that + ends never fails the call. Links to another namespace or endpoint are not + followed. The call waits like read, and the state resumes every open + stream. + """ + transfer: typing.Any = _FollowInputTransferTypeConverter().to_transfer_type( + input + ) + answer: typing.Any = await self.forward("follow", ctx, transfer) + return _FollowOutputTransferTypeConverter().from_transfer_type( + answer, FollowOutput + ) + + @nexusrpc.handler.sync_operation + async def follow_range( + self, + ctx: nexusrpc.handler.StartOperationContext, + input: FollowRangeInput, + ) -> FollowOutput: + """Answer again the records of an earlier follow that named a floor_holder, + for a Workflow that replays that follow. The call reads each range again + with the follow's input state, checks each against its digest and the + order against the follow's digest, and answers the same records in the + same order. Other records or another order are refused as + StreamReplayMismatchError. + """ + transfer: typing.Any = ( + _FollowRangeInputTransferTypeConverter().to_transfer_type(input) + ) + answer: typing.Any = await self.forward("follow_range", ctx, transfer) + return _FollowOutputTransferTypeConverter().from_transfer_type( + answer, FollowOutput + ) diff --git a/temporalio/contrib/streams/nexus/_generated/models.py b/temporalio/contrib/streams/nexus/_generated/models.py new file mode 100644 index 0000000000..6be4e72f62 --- /dev/null +++ b/temporalio/contrib/streams/nexus/_generated/models.py @@ -0,0 +1,5235 @@ +# Generated by nexgen v0.2.8. DO NOT EDIT! + +from __future__ import annotations + +import base64 +import dataclasses +import json +import typing + +import typing_extensions + +import temporalio.contrib.streams +import temporalio.converter +import temporalio.exceptions + +from ._definitions import ( + Violation, + _collect, + _format_base64, + _member_path, + _parse_base64, + _parse_spec_integer, + _transfer_type_convertible, +) + +_APPEND_OUTPUT_DECLARED: frozenset[str] = frozenset({"cursor"}) + + +_FOLLOW_OUTPUT_DECLARED: frozenset[str] = frozenset( + {"records", "done", "state", "sources", "replay"} +) + + +_FOLLOW_OUTPUT_REPLAY_DECLARED: frozenset[str] = frozenset({"ranges", "follow_digest"}) + + +_FOLLOW_REPLAY_DECLARED: frozenset[str] = frozenset({"ranges", "follow_digest"}) + + +_FOLLOW_SOURCE_WIRE_DECLARED: frozenset[str] = frozenset( + {"number", "parent", "depth", "stream", "name"} +) + + +_FOLLOWED_WIRE_DECLARED: frozenset[str] = frozenset({"source", "record", "link_state"}) + + +_FOLLOWED_WIRE_LINK_STATE_DECLARED: frozenset[str] = frozenset( + {"kind", "link", "error", "reason"} +) + + +_FOLLOWED_WIRE_RECORD_DECLARED: frozenset[str] = frozenset( + {"token", "record", "error", "stale", "superseded"} +) + + +_FOLLOWED_WIRE_RECORD_SUPERSEDED_DECLARED: frozenset[str] = frozenset( + {"producer_id", "previous_attempt", "attempt"} +) + + +_LINK_STATE_WIRE_DECLARED: frozenset[str] = frozenset( + {"kind", "link", "error", "reason"} +) + + +_READ_OUTPUT_DECLARED: frozenset[str] = frozenset( + {"records", "next_token", "done", "state", "replay"} +) + + +_READ_OUTPUT_REPLAY_DECLARED: frozenset[str] = frozenset( + {"chain_first_run_id", "after_token", "stored_records", "records_digest"} +) + + +_RECORD_WIRE_DECLARED: frozenset[str] = frozenset( + {"token", "record", "error", "stale", "superseded"} +) + + +_RECORD_WIRE_SUPERSEDED_DECLARED: frozenset[str] = frozenset( + {"producer_id", "previous_attempt", "attempt"} +) + + +_REPLAY_RANGE_DECLARED: frozenset[str] = frozenset( + {"chain_first_run_id", "after_token", "stored_records", "records_digest"} +) + + +_SUPERSESSION_DECLARED: frozenset[str] = frozenset( + {"producer_id", "previous_attempt", "attempt"} +) + + +class _AppendInputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["AppendInput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["AppendInput"] + ) -> "AppendInput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + stream_value: StreamRef = typing.cast("typing.Any", None) + if "stream" not in raw or raw["stream"] is None: + violations.append(Violation(path="stream", reason="required")) + else: + stream_value_raw = raw["stream"] + try: + stream_value = _StreamRefTransferTypeConverter().from_transfer_type( + stream_value_raw, StreamRef + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "stream", error) + + producer_id_value: str = typing.cast("typing.Any", None) + if "producer_id" not in raw or raw["producer_id"] is None: + violations.append(Violation(path="producer_id", reason="required")) + else: + producer_id_value_raw = raw["producer_id"] + if not isinstance(producer_id_value_raw, str): + violations.append( + Violation(path="producer_id", reason="expected string") + ) + else: + producer_id_value = producer_id_value_raw + + attempt_value: int = typing.cast("typing.Any", None) + if "attempt" not in raw or raw["attempt"] is None: + violations.append(Violation(path="attempt", reason="required")) + else: + attempt_value_raw = raw["attempt"] + attempt_value_parsed = _parse_spec_integer( + attempt_value_raw, "attempt", violations + ) + if attempt_value_parsed is not None: + attempt_value = attempt_value_parsed + if attempt_value < 1: + violations.append( + Violation( + path="attempt", reason=f"must be >= 1, got {attempt_value}" + ) + ) + + sequence_value: int = typing.cast("typing.Any", None) + if "sequence" not in raw or raw["sequence"] is None: + violations.append(Violation(path="sequence", reason="required")) + else: + sequence_value_raw = raw["sequence"] + sequence_value_parsed = _parse_spec_integer( + sequence_value_raw, "sequence", violations + ) + if sequence_value_parsed is not None: + sequence_value = sequence_value_parsed + if sequence_value < 1: + violations.append( + Violation( + path="sequence", + reason=f"must be >= 1, got {sequence_value}", + ) + ) + + payloads_value: list[bytes] | None = None + if "payloads" in raw: + payloads_value_raw = raw["payloads"] + if payloads_value_raw is None: + violations.append( + Violation(path="payloads", reason="explicit null not allowed") + ) + else: + if not isinstance(payloads_value_raw, list): + violations.append( + Violation(path="payloads", reason="expected array") + ) + else: + payloads_value_list: list[bytes] = [] + for payloads_value_index, payloads_value_element in enumerate( + typing.cast("list[typing.Any]", payloads_value_raw) + ): + payloads_value_item_path = f"payloads[{payloads_value_index}]" + payloads_value_item_violation_count = len(violations) + payloads_value_item: bytes = typing.cast("typing.Any", None) + if not isinstance(payloads_value_element, str): + violations.append( + Violation( + path=payloads_value_item_path, + reason="expected string", + ) + ) + else: + payloads_value_item_parsed = _parse_base64( + payloads_value_element, + payloads_value_item_path, + violations, + ) + if payloads_value_item_parsed is not None: + payloads_value_item = payloads_value_item_parsed + if len(violations) == payloads_value_item_violation_count: + payloads_value_list.append(payloads_value_item) + payloads_value = payloads_value_list + + finish_value: bool | None = None + if "finish" in raw: + finish_value_raw = raw["finish"] + if finish_value_raw is None: + violations.append( + Violation(path="finish", reason="explicit null not allowed") + ) + else: + if not isinstance(finish_value_raw, bool): + violations.append( + Violation(path="finish", reason="expected boolean") + ) + else: + finish_value = finish_value_raw + + for key in raw: + if ( + key != "stream" + and key != "producer_id" + and key != "attempt" + and key != "sequence" + and key != "payloads" + and key != "finish" + ): + violations.append( + Violation(path=_member_path(key), reason="unknown field") + ) + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return AppendInput( + stream=stream_value, + producer_id=producer_id_value, + attempt=attempt_value, + sequence=sequence_value, + payloads=payloads_value, + finish=finish_value, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "AppendInput") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, AppendInput): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + stream_value: typing.Any = runtime_value.stream + if stream_value is None: + violations.append(Violation(path="stream", reason="required")) + else: + stream_violation_count = len(violations) + if len(violations) == stream_violation_count: + try: + out["stream"] = _StreamRefTransferTypeConverter().to_transfer_type( + stream_value + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "stream", error) + producer_id_value: typing.Any = runtime_value.producer_id + if producer_id_value is None: + violations.append(Violation(path="producer_id", reason="required")) + else: + if not (isinstance(producer_id_value, str)): + violations.append( + Violation(path="producer_id", reason="expected string") + ) + out["producer_id"] = producer_id_value + attempt_value: typing.Any = runtime_value.attempt + if attempt_value is None: + violations.append(Violation(path="attempt", reason="required")) + else: + if not ( + not isinstance(attempt_value, bool) + and ( + isinstance(attempt_value, int) + or (isinstance(attempt_value, float) and attempt_value.is_integer()) + ) + ): + violations.append(Violation(path="attempt", reason="expected integer")) + else: + if abs(attempt_value) > 9007199254740991: + violations.append( + Violation( + path="attempt", reason="exceeds ±(2^53-1) integer cap" + ) + ) + if attempt_value < 1: + violations.append( + Violation( + path="attempt", reason=f"must be >= 1, got {attempt_value}" + ) + ) + out["attempt"] = attempt_value + sequence_value: typing.Any = runtime_value.sequence + if sequence_value is None: + violations.append(Violation(path="sequence", reason="required")) + else: + if not ( + not isinstance(sequence_value, bool) + and ( + isinstance(sequence_value, int) + or ( + isinstance(sequence_value, float) + and sequence_value.is_integer() + ) + ) + ): + violations.append(Violation(path="sequence", reason="expected integer")) + else: + if abs(sequence_value) > 9007199254740991: + violations.append( + Violation( + path="sequence", reason="exceeds ±(2^53-1) integer cap" + ) + ) + if sequence_value < 1: + violations.append( + Violation( + path="sequence", + reason=f"must be >= 1, got {sequence_value}", + ) + ) + out["sequence"] = sequence_value + payloads_value: typing.Any = runtime_value.payloads + if payloads_value is not None: + payloads_violation_count = len(violations) + if not (isinstance(payloads_value, list)): + violations.append(Violation(path="payloads", reason="expected array")) + else: + checked_array_8 = typing.cast("list[typing.Any]", payloads_value) + for item_index_8, item_element_8 in enumerate(checked_array_8): + if not (isinstance(item_element_8, bytes)): + violations.append( + Violation( + path=f"payloads[{item_index_8}]", + reason="expected bytes", + ) + ) + if len(violations) == payloads_violation_count: + payloads_checked = typing.cast("list[typing.Any]", payloads_value) + out["payloads"] = [ + _format_base64(element) for element in payloads_checked + ] + finish_value: typing.Any = runtime_value.finish + if finish_value is not None: + if not (isinstance(finish_value, bool)): + violations.append(Violation(path="finish", reason="expected boolean")) + out["finish"] = finish_value + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_AppendInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class AppendInput: + """One append call: who writes, where, and what.""" + + stream: StreamRef + + producer_id: str + """Names the writer across its retries, so the store can deduplicate a retry and a + reader can tell a retry from a new attempt. Never empty. + """ + + attempt: int + """The producer attempt this call writes. A higher attempt supersedes a lower one, and + its sequence starts again at one. + """ + + sequence: int + """The sequence of the first record this call writes. Records of an attempt are + numbered from one with no gaps, so it continues where the attempt's previous call + ended, or repeats that call's sequence for a retry. + """ + + payloads: list[bytes] | None = None + """The record bodies, in order, each a serialized temporal.api.common.v1.Payload as the + caller's payload converter left it, before any codec. The call crosses encoded as a + whole by the caller's codec, like any Nexus input, and the store encodes each body + with the serving Worker's codec, so a body is never plaintext in transit or at rest. + The store recognizes a retry by the bodies' plaintext, so a codec that encodes with + a fresh nonce does not make a retry look like other content. A call with a record + larger than the handler's per-record limit (1.5 MiB by default, below what one read + result can carry) is refused, so every record can be read. Empty on a call that + finishes. + """ + + finish: bool | None = None + """Write FINISH for this producer at sequence: it writes nothing more on the topic. A + finishing call carries no payloads, so every call is one store write and a retry of + it is deduplicated whole. Says nothing about the producer's outcome. + """ + + +class _AppendOutputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["AppendOutput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["AppendOutput"] + ) -> "AppendOutput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + cursor_value: str = typing.cast("typing.Any", None) + if "cursor" not in raw or raw["cursor"] is None: + violations.append(Violation(path="cursor", reason="required")) + else: + cursor_value_raw = raw["cursor"] + if not isinstance(cursor_value_raw, str): + violations.append(Violation(path="cursor", reason="expected string")) + else: + cursor_value = cursor_value_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _APPEND_OUTPUT_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return AppendOutput( + cursor=cursor_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "AppendOutput") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, AppendOutput): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + cursor_value: typing.Any = runtime_value.cursor + if cursor_value is None: + violations.append(Violation(path="cursor", reason="required")) + else: + if not (isinstance(cursor_value, str)): + violations.append(Violation(path="cursor", reason="expected string")) + out["cursor"] = cursor_value + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _APPEND_OUTPUT_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_AppendOutputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class AppendOutput: + """Where the append landed.""" + + cursor: str + """The cursor of the last record this call wrote, or of the original's last record when + the call was a deduplicated retry. + """ + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +class _FollowInputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["FollowInput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["FollowInput"] + ) -> "FollowInput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + stream_value: StreamRef = typing.cast("typing.Any", None) + if "stream" not in raw or raw["stream"] is None: + violations.append(Violation(path="stream", reason="required")) + else: + stream_value_raw = raw["stream"] + try: + stream_value = _StreamRefTransferTypeConverter().from_transfer_type( + stream_value_raw, StreamRef + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "stream", error) + + after_token_value: str | None = None + if "after_token" in raw: + after_token_value_raw = raw["after_token"] + if after_token_value_raw is None: + violations.append( + Violation(path="after_token", reason="explicit null not allowed") + ) + else: + if not isinstance(after_token_value_raw, str): + violations.append( + Violation(path="after_token", reason="expected string") + ) + else: + after_token_value = after_token_value_raw + + state_value: bytes | None = None + if "state" in raw: + state_value_raw = raw["state"] + if state_value_raw is None: + violations.append( + Violation(path="state", reason="explicit null not allowed") + ) + else: + if not isinstance(state_value_raw, str): + violations.append(Violation(path="state", reason="expected string")) + else: + state_value_parsed = _parse_base64( + state_value_raw, "state", violations + ) + if state_value_parsed is not None: + state_value = state_value_parsed + + max_records_value: int | None = None + if "max_records" in raw: + max_records_value_raw = raw["max_records"] + if max_records_value_raw is None: + violations.append( + Violation(path="max_records", reason="explicit null not allowed") + ) + else: + max_records_value_parsed = _parse_spec_integer( + max_records_value_raw, "max_records", violations + ) + if max_records_value_parsed is not None: + max_records_value = max_records_value_parsed + if max_records_value < 1: + violations.append( + Violation( + path="max_records", + reason=f"must be >= 1, got {max_records_value}", + ) + ) + if max_records_value > 1000: + violations.append( + Violation( + path="max_records", + reason=f"must be <= 1000, got {max_records_value}", + ) + ) + + wait_ms_value: int | None = None + if "wait_ms" in raw: + wait_ms_value_raw = raw["wait_ms"] + if wait_ms_value_raw is None: + violations.append( + Violation(path="wait_ms", reason="explicit null not allowed") + ) + else: + wait_ms_value_parsed = _parse_spec_integer( + wait_ms_value_raw, "wait_ms", violations + ) + if wait_ms_value_parsed is not None: + wait_ms_value = wait_ms_value_parsed + if wait_ms_value < 0: + violations.append( + Violation( + path="wait_ms", + reason=f"must be >= 0, got {wait_ms_value}", + ) + ) + if wait_ms_value > 60000: + violations.append( + Violation( + path="wait_ms", + reason=f"must be <= 60000, got {wait_ms_value}", + ) + ) + + max_depth_value: int | None = None + if "max_depth" in raw: + max_depth_value_raw = raw["max_depth"] + if max_depth_value_raw is None: + violations.append( + Violation(path="max_depth", reason="explicit null not allowed") + ) + else: + max_depth_value_parsed = _parse_spec_integer( + max_depth_value_raw, "max_depth", violations + ) + if max_depth_value_parsed is not None: + max_depth_value = max_depth_value_parsed + if max_depth_value < 1: + violations.append( + Violation( + path="max_depth", + reason=f"must be >= 1, got {max_depth_value}", + ) + ) + if max_depth_value > 16: + violations.append( + Violation( + path="max_depth", + reason=f"must be <= 16, got {max_depth_value}", + ) + ) + + max_open_value: int | None = None + if "max_open" in raw: + max_open_value_raw = raw["max_open"] + if max_open_value_raw is None: + violations.append( + Violation(path="max_open", reason="explicit null not allowed") + ) + else: + max_open_value_parsed = _parse_spec_integer( + max_open_value_raw, "max_open", violations + ) + if max_open_value_parsed is not None: + max_open_value = max_open_value_parsed + if max_open_value < 1: + violations.append( + Violation( + path="max_open", + reason=f"must be >= 1, got {max_open_value}", + ) + ) + if max_open_value > 32: + violations.append( + Violation( + path="max_open", + reason=f"must be <= 32, got {max_open_value}", + ) + ) + + floor_holder_value: str | None = None + if "floor_holder" in raw: + floor_holder_value_raw = raw["floor_holder"] + if floor_holder_value_raw is None: + violations.append( + Violation(path="floor_holder", reason="explicit null not allowed") + ) + else: + if not isinstance(floor_holder_value_raw, str): + violations.append( + Violation(path="floor_holder", reason="expected string") + ) + else: + floor_holder_value = floor_holder_value_raw + + wake_token_value: str | None = None + if "wake_token" in raw: + wake_token_value_raw = raw["wake_token"] + if wake_token_value_raw is None: + violations.append( + Violation(path="wake_token", reason="explicit null not allowed") + ) + else: + if not isinstance(wake_token_value_raw, str): + violations.append( + Violation(path="wake_token", reason="expected string") + ) + else: + wake_token_value = wake_token_value_raw + + for key in raw: + if ( + key != "stream" + and key != "after_token" + and key != "state" + and key != "max_records" + and key != "wait_ms" + and key != "max_depth" + and key != "max_open" + and key != "floor_holder" + and key != "wake_token" + ): + violations.append( + Violation(path=_member_path(key), reason="unknown field") + ) + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return FollowInput( + stream=stream_value, + after_token=after_token_value, + state=state_value, + max_records=max_records_value, + wait_ms=wait_ms_value, + max_depth=max_depth_value, + max_open=max_open_value, + floor_holder=floor_holder_value, + wake_token=wake_token_value, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "FollowInput") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, FollowInput): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + stream_value: typing.Any = runtime_value.stream + if stream_value is None: + violations.append(Violation(path="stream", reason="required")) + else: + stream_violation_count = len(violations) + if len(violations) == stream_violation_count: + try: + out["stream"] = _StreamRefTransferTypeConverter().to_transfer_type( + stream_value + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "stream", error) + after_token_value: typing.Any = runtime_value.after_token + if after_token_value is not None: + if not (isinstance(after_token_value, str)): + violations.append( + Violation(path="after_token", reason="expected string") + ) + out["after_token"] = after_token_value + state_value: typing.Any = runtime_value.state + if state_value is not None: + state_violation_count = len(violations) + if not (isinstance(state_value, bytes)): + violations.append(Violation(path="state", reason="expected bytes")) + if len(violations) == state_violation_count: + out["state"] = _format_base64(state_value) + max_records_value: typing.Any = runtime_value.max_records + if max_records_value is not None: + if not ( + not isinstance(max_records_value, bool) + and ( + isinstance(max_records_value, int) + or ( + isinstance(max_records_value, float) + and max_records_value.is_integer() + ) + ) + ): + violations.append( + Violation(path="max_records", reason="expected integer") + ) + else: + if abs(max_records_value) > 9007199254740991: + violations.append( + Violation( + path="max_records", reason="exceeds ±(2^53-1) integer cap" + ) + ) + if max_records_value < 1: + violations.append( + Violation( + path="max_records", + reason=f"must be >= 1, got {max_records_value}", + ) + ) + if max_records_value > 1000: + violations.append( + Violation( + path="max_records", + reason=f"must be <= 1000, got {max_records_value}", + ) + ) + out["max_records"] = max_records_value + wait_ms_value: typing.Any = runtime_value.wait_ms + if wait_ms_value is not None: + if not ( + not isinstance(wait_ms_value, bool) + and ( + isinstance(wait_ms_value, int) + or (isinstance(wait_ms_value, float) and wait_ms_value.is_integer()) + ) + ): + violations.append(Violation(path="wait_ms", reason="expected integer")) + else: + if abs(wait_ms_value) > 9007199254740991: + violations.append( + Violation( + path="wait_ms", reason="exceeds ±(2^53-1) integer cap" + ) + ) + if wait_ms_value < 0: + violations.append( + Violation( + path="wait_ms", reason=f"must be >= 0, got {wait_ms_value}" + ) + ) + if wait_ms_value > 60000: + violations.append( + Violation( + path="wait_ms", + reason=f"must be <= 60000, got {wait_ms_value}", + ) + ) + out["wait_ms"] = wait_ms_value + max_depth_value: typing.Any = runtime_value.max_depth + if max_depth_value is not None: + if not ( + not isinstance(max_depth_value, bool) + and ( + isinstance(max_depth_value, int) + or ( + isinstance(max_depth_value, float) + and max_depth_value.is_integer() + ) + ) + ): + violations.append( + Violation(path="max_depth", reason="expected integer") + ) + else: + if abs(max_depth_value) > 9007199254740991: + violations.append( + Violation( + path="max_depth", reason="exceeds ±(2^53-1) integer cap" + ) + ) + if max_depth_value < 1: + violations.append( + Violation( + path="max_depth", + reason=f"must be >= 1, got {max_depth_value}", + ) + ) + if max_depth_value > 16: + violations.append( + Violation( + path="max_depth", + reason=f"must be <= 16, got {max_depth_value}", + ) + ) + out["max_depth"] = max_depth_value + max_open_value: typing.Any = runtime_value.max_open + if max_open_value is not None: + if not ( + not isinstance(max_open_value, bool) + and ( + isinstance(max_open_value, int) + or ( + isinstance(max_open_value, float) + and max_open_value.is_integer() + ) + ) + ): + violations.append(Violation(path="max_open", reason="expected integer")) + else: + if abs(max_open_value) > 9007199254740991: + violations.append( + Violation( + path="max_open", reason="exceeds ±(2^53-1) integer cap" + ) + ) + if max_open_value < 1: + violations.append( + Violation( + path="max_open", + reason=f"must be >= 1, got {max_open_value}", + ) + ) + if max_open_value > 32: + violations.append( + Violation( + path="max_open", + reason=f"must be <= 32, got {max_open_value}", + ) + ) + out["max_open"] = max_open_value + floor_holder_value: typing.Any = runtime_value.floor_holder + if floor_holder_value is not None: + if not (isinstance(floor_holder_value, str)): + violations.append( + Violation(path="floor_holder", reason="expected string") + ) + out["floor_holder"] = floor_holder_value + wake_token_value: typing.Any = runtime_value.wake_token + if wake_token_value is not None: + if not (isinstance(wake_token_value, str)): + violations.append( + Violation(path="wake_token", reason="expected string") + ) + out["wake_token"] = wake_token_value + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_FollowInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class FollowInput: + """One follow call: the root stream, where to resume, the limits and the wait.""" + + stream: StreamRef + + after_token: str | None = None + """The root cursor on the follow's first call. Absent or empty starts at the oldest + record the root retains. A call with state ignores it. + """ + + state: bytes | None = None + """The state of this follow's previous answer, opaque to the caller. Absent on the + first call. It holds every open stream's position. + """ + + max_records: int | None = None + """Answer with at most this many stored records, across all streams. 100 when absent.""" + + wait_ms: int | None = None + """Wait at most this long for a record on any open stream before answering. The handler + shortens it to fit the request deadline. + """ + + max_depth: int | None = None + """How deep links are followed, the root being depth zero. 4 when absent. The first + call's limits hold for the whole follow. + """ + + max_open: int | None = None + """How many linked streams are open at once. 16 when absent.""" + + floor_holder: str | None = None + """As on read: each stream holds its records for this holder, and the answer carries + replay. At most 480 bytes. + """ + + wake_token: str | None = None + """The operation token of the caller's stream operation on the root. When the call + answers no records and not done, the endpoint asks the root's notifier to wake that + operation once the root moves, then follows once more. Refused when the token names + another stream. + """ + + +class _FollowOutputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["FollowOutput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["FollowOutput"] + ) -> "FollowOutput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + records_value: list[FollowedWire] = typing.cast("typing.Any", None) + if "records" not in raw or raw["records"] is None: + violations.append(Violation(path="records", reason="required")) + else: + records_value_raw = raw["records"] + if not isinstance(records_value_raw, list): + violations.append(Violation(path="records", reason="expected array")) + else: + records_value_list: list[FollowedWire] = [] + for records_value_index, records_value_element in enumerate( + typing.cast("list[typing.Any]", records_value_raw) + ): + records_value_item_path = f"records[{records_value_index}]" + records_value_item_violation_count = len(violations) + records_value_item: FollowedWire = typing.cast("typing.Any", None) + try: + records_value_item = ( + _FollowedWireTransferTypeConverter().from_transfer_type( + records_value_element, FollowedWire + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, records_value_item_path, error) + if len(violations) == records_value_item_violation_count: + records_value_list.append(records_value_item) + records_value = records_value_list + + done_value: bool = typing.cast("typing.Any", None) + if "done" not in raw or raw["done"] is None: + violations.append(Violation(path="done", reason="required")) + else: + done_value_raw = raw["done"] + if not isinstance(done_value_raw, bool): + violations.append(Violation(path="done", reason="expected boolean")) + else: + done_value = done_value_raw + + state_value: bytes | None = None + if "state" in raw: + state_value_raw = raw["state"] + if state_value_raw is None: + violations.append( + Violation(path="state", reason="explicit null not allowed") + ) + else: + if not isinstance(state_value_raw, str): + violations.append(Violation(path="state", reason="expected string")) + else: + state_value_parsed = _parse_base64( + state_value_raw, "state", violations + ) + if state_value_parsed is not None: + state_value = state_value_parsed + + sources_value: list[FollowSourceWire] = typing.cast("typing.Any", None) + if "sources" not in raw or raw["sources"] is None: + violations.append(Violation(path="sources", reason="required")) + else: + sources_value_raw = raw["sources"] + if not isinstance(sources_value_raw, list): + violations.append(Violation(path="sources", reason="expected array")) + else: + sources_value_list: list[FollowSourceWire] = [] + for sources_value_index, sources_value_element in enumerate( + typing.cast("list[typing.Any]", sources_value_raw) + ): + sources_value_item_path = f"sources[{sources_value_index}]" + sources_value_item_violation_count = len(violations) + sources_value_item: FollowSourceWire = typing.cast( + "typing.Any", None + ) + try: + sources_value_item = ( + _FollowSourceWireTransferTypeConverter().from_transfer_type( + sources_value_element, FollowSourceWire + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, sources_value_item_path, error) + if len(violations) == sources_value_item_violation_count: + sources_value_list.append(sources_value_item) + sources_value = sources_value_list + + replay_value: FollowOutputReplay | None = None + if "replay" in raw: + replay_value_raw = raw["replay"] + if replay_value_raw is None: + violations.append( + Violation(path="replay", reason="explicit null not allowed") + ) + else: + try: + replay_value = ( + _FollowOutputReplayTransferTypeConverter().from_transfer_type( + replay_value_raw, FollowOutputReplay + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "replay", error) + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _FOLLOW_OUTPUT_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return FollowOutput( + records=records_value, + done=done_value, + state=state_value, + sources=sources_value, + replay=replay_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "FollowOutput") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, FollowOutput): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + records_value: typing.Any = runtime_value.records + if records_value is None: + violations.append(Violation(path="records", reason="required")) + else: + records_violation_count = len(violations) + if not (isinstance(records_value, list)): + violations.append(Violation(path="records", reason="expected array")) + if len(violations) == records_violation_count: + records_checked = typing.cast("list[typing.Any]", records_value) + records_out: list[typing.Any] = [] + for records_index, records_element in enumerate(records_checked): + try: + records_out.append( + _FollowedWireTransferTypeConverter().to_transfer_type( + records_element + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, f"records[{records_index}]", error) + out["records"] = records_out + done_value: typing.Any = runtime_value.done + if done_value is None: + violations.append(Violation(path="done", reason="required")) + else: + if not (isinstance(done_value, bool)): + violations.append(Violation(path="done", reason="expected boolean")) + out["done"] = done_value + state_value: typing.Any = runtime_value.state + if state_value is not None: + state_violation_count = len(violations) + if not (isinstance(state_value, bytes)): + violations.append(Violation(path="state", reason="expected bytes")) + if len(violations) == state_violation_count: + out["state"] = _format_base64(state_value) + sources_value: typing.Any = runtime_value.sources + if sources_value is None: + violations.append(Violation(path="sources", reason="required")) + else: + sources_violation_count = len(violations) + if not (isinstance(sources_value, list)): + violations.append(Violation(path="sources", reason="expected array")) + if len(violations) == sources_violation_count: + sources_checked = typing.cast("list[typing.Any]", sources_value) + sources_out: list[typing.Any] = [] + for sources_index, sources_element in enumerate(sources_checked): + try: + sources_out.append( + _FollowSourceWireTransferTypeConverter().to_transfer_type( + sources_element + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, f"sources[{sources_index}]", error) + out["sources"] = sources_out + replay_value: typing.Any = runtime_value.replay + if replay_value is not None: + replay_violation_count = len(violations) + if len(violations) == replay_violation_count: + try: + out["replay"] = ( + _FollowOutputReplayTransferTypeConverter().to_transfer_type( + replay_value + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "replay", error) + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _FOLLOW_OUTPUT_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_FollowOutputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class FollowOutput: + """What one follow answered, and where to resume.""" + + records: list[FollowedWire] + """The items, in follow order.""" + + done: bool + """The root ended and every linked segment ended. Nothing more will arrive, so the + caller stops. + """ + + state: bytes | None = None + """Pass as state on the next call of this follow. The service always sets it. At most + 64 KiB, which is about 86 KiB as base64. + """ + + sources: list[FollowSourceWire] + """Each source that records name.""" + + replay: FollowOutputReplay | None = None + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +class _FollowOutputReplayTransferTypeConverter( + temporalio.converter.TransferTypeConverter["FollowOutputReplay", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["FollowOutputReplay"] + ) -> "FollowOutputReplay": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + ranges_value: list[bytes] = typing.cast("typing.Any", None) + if "ranges" not in raw or raw["ranges"] is None: + violations.append(Violation(path="ranges", reason="required")) + else: + ranges_value_raw = raw["ranges"] + if not isinstance(ranges_value_raw, list): + violations.append(Violation(path="ranges", reason="expected array")) + else: + ranges_value_list: list[bytes] = [] + for ranges_value_index, ranges_value_element in enumerate( + typing.cast("list[typing.Any]", ranges_value_raw) + ): + ranges_value_item_path = f"ranges[{ranges_value_index}]" + ranges_value_item_violation_count = len(violations) + ranges_value_item: bytes = typing.cast("typing.Any", None) + if not isinstance(ranges_value_element, str): + violations.append( + Violation( + path=ranges_value_item_path, reason="expected string" + ) + ) + else: + ranges_value_item_parsed = _parse_base64( + ranges_value_element, ranges_value_item_path, violations + ) + if ranges_value_item_parsed is not None: + ranges_value_item = ranges_value_item_parsed + if len(violations) == ranges_value_item_violation_count: + ranges_value_list.append(ranges_value_item) + ranges_value = ranges_value_list + + follow_digest_value: bytes = typing.cast("typing.Any", None) + if "follow_digest" not in raw or raw["follow_digest"] is None: + violations.append(Violation(path="follow_digest", reason="required")) + else: + follow_digest_value_raw = raw["follow_digest"] + if not isinstance(follow_digest_value_raw, str): + violations.append( + Violation(path="follow_digest", reason="expected string") + ) + else: + follow_digest_value_parsed = _parse_base64( + follow_digest_value_raw, "follow_digest", violations + ) + if follow_digest_value_parsed is not None: + follow_digest_value = follow_digest_value_parsed + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _FOLLOW_OUTPUT_REPLAY_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return FollowOutputReplay( + ranges=ranges_value, + follow_digest=follow_digest_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "FollowOutputReplay") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, FollowOutputReplay): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + ranges_value: typing.Any = runtime_value.ranges + if ranges_value is None: + violations.append(Violation(path="ranges", reason="required")) + else: + ranges_violation_count = len(violations) + if not (isinstance(ranges_value, list)): + violations.append(Violation(path="ranges", reason="expected array")) + else: + checked_array_8 = typing.cast("list[typing.Any]", ranges_value) + for item_index_8, item_element_8 in enumerate(checked_array_8): + if not (isinstance(item_element_8, bytes)): + violations.append( + Violation( + path=f"ranges[{item_index_8}]", reason="expected bytes" + ) + ) + if len(violations) == ranges_violation_count: + ranges_checked = typing.cast("list[typing.Any]", ranges_value) + out["ranges"] = [_format_base64(element) for element in ranges_checked] + follow_digest_value: typing.Any = runtime_value.follow_digest + if follow_digest_value is None: + violations.append(Violation(path="follow_digest", reason="required")) + else: + follow_digest_violation_count = len(violations) + if not (isinstance(follow_digest_value, bytes)): + violations.append( + Violation(path="follow_digest", reason="expected bytes") + ) + if len(violations) == follow_digest_violation_count: + out["follow_digest"] = _format_base64(follow_digest_value) + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _FOLLOW_OUTPUT_REPLAY_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_FollowOutputReplayTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class FollowOutputReplay: + """Set when the call named a floor_holder.""" + + ranges: list[bytes] + """One opaque range per read the follow made, in read order.""" + + follow_digest: bytes + """SHA-256 over the order of the answer's items.""" + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +class _FollowRangeInputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["FollowRangeInput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["FollowRangeInput"] + ) -> "FollowRangeInput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + stream_value: StreamRef = typing.cast("typing.Any", None) + if "stream" not in raw or raw["stream"] is None: + violations.append(Violation(path="stream", reason="required")) + else: + stream_value_raw = raw["stream"] + try: + stream_value = _StreamRefTransferTypeConverter().from_transfer_type( + stream_value_raw, StreamRef + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "stream", error) + + state_value: bytes | None = None + if "state" in raw: + state_value_raw = raw["state"] + if state_value_raw is None: + violations.append( + Violation(path="state", reason="explicit null not allowed") + ) + else: + if not isinstance(state_value_raw, str): + violations.append(Violation(path="state", reason="expected string")) + else: + state_value_parsed = _parse_base64( + state_value_raw, "state", violations + ) + if state_value_parsed is not None: + state_value = state_value_parsed + + ranges_value: list[bytes] = typing.cast("typing.Any", None) + if "ranges" not in raw or raw["ranges"] is None: + violations.append(Violation(path="ranges", reason="required")) + else: + ranges_value_raw = raw["ranges"] + if not isinstance(ranges_value_raw, list): + violations.append(Violation(path="ranges", reason="expected array")) + else: + ranges_value_list: list[bytes] = [] + for ranges_value_index, ranges_value_element in enumerate( + typing.cast("list[typing.Any]", ranges_value_raw) + ): + ranges_value_item_path = f"ranges[{ranges_value_index}]" + ranges_value_item_violation_count = len(violations) + ranges_value_item: bytes = typing.cast("typing.Any", None) + if not isinstance(ranges_value_element, str): + violations.append( + Violation( + path=ranges_value_item_path, reason="expected string" + ) + ) + else: + ranges_value_item_parsed = _parse_base64( + ranges_value_element, ranges_value_item_path, violations + ) + if ranges_value_item_parsed is not None: + ranges_value_item = ranges_value_item_parsed + if len(violations) == ranges_value_item_violation_count: + ranges_value_list.append(ranges_value_item) + ranges_value = ranges_value_list + + follow_digest_value: bytes = typing.cast("typing.Any", None) + if "follow_digest" not in raw or raw["follow_digest"] is None: + violations.append(Violation(path="follow_digest", reason="required")) + else: + follow_digest_value_raw = raw["follow_digest"] + if not isinstance(follow_digest_value_raw, str): + violations.append( + Violation(path="follow_digest", reason="expected string") + ) + else: + follow_digest_value_parsed = _parse_base64( + follow_digest_value_raw, "follow_digest", violations + ) + if follow_digest_value_parsed is not None: + follow_digest_value = follow_digest_value_parsed + + max_depth_value: int | None = None + if "max_depth" in raw: + max_depth_value_raw = raw["max_depth"] + if max_depth_value_raw is None: + violations.append( + Violation(path="max_depth", reason="explicit null not allowed") + ) + else: + max_depth_value_parsed = _parse_spec_integer( + max_depth_value_raw, "max_depth", violations + ) + if max_depth_value_parsed is not None: + max_depth_value = max_depth_value_parsed + if max_depth_value < 1: + violations.append( + Violation( + path="max_depth", + reason=f"must be >= 1, got {max_depth_value}", + ) + ) + if max_depth_value > 16: + violations.append( + Violation( + path="max_depth", + reason=f"must be <= 16, got {max_depth_value}", + ) + ) + + max_open_value: int | None = None + if "max_open" in raw: + max_open_value_raw = raw["max_open"] + if max_open_value_raw is None: + violations.append( + Violation(path="max_open", reason="explicit null not allowed") + ) + else: + max_open_value_parsed = _parse_spec_integer( + max_open_value_raw, "max_open", violations + ) + if max_open_value_parsed is not None: + max_open_value = max_open_value_parsed + if max_open_value < 1: + violations.append( + Violation( + path="max_open", + reason=f"must be >= 1, got {max_open_value}", + ) + ) + if max_open_value > 32: + violations.append( + Violation( + path="max_open", + reason=f"must be <= 32, got {max_open_value}", + ) + ) + + floor_holder_value: str | None = None + if "floor_holder" in raw: + floor_holder_value_raw = raw["floor_holder"] + if floor_holder_value_raw is None: + violations.append( + Violation(path="floor_holder", reason="explicit null not allowed") + ) + else: + if not isinstance(floor_holder_value_raw, str): + violations.append( + Violation(path="floor_holder", reason="expected string") + ) + else: + floor_holder_value = floor_holder_value_raw + + for key in raw: + if ( + key != "stream" + and key != "state" + and key != "ranges" + and key != "follow_digest" + and key != "max_depth" + and key != "max_open" + and key != "floor_holder" + ): + violations.append( + Violation(path=_member_path(key), reason="unknown field") + ) + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return FollowRangeInput( + stream=stream_value, + state=state_value, + ranges=ranges_value, + follow_digest=follow_digest_value, + max_depth=max_depth_value, + max_open=max_open_value, + floor_holder=floor_holder_value, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "FollowRangeInput") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, FollowRangeInput): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + stream_value: typing.Any = runtime_value.stream + if stream_value is None: + violations.append(Violation(path="stream", reason="required")) + else: + stream_violation_count = len(violations) + if len(violations) == stream_violation_count: + try: + out["stream"] = _StreamRefTransferTypeConverter().to_transfer_type( + stream_value + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "stream", error) + state_value: typing.Any = runtime_value.state + if state_value is not None: + state_violation_count = len(violations) + if not (isinstance(state_value, bytes)): + violations.append(Violation(path="state", reason="expected bytes")) + if len(violations) == state_violation_count: + out["state"] = _format_base64(state_value) + ranges_value: typing.Any = runtime_value.ranges + if ranges_value is None: + violations.append(Violation(path="ranges", reason="required")) + else: + ranges_violation_count = len(violations) + if not (isinstance(ranges_value, list)): + violations.append(Violation(path="ranges", reason="expected array")) + else: + checked_array_8 = typing.cast("list[typing.Any]", ranges_value) + for item_index_8, item_element_8 in enumerate(checked_array_8): + if not (isinstance(item_element_8, bytes)): + violations.append( + Violation( + path=f"ranges[{item_index_8}]", reason="expected bytes" + ) + ) + if len(violations) == ranges_violation_count: + ranges_checked = typing.cast("list[typing.Any]", ranges_value) + out["ranges"] = [_format_base64(element) for element in ranges_checked] + follow_digest_value: typing.Any = runtime_value.follow_digest + if follow_digest_value is None: + violations.append(Violation(path="follow_digest", reason="required")) + else: + follow_digest_violation_count = len(violations) + if not (isinstance(follow_digest_value, bytes)): + violations.append( + Violation(path="follow_digest", reason="expected bytes") + ) + if len(violations) == follow_digest_violation_count: + out["follow_digest"] = _format_base64(follow_digest_value) + max_depth_value: typing.Any = runtime_value.max_depth + if max_depth_value is not None: + if not ( + not isinstance(max_depth_value, bool) + and ( + isinstance(max_depth_value, int) + or ( + isinstance(max_depth_value, float) + and max_depth_value.is_integer() + ) + ) + ): + violations.append( + Violation(path="max_depth", reason="expected integer") + ) + else: + if abs(max_depth_value) > 9007199254740991: + violations.append( + Violation( + path="max_depth", reason="exceeds ±(2^53-1) integer cap" + ) + ) + if max_depth_value < 1: + violations.append( + Violation( + path="max_depth", + reason=f"must be >= 1, got {max_depth_value}", + ) + ) + if max_depth_value > 16: + violations.append( + Violation( + path="max_depth", + reason=f"must be <= 16, got {max_depth_value}", + ) + ) + out["max_depth"] = max_depth_value + max_open_value: typing.Any = runtime_value.max_open + if max_open_value is not None: + if not ( + not isinstance(max_open_value, bool) + and ( + isinstance(max_open_value, int) + or ( + isinstance(max_open_value, float) + and max_open_value.is_integer() + ) + ) + ): + violations.append(Violation(path="max_open", reason="expected integer")) + else: + if abs(max_open_value) > 9007199254740991: + violations.append( + Violation( + path="max_open", reason="exceeds ±(2^53-1) integer cap" + ) + ) + if max_open_value < 1: + violations.append( + Violation( + path="max_open", + reason=f"must be >= 1, got {max_open_value}", + ) + ) + if max_open_value > 32: + violations.append( + Violation( + path="max_open", + reason=f"must be <= 32, got {max_open_value}", + ) + ) + out["max_open"] = max_open_value + floor_holder_value: typing.Any = runtime_value.floor_holder + if floor_holder_value is not None: + if not (isinstance(floor_holder_value, str)): + violations.append( + Violation(path="floor_holder", reason="expected string") + ) + out["floor_holder"] = floor_holder_value + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_FollowRangeInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class FollowRangeInput: + """One replay of an earlier follow that named a floor_holder. Every member but stream + and floor_holder comes from that follow's input and answer. + """ + + stream: StreamRef + + state: bytes | None = None + """The state the follow sent. Absent when it sent none.""" + + ranges: list[bytes] + """The follow answer's replay ranges, as they came.""" + + follow_digest: bytes + """The follow answer's replay follow_digest.""" + + max_depth: int | None = None + """As the follow's first call sent it, for a replay of that call.""" + + max_open: int | None = None + """As the follow's first call sent it, for a replay of that call.""" + + floor_holder: str | None = None + """As on follow, so a replay renews the holds.""" + + +class _FollowReplayTransferTypeConverter( + temporalio.converter.TransferTypeConverter["FollowReplay", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["FollowReplay"] + ) -> "FollowReplay": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + ranges_value: list[bytes] = typing.cast("typing.Any", None) + if "ranges" not in raw or raw["ranges"] is None: + violations.append(Violation(path="ranges", reason="required")) + else: + ranges_value_raw = raw["ranges"] + if not isinstance(ranges_value_raw, list): + violations.append(Violation(path="ranges", reason="expected array")) + else: + ranges_value_list: list[bytes] = [] + for ranges_value_index, ranges_value_element in enumerate( + typing.cast("list[typing.Any]", ranges_value_raw) + ): + ranges_value_item_path = f"ranges[{ranges_value_index}]" + ranges_value_item_violation_count = len(violations) + ranges_value_item: bytes = typing.cast("typing.Any", None) + if not isinstance(ranges_value_element, str): + violations.append( + Violation( + path=ranges_value_item_path, reason="expected string" + ) + ) + else: + ranges_value_item_parsed = _parse_base64( + ranges_value_element, ranges_value_item_path, violations + ) + if ranges_value_item_parsed is not None: + ranges_value_item = ranges_value_item_parsed + if len(violations) == ranges_value_item_violation_count: + ranges_value_list.append(ranges_value_item) + ranges_value = ranges_value_list + + follow_digest_value: bytes = typing.cast("typing.Any", None) + if "follow_digest" not in raw or raw["follow_digest"] is None: + violations.append(Violation(path="follow_digest", reason="required")) + else: + follow_digest_value_raw = raw["follow_digest"] + if not isinstance(follow_digest_value_raw, str): + violations.append( + Violation(path="follow_digest", reason="expected string") + ) + else: + follow_digest_value_parsed = _parse_base64( + follow_digest_value_raw, "follow_digest", violations + ) + if follow_digest_value_parsed is not None: + follow_digest_value = follow_digest_value_parsed + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _FOLLOW_REPLAY_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return FollowReplay( + ranges=ranges_value, + follow_digest=follow_digest_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "FollowReplay") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, FollowReplay): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + ranges_value: typing.Any = runtime_value.ranges + if ranges_value is None: + violations.append(Violation(path="ranges", reason="required")) + else: + ranges_violation_count = len(violations) + if not (isinstance(ranges_value, list)): + violations.append(Violation(path="ranges", reason="expected array")) + else: + checked_array_8 = typing.cast("list[typing.Any]", ranges_value) + for item_index_8, item_element_8 in enumerate(checked_array_8): + if not (isinstance(item_element_8, bytes)): + violations.append( + Violation( + path=f"ranges[{item_index_8}]", reason="expected bytes" + ) + ) + if len(violations) == ranges_violation_count: + ranges_checked = typing.cast("list[typing.Any]", ranges_value) + out["ranges"] = [_format_base64(element) for element in ranges_checked] + follow_digest_value: typing.Any = runtime_value.follow_digest + if follow_digest_value is None: + violations.append(Violation(path="follow_digest", reason="required")) + else: + follow_digest_violation_count = len(violations) + if not (isinstance(follow_digest_value, bytes)): + violations.append( + Violation(path="follow_digest", reason="expected bytes") + ) + if len(violations) == follow_digest_violation_count: + out["follow_digest"] = _format_base64(follow_digest_value) + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _FOLLOW_REPLAY_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_FollowReplayTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class FollowReplay: + """What a replay passes to follow_range to get a follow answer's records again, with + the state that follow sent. + """ + + ranges: list[bytes] + """One opaque range per read the follow made, in read order.""" + + follow_digest: bytes + """SHA-256 over the order of the answer's items.""" + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +class _FollowSourceWireTransferTypeConverter( + temporalio.converter.TransferTypeConverter["FollowSourceWire", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["FollowSourceWire"] + ) -> "FollowSourceWire": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + number_value: int = typing.cast("typing.Any", None) + if "number" not in raw or raw["number"] is None: + violations.append(Violation(path="number", reason="required")) + else: + number_value_raw = raw["number"] + number_value_parsed = _parse_spec_integer( + number_value_raw, "number", violations + ) + if number_value_parsed is not None: + number_value = number_value_parsed + if number_value < 0: + violations.append( + Violation( + path="number", reason=f"must be >= 0, got {number_value}" + ) + ) + + parent_value: int = typing.cast("typing.Any", None) + if "parent" not in raw or raw["parent"] is None: + violations.append(Violation(path="parent", reason="required")) + else: + parent_value_raw = raw["parent"] + parent_value_parsed = _parse_spec_integer( + parent_value_raw, "parent", violations + ) + if parent_value_parsed is not None: + parent_value = parent_value_parsed + if parent_value < 0: + violations.append( + Violation( + path="parent", reason=f"must be >= 0, got {parent_value}" + ) + ) + + depth_value: int = typing.cast("typing.Any", None) + if "depth" not in raw or raw["depth"] is None: + violations.append(Violation(path="depth", reason="required")) + else: + depth_value_raw = raw["depth"] + depth_value_parsed = _parse_spec_integer( + depth_value_raw, "depth", violations + ) + if depth_value_parsed is not None: + depth_value = depth_value_parsed + if depth_value < 0: + violations.append( + Violation( + path="depth", reason=f"must be >= 0, got {depth_value}" + ) + ) + + stream_value: StreamRef = typing.cast("typing.Any", None) + if "stream" not in raw or raw["stream"] is None: + violations.append(Violation(path="stream", reason="required")) + else: + stream_value_raw = raw["stream"] + try: + stream_value = _StreamRefTransferTypeConverter().from_transfer_type( + stream_value_raw, StreamRef + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "stream", error) + + name_value: str = typing.cast("typing.Any", None) + if "name" not in raw or raw["name"] is None: + violations.append(Violation(path="name", reason="required")) + else: + name_value_raw = raw["name"] + if not isinstance(name_value_raw, str): + violations.append(Violation(path="name", reason="expected string")) + else: + name_value = name_value_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _FOLLOW_SOURCE_WIRE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return FollowSourceWire( + number=number_value, + parent=parent_value, + depth=depth_value, + stream=stream_value, + name=name_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "FollowSourceWire") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, FollowSourceWire): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + number_value: typing.Any = runtime_value.number + if number_value is None: + violations.append(Violation(path="number", reason="required")) + else: + if not ( + not isinstance(number_value, bool) + and ( + isinstance(number_value, int) + or (isinstance(number_value, float) and number_value.is_integer()) + ) + ): + violations.append(Violation(path="number", reason="expected integer")) + else: + if abs(number_value) > 9007199254740991: + violations.append( + Violation(path="number", reason="exceeds ±(2^53-1) integer cap") + ) + if number_value < 0: + violations.append( + Violation( + path="number", reason=f"must be >= 0, got {number_value}" + ) + ) + out["number"] = number_value + parent_value: typing.Any = runtime_value.parent + if parent_value is None: + violations.append(Violation(path="parent", reason="required")) + else: + if not ( + not isinstance(parent_value, bool) + and ( + isinstance(parent_value, int) + or (isinstance(parent_value, float) and parent_value.is_integer()) + ) + ): + violations.append(Violation(path="parent", reason="expected integer")) + else: + if abs(parent_value) > 9007199254740991: + violations.append( + Violation(path="parent", reason="exceeds ±(2^53-1) integer cap") + ) + if parent_value < 0: + violations.append( + Violation( + path="parent", reason=f"must be >= 0, got {parent_value}" + ) + ) + out["parent"] = parent_value + depth_value: typing.Any = runtime_value.depth + if depth_value is None: + violations.append(Violation(path="depth", reason="required")) + else: + if not ( + not isinstance(depth_value, bool) + and ( + isinstance(depth_value, int) + or (isinstance(depth_value, float) and depth_value.is_integer()) + ) + ): + violations.append(Violation(path="depth", reason="expected integer")) + else: + if abs(depth_value) > 9007199254740991: + violations.append( + Violation(path="depth", reason="exceeds ±(2^53-1) integer cap") + ) + if depth_value < 0: + violations.append( + Violation( + path="depth", reason=f"must be >= 0, got {depth_value}" + ) + ) + out["depth"] = depth_value + stream_value: typing.Any = runtime_value.stream + if stream_value is None: + violations.append(Violation(path="stream", reason="required")) + else: + stream_violation_count = len(violations) + if len(violations) == stream_violation_count: + try: + out["stream"] = _StreamRefTransferTypeConverter().to_transfer_type( + stream_value + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "stream", error) + name_value: typing.Any = runtime_value.name + if name_value is None: + violations.append(Violation(path="name", reason="required")) + else: + if not (isinstance(name_value, str)): + violations.append(Violation(path="name", reason="expected string")) + out["name"] = name_value + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _FOLLOW_SOURCE_WIRE_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_FollowSourceWireTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class FollowSourceWire: + """A stream that a follow answer's items name.""" + + number: int + """The source number items use.""" + + parent: int + """The source that holds the link that opened it. 0 for the root.""" + + depth: int + """How many links from the root.""" + + stream: StreamRef + + name: str + """The link's name. Empty for the root.""" + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +class _FollowedWireTransferTypeConverter( + temporalio.converter.TransferTypeConverter["FollowedWire", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["FollowedWire"] + ) -> "FollowedWire": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + source_value: int = typing.cast("typing.Any", None) + if "source" not in raw or raw["source"] is None: + violations.append(Violation(path="source", reason="required")) + else: + source_value_raw = raw["source"] + source_value_parsed = _parse_spec_integer( + source_value_raw, "source", violations + ) + if source_value_parsed is not None: + source_value = source_value_parsed + if source_value < 0: + violations.append( + Violation( + path="source", reason=f"must be >= 0, got {source_value}" + ) + ) + + record_value: FollowedWireRecord | None = None + if "record" in raw: + record_value_raw = raw["record"] + if record_value_raw is None: + violations.append( + Violation(path="record", reason="explicit null not allowed") + ) + else: + try: + record_value = ( + _FollowedWireRecordTransferTypeConverter().from_transfer_type( + record_value_raw, FollowedWireRecord + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "record", error) + + link_state_value: FollowedWireLinkState | None = None + if "link_state" in raw: + link_state_value_raw = raw["link_state"] + if link_state_value_raw is None: + violations.append( + Violation(path="link_state", reason="explicit null not allowed") + ) + else: + try: + link_state_value = _FollowedWireLinkStateTransferTypeConverter().from_transfer_type( + link_state_value_raw, FollowedWireLinkState + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "link_state", error) + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _FOLLOWED_WIRE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return FollowedWire( + source=source_value, + record=record_value, + link_state=link_state_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "FollowedWire") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, FollowedWire): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + source_value: typing.Any = runtime_value.source + if source_value is None: + violations.append(Violation(path="source", reason="required")) + else: + if not ( + not isinstance(source_value, bool) + and ( + isinstance(source_value, int) + or (isinstance(source_value, float) and source_value.is_integer()) + ) + ): + violations.append(Violation(path="source", reason="expected integer")) + else: + if abs(source_value) > 9007199254740991: + violations.append( + Violation(path="source", reason="exceeds ±(2^53-1) integer cap") + ) + if source_value < 0: + violations.append( + Violation( + path="source", reason=f"must be >= 0, got {source_value}" + ) + ) + out["source"] = source_value + record_value: typing.Any = runtime_value.record + if record_value is not None: + record_violation_count = len(violations) + if len(violations) == record_violation_count: + try: + out["record"] = ( + _FollowedWireRecordTransferTypeConverter().to_transfer_type( + record_value + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "record", error) + link_state_value: typing.Any = runtime_value.link_state + if link_state_value is not None: + link_state_violation_count = len(violations) + if len(violations) == link_state_violation_count: + try: + out["link_state"] = ( + _FollowedWireLinkStateTransferTypeConverter().to_transfer_type( + link_state_value + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "link_state", error) + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _FOLLOWED_WIRE_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_FollowedWireTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class FollowedWire: + """One item of a follow: a record of a source, or the state of a link.""" + + source: int + """The stream the item belongs to. 0 is the root. See sources.""" + + record: FollowedWireRecord | None = None + + link_state: FollowedWireLinkState | None = None + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +class _FollowedWireLinkStateTransferTypeConverter( + temporalio.converter.TransferTypeConverter["FollowedWireLinkState", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["FollowedWireLinkState"] + ) -> "FollowedWireLinkState": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + kind_value: str = typing.cast("typing.Any", None) + if "kind" not in raw or raw["kind"] is None: + violations.append(Violation(path="kind", reason="required")) + else: + kind_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + else: + kind_value = kind_value_raw + + link_value: bytes = typing.cast("typing.Any", None) + if "link" not in raw or raw["link"] is None: + violations.append(Violation(path="link", reason="required")) + else: + link_value_raw = raw["link"] + if not isinstance(link_value_raw, str): + violations.append(Violation(path="link", reason="expected string")) + else: + link_value_parsed = _parse_base64(link_value_raw, "link", violations) + if link_value_parsed is not None: + link_value = link_value_parsed + + error_value: str | None = None + if "error" in raw: + error_value_raw = raw["error"] + if error_value_raw is None: + violations.append( + Violation(path="error", reason="explicit null not allowed") + ) + else: + if not isinstance(error_value_raw, str): + violations.append(Violation(path="error", reason="expected string")) + else: + error_value = error_value_raw + + reason_value: str | None = None + if "reason" in raw: + reason_value_raw = raw["reason"] + if reason_value_raw is None: + violations.append( + Violation(path="reason", reason="explicit null not allowed") + ) + else: + if not isinstance(reason_value_raw, str): + violations.append( + Violation(path="reason", reason="expected string") + ) + else: + reason_value = reason_value_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _FOLLOWED_WIRE_LINK_STATE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return FollowedWireLinkState( + kind=kind_value, + link=link_value, + error=error_value, + reason=reason_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "FollowedWireLinkState") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, FollowedWireLinkState): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + kind_value: typing.Any = runtime_value.kind + if kind_value is None: + violations.append(Violation(path="kind", reason="required")) + else: + if not (isinstance(kind_value, str)): + violations.append(Violation(path="kind", reason="expected string")) + out["kind"] = kind_value + link_value: typing.Any = runtime_value.link + if link_value is None: + violations.append(Violation(path="link", reason="required")) + else: + link_violation_count = len(violations) + if not (isinstance(link_value, bytes)): + violations.append(Violation(path="link", reason="expected bytes")) + if len(violations) == link_violation_count: + out["link"] = _format_base64(link_value) + error_value: typing.Any = runtime_value.error + if error_value is not None: + if not (isinstance(error_value, str)): + violations.append(Violation(path="error", reason="expected string")) + out["error"] = error_value + reason_value: typing.Any = runtime_value.reason + if reason_value is not None: + if not (isinstance(reason_value, str)): + violations.append(Violation(path="reason", reason="expected string")) + out["reason"] = reason_value + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _FOLLOWED_WIRE_LINK_STATE_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_FollowedWireLinkStateTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class FollowedWireLinkState: + """What happened to a link of the source.""" + + kind: str + """"opened": the linked stream's records follow. "done": its segment ended. + "unavailable": it can't be read, and error says why. "skipped": the link was not + followed, and reason says why. + """ + + link: bytes + """The serialized temporal.sdk.streams.v1.StreamLink.""" + + error: str | None = None + """Why a linked stream can't be read, for kind unavailable.""" + + reason: str | None = None + """Why a link was not followed, for kind skipped: depth, limit, cycle, open or + endpoint. + """ + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +class _FollowedWireRecordTransferTypeConverter( + temporalio.converter.TransferTypeConverter["FollowedWireRecord", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["FollowedWireRecord"] + ) -> "FollowedWireRecord": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + token_value: str = typing.cast("typing.Any", None) + if "token" not in raw or raw["token"] is None: + violations.append(Violation(path="token", reason="required")) + else: + token_value_raw = raw["token"] + if not isinstance(token_value_raw, str): + violations.append(Violation(path="token", reason="expected string")) + else: + token_value = token_value_raw + + record_value: bytes = typing.cast("typing.Any", None) + if "record" not in raw or raw["record"] is None: + violations.append(Violation(path="record", reason="required")) + else: + record_value_raw = raw["record"] + if not isinstance(record_value_raw, str): + violations.append(Violation(path="record", reason="expected string")) + else: + record_value_parsed = _parse_base64( + record_value_raw, "record", violations + ) + if record_value_parsed is not None: + record_value = record_value_parsed + + error_value: str | None = None + if "error" in raw: + error_value_raw = raw["error"] + if error_value_raw is None: + violations.append( + Violation(path="error", reason="explicit null not allowed") + ) + else: + if not isinstance(error_value_raw, str): + violations.append(Violation(path="error", reason="expected string")) + else: + error_value = error_value_raw + + stale_value: bool | None = None + if "stale" in raw: + stale_value_raw = raw["stale"] + if stale_value_raw is None: + violations.append( + Violation(path="stale", reason="explicit null not allowed") + ) + else: + if not isinstance(stale_value_raw, bool): + violations.append( + Violation(path="stale", reason="expected boolean") + ) + else: + stale_value = stale_value_raw + + superseded_value: FollowedWireRecordSuperseded | None = None + if "superseded" in raw: + superseded_value_raw = raw["superseded"] + if superseded_value_raw is None: + violations.append( + Violation(path="superseded", reason="explicit null not allowed") + ) + else: + try: + superseded_value = _FollowedWireRecordSupersededTransferTypeConverter().from_transfer_type( + superseded_value_raw, FollowedWireRecordSuperseded + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "superseded", error) + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _FOLLOWED_WIRE_RECORD_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return FollowedWireRecord( + token=token_value, + record=record_value, + error=error_value, + stale=stale_value, + superseded=superseded_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "FollowedWireRecord") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, FollowedWireRecord): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + token_value: typing.Any = runtime_value.token + if token_value is None: + violations.append(Violation(path="token", reason="required")) + else: + if not (isinstance(token_value, str)): + violations.append(Violation(path="token", reason="expected string")) + out["token"] = token_value + record_value: typing.Any = runtime_value.record + if record_value is None: + violations.append(Violation(path="record", reason="required")) + else: + record_violation_count = len(violations) + if not (isinstance(record_value, bytes)): + violations.append(Violation(path="record", reason="expected bytes")) + if len(violations) == record_violation_count: + out["record"] = _format_base64(record_value) + error_value: typing.Any = runtime_value.error + if error_value is not None: + if not (isinstance(error_value, str)): + violations.append(Violation(path="error", reason="expected string")) + out["error"] = error_value + stale_value: typing.Any = runtime_value.stale + if stale_value is not None: + if not (isinstance(stale_value, bool)): + violations.append(Violation(path="stale", reason="expected boolean")) + out["stale"] = stale_value + superseded_value: typing.Any = runtime_value.superseded + if superseded_value is not None: + superseded_violation_count = len(violations) + if len(violations) == superseded_violation_count: + try: + out["superseded"] = ( + _FollowedWireRecordSupersededTransferTypeConverter().to_transfer_type( + superseded_value + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "superseded", error) + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _FOLLOWED_WIRE_RECORD_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_FollowedWireRecordTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class FollowedWireRecord: + """A record of the source, as read answers it.""" + + token: str + """The cursor of this record. Pass it as after_token to resume past it.""" + + record: bytes + """The serialized temporal.sdk.streams.v1.StreamRecord: topic, kind, producer, attempt, + sequence and body. The body is the producer's payload decoded by the serving + Worker's data converter, payload codec and external storage included. The answer as + a whole is encoded on its way back like any operation result, so the caller's codec + decodes it and the body crosses encoded once. Empty when error or superseded is set. + """ + + error: str | None = None + """Why the endpoint can't hand over the record at this cursor, such as one written to + the store directly that is larger than a read result can carry. The record is left + out, and a reader goes on past the cursor. + """ + + stale: bool | None = None + """The record is from an attempt of its producer below the newest one this read + delivered: an older attempt wrote after a newer one started. It is still delivered, + so a reader can keep it out of the current answer. + """ + + superseded: FollowedWireRecordSuperseded | None = None + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +class _FollowedWireRecordSupersededTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + "FollowedWireRecordSuperseded", typing.Any + ] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["FollowedWireRecordSuperseded"] + ) -> "FollowedWireRecordSuperseded": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + producer_id_value: str = typing.cast("typing.Any", None) + if "producer_id" not in raw or raw["producer_id"] is None: + violations.append(Violation(path="producer_id", reason="required")) + else: + producer_id_value_raw = raw["producer_id"] + if not isinstance(producer_id_value_raw, str): + violations.append( + Violation(path="producer_id", reason="expected string") + ) + else: + producer_id_value = producer_id_value_raw + + previous_attempt_value: int = typing.cast("typing.Any", None) + if "previous_attempt" not in raw or raw["previous_attempt"] is None: + violations.append(Violation(path="previous_attempt", reason="required")) + else: + previous_attempt_value_raw = raw["previous_attempt"] + previous_attempt_value_parsed = _parse_spec_integer( + previous_attempt_value_raw, "previous_attempt", violations + ) + if previous_attempt_value_parsed is not None: + previous_attempt_value = previous_attempt_value_parsed + + attempt_value: int = typing.cast("typing.Any", None) + if "attempt" not in raw or raw["attempt"] is None: + violations.append(Violation(path="attempt", reason="required")) + else: + attempt_value_raw = raw["attempt"] + attempt_value_parsed = _parse_spec_integer( + attempt_value_raw, "attempt", violations + ) + if attempt_value_parsed is not None: + attempt_value = attempt_value_parsed + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _FOLLOWED_WIRE_RECORD_SUPERSEDED_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return FollowedWireRecordSuperseded( + producer_id=producer_id_value, + previous_attempt=previous_attempt_value, + attempt=attempt_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "FollowedWireRecordSuperseded") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, FollowedWireRecordSuperseded): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + producer_id_value: typing.Any = runtime_value.producer_id + if producer_id_value is None: + violations.append(Violation(path="producer_id", reason="required")) + else: + if not (isinstance(producer_id_value, str)): + violations.append( + Violation(path="producer_id", reason="expected string") + ) + out["producer_id"] = producer_id_value + previous_attempt_value: typing.Any = runtime_value.previous_attempt + if previous_attempt_value is None: + violations.append(Violation(path="previous_attempt", reason="required")) + else: + if not ( + not isinstance(previous_attempt_value, bool) + and ( + isinstance(previous_attempt_value, int) + or ( + isinstance(previous_attempt_value, float) + and previous_attempt_value.is_integer() + ) + ) + ): + violations.append( + Violation(path="previous_attempt", reason="expected integer") + ) + else: + if abs(previous_attempt_value) > 9007199254740991: + violations.append( + Violation( + path="previous_attempt", + reason="exceeds ±(2^53-1) integer cap", + ) + ) + out["previous_attempt"] = previous_attempt_value + attempt_value: typing.Any = runtime_value.attempt + if attempt_value is None: + violations.append(Violation(path="attempt", reason="required")) + else: + if not ( + not isinstance(attempt_value, bool) + and ( + isinstance(attempt_value, int) + or (isinstance(attempt_value, float) and attempt_value.is_integer()) + ) + ): + violations.append(Violation(path="attempt", reason="expected integer")) + else: + if abs(attempt_value) > 9007199254740991: + violations.append( + Violation( + path="attempt", reason="exceeds ±(2^53-1) integer cap" + ) + ) + out["attempt"] = attempt_value + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _FOLLOWED_WIRE_RECORD_SUPERSEDED_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_FollowedWireRecordSupersededTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class FollowedWireRecordSuperseded: + """Set instead of a record when a later attempt of a producer started writing. The + store's reader reports it, at the cursor before the new attempt's first record, also + on a read that resumes after a cursor and saw no earlier attempt itself. + """ + + producer_id: str + """The producer whose attempt changed.""" + + previous_attempt: int + """The attempt readers saw before.""" + + attempt: int + """The attempt that started writing.""" + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +class _LinkStateWireTransferTypeConverter( + temporalio.converter.TransferTypeConverter["LinkStateWire", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["LinkStateWire"] + ) -> "LinkStateWire": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + kind_value: str = typing.cast("typing.Any", None) + if "kind" not in raw or raw["kind"] is None: + violations.append(Violation(path="kind", reason="required")) + else: + kind_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + else: + kind_value = kind_value_raw + + link_value: bytes = typing.cast("typing.Any", None) + if "link" not in raw or raw["link"] is None: + violations.append(Violation(path="link", reason="required")) + else: + link_value_raw = raw["link"] + if not isinstance(link_value_raw, str): + violations.append(Violation(path="link", reason="expected string")) + else: + link_value_parsed = _parse_base64(link_value_raw, "link", violations) + if link_value_parsed is not None: + link_value = link_value_parsed + + error_value: str | None = None + if "error" in raw: + error_value_raw = raw["error"] + if error_value_raw is None: + violations.append( + Violation(path="error", reason="explicit null not allowed") + ) + else: + if not isinstance(error_value_raw, str): + violations.append(Violation(path="error", reason="expected string")) + else: + error_value = error_value_raw + + reason_value: str | None = None + if "reason" in raw: + reason_value_raw = raw["reason"] + if reason_value_raw is None: + violations.append( + Violation(path="reason", reason="explicit null not allowed") + ) + else: + if not isinstance(reason_value_raw, str): + violations.append( + Violation(path="reason", reason="expected string") + ) + else: + reason_value = reason_value_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _LINK_STATE_WIRE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return LinkStateWire( + kind=kind_value, + link=link_value, + error=error_value, + reason=reason_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "LinkStateWire") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, LinkStateWire): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + kind_value: typing.Any = runtime_value.kind + if kind_value is None: + violations.append(Violation(path="kind", reason="required")) + else: + if not (isinstance(kind_value, str)): + violations.append(Violation(path="kind", reason="expected string")) + out["kind"] = kind_value + link_value: typing.Any = runtime_value.link + if link_value is None: + violations.append(Violation(path="link", reason="required")) + else: + link_violation_count = len(violations) + if not (isinstance(link_value, bytes)): + violations.append(Violation(path="link", reason="expected bytes")) + if len(violations) == link_violation_count: + out["link"] = _format_base64(link_value) + error_value: typing.Any = runtime_value.error + if error_value is not None: + if not (isinstance(error_value, str)): + violations.append(Violation(path="error", reason="expected string")) + out["error"] = error_value + reason_value: typing.Any = runtime_value.reason + if reason_value is not None: + if not (isinstance(reason_value, str)): + violations.append(Violation(path="reason", reason="expected string")) + out["reason"] = reason_value + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _LINK_STATE_WIRE_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_LinkStateWireTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class LinkStateWire: + """What happened to a link, as the follower saw it.""" + + kind: str + """"opened": the linked stream's records follow. "done": its segment ended. + "unavailable": it can't be read, and error says why. "skipped": the link was not + followed, and reason says why. + """ + + link: bytes + """The serialized temporal.sdk.streams.v1.StreamLink.""" + + error: str | None = None + """Why a linked stream can't be read, for kind unavailable.""" + + reason: str | None = None + """Why a link was not followed, for kind skipped: depth, limit, cycle, open or + endpoint. + """ + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +class _ReadInputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ReadInput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ReadInput"] + ) -> "ReadInput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + stream_value: StreamRef = typing.cast("typing.Any", None) + if "stream" not in raw or raw["stream"] is None: + violations.append(Violation(path="stream", reason="required")) + else: + stream_value_raw = raw["stream"] + try: + stream_value = _StreamRefTransferTypeConverter().from_transfer_type( + stream_value_raw, StreamRef + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "stream", error) + + after_token_value: str | None = None + if "after_token" in raw: + after_token_value_raw = raw["after_token"] + if after_token_value_raw is None: + violations.append( + Violation(path="after_token", reason="explicit null not allowed") + ) + else: + if not isinstance(after_token_value_raw, str): + violations.append( + Violation(path="after_token", reason="expected string") + ) + else: + after_token_value = after_token_value_raw + + max_records_value: int | None = None + if "max_records" in raw: + max_records_value_raw = raw["max_records"] + if max_records_value_raw is None: + violations.append( + Violation(path="max_records", reason="explicit null not allowed") + ) + else: + max_records_value_parsed = _parse_spec_integer( + max_records_value_raw, "max_records", violations + ) + if max_records_value_parsed is not None: + max_records_value = max_records_value_parsed + if max_records_value < 1: + violations.append( + Violation( + path="max_records", + reason=f"must be >= 1, got {max_records_value}", + ) + ) + if max_records_value > 1000: + violations.append( + Violation( + path="max_records", + reason=f"must be <= 1000, got {max_records_value}", + ) + ) + + wait_ms_value: int | None = None + if "wait_ms" in raw: + wait_ms_value_raw = raw["wait_ms"] + if wait_ms_value_raw is None: + violations.append( + Violation(path="wait_ms", reason="explicit null not allowed") + ) + else: + wait_ms_value_parsed = _parse_spec_integer( + wait_ms_value_raw, "wait_ms", violations + ) + if wait_ms_value_parsed is not None: + wait_ms_value = wait_ms_value_parsed + if wait_ms_value < 0: + violations.append( + Violation( + path="wait_ms", + reason=f"must be >= 0, got {wait_ms_value}", + ) + ) + if wait_ms_value > 60000: + violations.append( + Violation( + path="wait_ms", + reason=f"must be <= 60000, got {wait_ms_value}", + ) + ) + + latest_only_value: bool | None = None + if "latest_only" in raw: + latest_only_value_raw = raw["latest_only"] + if latest_only_value_raw is None: + violations.append( + Violation(path="latest_only", reason="explicit null not allowed") + ) + else: + if not isinstance(latest_only_value_raw, bool): + violations.append( + Violation(path="latest_only", reason="expected boolean") + ) + else: + latest_only_value = latest_only_value_raw + + state_value: bytes | None = None + if "state" in raw: + state_value_raw = raw["state"] + if state_value_raw is None: + violations.append( + Violation(path="state", reason="explicit null not allowed") + ) + else: + if not isinstance(state_value_raw, str): + violations.append(Violation(path="state", reason="expected string")) + else: + state_value_parsed = _parse_base64( + state_value_raw, "state", violations + ) + if state_value_parsed is not None: + state_value = state_value_parsed + + floor_holder_value: str | None = None + if "floor_holder" in raw: + floor_holder_value_raw = raw["floor_holder"] + if floor_holder_value_raw is None: + violations.append( + Violation(path="floor_holder", reason="explicit null not allowed") + ) + else: + if not isinstance(floor_holder_value_raw, str): + violations.append( + Violation(path="floor_holder", reason="expected string") + ) + else: + floor_holder_value = floor_holder_value_raw + + wake_token_value: str | None = None + if "wake_token" in raw: + wake_token_value_raw = raw["wake_token"] + if wake_token_value_raw is None: + violations.append( + Violation(path="wake_token", reason="explicit null not allowed") + ) + else: + if not isinstance(wake_token_value_raw, str): + violations.append( + Violation(path="wake_token", reason="expected string") + ) + else: + wake_token_value = wake_token_value_raw + + for key in raw: + if ( + key != "stream" + and key != "after_token" + and key != "max_records" + and key != "wait_ms" + and key != "latest_only" + and key != "state" + and key != "floor_holder" + and key != "wake_token" + ): + violations.append( + Violation(path=_member_path(key), reason="unknown field") + ) + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return ReadInput( + stream=stream_value, + after_token=after_token_value, + max_records=max_records_value, + wait_ms=wait_ms_value, + latest_only=latest_only_value, + state=state_value, + floor_holder=floor_holder_value, + wake_token=wake_token_value, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ReadInput") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, ReadInput): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + stream_value: typing.Any = runtime_value.stream + if stream_value is None: + violations.append(Violation(path="stream", reason="required")) + else: + stream_violation_count = len(violations) + if len(violations) == stream_violation_count: + try: + out["stream"] = _StreamRefTransferTypeConverter().to_transfer_type( + stream_value + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "stream", error) + after_token_value: typing.Any = runtime_value.after_token + if after_token_value is not None: + if not (isinstance(after_token_value, str)): + violations.append( + Violation(path="after_token", reason="expected string") + ) + out["after_token"] = after_token_value + max_records_value: typing.Any = runtime_value.max_records + if max_records_value is not None: + if not ( + not isinstance(max_records_value, bool) + and ( + isinstance(max_records_value, int) + or ( + isinstance(max_records_value, float) + and max_records_value.is_integer() + ) + ) + ): + violations.append( + Violation(path="max_records", reason="expected integer") + ) + else: + if abs(max_records_value) > 9007199254740991: + violations.append( + Violation( + path="max_records", reason="exceeds ±(2^53-1) integer cap" + ) + ) + if max_records_value < 1: + violations.append( + Violation( + path="max_records", + reason=f"must be >= 1, got {max_records_value}", + ) + ) + if max_records_value > 1000: + violations.append( + Violation( + path="max_records", + reason=f"must be <= 1000, got {max_records_value}", + ) + ) + out["max_records"] = max_records_value + wait_ms_value: typing.Any = runtime_value.wait_ms + if wait_ms_value is not None: + if not ( + not isinstance(wait_ms_value, bool) + and ( + isinstance(wait_ms_value, int) + or (isinstance(wait_ms_value, float) and wait_ms_value.is_integer()) + ) + ): + violations.append(Violation(path="wait_ms", reason="expected integer")) + else: + if abs(wait_ms_value) > 9007199254740991: + violations.append( + Violation( + path="wait_ms", reason="exceeds ±(2^53-1) integer cap" + ) + ) + if wait_ms_value < 0: + violations.append( + Violation( + path="wait_ms", reason=f"must be >= 0, got {wait_ms_value}" + ) + ) + if wait_ms_value > 60000: + violations.append( + Violation( + path="wait_ms", + reason=f"must be <= 60000, got {wait_ms_value}", + ) + ) + out["wait_ms"] = wait_ms_value + latest_only_value: typing.Any = runtime_value.latest_only + if latest_only_value is not None: + if not (isinstance(latest_only_value, bool)): + violations.append( + Violation(path="latest_only", reason="expected boolean") + ) + out["latest_only"] = latest_only_value + state_value: typing.Any = runtime_value.state + if state_value is not None: + state_violation_count = len(violations) + if not (isinstance(state_value, bytes)): + violations.append(Violation(path="state", reason="expected bytes")) + if len(violations) == state_violation_count: + out["state"] = _format_base64(state_value) + floor_holder_value: typing.Any = runtime_value.floor_holder + if floor_holder_value is not None: + if not (isinstance(floor_holder_value, str)): + violations.append( + Violation(path="floor_holder", reason="expected string") + ) + out["floor_holder"] = floor_holder_value + wake_token_value: typing.Any = runtime_value.wake_token + if wake_token_value is not None: + if not (isinstance(wake_token_value, str)): + violations.append( + Violation(path="wake_token", reason="expected string") + ) + out["wake_token"] = wake_token_value + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_ReadInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ReadInput: + """One read call: where to resume and how long to wait.""" + + stream: StreamRef + + after_token: str | None = None + """A cursor from an earlier record, read or append of this stream. The read resumes + strictly after the record it names. Absent or empty starts at the oldest record the + stream retains. A cursor from another stream or another store is refused, and one + whose record retention dropped is refused as expired. + """ + + max_records: int | None = None + """Answer with at most this many stored records. 100 when absent. A supersession comes + on top. + """ + + wait_ms: int | None = None + """Wait at most this long for a record before answering. The handler shortens it to fit + the request deadline. A call that collects nothing answers with no records and the + caller's own cursor. + """ + + latest_only: bool | None = None + """Answer with the cursor of the newest record and no records, for a reader that + follows from now. Refused beside after_token. + """ + + state: bytes | None = None + """The state of this read's previous answer, opaque to the caller. Absent on a read's + first call. With it the endpoint knows which producer attempts the reader already + saw, so its superseded and stale marks are exact across calls and across handler + Workers. A state that doesn't parse is refused as a cursor mistake. + """ + + floor_holder: str | None = None + """Names a reader that keeps the range of each answer in place of its records and reads + the range again through read_range on replay. With it the endpoint holds the records + after the read's start against retention for this holder, and the answer carries + replay. Each read and read_range of the holder renews the hold, which lapses the + store's floor lease after the last one. At most 512 bytes. Refused beside + latest_only, which answers no records. + """ + + wake_token: str | None = None + """The operation token of the reader's stream operation. When the call answers no + records and not done, the endpoint asks the stream's notifier to wake that + operation's callback once the stream moves past the answer, then reads once more, so + a record that landed meanwhile is in the answer. A reader that finds records on each + call never waits, and its operation hears nothing. Refused when the token names + another stream. + """ + + +class _ReadOutputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ReadOutput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ReadOutput"] + ) -> "ReadOutput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + records_value: list[RecordWire] = typing.cast("typing.Any", None) + if "records" not in raw or raw["records"] is None: + violations.append(Violation(path="records", reason="required")) + else: + records_value_raw = raw["records"] + if not isinstance(records_value_raw, list): + violations.append(Violation(path="records", reason="expected array")) + else: + records_value_list: list[RecordWire] = [] + for records_value_index, records_value_element in enumerate( + typing.cast("list[typing.Any]", records_value_raw) + ): + records_value_item_path = f"records[{records_value_index}]" + records_value_item_violation_count = len(violations) + records_value_item: RecordWire = typing.cast("typing.Any", None) + try: + records_value_item = ( + _RecordWireTransferTypeConverter().from_transfer_type( + records_value_element, RecordWire + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, records_value_item_path, error) + if len(violations) == records_value_item_violation_count: + records_value_list.append(records_value_item) + records_value = records_value_list + + next_token_value: str = typing.cast("typing.Any", None) + if "next_token" not in raw or raw["next_token"] is None: + violations.append(Violation(path="next_token", reason="required")) + else: + next_token_value_raw = raw["next_token"] + if not isinstance(next_token_value_raw, str): + violations.append( + Violation(path="next_token", reason="expected string") + ) + else: + next_token_value = next_token_value_raw + + done_value: bool = typing.cast("typing.Any", None) + if "done" not in raw or raw["done"] is None: + violations.append(Violation(path="done", reason="required")) + else: + done_value_raw = raw["done"] + if not isinstance(done_value_raw, bool): + violations.append(Violation(path="done", reason="expected boolean")) + else: + done_value = done_value_raw + + state_value: bytes | None = None + if "state" in raw: + state_value_raw = raw["state"] + if state_value_raw is None: + violations.append( + Violation(path="state", reason="explicit null not allowed") + ) + else: + if not isinstance(state_value_raw, str): + violations.append(Violation(path="state", reason="expected string")) + else: + state_value_parsed = _parse_base64( + state_value_raw, "state", violations + ) + if state_value_parsed is not None: + state_value = state_value_parsed + + replay_value: ReadOutputReplay | None = None + if "replay" in raw: + replay_value_raw = raw["replay"] + if replay_value_raw is None: + violations.append( + Violation(path="replay", reason="explicit null not allowed") + ) + else: + try: + replay_value = ( + _ReadOutputReplayTransferTypeConverter().from_transfer_type( + replay_value_raw, ReadOutputReplay + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "replay", error) + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _READ_OUTPUT_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return ReadOutput( + records=records_value, + next_token=next_token_value, + done=done_value, + state=state_value, + replay=replay_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ReadOutput") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, ReadOutput): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + records_value: typing.Any = runtime_value.records + if records_value is None: + violations.append(Violation(path="records", reason="required")) + else: + records_violation_count = len(violations) + if not (isinstance(records_value, list)): + violations.append(Violation(path="records", reason="expected array")) + if len(violations) == records_violation_count: + records_checked = typing.cast("list[typing.Any]", records_value) + records_out: list[typing.Any] = [] + for records_index, records_element in enumerate(records_checked): + try: + records_out.append( + _RecordWireTransferTypeConverter().to_transfer_type( + records_element + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, f"records[{records_index}]", error) + out["records"] = records_out + next_token_value: typing.Any = runtime_value.next_token + if next_token_value is None: + violations.append(Violation(path="next_token", reason="required")) + else: + if not (isinstance(next_token_value, str)): + violations.append( + Violation(path="next_token", reason="expected string") + ) + out["next_token"] = next_token_value + done_value: typing.Any = runtime_value.done + if done_value is None: + violations.append(Violation(path="done", reason="required")) + else: + if not (isinstance(done_value, bool)): + violations.append(Violation(path="done", reason="expected boolean")) + out["done"] = done_value + state_value: typing.Any = runtime_value.state + if state_value is not None: + state_violation_count = len(violations) + if not (isinstance(state_value, bytes)): + violations.append(Violation(path="state", reason="expected bytes")) + if len(violations) == state_violation_count: + out["state"] = _format_base64(state_value) + replay_value: typing.Any = runtime_value.replay + if replay_value is not None: + replay_violation_count = len(violations) + if len(violations) == replay_violation_count: + try: + out["replay"] = ( + _ReadOutputReplayTransferTypeConverter().to_transfer_type( + replay_value + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "replay", error) + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _READ_OUTPUT_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_ReadOutputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ReadOutput: + """What one read answered, and where to resume.""" + + records: list[RecordWire] + """The records after the caller's cursor, in stream order.""" + + next_token: str + """The cursor to pass as after_token next. The caller's own cursor when the call + collected nothing. + """ + + done: bool + """The stream ended for this reader: the stream is closed, or its owner's run chain + ended, and every retained record after the cursor has been delivered. Nothing more + will arrive, so the caller stops. + """ + + state: bytes | None = None + """Pass as state on the next call of this read. It matches next_token, also when the + answer was cut short by its byte budget. It holds at most 16 KiB, which is about 22 + KiB as base64, so it can ride in a Workflow's History. A longer state is refused. + """ + + replay: ReadOutputReplay | None = None + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +class _ReadOutputReplayTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ReadOutputReplay", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ReadOutputReplay"] + ) -> "ReadOutputReplay": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + chain_first_run_id_value: str = typing.cast("typing.Any", None) + if "chain_first_run_id" not in raw or raw["chain_first_run_id"] is None: + violations.append(Violation(path="chain_first_run_id", reason="required")) + else: + chain_first_run_id_value_raw = raw["chain_first_run_id"] + if not isinstance(chain_first_run_id_value_raw, str): + violations.append( + Violation(path="chain_first_run_id", reason="expected string") + ) + else: + chain_first_run_id_value = chain_first_run_id_value_raw + + after_token_value: str = typing.cast("typing.Any", None) + if "after_token" not in raw or raw["after_token"] is None: + violations.append(Violation(path="after_token", reason="required")) + else: + after_token_value_raw = raw["after_token"] + if not isinstance(after_token_value_raw, str): + violations.append( + Violation(path="after_token", reason="expected string") + ) + else: + after_token_value = after_token_value_raw + + stored_records_value: int = typing.cast("typing.Any", None) + if "stored_records" not in raw or raw["stored_records"] is None: + violations.append(Violation(path="stored_records", reason="required")) + else: + stored_records_value_raw = raw["stored_records"] + stored_records_value_parsed = _parse_spec_integer( + stored_records_value_raw, "stored_records", violations + ) + if stored_records_value_parsed is not None: + stored_records_value = stored_records_value_parsed + if stored_records_value < 0: + violations.append( + Violation( + path="stored_records", + reason=f"must be >= 0, got {stored_records_value}", + ) + ) + + records_digest_value: bytes = typing.cast("typing.Any", None) + if "records_digest" not in raw or raw["records_digest"] is None: + violations.append(Violation(path="records_digest", reason="required")) + else: + records_digest_value_raw = raw["records_digest"] + if not isinstance(records_digest_value_raw, str): + violations.append( + Violation(path="records_digest", reason="expected string") + ) + else: + records_digest_value_parsed = _parse_base64( + records_digest_value_raw, "records_digest", violations + ) + if records_digest_value_parsed is not None: + records_digest_value = records_digest_value_parsed + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _READ_OUTPUT_REPLAY_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return ReadOutputReplay( + chain_first_run_id=chain_first_run_id_value, + after_token=after_token_value, + stored_records=stored_records_value, + records_digest=records_digest_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ReadOutputReplay") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, ReadOutputReplay): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + chain_first_run_id_value: typing.Any = runtime_value.chain_first_run_id + if chain_first_run_id_value is None: + violations.append(Violation(path="chain_first_run_id", reason="required")) + else: + if not (isinstance(chain_first_run_id_value, str)): + violations.append( + Violation(path="chain_first_run_id", reason="expected string") + ) + out["chain_first_run_id"] = chain_first_run_id_value + after_token_value: typing.Any = runtime_value.after_token + if after_token_value is None: + violations.append(Violation(path="after_token", reason="required")) + else: + if not (isinstance(after_token_value, str)): + violations.append( + Violation(path="after_token", reason="expected string") + ) + out["after_token"] = after_token_value + stored_records_value: typing.Any = runtime_value.stored_records + if stored_records_value is None: + violations.append(Violation(path="stored_records", reason="required")) + else: + if not ( + not isinstance(stored_records_value, bool) + and ( + isinstance(stored_records_value, int) + or ( + isinstance(stored_records_value, float) + and stored_records_value.is_integer() + ) + ) + ): + violations.append( + Violation(path="stored_records", reason="expected integer") + ) + else: + if abs(stored_records_value) > 9007199254740991: + violations.append( + Violation( + path="stored_records", + reason="exceeds ±(2^53-1) integer cap", + ) + ) + if stored_records_value < 0: + violations.append( + Violation( + path="stored_records", + reason=f"must be >= 0, got {stored_records_value}", + ) + ) + out["stored_records"] = stored_records_value + records_digest_value: typing.Any = runtime_value.records_digest + if records_digest_value is None: + violations.append(Violation(path="records_digest", reason="required")) + else: + records_digest_violation_count = len(violations) + if not (isinstance(records_digest_value, bytes)): + violations.append( + Violation(path="records_digest", reason="expected bytes") + ) + if len(violations) == records_digest_violation_count: + out["records_digest"] = _format_base64(records_digest_value) + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _READ_OUTPUT_REPLAY_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_ReadOutputReplayTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ReadOutputReplay: + """Set when the call named a floor_holder. What a replay passes to read_range to get + these records again. + """ + + chain_first_run_id: str + """The first run of the run chain the read read.""" + + after_token: str + """The cursor the answer reads strictly after, with the read's start resolved. Empty + when the answer starts at the oldest record. An answer whose only record is an error + the store's reader raised has an empty range at that record's cursor. + """ + + stored_records: int + """How many stored records the answer holds, the ones that crossed as an error included + and supersessions not counted. + """ + + records_digest: bytes + """SHA-256 over the answer's records. Per record it covers the cursor, the stale mark, + and the kind, producer, attempt, sequence and content hash of a stored record or the + producer and both attempts of a supersession. It leaves out the bodies, so a codec + change keeps it. + """ + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +class _ReadRangeInputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ReadRangeInput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ReadRangeInput"] + ) -> "ReadRangeInput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + stream_value: StreamRef = typing.cast("typing.Any", None) + if "stream" not in raw or raw["stream"] is None: + violations.append(Violation(path="stream", reason="required")) + else: + stream_value_raw = raw["stream"] + try: + stream_value = _StreamRefTransferTypeConverter().from_transfer_type( + stream_value_raw, StreamRef + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "stream", error) + + chain_first_run_id_value: str = typing.cast("typing.Any", None) + if "chain_first_run_id" not in raw or raw["chain_first_run_id"] is None: + violations.append(Violation(path="chain_first_run_id", reason="required")) + else: + chain_first_run_id_value_raw = raw["chain_first_run_id"] + if not isinstance(chain_first_run_id_value_raw, str): + violations.append( + Violation(path="chain_first_run_id", reason="expected string") + ) + else: + chain_first_run_id_value = chain_first_run_id_value_raw + + after_token_value: str = typing.cast("typing.Any", None) + if "after_token" not in raw or raw["after_token"] is None: + violations.append(Violation(path="after_token", reason="required")) + else: + after_token_value_raw = raw["after_token"] + if not isinstance(after_token_value_raw, str): + violations.append( + Violation(path="after_token", reason="expected string") + ) + else: + after_token_value = after_token_value_raw + + to_token_value: str = typing.cast("typing.Any", None) + if "to_token" not in raw or raw["to_token"] is None: + violations.append(Violation(path="to_token", reason="required")) + else: + to_token_value_raw = raw["to_token"] + if not isinstance(to_token_value_raw, str): + violations.append(Violation(path="to_token", reason="expected string")) + else: + to_token_value = to_token_value_raw + + stored_records_value: int = typing.cast("typing.Any", None) + if "stored_records" not in raw or raw["stored_records"] is None: + violations.append(Violation(path="stored_records", reason="required")) + else: + stored_records_value_raw = raw["stored_records"] + stored_records_value_parsed = _parse_spec_integer( + stored_records_value_raw, "stored_records", violations + ) + if stored_records_value_parsed is not None: + stored_records_value = stored_records_value_parsed + if stored_records_value < 0: + violations.append( + Violation( + path="stored_records", + reason=f"must be >= 0, got {stored_records_value}", + ) + ) + if stored_records_value > 1000: + violations.append( + Violation( + path="stored_records", + reason=f"must be <= 1000, got {stored_records_value}", + ) + ) + + state_value: bytes | None = None + if "state" in raw: + state_value_raw = raw["state"] + if state_value_raw is None: + violations.append( + Violation(path="state", reason="explicit null not allowed") + ) + else: + if not isinstance(state_value_raw, str): + violations.append(Violation(path="state", reason="expected string")) + else: + state_value_parsed = _parse_base64( + state_value_raw, "state", violations + ) + if state_value_parsed is not None: + state_value = state_value_parsed + + records_digest_value: bytes = typing.cast("typing.Any", None) + if "records_digest" not in raw or raw["records_digest"] is None: + violations.append(Violation(path="records_digest", reason="required")) + else: + records_digest_value_raw = raw["records_digest"] + if not isinstance(records_digest_value_raw, str): + violations.append( + Violation(path="records_digest", reason="expected string") + ) + else: + records_digest_value_parsed = _parse_base64( + records_digest_value_raw, "records_digest", violations + ) + if records_digest_value_parsed is not None: + records_digest_value = records_digest_value_parsed + + floor_holder_value: str | None = None + if "floor_holder" in raw: + floor_holder_value_raw = raw["floor_holder"] + if floor_holder_value_raw is None: + violations.append( + Violation(path="floor_holder", reason="explicit null not allowed") + ) + else: + if not isinstance(floor_holder_value_raw, str): + violations.append( + Violation(path="floor_holder", reason="expected string") + ) + else: + floor_holder_value = floor_holder_value_raw + + for key in raw: + if ( + key != "stream" + and key != "chain_first_run_id" + and key != "after_token" + and key != "to_token" + and key != "stored_records" + and key != "state" + and key != "records_digest" + and key != "floor_holder" + ): + violations.append( + Violation(path=_member_path(key), reason="unknown field") + ) + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return ReadRangeInput( + stream=stream_value, + chain_first_run_id=chain_first_run_id_value, + after_token=after_token_value, + to_token=to_token_value, + stored_records=stored_records_value, + state=state_value, + records_digest=records_digest_value, + floor_holder=floor_holder_value, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ReadRangeInput") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, ReadRangeInput): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + stream_value: typing.Any = runtime_value.stream + if stream_value is None: + violations.append(Violation(path="stream", reason="required")) + else: + stream_violation_count = len(violations) + if len(violations) == stream_violation_count: + try: + out["stream"] = _StreamRefTransferTypeConverter().to_transfer_type( + stream_value + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "stream", error) + chain_first_run_id_value: typing.Any = runtime_value.chain_first_run_id + if chain_first_run_id_value is None: + violations.append(Violation(path="chain_first_run_id", reason="required")) + else: + if not (isinstance(chain_first_run_id_value, str)): + violations.append( + Violation(path="chain_first_run_id", reason="expected string") + ) + out["chain_first_run_id"] = chain_first_run_id_value + after_token_value: typing.Any = runtime_value.after_token + if after_token_value is None: + violations.append(Violation(path="after_token", reason="required")) + else: + if not (isinstance(after_token_value, str)): + violations.append( + Violation(path="after_token", reason="expected string") + ) + out["after_token"] = after_token_value + to_token_value: typing.Any = runtime_value.to_token + if to_token_value is None: + violations.append(Violation(path="to_token", reason="required")) + else: + if not (isinstance(to_token_value, str)): + violations.append(Violation(path="to_token", reason="expected string")) + out["to_token"] = to_token_value + stored_records_value: typing.Any = runtime_value.stored_records + if stored_records_value is None: + violations.append(Violation(path="stored_records", reason="required")) + else: + if not ( + not isinstance(stored_records_value, bool) + and ( + isinstance(stored_records_value, int) + or ( + isinstance(stored_records_value, float) + and stored_records_value.is_integer() + ) + ) + ): + violations.append( + Violation(path="stored_records", reason="expected integer") + ) + else: + if abs(stored_records_value) > 9007199254740991: + violations.append( + Violation( + path="stored_records", + reason="exceeds ±(2^53-1) integer cap", + ) + ) + if stored_records_value < 0: + violations.append( + Violation( + path="stored_records", + reason=f"must be >= 0, got {stored_records_value}", + ) + ) + if stored_records_value > 1000: + violations.append( + Violation( + path="stored_records", + reason=f"must be <= 1000, got {stored_records_value}", + ) + ) + out["stored_records"] = stored_records_value + state_value: typing.Any = runtime_value.state + if state_value is not None: + state_violation_count = len(violations) + if not (isinstance(state_value, bytes)): + violations.append(Violation(path="state", reason="expected bytes")) + if len(violations) == state_violation_count: + out["state"] = _format_base64(state_value) + records_digest_value: typing.Any = runtime_value.records_digest + if records_digest_value is None: + violations.append(Violation(path="records_digest", reason="required")) + else: + records_digest_violation_count = len(violations) + if not (isinstance(records_digest_value, bytes)): + violations.append( + Violation(path="records_digest", reason="expected bytes") + ) + if len(violations) == records_digest_violation_count: + out["records_digest"] = _format_base64(records_digest_value) + floor_holder_value: typing.Any = runtime_value.floor_holder + if floor_holder_value is not None: + if not (isinstance(floor_holder_value, str)): + violations.append( + Violation(path="floor_holder", reason="expected string") + ) + out["floor_holder"] = floor_holder_value + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_ReadRangeInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ReadRangeInput: + """One replay of an earlier read that named a floor_holder. Every member but stream and + floor_holder comes from that read's input and answer. + """ + + stream: StreamRef + + chain_first_run_id: str + """The replay's chain_first_run_id.""" + + after_token: str + """The replay's after_token.""" + + to_token: str + """The read answer's next_token.""" + + stored_records: int + """The replay's stored_records.""" + + state: bytes | None = None + """The state the read sent, so the replay's superseded and stale marks are the read's. + Absent when the read sent none. + """ + + records_digest: bytes + """The replay's records_digest.""" + + floor_holder: str | None = None + """As on read, so a replay renews the hold, also after a reset that replays a run whose + reads stopped long ago. + """ + + +class _RecordWireTransferTypeConverter( + temporalio.converter.TransferTypeConverter["RecordWire", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["RecordWire"] + ) -> "RecordWire": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + token_value: str = typing.cast("typing.Any", None) + if "token" not in raw or raw["token"] is None: + violations.append(Violation(path="token", reason="required")) + else: + token_value_raw = raw["token"] + if not isinstance(token_value_raw, str): + violations.append(Violation(path="token", reason="expected string")) + else: + token_value = token_value_raw + + record_value: bytes = typing.cast("typing.Any", None) + if "record" not in raw or raw["record"] is None: + violations.append(Violation(path="record", reason="required")) + else: + record_value_raw = raw["record"] + if not isinstance(record_value_raw, str): + violations.append(Violation(path="record", reason="expected string")) + else: + record_value_parsed = _parse_base64( + record_value_raw, "record", violations + ) + if record_value_parsed is not None: + record_value = record_value_parsed + + error_value: str | None = None + if "error" in raw: + error_value_raw = raw["error"] + if error_value_raw is None: + violations.append( + Violation(path="error", reason="explicit null not allowed") + ) + else: + if not isinstance(error_value_raw, str): + violations.append(Violation(path="error", reason="expected string")) + else: + error_value = error_value_raw + + stale_value: bool | None = None + if "stale" in raw: + stale_value_raw = raw["stale"] + if stale_value_raw is None: + violations.append( + Violation(path="stale", reason="explicit null not allowed") + ) + else: + if not isinstance(stale_value_raw, bool): + violations.append( + Violation(path="stale", reason="expected boolean") + ) + else: + stale_value = stale_value_raw + + superseded_value: RecordWireSuperseded | None = None + if "superseded" in raw: + superseded_value_raw = raw["superseded"] + if superseded_value_raw is None: + violations.append( + Violation(path="superseded", reason="explicit null not allowed") + ) + else: + try: + superseded_value = ( + _RecordWireSupersededTransferTypeConverter().from_transfer_type( + superseded_value_raw, RecordWireSuperseded + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "superseded", error) + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _RECORD_WIRE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return RecordWire( + token=token_value, + record=record_value, + error=error_value, + stale=stale_value, + superseded=superseded_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "RecordWire") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, RecordWire): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + token_value: typing.Any = runtime_value.token + if token_value is None: + violations.append(Violation(path="token", reason="required")) + else: + if not (isinstance(token_value, str)): + violations.append(Violation(path="token", reason="expected string")) + out["token"] = token_value + record_value: typing.Any = runtime_value.record + if record_value is None: + violations.append(Violation(path="record", reason="required")) + else: + record_violation_count = len(violations) + if not (isinstance(record_value, bytes)): + violations.append(Violation(path="record", reason="expected bytes")) + if len(violations) == record_violation_count: + out["record"] = _format_base64(record_value) + error_value: typing.Any = runtime_value.error + if error_value is not None: + if not (isinstance(error_value, str)): + violations.append(Violation(path="error", reason="expected string")) + out["error"] = error_value + stale_value: typing.Any = runtime_value.stale + if stale_value is not None: + if not (isinstance(stale_value, bool)): + violations.append(Violation(path="stale", reason="expected boolean")) + out["stale"] = stale_value + superseded_value: typing.Any = runtime_value.superseded + if superseded_value is not None: + superseded_violation_count = len(violations) + if len(violations) == superseded_violation_count: + try: + out["superseded"] = ( + _RecordWireSupersededTransferTypeConverter().to_transfer_type( + superseded_value + ) + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "superseded", error) + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _RECORD_WIRE_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_RecordWireTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class RecordWire: + """One record on the wire: its cursor and the record.""" + + token: str + """The cursor of this record. Pass it as after_token to resume past it.""" + + record: bytes + """The serialized temporal.sdk.streams.v1.StreamRecord: topic, kind, producer, attempt, + sequence and body. The body is the producer's payload decoded by the serving + Worker's data converter, payload codec and external storage included. The answer as + a whole is encoded on its way back like any operation result, so the caller's codec + decodes it and the body crosses encoded once. Empty when error or superseded is set. + """ + + error: str | None = None + """Why the endpoint can't hand over the record at this cursor, such as one written to + the store directly that is larger than a read result can carry. The record is left + out, and a reader goes on past the cursor. + """ + + stale: bool | None = None + """The record is from an attempt of its producer below the newest one this read + delivered: an older attempt wrote after a newer one started. It is still delivered, + so a reader can keep it out of the current answer. + """ + + superseded: RecordWireSuperseded | None = None + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +class _RecordWireSupersededTransferTypeConverter( + temporalio.converter.TransferTypeConverter["RecordWireSuperseded", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["RecordWireSuperseded"] + ) -> "RecordWireSuperseded": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + producer_id_value: str = typing.cast("typing.Any", None) + if "producer_id" not in raw or raw["producer_id"] is None: + violations.append(Violation(path="producer_id", reason="required")) + else: + producer_id_value_raw = raw["producer_id"] + if not isinstance(producer_id_value_raw, str): + violations.append( + Violation(path="producer_id", reason="expected string") + ) + else: + producer_id_value = producer_id_value_raw + + previous_attempt_value: int = typing.cast("typing.Any", None) + if "previous_attempt" not in raw or raw["previous_attempt"] is None: + violations.append(Violation(path="previous_attempt", reason="required")) + else: + previous_attempt_value_raw = raw["previous_attempt"] + previous_attempt_value_parsed = _parse_spec_integer( + previous_attempt_value_raw, "previous_attempt", violations + ) + if previous_attempt_value_parsed is not None: + previous_attempt_value = previous_attempt_value_parsed + + attempt_value: int = typing.cast("typing.Any", None) + if "attempt" not in raw or raw["attempt"] is None: + violations.append(Violation(path="attempt", reason="required")) + else: + attempt_value_raw = raw["attempt"] + attempt_value_parsed = _parse_spec_integer( + attempt_value_raw, "attempt", violations + ) + if attempt_value_parsed is not None: + attempt_value = attempt_value_parsed + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _RECORD_WIRE_SUPERSEDED_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return RecordWireSuperseded( + producer_id=producer_id_value, + previous_attempt=previous_attempt_value, + attempt=attempt_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "RecordWireSuperseded") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, RecordWireSuperseded): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + producer_id_value: typing.Any = runtime_value.producer_id + if producer_id_value is None: + violations.append(Violation(path="producer_id", reason="required")) + else: + if not (isinstance(producer_id_value, str)): + violations.append( + Violation(path="producer_id", reason="expected string") + ) + out["producer_id"] = producer_id_value + previous_attempt_value: typing.Any = runtime_value.previous_attempt + if previous_attempt_value is None: + violations.append(Violation(path="previous_attempt", reason="required")) + else: + if not ( + not isinstance(previous_attempt_value, bool) + and ( + isinstance(previous_attempt_value, int) + or ( + isinstance(previous_attempt_value, float) + and previous_attempt_value.is_integer() + ) + ) + ): + violations.append( + Violation(path="previous_attempt", reason="expected integer") + ) + else: + if abs(previous_attempt_value) > 9007199254740991: + violations.append( + Violation( + path="previous_attempt", + reason="exceeds ±(2^53-1) integer cap", + ) + ) + out["previous_attempt"] = previous_attempt_value + attempt_value: typing.Any = runtime_value.attempt + if attempt_value is None: + violations.append(Violation(path="attempt", reason="required")) + else: + if not ( + not isinstance(attempt_value, bool) + and ( + isinstance(attempt_value, int) + or (isinstance(attempt_value, float) and attempt_value.is_integer()) + ) + ): + violations.append(Violation(path="attempt", reason="expected integer")) + else: + if abs(attempt_value) > 9007199254740991: + violations.append( + Violation( + path="attempt", reason="exceeds ±(2^53-1) integer cap" + ) + ) + out["attempt"] = attempt_value + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _RECORD_WIRE_SUPERSEDED_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_RecordWireSupersededTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class RecordWireSuperseded: + """Set instead of a record when a later attempt of a producer started writing. The + store's reader reports it, at the cursor before the new attempt's first record, also + on a read that resumes after a cursor and saw no earlier attempt itself. + """ + + producer_id: str + """The producer whose attempt changed.""" + + previous_attempt: int + """The attempt readers saw before.""" + + attempt: int + """The attempt that started writing.""" + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +class _ReplayRangeTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ReplayRange", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ReplayRange"] + ) -> "ReplayRange": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + chain_first_run_id_value: str = typing.cast("typing.Any", None) + if "chain_first_run_id" not in raw or raw["chain_first_run_id"] is None: + violations.append(Violation(path="chain_first_run_id", reason="required")) + else: + chain_first_run_id_value_raw = raw["chain_first_run_id"] + if not isinstance(chain_first_run_id_value_raw, str): + violations.append( + Violation(path="chain_first_run_id", reason="expected string") + ) + else: + chain_first_run_id_value = chain_first_run_id_value_raw + + after_token_value: str = typing.cast("typing.Any", None) + if "after_token" not in raw or raw["after_token"] is None: + violations.append(Violation(path="after_token", reason="required")) + else: + after_token_value_raw = raw["after_token"] + if not isinstance(after_token_value_raw, str): + violations.append( + Violation(path="after_token", reason="expected string") + ) + else: + after_token_value = after_token_value_raw + + stored_records_value: int = typing.cast("typing.Any", None) + if "stored_records" not in raw or raw["stored_records"] is None: + violations.append(Violation(path="stored_records", reason="required")) + else: + stored_records_value_raw = raw["stored_records"] + stored_records_value_parsed = _parse_spec_integer( + stored_records_value_raw, "stored_records", violations + ) + if stored_records_value_parsed is not None: + stored_records_value = stored_records_value_parsed + if stored_records_value < 0: + violations.append( + Violation( + path="stored_records", + reason=f"must be >= 0, got {stored_records_value}", + ) + ) + + records_digest_value: bytes = typing.cast("typing.Any", None) + if "records_digest" not in raw or raw["records_digest"] is None: + violations.append(Violation(path="records_digest", reason="required")) + else: + records_digest_value_raw = raw["records_digest"] + if not isinstance(records_digest_value_raw, str): + violations.append( + Violation(path="records_digest", reason="expected string") + ) + else: + records_digest_value_parsed = _parse_base64( + records_digest_value_raw, "records_digest", violations + ) + if records_digest_value_parsed is not None: + records_digest_value = records_digest_value_parsed + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _REPLAY_RANGE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return ReplayRange( + chain_first_run_id=chain_first_run_id_value, + after_token=after_token_value, + stored_records=stored_records_value, + records_digest=records_digest_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ReplayRange") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, ReplayRange): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + chain_first_run_id_value: typing.Any = runtime_value.chain_first_run_id + if chain_first_run_id_value is None: + violations.append(Violation(path="chain_first_run_id", reason="required")) + else: + if not (isinstance(chain_first_run_id_value, str)): + violations.append( + Violation(path="chain_first_run_id", reason="expected string") + ) + out["chain_first_run_id"] = chain_first_run_id_value + after_token_value: typing.Any = runtime_value.after_token + if after_token_value is None: + violations.append(Violation(path="after_token", reason="required")) + else: + if not (isinstance(after_token_value, str)): + violations.append( + Violation(path="after_token", reason="expected string") + ) + out["after_token"] = after_token_value + stored_records_value: typing.Any = runtime_value.stored_records + if stored_records_value is None: + violations.append(Violation(path="stored_records", reason="required")) + else: + if not ( + not isinstance(stored_records_value, bool) + and ( + isinstance(stored_records_value, int) + or ( + isinstance(stored_records_value, float) + and stored_records_value.is_integer() + ) + ) + ): + violations.append( + Violation(path="stored_records", reason="expected integer") + ) + else: + if abs(stored_records_value) > 9007199254740991: + violations.append( + Violation( + path="stored_records", + reason="exceeds ±(2^53-1) integer cap", + ) + ) + if stored_records_value < 0: + violations.append( + Violation( + path="stored_records", + reason=f"must be >= 0, got {stored_records_value}", + ) + ) + out["stored_records"] = stored_records_value + records_digest_value: typing.Any = runtime_value.records_digest + if records_digest_value is None: + violations.append(Violation(path="records_digest", reason="required")) + else: + records_digest_violation_count = len(violations) + if not (isinstance(records_digest_value, bytes)): + violations.append( + Violation(path="records_digest", reason="expected bytes") + ) + if len(violations) == records_digest_violation_count: + out["records_digest"] = _format_base64(records_digest_value) + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _REPLAY_RANGE_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_ReplayRangeTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ReplayRange: + """The range one read answered, after its byte budget cut it. A Workflow keeps it with + the answer's next_token and state in place of the records. + """ + + chain_first_run_id: str + """The first run of the run chain the read read.""" + + after_token: str + """The cursor the answer reads strictly after, with the read's start resolved. Empty + when the answer starts at the oldest record. An answer whose only record is an error + the store's reader raised has an empty range at that record's cursor. + """ + + stored_records: int + """How many stored records the answer holds, the ones that crossed as an error included + and supersessions not counted. + """ + + records_digest: bytes + """SHA-256 over the answer's records. Per record it covers the cursor, the stale mark, + and the kind, producer, attempt, sequence and content hash of a stored record or the + producer and both attempts of a supersession. It leaves out the bodies, so a codec + change keeps it. + """ + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +class _StreamOperationTokenTransferTypeConverter( + temporalio.converter.TransferTypeConverter["StreamOperationToken", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["StreamOperationToken"] + ) -> "StreamOperationToken": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + v_value: typing.Literal[2] = typing.cast("typing.Any", None) + if "v" not in raw or raw["v"] is None: + violations.append(Violation(path="v", reason="required")) + else: + v_value_raw = raw["v"] + v_value_parsed = _parse_spec_integer(v_value_raw, "v", violations) + if v_value_parsed is not None: + if v_value_parsed not in (2,): + violations.append(Violation(path="v", reason="must equal 2")) + else: + v_value = typing.cast("typing.Any", v_value_parsed) + + ref_value: StreamRef = typing.cast("typing.Any", None) + if "ref" not in raw or raw["ref"] is None: + violations.append(Violation(path="ref", reason="required")) + else: + ref_value_raw = raw["ref"] + try: + ref_value = _StreamRefTransferTypeConverter().from_transfer_type( + ref_value_raw, StreamRef + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "ref", error) + + attach_value: str = typing.cast("typing.Any", None) + if "attach" not in raw or raw["attach"] is None: + violations.append(Violation(path="attach", reason="required")) + else: + attach_value_raw = raw["attach"] + if not isinstance(attach_value_raw, str): + violations.append(Violation(path="attach", reason="expected string")) + else: + attach_value = attach_value_raw + + chain_value: str = typing.cast("typing.Any", None) + if "chain" not in raw or raw["chain"] is None: + violations.append(Violation(path="chain", reason="required")) + else: + chain_value_raw = raw["chain"] + if not isinstance(chain_value_raw, str): + violations.append(Violation(path="chain", reason="expected string")) + else: + chain_value = chain_value_raw + + for key in raw: + if key != "v" and key != "ref" and key != "attach" and key != "chain": + violations.append( + Violation(path=_member_path(key), reason="unknown field") + ) + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return StreamOperationToken( + v=v_value, + ref=ref_value, + attach=attach_value, + chain=chain_value, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "StreamOperationToken") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, StreamOperationToken): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + v_value: typing.Any = runtime_value.v + if v_value is None: + violations.append(Violation(path="v", reason="required")) + else: + if not ( + not isinstance(v_value, bool) + and ( + isinstance(v_value, int) + or (isinstance(v_value, float) and v_value.is_integer()) + ) + ): + violations.append(Violation(path="v", reason="expected integer")) + else: + if typing.cast("object", v_value) not in (2,): + violations.append(Violation(path="v", reason="must equal 2")) + out["v"] = v_value + ref_value: typing.Any = runtime_value.ref + if ref_value is None: + violations.append(Violation(path="ref", reason="required")) + else: + ref_violation_count = len(violations) + if len(violations) == ref_violation_count: + try: + out["ref"] = _StreamRefTransferTypeConverter().to_transfer_type( + ref_value + ) + except temporalio.exceptions.ApplicationError as error: + _collect(violations, "ref", error) + attach_value: typing.Any = runtime_value.attach + if attach_value is None: + violations.append(Violation(path="attach", reason="required")) + else: + if not (isinstance(attach_value, str)): + violations.append(Violation(path="attach", reason="expected string")) + out["attach"] = attach_value + chain_value: typing.Any = runtime_value.chain + if chain_value is None: + violations.append(Violation(path="chain", reason="required")) + else: + if not (isinstance(chain_value, str)): + violations.append(Violation(path="chain", reason="expected string")) + out["chain"] = chain_value + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_StreamOperationTokenTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class StreamOperationToken: + """What a stream-returning operation's token carries. The service mints it when the + operation starts, and a caller reads the stream back from it. The token is this + model's transfer form as JSON with sorted keys and no spaces, in base64url with no + padding. + """ + + v: typing.Literal[2] = 2 + """The token version. A token of another version is refused.""" + + ref: StreamRef + + attach: str + """The request id of the start that attached the caller.""" + + chain: str + """The first run of the run chain the start attached to, so a cancel detaches from it + even after the Workflow id moved on to a new chain. For an Activity owner, the + Activity run. For a Workflow Activity owner, the run that scheduled it. + """ + + def to_token(self) -> str: + """This value as an operation token. + + The token is the JSON transfer form with sorted keys and no spaces, in + base64url with no padding, so any implementation writes the same text. + """ + transfer: typing.Any = ( + _StreamOperationTokenTransferTypeConverter().to_transfer_type(self) + ) + text = json.dumps(transfer, separators=(",", ":"), sort_keys=True) + return base64.urlsafe_b64encode(text.encode()).decode().rstrip("=") + + @classmethod + def from_token(cls, token: str) -> "StreamOperationToken": + """Reads an operation token that ``to_token`` wrote. + + Raises: + ValueError: ``token`` is not one, or it names another version. + """ + # The decoder drops characters outside the alphabet and takes the + # standard one, so the alphabet is checked first, as Core does. + letters = token.rstrip("=").replace("-", "").replace("_", "") + if not (letters.isascii() and letters.isalnum()): + raise ValueError("not a StreamOperationToken: not base64url text") + try: + text = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)) + transfer: typing.Any = json.loads(text) + except ValueError as error: + raise ValueError(f"not a StreamOperationToken: {error}") from None + # The converter takes 2.0 as 2, and Core doesn't. + if not isinstance(transfer, dict) or type(transfer.get("v")) is not int: + raise ValueError( + "not a StreamOperationToken: the version is not an integer" + ) + try: + return _StreamOperationTokenTransferTypeConverter().from_transfer_type( + transfer, cls + ) + except temporalio.exceptions.ApplicationError as error: + raise ValueError(f"not a StreamOperationToken: {error}") from None + + +class _StreamRefTransferTypeConverter( + temporalio.converter.TransferTypeConverter["StreamRef", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["StreamRef"] + ) -> "StreamRef": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + kind_value: str = typing.cast("typing.Any", None) + if "kind" not in raw or raw["kind"] is None: + violations.append(Violation(path="kind", reason="required")) + else: + kind_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + else: + kind_value = kind_value_raw + + workflow_id_value: str = typing.cast("typing.Any", None) + if "workflow_id" not in raw or raw["workflow_id"] is None: + violations.append(Violation(path="workflow_id", reason="required")) + else: + workflow_id_value_raw = raw["workflow_id"] + if not isinstance(workflow_id_value_raw, str): + violations.append( + Violation(path="workflow_id", reason="expected string") + ) + else: + workflow_id_value = workflow_id_value_raw + + activity_id_value: str | None = None + if "activity_id" in raw: + activity_id_value_raw = raw["activity_id"] + if activity_id_value_raw is None: + activity_id_value = None + else: + if not isinstance(activity_id_value_raw, str): + violations.append( + Violation(path="activity_id", reason="expected string") + ) + else: + activity_id_value = activity_id_value_raw + + chain_run_id_value: str | None = None + if "chain_run_id" in raw: + chain_run_id_value_raw = raw["chain_run_id"] + if chain_run_id_value_raw is None: + chain_run_id_value = None + else: + if not isinstance(chain_run_id_value_raw, str): + violations.append( + Violation(path="chain_run_id", reason="expected string") + ) + else: + chain_run_id_value = chain_run_id_value_raw + + topic_value: str = typing.cast("typing.Any", None) + if "topic" not in raw or raw["topic"] is None: + violations.append(Violation(path="topic", reason="required")) + else: + topic_value_raw = raw["topic"] + if not isinstance(topic_value_raw, str): + violations.append(Violation(path="topic", reason="expected string")) + else: + topic_value = topic_value_raw + + for key in raw: + if ( + key != "kind" + and key != "workflow_id" + and key != "activity_id" + and key != "chain_run_id" + and key != "topic" + ): + violations.append( + Violation(path=_member_path(key), reason="unknown field") + ) + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return StreamRef( + kind=kind_value, + workflow_id=workflow_id_value, + activity_id=activity_id_value, + chain_run_id=chain_run_id_value, + topic=topic_value, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "StreamRef") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, StreamRef): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + kind_value: typing.Any = runtime_value.kind + if kind_value is None: + violations.append(Violation(path="kind", reason="required")) + else: + if not (isinstance(kind_value, str)): + violations.append(Violation(path="kind", reason="expected string")) + out["kind"] = kind_value + workflow_id_value: typing.Any = runtime_value.workflow_id + if workflow_id_value is None: + violations.append(Violation(path="workflow_id", reason="required")) + else: + if not (isinstance(workflow_id_value, str)): + violations.append( + Violation(path="workflow_id", reason="expected string") + ) + out["workflow_id"] = workflow_id_value + activity_id_value: typing.Any = runtime_value.activity_id + if activity_id_value is not None: + if not (isinstance(activity_id_value, str)): + violations.append( + Violation(path="activity_id", reason="expected string") + ) + out["activity_id"] = activity_id_value + chain_run_id_value: typing.Any = runtime_value.chain_run_id + if chain_run_id_value is not None: + if not (isinstance(chain_run_id_value, str)): + violations.append( + Violation(path="chain_run_id", reason="expected string") + ) + out["chain_run_id"] = chain_run_id_value + topic_value: typing.Any = runtime_value.topic + if topic_value is None: + violations.append(Violation(path="topic", reason="required")) + else: + if not (isinstance(topic_value, str)): + violations.append(Violation(path="topic", reason="expected string")) + out["topic"] = topic_value + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +StreamRef: typing.TypeAlias = temporalio.contrib.streams.StreamRef +"""One stream, named by its owner and a topic. It names no cursor and no store, so the same +reference is good behind any endpoint that serves the owner. +""" + + +class _SupersessionTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Supersession", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Supersession"] + ) -> "Supersession": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + raw = typing.cast("dict[str, typing.Any]", value) + + producer_id_value: str = typing.cast("typing.Any", None) + if "producer_id" not in raw or raw["producer_id"] is None: + violations.append(Violation(path="producer_id", reason="required")) + else: + producer_id_value_raw = raw["producer_id"] + if not isinstance(producer_id_value_raw, str): + violations.append( + Violation(path="producer_id", reason="expected string") + ) + else: + producer_id_value = producer_id_value_raw + + previous_attempt_value: int = typing.cast("typing.Any", None) + if "previous_attempt" not in raw or raw["previous_attempt"] is None: + violations.append(Violation(path="previous_attempt", reason="required")) + else: + previous_attempt_value_raw = raw["previous_attempt"] + previous_attempt_value_parsed = _parse_spec_integer( + previous_attempt_value_raw, "previous_attempt", violations + ) + if previous_attempt_value_parsed is not None: + previous_attempt_value = previous_attempt_value_parsed + + attempt_value: int = typing.cast("typing.Any", None) + if "attempt" not in raw or raw["attempt"] is None: + violations.append(Violation(path="attempt", reason="required")) + else: + attempt_value_raw = raw["attempt"] + attempt_value_parsed = _parse_spec_integer( + attempt_value_raw, "attempt", violations + ) + if attempt_value_parsed is not None: + attempt_value = attempt_value_parsed + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _SUPERSESSION_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return Supersession( + producer_id=producer_id_value, + previous_attempt=previous_attempt_value, + attempt=attempt_value, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Supersession") -> typing.Any: + runtime_value: typing.Any = value + if not isinstance(runtime_value, Supersession): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + producer_id_value: typing.Any = runtime_value.producer_id + if producer_id_value is None: + violations.append(Violation(path="producer_id", reason="required")) + else: + if not (isinstance(producer_id_value, str)): + violations.append( + Violation(path="producer_id", reason="expected string") + ) + out["producer_id"] = producer_id_value + previous_attempt_value: typing.Any = runtime_value.previous_attempt + if previous_attempt_value is None: + violations.append(Violation(path="previous_attempt", reason="required")) + else: + if not ( + not isinstance(previous_attempt_value, bool) + and ( + isinstance(previous_attempt_value, int) + or ( + isinstance(previous_attempt_value, float) + and previous_attempt_value.is_integer() + ) + ) + ): + violations.append( + Violation(path="previous_attempt", reason="expected integer") + ) + else: + if abs(previous_attempt_value) > 9007199254740991: + violations.append( + Violation( + path="previous_attempt", + reason="exceeds ±(2^53-1) integer cap", + ) + ) + out["previous_attempt"] = previous_attempt_value + attempt_value: typing.Any = runtime_value.attempt + if attempt_value is None: + violations.append(Violation(path="attempt", reason="required")) + else: + if not ( + not isinstance(attempt_value, bool) + and ( + isinstance(attempt_value, int) + or (isinstance(attempt_value, float) and attempt_value.is_integer()) + ) + ): + violations.append(Violation(path="attempt", reason="expected integer")) + else: + if abs(attempt_value) > 9007199254740991: + violations.append( + Violation( + path="attempt", reason="exceeds ±(2^53-1) integer cap" + ) + ) + out["attempt"] = attempt_value + additional_properties_value: typing.Any = runtime_value.additional_properties + if not isinstance(additional_properties_value, dict): + raise temporalio.converter.create_payload_validation_error( + [Violation(path="", reason="expected object")] + ) + checked_additional_properties = typing.cast( + "dict[str, typing.Any]", additional_properties_value + ) + for key, entry in checked_additional_properties.items(): + path = _member_path(key) + if key in _SUPERSESSION_DECLARED: + violations.append( + Violation( + path=path, + reason="additional property collides with declared property", + ) + ) + else: + out[key] = entry + if violations: + raise temporalio.converter.create_payload_validation_error(violations) + return out + + +@_transfer_type_convertible(_SupersessionTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Supersession: + """A later attempt of a producer started writing.""" + + producer_id: str + """The producer whose attempt changed.""" + + previous_attempt: int + """The attempt readers saw before.""" + + attempt: int + """The attempt that started writing.""" + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) diff --git a/temporalio/contrib/streams/nexus/_generated/services.py b/temporalio/contrib/streams/nexus/_generated/services.py new file mode 100644 index 0000000000..15cd443c36 --- /dev/null +++ b/temporalio/contrib/streams/nexus/_generated/services.py @@ -0,0 +1,89 @@ +# Generated by nexgen v0.2.8. DO NOT EDIT! + +from __future__ import annotations + +from nexusrpc import Operation, service + +from .models import ( + AppendInput, + AppendOutput, + FollowInput, + FollowOutput, + FollowRangeInput, + ReadInput, + ReadOutput, + ReadRangeInput, +) + + +@service(name="temporal.sdk.streams.v1.TemporalStreams") +class TemporalStreams: + """Appends to and reads from streams through one Nexus endpoint, so the store behind it + stays the endpoint's business and an operator can change it without touching + callers. An append carries one batch and a read answers with a batch, because a + Nexus operation per record costs too much for token streams. A record crosses as the + serialized temporal.sdk.streams.v1.StreamRecord, the bytes every store keeps, so a + caller in any language decodes it with that proto alone. A Workflow caller goes + through its Worker, whose codec applies. + """ + + append: Operation[ + AppendInput, + AppendOutput, + ] = Operation(name="append") + """Write one batch as one producer attempt, or finish the producer. The store numbers + the batch from sequence and deduplicates a retry: a repeat of the producer attempt's + newest batch with the same content answers with where the original landed and writes + nothing. A sequence already used with other content, or below the newest one the + store holds for the attempt, is refused. Supersession records are never written: a + reader synthesizes them from the attempts it observes. + """ + + read: Operation[ + ReadInput, + ReadOutput, + ] = Operation(name="read") + """Answer with the records after the caller's cursor. The call waits until it has + max_records records or wait_ms passes, whichever comes first, and answers with what + it collected. It says when the stream has ended for the reader, so the caller can + stop. + """ + + read_range: Operation[ + ReadRangeInput, + ReadOutput, + ] = Operation(name="read_range") + """Answer again the records of an earlier read that named a floor_holder, for a + Workflow that replays that read. The call reads the range its replay names with the + read's input state, so the superseded and stale marks come out the same, and checks + the records against the read's digest. It waits for nothing, asks nothing of the + owner and has no byte budget, since it must answer exactly what the read answered. + Records with other content are refused as StreamReplayMismatchError, and records the + store no longer holds as StreamReplayRangeLostError. The answer holds the records + and next_token only, since the caller keeps the read's state, done and replay. + """ + + follow: Operation[ + FollowInput, + FollowOutput, + ] = Operation(name="follow") + """Answer with the records after the caller's position on a stream and on every stream + its link records open, in one order. Order within each stream is kept. A linked + stream's records come after its OPEN link, and the linking stream's records after a + JOIN come after the linked stream reached the JOIN's point. Each item names its + source, and a linked stream's state comes as a link_state item. A linked stream that + can't be read or that ends never fails the call. Links to another namespace or + endpoint are not followed. The call waits like read, and the state resumes every + open stream. + """ + + follow_range: Operation[ + FollowRangeInput, + FollowOutput, + ] = Operation(name="follow_range") + """Answer again the records of an earlier follow that named a floor_holder, for a + Workflow that replays that follow. The call reads each range again with the follow's + input state, checks each against its digest and the order against the follow's + digest, and answers the same records in the same order. Other records or another + order are refused as StreamReplayMismatchError. + """ diff --git a/tests/contrib/streams/_support.py b/tests/contrib/streams/_support.py index c817c5081e..f026803b99 100644 --- a/tests/contrib/streams/_support.py +++ b/tests/contrib/streams/_support.py @@ -82,6 +82,11 @@ def streams_crate_dir() -> Path: raise RuntimeError("temporalio-streams is not a dependency of the bridge") +def streams_crate_nexus() -> Path: + """The crate's ``nexus`` directory, with the contract and the token vectors.""" + return streams_crate_dir() / "nexus" + + def new_workflow_id() -> str: return f"streams-{uuid.uuid4().hex}" diff --git a/tests/contrib/streams/test_nexus_service_idl.py b/tests/contrib/streams/test_nexus_service_idl.py new file mode 100644 index 0000000000..78aeb3f88b --- /dev/null +++ b/tests/contrib/streams/test_nexus_service_idl.py @@ -0,0 +1,336 @@ +"""The stream service bindings generated from its Nexus contract. + +``scripts/gen_nexus_streams_api.py --check`` keeps the bindings in step with +the contract; these pin what the rest of the SDK relies on from them. +""" + +from __future__ import annotations + +import base64 +import json +from collections.abc import Mapping +from pathlib import Path +from typing import Any, TypeVar + +import nexusrpc +import pytest + +import temporalio.contrib.streams +from temporalio.contrib.streams import StreamRef, StreamUnsupportedError +from temporalio.contrib.streams.nexus import ( + AppendInput, + AppendOutput, + ReadInput, + ReadOutput, + RecordWire, + TemporalStreams, + TemporalStreamsHttpClient, +) +from temporalio.contrib.streams.nexus._generated import ( + FollowInput, + FollowOutput, + FollowRangeInput, + ReadRangeInput, + StreamOperationToken, +) +from temporalio.contrib.streams.nexus._generated import client as generated_client +from temporalio.converter import DataConverter +from temporalio.exceptions import ApplicationError +from tests.contrib.streams._support import streams_crate_nexus + +T = TypeVar("T") + +_converter = DataConverter.default.payload_converter + + +def _to_json(value: object) -> dict[str, Any]: + return json.loads(_converter.to_payloads([value])[0].data) + + +def _from_json(document: Mapping[str, Any], type_hint: type[T]) -> T: + payload = _converter.to_payloads([document])[0] + return _converter.from_payloads([payload], [type_hint])[0] + + +def test_the_service_definition_carries_the_contract_operations(): + definition = nexusrpc.get_service_definition(TemporalStreams) + assert definition is not None + assert definition.name == "temporal.sdk.streams.v1.TemporalStreams" + operations = { + operation.name: (operation.input_type, operation.output_type) + for operation in definition.operation_definitions.values() + } + assert operations == { + "append": (AppendInput, AppendOutput), + "read": (ReadInput, ReadOutput), + "read_range": (ReadRangeInput, ReadOutput), + "follow": (FollowInput, FollowOutput), + "follow_range": (FollowRangeInput, FollowOutput), + } + + +def test_the_contract_comes_from_the_vendored_core(): + # Core serves the contract, so the SDK generates from Core's copy and keeps + # none of its own. + package = Path(temporalio.contrib.streams.__file__).parent + assert not (package / "nexus" / "temporal_streams.nexusrpc.yaml").exists() + contract = streams_crate_nexus() / "temporal_streams.nexusrpc.yaml" + assert "x-nexus-stream-ref: { py: temporalio.contrib.streams.StreamRef }" in ( + contract.read_text() + ) + + +def test_the_stream_reference_is_the_sdk_type(): + from temporalio.contrib.streams.nexus import StreamRef as ServiceStreamRef + + assert ServiceStreamRef is StreamRef + + +def test_an_append_crosses_as_the_contract_spells_it(): + ref = StreamRef.for_workflow("wf-1", topic="tokens") + append = AppendInput( + stream=ref, + producer_id="model", + attempt=2, + sequence=5, + payloads=[b"\x0a\x01x", b""], + ) + document = _to_json(append) + assert document == { + "stream": { + "kind": "workflow", + "workflow_id": "wf-1", + "topic": "tokens", + }, + "producer_id": "model", + "attempt": 2, + "sequence": 5, + "payloads": [base64.b64encode(b"\x0a\x01x").decode(), ""], + } + decoded = _from_json(document, AppendInput) + assert decoded == append + assert type(decoded.stream) is StreamRef + + +def test_an_activity_reference_crosses_with_its_activity_id(): + ref = StreamRef.for_activity("tool-1", run_id="act-run-1", topic="tokens") + read = ReadInput(stream=ref, wait_ms=2000) + document = _to_json(read) + assert document["stream"] == { + "kind": "activity", + "workflow_id": "", + "activity_id": "tool-1", + "chain_run_id": "act-run-1", + "topic": "tokens", + } + decoded = _from_json(document, ReadInput) + assert decoded.stream == ref + assert type(decoded.stream) is StreamRef + + +def test_a_workflow_reference_sends_no_activity_id(): + document = _to_json(ReadInput(stream=StreamRef.for_workflow("wf-1"))) + stream = document["stream"] + assert isinstance(stream, dict) + assert "activity_id" not in stream + + +def test_a_read_answer_decodes_with_its_cursors_and_records(): + document = { + "records": [{"token": "memory:abcd1234:0", "record": "CgF0"}], + "next_token": "memory:abcd1234:0", + "done": False, + "added_later": True, + } + answer = _from_json(document, ReadOutput) + assert isinstance(answer, ReadOutput) + assert answer.records == [ + RecordWire(token="memory:abcd1234:0", record=b"\x0a\x01t") + ] + assert answer.next_token == "memory:abcd1234:0" + assert answer.done is False + + +def test_a_record_that_cannot_cross_decodes_as_an_error_at_its_cursor(): + document = { + "records": [ + {"token": "memory:abcd1234:0", "record": "", "error": "too large"}, + ], + "next_token": "memory:abcd1234:0", + "done": False, + } + answer = _from_json(document, ReadOutput) + assert isinstance(answer, ReadOutput) + [record] = answer.records + assert record.error == "too large" + assert record.record == b"" + + +def test_an_input_with_an_unknown_member_is_refused(): + document = _to_json(ReadInput(stream=StreamRef.for_workflow("wf-1"))) + document["max_record"] = 5 + # The type a Nexus Worker answers BAD_REQUEST for. + with pytest.raises(ApplicationError) as refused: + _from_json(document, ReadInput) + assert refused.value.type == "PayloadValidationError" + assert "max_record" in str(refused.value.details) + + +def test_a_reference_of_another_kind_decodes_for_the_handler_to_refuse(): + document = _to_json(ReadInput(stream=StreamRef.for_workflow("wf-1"))) + stream = document["stream"] + assert isinstance(stream, dict) + stream["kind"] = "nexus" + decoded = _from_json(document, ReadInput) + assert isinstance(decoded, ReadInput) + assert decoded.stream.kind == "nexus" + # Opening a handle on it is what refuses it. + with pytest.raises(StreamUnsupportedError, match="'nexus'"): + decoded.stream._require_supported() # type: ignore[reportPrivateUsage] + + +def test_a_supersession_decodes_at_its_cursor(): + document = { + "records": [ + { + "token": "memory:abcd1234:2", + "record": "", + "superseded": { + "producer_id": "tokens@run-1", + "previous_attempt": 1, + "attempt": 2, + }, + }, + ], + "next_token": "memory:abcd1234:2", + "done": False, + } + answer = _from_json(document, ReadOutput) + assert isinstance(answer, ReadOutput) + [record] = answer.records + assert record.superseded is not None + assert ( + record.superseded.producer_id, + record.superseded.previous_attempt, + record.superseded.attempt, + ) == ("tokens@run-1", 1, 2) + + +def test_a_read_carries_its_state_both_ways(): + # The reader carries Core's read state, so the handler holds none. + read = ReadInput(stream=StreamRef.for_workflow("chat-1"), state=b"\x01\x02") + assert _to_json(read)["state"] == base64.b64encode(b"\x01\x02").decode() + answer = _from_json( + { + "records": [], + "next_token": "memory:abcd1234:2", + "done": False, + "state": "AQI=", + }, + ReadOutput, + ) + assert answer.state == b"\x01\x02" + + +def test_a_stale_record_decodes_with_its_mark(): + record = base64.b64encode(b"\x1a\x06tokens").decode() + answer = _from_json( + { + "records": [ + {"token": "memory:abcd1234:3", "record": record, "stale": True} + ], + "next_token": "memory:abcd1234:3", + "done": False, + }, + ReadOutput, + ) + [stored] = answer.records + assert stored.stale is True + + +async def test_the_generated_long_poll_sends_back_each_answers_state( + monkeypatch: pytest.MonkeyPatch, +): + # Two empty answers, then records. Each answer's state rides the next call. + answers = [ + {"records": [], "next_token": "c", "done": False, "state": "AQ=="}, + {"records": [], "next_token": "c", "done": False, "state": "Ag=="}, + { + "records": [{"token": "d", "record": ""}], + "next_token": "d", + "done": False, + "state": "Aw==", + }, + ] + sent: list[Any] = [] + + def post( + _url: str, body: bytes, _headers: Any, _timeout: float, **_: Any + ) -> tuple[bytes, str]: + sent.append(json.loads(body).get("state")) + return json.dumps(answers.pop(0)).encode(), "application/json" + + monkeypatch.setattr(generated_client, "_post", post) + caller = TemporalStreamsHttpClient("http://stub") + answer = await caller.read_until_records( + ReadInput(stream=StreamRef.for_workflow("chat-1"), state=b"\x00"), + deadline=10.0, + ) + assert answer.state == b"\x03" + assert sent == ["AA==", "AQ==", "Ag=="] + + +# The vectors Core's encoder and decoder read, in the Core this SDK vendors. +_TOKEN_VECTORS = json.loads( + (streams_crate_nexus() / "operation_token_vectors.json").read_text() +) + + +def _token_of(vector: dict[str, Any]) -> StreamOperationToken: + ref = vector["ref"] + return StreamOperationToken( + ref=StreamRef( + kind=ref["kind"], + workflow_id=ref["workflow_id"], + chain_run_id=ref.get("chain_run_id"), + activity_id=ref.get("activity_id"), + topic=ref["topic"], + ), + attach=vector["attach"], + chain=vector["chain"], + ) + + +@pytest.mark.parametrize("vector", _TOKEN_VECTORS["vectors"]) +def test_the_operation_token_writes_and_reads_cores_vectors(vector: dict[str, Any]): + token = _token_of(vector) + assert token.to_token() == vector["token"] + assert StreamOperationToken.from_token(vector["token"]) == token + + +@pytest.mark.parametrize("vector", _TOKEN_VECTORS["decode_only"]) +def test_the_operation_token_reads_cores_decode_only_vectors(vector: dict[str, Any]): + assert StreamOperationToken.from_token(vector["token"]) == _token_of(vector) + + +@pytest.mark.parametrize("token", _TOKEN_VECTORS["refused"]) +def test_the_operation_token_refuses_cores_refused_vectors(token: str): + try: + decoded = StreamOperationToken.from_token(token) + except ValueError: + return + # A ref of a kind this release lacks decodes, and opening it refuses it. + with pytest.raises(StreamUnsupportedError): + decoded.ref._require_supported() # type: ignore[reportPrivateUsage] + + +def test_a_read_and_its_answer_carry_no_arm(): + read = ReadInput(stream=StreamRef.for_workflow("chat-1"), wait_ms=2000) + document = _to_json(read) + assert "arm" not in document + assert _from_json(document, ReadInput) == read + # An answer recorded before the arm was removed still decodes. + old = _from_json( + {"records": [], "next_token": "", "done": False, "armed": True}, ReadOutput + ) + assert old.records == [] and not old.done From ba6cd63e8295b1bd42ee7eb14a5e95f205bfedeb Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Sun, 18 Oct 2026 00:46:22 -0700 Subject: [PATCH 2/3] Moved the Core pin to kb1 and sdk-streams to the new #12. kb1 holds the kj1 fix for old JSON Histories. Nothing generated changes. --- temporalio/bridge/Cargo.lock | 2 +- temporalio/bridge/Cargo.toml | 2 +- temporalio/bridge/sdk-core | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/temporalio/bridge/Cargo.lock b/temporalio/bridge/Cargo.lock index 8a624a0490..6c9c2faa12 100644 --- a/temporalio/bridge/Cargo.lock +++ b/temporalio/bridge/Cargo.lock @@ -2926,7 +2926,7 @@ dependencies = [ [[package]] name = "temporalio-streams" version = "0.1.0" -source = "git+https://github.com/moetemp/sdk-streams?rev=472e700f2ed59e599d62c6572252978767c6f858#472e700f2ed59e599d62c6572252978767c6f858" +source = "git+https://github.com/moetemp/sdk-streams?rev=a08e6f32ccdebc64a8d208ac9aa1b1549e85479c#a08e6f32ccdebc64a8d208ac9aa1b1549e85479c" dependencies = [ "async-trait", "aws-lc-rs", diff --git a/temporalio/bridge/Cargo.toml b/temporalio/bridge/Cargo.toml index e8f63b5075..4d58edd3d7 100644 --- a/temporalio/bridge/Cargo.toml +++ b/temporalio/bridge/Cargo.toml @@ -91,7 +91,7 @@ url = "2.2" # makes it use the submodule's crates, so the bridge sees one `Connection` type. [dependencies.temporalio-streams] git = "https://github.com/moetemp/sdk-streams" -rev = "472e700f2ed59e599d62c6572252978767c6f858" +rev = "a08e6f32ccdebc64a8d208ac9aa1b1549e85479c" default-features = false features = ["envconfig", "redis"] diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 84dc65fdc1..76a64b43ff 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 84dc65fdc137ac3b78db1389bb1bad9ea82b2715 +Subproject commit 76a64b43ff06d0d9bb4e3ca76f83ff5be893d283 From abdab509ded4830698df7582a55e785173171ab0 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Sun, 18 Oct 2026 02:15:11 -0700 Subject: [PATCH 3/3] Moved sdk-streams to the sb3 head that withdraws failed task calls. --- 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 6c9c2faa12..4af6836305 100644 --- a/temporalio/bridge/Cargo.lock +++ b/temporalio/bridge/Cargo.lock @@ -2926,7 +2926,7 @@ dependencies = [ [[package]] name = "temporalio-streams" version = "0.1.0" -source = "git+https://github.com/moetemp/sdk-streams?rev=a08e6f32ccdebc64a8d208ac9aa1b1549e85479c#a08e6f32ccdebc64a8d208ac9aa1b1549e85479c" +source = "git+https://github.com/moetemp/sdk-streams?rev=02ef7af32246475e209980bb87e11f72923c7801#02ef7af32246475e209980bb87e11f72923c7801" dependencies = [ "async-trait", "aws-lc-rs", diff --git a/temporalio/bridge/Cargo.toml b/temporalio/bridge/Cargo.toml index 4d58edd3d7..71b657b117 100644 --- a/temporalio/bridge/Cargo.toml +++ b/temporalio/bridge/Cargo.toml @@ -91,7 +91,7 @@ url = "2.2" # makes it use the submodule's crates, so the bridge sees one `Connection` type. [dependencies.temporalio-streams] git = "https://github.com/moetemp/sdk-streams" -rev = "a08e6f32ccdebc64a8d208ac9aa1b1549e85479c" +rev = "02ef7af32246475e209980bb87e11f72923c7801" default-features = false features = ["envconfig", "redis"]