From 44519d8a0f4e16a455b032b044e9326bf4141509 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Sun, 25 Oct 2026 14:25:33 -0700 Subject: [PATCH 01/20] Added the Stream service messages as experimental. The crate, sdk-python and later the server read one copy of these messages. The gRPC service and its Nexus annotations come with the server. --- temporal/api/enums/v1/stream.proto | 90 +++++++++ temporal/api/stream/v1/message.proto | 127 ++++++++++++ .../streamservice/v1/request_response.proto | 187 ++++++++++++++++++ 3 files changed, 404 insertions(+) create mode 100644 temporal/api/enums/v1/stream.proto create mode 100644 temporal/api/stream/v1/message.proto create mode 100644 temporal/api/streamservice/v1/request_response.proto diff --git a/temporal/api/enums/v1/stream.proto b/temporal/api/enums/v1/stream.proto new file mode 100644 index 000000000..b64f7d5d4 --- /dev/null +++ b/temporal/api/enums/v1/stream.proto @@ -0,0 +1,90 @@ +syntax = "proto3"; + +package temporal.api.enums.v1; + +option go_package = "go.temporal.io/api/enums/v1;enums"; +option java_package = "io.temporal.api.enums.v1"; +option java_multiple_files = true; +option java_outer_classname = "StreamProto"; +option ruby_package = "Temporalio::Api::Enums::V1"; +option csharp_namespace = "Temporalio.Api.Enums.V1"; + +// Experimental. Might change in the future. +enum StreamProducerKind { + STREAM_PRODUCER_KIND_UNSPECIFIED = 0; + STREAM_PRODUCER_KIND_WORKFLOW = 1; + STREAM_PRODUCER_KIND_LOCAL_ACTIVITY = 2; + STREAM_PRODUCER_KIND_ACTIVITY = 3; + STREAM_PRODUCER_KIND_CLIENT = 4; + STREAM_PRODUCER_KIND_STANDALONE_ACTIVITY = 5; +} + +// Experimental. Might change in the future. +// How a Stream closed, so a reader tells a terminated or timed out owner from a failed one +// without parsing the failure. +// (-- api-linter: core::0216::synonyms=disabled +// aip.dev/not-precedent: Named consistently with WorkflowExecutionStatus. --) +enum StreamCloseStatus { + STREAM_CLOSE_STATUS_UNSPECIFIED = 0; + STREAM_CLOSE_STATUS_COMPLETED = 1; + STREAM_CLOSE_STATUS_FAILED = 2; + STREAM_CLOSE_STATUS_CANCELED = 3; + // TERMINATED and TIMED_OUT come only from the owner's end, never from a caller. + STREAM_CLOSE_STATUS_TERMINATED = 4; + STREAM_CLOSE_STATUS_TIMED_OUT = 5; +} + +// Experimental. Might change in the future. +// (-- api-linter: core::0216::synonyms=disabled +// aip.dev/not-precedent: Named consistently with WorkflowExecutionStatus. --) +enum StreamStatus { + STREAM_STATUS_UNSPECIFIED = 0; + STREAM_STATUS_OPEN = 1; + STREAM_STATUS_CLOSED = 2; + STREAM_STATUS_EXPIRED = 3; +} + +// Experimental. Might change in the future. +enum StreamReadStart { + STREAM_READ_START_UNSPECIFIED = 0; + STREAM_READ_START_BEGINNING = 1; + STREAM_READ_START_END = 2; +} + +// Experimental. Might change in the future. +// Why a Stream call failed. Each reason has one gRPC status code. +enum StreamErrorReason { + STREAM_ERROR_REASON_UNSPECIFIED = 0; + // Nobody created the Stream, even after `create_wait`. NOT_FOUND. + STREAM_ERROR_REASON_NOT_FOUND = 1; + // The Stream's Retention or safety TTL passed. NOT_FOUND. + STREAM_ERROR_REASON_EXPIRED = 2; + // The Stream is closed. FAILED_PRECONDITION. + STREAM_ERROR_REASON_CLOSED = 3; + // The Producer's epoch is lower than the newest one, its Workflow Task was withdrawn, or a + // Workflow retry withdrew its run. FAILED_PRECONDITION. + STREAM_ERROR_REASON_FENCED = 4; + // The batch has a lower `sequence` than the last one of its epoch. FAILED_PRECONDITION. + STREAM_ERROR_REASON_SEQUENCE = 5; + // The batch has the `sequence` of the last one of its epoch, and other content. + // FAILED_PRECONDITION. + STREAM_ERROR_REASON_SEQ_DIVERGENT = 6; + // A create names another owner than the Stream has, or the owner doesn't allow this close. + // FAILED_PRECONDITION. + STREAM_ERROR_REASON_NOT_OWNER = 7; + // A newer format version wrote the Stream. FAILED_PRECONDITION. + STREAM_ERROR_REASON_FORMAT_TOO_NEW = 8; + // The offset is past the newest offset + 1, for example after a failover lost the newest + // Records. A read from it works again once the Stream has Records up to it. OUT_OF_RANGE. + STREAM_ERROR_REASON_OFFSET = 9; + // A Record or a batch is over the Store's limit. INVALID_ARGUMENT. + STREAM_ERROR_REASON_TOO_LARGE = 10; + // The request is malformed. INVALID_ARGUMENT. + STREAM_ERROR_REASON_INVALID = 11; + // The Store failed or did not answer. UNAVAILABLE. + STREAM_ERROR_REASON_STORAGE = 12; + // No writer has the id. The SDK's in-process writer only. NOT_FOUND. + STREAM_ERROR_REASON_UNKNOWN_WRITER = 13; + // The Store lacks a capability that the call needs. UNIMPLEMENTED. + STREAM_ERROR_REASON_UNSUPPORTED = 14; +} diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto new file mode 100644 index 000000000..e4984081e --- /dev/null +++ b/temporal/api/stream/v1/message.proto @@ -0,0 +1,127 @@ +syntax = "proto3"; + +package temporal.api.stream.v1; + +option go_package = "go.temporal.io/api/stream/v1;stream"; +option java_package = "io.temporal.api.stream.v1"; +option java_multiple_files = true; +option java_outer_classname = "MessageProto"; +option ruby_package = "Temporalio::Api::Stream::V1"; +option csharp_namespace = "Temporalio.Api.Stream.V1"; + +import "google/protobuf/timestamp.proto"; + +import "temporal/api/common/v1/message.proto"; +import "temporal/api/enums/v1/stream.proto"; +import "temporal/api/failure/v1/message.proto"; + +// Experimental. Might change in the future. +// Identifies one Stream. The id is opaque to callers. +message StreamRef { + string id = 1; + // Unset for a client's Stream. For a Workflow, business_id is the Workflow ID and run_id is + // the first execution run id of its run chain. For a Standalone Activity, business_id is the + // activity ID and run_id is its run id. A create and a close of a Workflow-owned Stream must + // name its owner. + temporal.api.common.v1.Execution owner = 2; + // The name of the backend that holds the Stream, as the Worker and client config register + // it, so every process routes a ref it receives. Empty is the default backend. The Store + // never reads it. + string backend = 3; +} + +// Experimental. Might change in the future. +// The ordered attempt of a Producer. Compared in this order: task_started_event_id, +// task_attempt, task_started_time, attempt_scheduled_time, attempt. A field that a kind does +// not use is empty and compares as zero. A timestamp compares seconds, then nanos. +message ProducerEpoch { + // The Workflow Task of a Workflow, or the one that dispatched a Local Activity. + int64 task_started_event_id = 1; + int32 task_attempt = 2; + // Tells a reused started event id apart, for example after a dropped speculative task. + google.protobuf.Timestamp task_started_time = 5; + // When the server scheduled an Activity's attempt. A reset makes it later. + google.protobuf.Timestamp attempt_scheduled_time = 3; + // Activity, Local Activity and client attempt. + int32 attempt = 4; +} + +// Experimental. Might change in the future. +message Producer { + temporal.api.enums.v1.StreamProducerKind kind = 1; + string id = 2; + ProducerEpoch epoch = 3; + // The run of the Producer, so nobody parses the id. Empty for a client. + string run_id = 4; +} + +// Experimental. Might change in the future. +message WorkflowTaskEpoch { + int64 started_event_id = 1; + int32 attempt = 2; + google.protobuf.Timestamp started_time = 3; +} + +// Experimental. Might change in the future. +message AppendRecord { + reserved 1; + reserved "topic"; + temporal.api.common.v1.Payload body = 2; +} + +// Experimental. Might change in the future. +message Supersession { + temporal.api.enums.v1.StreamProducerKind producer_kind = 1; + string producer_id = 2; + ProducerEpoch epoch = 3; + // Workflow only. + WorkflowTaskEpoch previous_completed_task = 4; + repeated int64 failed_tasks = 5; + // Workflow retry only. The previous run. Every Record whose Producer carries it is withdrawn. + string withdraw_run = 6; + // The run of the Producer, so nobody parses the id. Empty for a client. + string run_id = 7; +} + +// Experimental. Might change in the future. +// One Record that a Producer appended. +message StreamData { + reserved 1; + reserved "topic"; + Producer producer = 2; + temporal.api.common.v1.Payload body = 3; +} + +// Experimental. Might change in the future. +// The close of a Stream. A Stream has at most one, and a read returns it last. +message StreamClose { + // The Producer that closed the Stream. + Producer producer = 1; + temporal.api.common.v1.Payload result = 2; + temporal.api.failure.v1.Failure failure = 3; + // The result or the failure stays next to it. + temporal.api.enums.v1.StreamCloseStatus status = 4; +} + +// Experimental. Might change in the future. +// An offset is ordered and unique in one Stream, and it is sparse. Offsets have gaps, so they +// don't count Records. Zero is never an offset. A read names its Stream, so the read checks the +// offset against that Stream. +message StreamRecord { + int64 offset = 1; + // A read item is a Record, a Superseded item or the close. + oneof item { + StreamData data = 2; + // The first batch of a higher epoch carries it. It withdraws the earlier Records that its + // facts name, and a reader drops them. + Supersession superseded = 3; + StreamClose close = 4; + } +} + +// Experimental. Might change in the future. +// The details of a failed Stream call, in the gRPC status details and in the details of the +// application failure of a Nexus operation. +message StreamErrorDetails { + temporal.api.enums.v1.StreamErrorReason reason = 1; +} diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto new file mode 100644 index 000000000..f48dfcf65 --- /dev/null +++ b/temporal/api/streamservice/v1/request_response.proto @@ -0,0 +1,187 @@ +syntax = "proto3"; + +package temporal.api.streamservice.v1; + +option go_package = "go.temporal.io/api/streamservice/v1;streamservice"; +option java_package = "io.temporal.api.streamservice.v1"; +option java_multiple_files = true; +option java_outer_classname = "RequestResponseProto"; +option ruby_package = "Temporalio::Api::StreamService::V1"; +option csharp_namespace = "Temporalio.Api.StreamService.V1"; + +// The messages of the Stream service. An SDK Store (Redis or memory) and the server answer the +// same five operations with them. A field that only the server reads says so. + +import "google/protobuf/duration.proto"; + +import "temporal/api/common/v1/message.proto"; +import "temporal/api/enums/v1/stream.proto"; +import "temporal/api/failure/v1/message.proto"; +import "temporal/api/stream/v1/message.proto"; + +// Experimental. Might change in the future. +// Creates the Stream, or gets it when it exists. Then created is false, and the Stream keeps its +// own Retention. It fails with CLOSED on a closed Stream, EXPIRED on an expired one, and NOT_OWNER +// on one with another owner. +// (-- api-linter: core::0133=disabled +// aip.dev/not-precedent: The request names a Stream by namespace and stream_id. --) +message CreateStreamRequest { + reserved 5; + reserved "auto_close"; + string namespace = 1; + // Required, and never empty. + string stream_id = 2; + // The age after which a Record is dropped. Empty is the Store default. + google.protobuf.Duration retention = 3; + // Workflow only. The previous run on a retry run's first create, to withdraw its Records. + string withdraw_run = 4; + // The Workflow run that owns the Stream. Workflow kind, with its run_id and epoch. It needs a + // ref whose owner names the run's chain. + temporal.api.stream.v1.Producer owner = 6; + // Only the server reads it. The server closes the Stream with the outcome of the owner's run + // chain, terminate and timeout included. It needs owner, and a ref with a Workflow owner. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "with" ties the close to the owner's run chain. --) + bool close_with_run = 7; + // The ref of the Stream, with stream_id as its id. A Workflow-owned Stream sets the ref's + // owner, which names the owner's run chain. + temporal.api.stream.v1.StreamRef ref = 8; + // Only the server reads it. The server checks that the token is the Activity's current + // attempt, so a caller can't put a Stream into a Standalone Activity it isn't. + bytes task_token = 9; +} + +// Experimental. Might change in the future. +message CreateStreamResponse { + reserved 3; + reserved "status"; + // The ref, with the owner of a Workflow-owned Stream. + temporal.api.stream.v1.StreamRef stream = 1; + bool created = 2; +} + +// Experimental. Might change in the future. +// FAILED_PRECONDITION if closed, fenced, or if the Stream's meta has a higher format version v +// (detail stream_format_too_new). NOT_FOUND if absent. +message AppendRecordsRequest { + string namespace = 1; + temporal.api.stream.v1.StreamRef stream = 2; + // Required outside a Workflow. In a Workflow the transport sets it. + temporal.api.stream.v1.Producer producer = 3; + // The batch number in this epoch. With the Producer and epoch it is the dedupe key. + int64 sequence = 4; + repeated temporal.api.stream.v1.AppendRecord records = 5; + // The run's last completed Workflow Task (started_event_id 0 before the first) and its failed + // tasks, from a Local Activity's start. The batch withdraws the run's Local Activity Records + // whose task never completed. + temporal.api.stream.v1.WorkflowTaskEpoch previous_completed_task = 6; + repeated int64 failed_tasks = 7; + // Local Activity only. The previous run on a retry run's first batch, to withdraw its Records. + string withdraw_run = 8; + // Only the server reads it, on a Workflow Producer's batch. The batch withdraws no Workflow + // Task and keeps no task facts, but withdraw_run still applies. + bool defer_withdrawal = 9; + // Only the server reads it. The server checks the Producer against the Activity's current + // attempt, so nobody else writes as that Activity. + bytes task_token = 10; +} + +// Experimental. Might change in the future. +message AppendRecordsResponse { + int64 first_offset = 1; + int64 last_offset = 2; +} + +// Experimental. Might change in the future. +message CloseStreamRequest { + string namespace = 1; + temporal.api.stream.v1.StreamRef stream = 2; + temporal.api.stream.v1.Producer producer = 3; + temporal.api.common.v1.Payload result = 4; + temporal.api.failure.v1.Failure failure = 5; + // Only the server reads these. They are the run's task facts, as on the append. + temporal.api.stream.v1.WorkflowTaskEpoch previous_completed_task = 6; + repeated int64 failed_tasks = 7; + // Only the server reads it. The previous run on a retry run's close, to withdraw its Records. + string withdraw_run = 8; + // Empty is COMPLETED with no failure, CANCELED with a failure that has canceled_failure_info, + // and FAILED with any other failure. + temporal.api.enums.v1.StreamCloseStatus status = 9; + // Only the server reads it. It checks the token as on the append. + bytes task_token = 10; +} + +// Experimental. Might change in the future. +message CloseStreamResponse { +} + +// Experimental. Might change in the future. +message ReadRecordsRequest { + string namespace = 1; + temporal.api.stream.v1.StreamRef stream = 2; + // Start at this offset. Zero uses start. OUT_OF_RANGE if past the newest offset + 1. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" and "to" mark the ends of a range of offsets. --) + int64 from_offset = 3; + temporal.api.enums.v1.StreamReadStart start = 4; + reserved 5, 10; + reserved "topics", "topic_prefixes"; + // The most items in one page, Superseded items included. Zero is 1000. A Superseded item and + // the Record after it stay in one page, so a page of one Record can hold two items. + int32 max_records = 6; + google.protobuf.Duration wait = 7; + // How long to wait for a Stream that nobody created yet. Empty fails at once with + // NOT_FOUND. At most 60 s. + google.protobuf.Duration create_wait = 8; + // The exclusive end of the read. The read scans no entry at or after it, the close Record + // included, so a replay reads the same range. Zero means no end. Only the server serves it, + // and a Store refuses it. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" and "to" mark the ends of a range of offsets. --) + int64 to_offset = 11; + // Park as a normal read, then answer with no Records and next_offset = from_offset, when a + // Record at or after from_offset exists, when the Stream closes (done), or when wait ends. Its + // wait may be days, since a parked read adds nothing to History while it waits. Only the + // server serves it, and a Store refuses it. + bool wake_only = 12; + // Read the newest `last` Records, oldest first. It needs no from_offset and no start. Not with + // wake_only, and not in Workflow code, where replay needs a fixed range. + int32 last = 13; + // The most Record bytes in one page, so a page fits the caller's limit, such as a History + // marker. A larger Record still comes alone, so the read always moves on. Zero is no cap. + int64 max_bytes = 14; + // The lowest offset this run read on replay. A reset run copies the earlier run's reads into + // its History, so its floor must cover them too, not only its first live page. Only the + // server reads it. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: The floor is the lowest from_offset of the run's reads. --) + int64 floor_from = 15; +} + +// Experimental. Might change in the future. +message ReadRecordsResponse { + repeated temporal.api.stream.v1.StreamRecord records = 1; + // The from_offset of the next read, which is the newest offset read + 1. + int64 next_offset = 2; + bool done = 3; + // This reader's floor keeps more than half of the Stream's byte cap. The writer fails when the + // cap fills, and only the reader's continue-as-new frees it. Only the server sets it. + bool continue_as_new_suggested = 4; +} + +// Experimental. Might change in the future. +message DescribeStreamRequest { + string namespace = 1; + temporal.api.stream.v1.StreamRef stream = 2; +} + +// Experimental. Might change in the future. +message DescribeStreamResponse { + reserved 4; + reserved "auto_close"; + temporal.api.enums.v1.StreamStatus status = 1; + // The newest offset, of a Record, a Superseded item or the close. Zero when the Stream has + // none. + int64 last_offset = 2; + google.protobuf.Duration retention = 3; +} From 0a4e6670623207428fd6d29fc365a4a92f7753e1 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 30 Oct 2026 09:59:30 -0700 Subject: [PATCH 02/20] Dropped the close status, and let a zero sequence take the next one. The failure gives the close status, so the field only let a caller contradict it. An append with no sequence gets the next batch number, so a call that has no writer state still works. --- temporal/api/streamservice/v1/request_response.proto | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index f48dfcf65..a2bfeba35 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -68,7 +68,8 @@ message AppendRecordsRequest { temporal.api.stream.v1.StreamRef stream = 2; // Required outside a Workflow. In a Workflow the transport sets it. temporal.api.stream.v1.Producer producer = 3; - // The batch number in this epoch. With the Producer and epoch it is the dedupe key. + // The batch number in this epoch. With the Producer and epoch it is the dedupe key. Zero takes + // the next number of the epoch. Such a batch has no dedupe, so a retry writes it again. int64 sequence = 4; repeated temporal.api.stream.v1.AppendRecord records = 5; // The run's last completed Workflow Task (started_event_id 0 before the first) and its failed @@ -98,15 +99,17 @@ message CloseStreamRequest { temporal.api.stream.v1.StreamRef stream = 2; temporal.api.stream.v1.Producer producer = 3; temporal.api.common.v1.Payload result = 4; + // The close status comes from it. No failure is COMPLETED. A failure is CANCELED when it has + // canceled_failure_info, or when it is an Activity, child Workflow or Nexus Operation failure + // whose cause has canceled_failure_info. Any other failure is FAILED. temporal.api.failure.v1.Failure failure = 5; // Only the server reads these. They are the run's task facts, as on the append. temporal.api.stream.v1.WorkflowTaskEpoch previous_completed_task = 6; repeated int64 failed_tasks = 7; // Only the server reads it. The previous run on a retry run's close, to withdraw its Records. string withdraw_run = 8; - // Empty is COMPLETED with no failure, CANCELED with a failure that has canceled_failure_info, - // and FAILED with any other failure. - temporal.api.enums.v1.StreamCloseStatus status = 9; + reserved 9; + reserved "status"; // Only the server reads it. It checks the token as on the append. bytes task_token = 10; } From 9f6f1a55e066b3e78ac9bc9a0976a16a7c72282c Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 30 Oct 2026 14:43:16 -0700 Subject: [PATCH 03/20] Added the Stream service WIT, generated with a pinned nexgen fork. The SDKs copy this WIT to generate their Stream Workflow API and client. The Makefile runs add-message on it, so its records follow the Stream protos. The fork has the directives the WIT uses, until a nexgen release does. --- Makefile | 24 +- nexus/stream-service.wit | 496 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 516 insertions(+), 4 deletions(-) create mode 100644 nexus/stream-service.wit diff --git a/Makefile b/Makefile index a0b49fe91..4870f9433 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,11 @@ PROTO_PATHS = paths=source_relative:$(PROTO_OUT) OAPI_OUT := openapi OAPI3_PATH := .components.schemas.Payload -NEX_GEN ?= nexgen +# The Stream WIT uses nexgen directives that no release has yet. Pin the fork commit that has them +# until a release does. +NEX_GEN_REV := 526cf81c44620b6e85d2256e19604885300ed6fd +NEX_GEN_ROOT := $(CURDIR)/$(STAMPDIR)/nexgen-$(NEX_GEN_REV) +NEX_GEN ?= $(NEX_GEN_ROOT)/bin/nexgen $(PROTO_OUT): mkdir $(PROTO_OUT) @@ -142,6 +146,7 @@ nexus-rpc-yaml-install: @cd cmd/protoc-gen-nexus-rpc-yaml && go install . ##### Compile system Nexus WIT files ##### +# No Stream operation returns StreamErrorDetails, so add-message selects it by name. system-nexus-wit: system-nexus-wit-install nex-gen-install printf $(COLOR) "Generate system Nexus WIT..." tmp=$$(mktemp -d); trap 'rm -rf "$$tmp"' EXIT; \ @@ -152,21 +157,32 @@ system-nexus-wit: system-nexus-wit-install nex-gen-install --system-nexus-wit_opt=linked_input=nexus/deps \ --system-nexus-wit_out=. \ temporal/api/workflowservice/v1/service.proto \ - temporal/api/notificationservice/v1/request_response.proto && \ + temporal/api/notificationservice/v1/request_response.proto \ + temporal/api/streamservice/v1/request_response.proto && \ $(NEX_GEN) add-message \ --descriptors "$$tmp/system.bin" \ --file temporal/api/notificationservice/v1/request_response.proto \ --input nexus/notification-service.wit \ --input nexus/deps \ - --output nexus/notification-service.wit + --output nexus/notification-service.wit && \ + $(NEX_GEN) add-message \ + --descriptors "$$tmp/system.bin" \ + --file temporal/api/streamservice/v1/request_response.proto \ + --message temporal.api.stream.v1.StreamErrorDetails \ + --input nexus/stream-service.wit \ + --input nexus/deps \ + --output nexus/stream-service.wit system-nexus-wit-install: printf $(COLOR) "Build and install protoc-gen-system-nexus-wit..." @cd cmd/protoc-gen-system-nexus-wit && go install . +# The git CLI follows a local URL rewrite to SSH, which cargo's own git client can't authenticate. nex-gen-install: printf $(COLOR) "Install nexgen if missing..." - command -v $(NEX_GEN) >/dev/null || cargo install nexgen --version ^0.2 --features advanced + command -v $(NEX_GEN) >/dev/null || CARGO_NET_GIT_FETCH_WITH_CLI=true cargo install \ + --git https://github.com/moetemp/nexgen --rev $(NEX_GEN_REV) --locked --features advanced \ + --root $(NEX_GEN_ROOT) nexgen ##### Clean ##### clean: diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit new file mode 100644 index 000000000..955426248 --- /dev/null +++ b/nexus/stream-service.wit @@ -0,0 +1,496 @@ +// Draft. The Stream service as a System Nexus service, for the Streams API proposal. Each record +// maps to a message of the protos under ../temporal/api (stream/v1, streamservice/v1 and the +// Stream enums in enums/v1). This file is the source. Each SDK copies it and generates its Workflow +// API and its client with nexgen (`--targets workflow,client --system-nexus`). +package temporal:nexus@1.0.0; + +world system { + export stream-service; +} + +/// @nexus.endpoint "__temporal_system" +/// @nexus.service-name "temporal.api.streamservice.v1.StreamService" +/// @nexus.experimental +interface stream-service { + use nexus:temporal-types/model@1.0.0.{ + duration, + failure, + payload, + placeholder, + }; + + /// @nexus.doc "The value type of a Stream's Records. A Stream definition sets it." + /// @nexus.type-parameter + type body-t = placeholder; + + /// @nexus.proto "google.protobuf.Timestamp" + record timestamp { + seconds: option, + nanos: option, + } + + // A Stream that a Workflow creates has the id ::. Each part is + // percent-encoded. So a caller that knows the first run id can build the id with no call. + /// @nexus.doc "Identifies one Stream. The id is opaque to callers." + /// @nexus.proto "temporal.api.stream.v1.StreamRef" + record stream-ref { + /// @nexus.doc "The Stream id, 1 to 4096 bytes." + id: string, + /// @nexus.doc "The owning Workflow run chain or Standalone Activity. Empty on a client ref." + owner: option, + /// @nexus.doc "Registered backend name, empty for the default. The Store never reads it." + backend: option, + } + + /// @nexus.proto "temporal.api.enums.v1.ExecutionType" + enum execution-type { + execution-type-unspecified, + execution-type-workflow, + execution-type-activity, + execution-type-nexus-operation, + } + + /// @nexus.doc "A Workflow run chain or a Standalone Activity run." + /// @nexus.proto "temporal.api.common.v1.Execution" + record execution { + %type: option, + /// @nexus.doc "The Workflow id, or the Standalone Activity's id." + business-id: option, + /// @nexus.doc "The run chain's first run id, or the Standalone Activity's run id." + run-id: option, + } + + /// @nexus.proto "temporal.api.enums.v1.StreamProducerKind" + enum stream-producer-kind { + stream-producer-kind-unspecified, + stream-producer-kind-workflow, + stream-producer-kind-local-activity, + stream-producer-kind-activity, + stream-producer-kind-client, + stream-producer-kind-standalone-activity, + } + + // Epochs compare field by field, in the order of the fields. A field that a kind does not use is + // empty and compares as zero. A timestamp compares seconds, then nanos. A Workflow epoch has + // task-started-event-id, task-attempt and task-started-time. A Local Activity epoch has the same + // three of the dispatching Workflow Task, then attempt. An Activity epoch has + // attempt-scheduled-time, then attempt. A client epoch has attempt. + /// @nexus.doc "The ordered attempt of a Producer. A higher epoch supersedes a lower one." + /// @nexus.proto "temporal.api.stream.v1.ProducerEpoch" + record producer-epoch { + /// @nexus.doc "The started event id of the Workflow Task." + task-started-event-id: option, + /// @nexus.doc "The attempt of the Workflow Task." + task-attempt: option, + /// @nexus.doc "When the Workflow Task attempt started. It tells a reused started id apart." + task-started-time: option, + /// @nexus.doc "When the server scheduled this Activity attempt. A reset makes it later." + attempt-scheduled-time: option, + /// @nexus.doc "The Activity, Local Activity or client attempt." + attempt: option, + } + + /// @nexus.doc "The Producer of a batch." + /// @nexus.proto "temporal.api.stream.v1.Producer" + record producer { + /// @nexus.doc "Stable across attempts. The proposal gives the form per kind." + id: string, + kind: option, + epoch: option, + /// @nexus.doc "The run of the Producer, so nobody parses the id. Empty for a client." + run-id: option, + } + + /// @nexus.doc "A Workflow Task attempt of the run." + /// @nexus.proto "temporal.api.stream.v1.WorkflowTaskEpoch" + record workflow-task-epoch { + started-event-id: option, + attempt: option, + started-time: option, + } + + /// @nexus.doc "One Record to append." + /// @nexus.proto "temporal.api.stream.v1.AppendRecord" + /// @nexus.unwrap + record append-record { + /// @nexus.doc "The value." + body: body-t, + } + + // An Activity or Local Activity item withdraws the Records of its Producer with a lower epoch. A + // Workflow item, with this task t in epoch and the previous completed task c, withdraws the + // Records of the run and of its Local Activities whose task-started-event-id is in failed-tasks + // or strictly between c and t. It also withdraws those at the started event id of c or of t with + // a lower epoch than c or t. No task in that range can have completed. + /// @nexus.doc "Which earlier Records a Superseded item withdraws." + /// @nexus.proto "temporal.api.stream.v1.Supersession" + record supersession { + producer-id: string, + /// @nexus.doc "Workflow only. Started event ids of failed or timed out Workflow Tasks." + failed-tasks: list, + producer-kind: option, + epoch: option, + /// @nexus.doc "Workflow only. The previous completed Workflow Task of the run." + previous-completed-task: option, + /// @nexus.doc "Workflow retry only. The previous run, all of whose Records are withdrawn." + withdraw-run: option, + /// @nexus.doc "The withdrawing Producer's run, so no Reader parses ids. Empty for a client." + run-id: option, + } + + /// @nexus.doc "How a Stream closed. Only the owner's end on the server terminates or times out." + /// @nexus.proto "temporal.api.enums.v1.StreamCloseStatus" + enum stream-close-status { + stream-close-status-unspecified, + stream-close-status-completed, + stream-close-status-failed, + stream-close-status-canceled, + stream-close-status-terminated, + stream-close-status-timed-out, + } + + /// @nexus.doc "One Record that a Producer appended." + /// @nexus.proto "temporal.api.stream.v1.StreamData" + record stream-data { + /// @nexus.doc "The value." + body: body-t, + producer: option, + } + + /// @nexus.doc "The close of a Stream. A read gives it last." + /// @nexus.proto "temporal.api.stream.v1.StreamClose" + record stream-close { + /// @nexus.doc "The Producer that closed the Stream." + producer: option, + %result: option, + failure: option, + /// @nexus.doc "Set together with the result or the failure." + status: option, + } + + // The read builds a Superseded item from a flagged batch. + /// @nexus.doc "A read item: a Record, a Superseded item or the close." + /// @nexus.proto "temporal.api.stream.v1.StreamRecord.item" + variant stream-item { + data(stream-data), + superseded(supersession), + close(stream-close), + } + + // An offset is ordered and unique in one Stream, and sparse. Offsets have gaps, so they don't + // count Records. A read names its Stream, so the read checks the offset against that Stream. + /// @nexus.doc "One item that a read gives." + /// @nexus.proto "temporal.api.stream.v1.StreamRecord" + record stream-record { + // A newer server can send an item kind that this SDK doesn't know. Then the item is empty. + item: option, + /// @nexus.doc "The place of this item in the Stream. Ordered and unique, with gaps." + offset: option, + } + + /// @nexus.doc + /// "Create a Stream, or get the open one. A closed, expired or another owner's Stream fails." + /// @nexus.proto "temporal.api.streamservice.v1.CreateStreamRequest" + record create-stream-request { + /// @nexus.doc "The Stream id. Required, and never empty." + /// @nexus.name python="id" typescript="id" dotnet="Id" + /// @nexus.source target="workflow" python="workflow_stream_id(definition)" + /// typescript="workflowStreamId(definition)" + /// dotnet="StreamWorkflowContext.StreamId(definition)" + stream-id: string, + /// @nexus.doc "The age after which a Record is dropped. Empty is the Store default." + retention: option, + // A wrong run would silently withdraw real Records, so it is never a parameter. The helper + // gives the previous run in a retry run's Workflow, and nothing elsewhere. + /// @nexus.doc "Retry run only. The previous run, whose Records this withdraws." + /// @nexus.source target="workflow" python="withdraw_run()" typescript="withdrawRun()" + /// dotnet="StreamWorkflowContext.WithdrawRun()" + /// @nexus.source target="client" python="withdraw_run()" typescript="withdrawRun()" + /// dotnet="StreamWorkflowContext.WithdrawRun()" + withdraw-run: option, + /// @nexus.doc "The Workflow run that owns the Stream. The Workflow transport sets it." + /// @nexus.source target="workflow" python="workflow_owner()" typescript="workflowOwner()" + /// dotnet="StreamWorkflowContext.Owner()" + /// @nexus.api-omit target="client" + owner: option, + /// @nexus.doc "The ref, whose id is stream-id. A create with an owner needs the ref's owner." + /// @nexus.flatten-in-api fields="backend" fill="id=stream-id" + %ref: option, + // Native only. The SDK Stores don't read it. + /// @nexus.omit + close-with-run: placeholder, + /// @nexus.omit + namespace: placeholder, + // Native only. The SDK Stores don't read it. + /// @nexus.omit + task-token: placeholder, + } + + /// @nexus.proto "temporal.api.enums.v1.StreamStatus" + enum stream-status { + stream-status-unspecified, + stream-status-open, + stream-status-closed, + stream-status-expired, + } + + // The meta key of each Stream has a format version v. Lua refuses a write to a Stream with a + // higher v with FAILED_PRECONDITION and the detail stream_format_too_new. + /// @nexus.doc + /// "Append. FAILED_PRECONDITION if closed, fenced or format too new. NOT_FOUND if absent." + /// @nexus.proto "temporal.api.streamservice.v1.AppendRecordsRequest" + record append-records-request { + %stream: stream-ref, + records: list, + /// @nexus.doc "Local Activity only. The run's failed or timed out Workflow Tasks." + /// @nexus.api-omit target="workflow" + /// @nexus.source target="client" python="local_activity_failed_tasks()" + /// typescript="localActivityFailedTasks()" + /// dotnet="StreamActivityContext.LocalActivityFailedTasks()" + failed-tasks: list, + /// @nexus.doc "Required outside a Workflow. In a Workflow the transport sets it." + /// @nexus.source target="workflow" python="workflow_producer()" + /// typescript="workflowProducer()" dotnet="StreamWorkflowContext.Producer()" + /// @nexus.source target="client" python="client_producer()" typescript="clientProducer()" + /// dotnet="StreamActivityContext.Producer()" + producer: option, + // A call with no writer state, such as publish, sends no sequence. The Store then numbers the + // batch, so nothing dedupes it. + /// @nexus.doc + /// "The batch number in this epoch. Empty takes the next one, and a retry writes it again." + sequence: option, + /// @nexus.doc "Local Activity only. The run's last completed Workflow Task." + /// @nexus.api-omit target="workflow" + /// @nexus.source target="client" python="local_activity_previous_completed_task()" + /// typescript="localActivityPreviousCompletedTask()" + /// dotnet="StreamActivityContext.LocalActivityPreviousCompletedTask()" + previous-completed-task: option, + // A wrong run would silently withdraw real Records, so it is never a parameter. The helper + // gives the previous run in a retry run's Workflow, and nothing elsewhere. + /// @nexus.doc "Retry run only. The previous run, whose Records this withdraws." + /// @nexus.source target="workflow" python="withdraw_run()" typescript="withdrawRun()" + /// dotnet="StreamWorkflowContext.WithdrawRun()" + /// @nexus.source target="client" python="withdraw_run()" typescript="withdrawRun()" + /// dotnet="StreamWorkflowContext.WithdrawRun()" + withdraw-run: option, + /// @nexus.omit + namespace: placeholder, + // Native only. The SDK Stores don't read them. + /// @nexus.omit + defer-withdrawal: placeholder, + /// @nexus.omit + task-token: placeholder, + } + + /// @nexus.proto "temporal.api.streamservice.v1.AppendRecordsResponse" + record append-records-response { + first-offset: option, + last-offset: option, + } + + /// @nexus.doc "Close a Stream. Readers drain what is left, then end. Later appends are refused." + /// @nexus.proto "temporal.api.streamservice.v1.CloseStreamRequest" + record close-stream-request { + %stream: stream-ref, + // Native only. The SDK Stores don't read it. + /// @nexus.omit + failed-tasks: placeholder, + /// @nexus.source target="workflow" python="workflow_producer()" + /// typescript="workflowProducer()" dotnet="StreamWorkflowContext.Producer()" + /// @nexus.source target="client" python="client_producer()" typescript="clientProducer()" + /// dotnet="StreamActivityContext.Producer()" + producer: option, + %result: option, + // Each SDK gives a canceled Activity, child Workflow or Nexus Operation as a failure of that + // call whose cause is the cancel. So such a failure also cancels. + /// @nexus.doc "Empty completes the Stream. A cancel failure cancels it. Any other fails it." + failure: option, + // Native only. The SDK Stores don't read it. + /// @nexus.omit + previous-completed-task: placeholder, + /// @nexus.omit + namespace: placeholder, + // Native only. The SDK Stores don't read them. + /// @nexus.omit + withdraw-run: placeholder, + /// @nexus.omit + task-token: placeholder, + } + + /// @nexus.proto "temporal.api.streamservice.v1.CloseStreamResponse" + record close-stream-response { + } + + /// @nexus.proto "temporal.api.enums.v1.StreamReadStart" + enum stream-read-start { + stream-read-start-unspecified, + stream-read-start-beginning, + stream-read-start-end, + } + + /// @nexus.doc "Read Records from an offset." + /// @nexus.proto "temporal.api.streamservice.v1.ReadRecordsRequest" + record read-records-request { + %stream: stream-ref, + /// @nexus.doc "Start offset. Empty uses start. OUT_OF_RANGE past the newest offset + 1." + from-offset: option, + /// @nexus.doc "Where to start when from-offset is empty. Unspecified is the beginning." + start: option, + /// @nexus.doc + /// "About this many items per page, 1 to 1000. A Superseded item stays with its Record." + max-records: option, + /// @nexus.doc "How long to wait for a new Record before an empty answer. At most 60 s." + wait: option, + /// @nexus.doc "How long to wait for an uncreated Stream. Empty fails at once. At most 60 s." + create-wait: option, + // Replay needs a fixed range, so the Workflow API omits `last`. + /// @nexus.doc "Read the newest this many Records, oldest first. No from-offset. No wait." + /// @nexus.api-omit target="workflow" + last: option, + /// @nexus.doc "The maximum Record bytes in a page. A larger Record is still returned alone." + max-bytes: option, + /// @nexus.omit + namespace: placeholder, + // Native only. The SDK Stores refuse to-offset and wake-only. + /// @nexus.omit + to-offset: placeholder, + /// @nexus.omit + wake-only: placeholder, + /// @nexus.omit + floor-from: placeholder, + } + + /// @nexus.proto "temporal.api.streamservice.v1.ReadRecordsResponse" + record read-records-response { + records: list, + /// @nexus.doc "The from-offset of the next read, which is the newest offset read + 1." + next-offset: option, + /// @nexus.doc "True after the close Record. No later read gives a Record." + done: option, + // Native only. The SDK Stores never set it. + /// @nexus.omit + continue-as-new-suggested: placeholder, + } + + /// @nexus.proto "temporal.api.streamservice.v1.DescribeStreamRequest" + record describe-stream-request { + %stream: stream-ref, + /// @nexus.omit + namespace: placeholder, + } + + /// @nexus.proto "temporal.api.streamservice.v1.DescribeStreamResponse" + record describe-stream-response { + status: option, + /// @nexus.doc "The newest offset, of a Record, a Superseded item or the close. Empty for none." + last-offset: option, + retention: option, + } + + // A handle never changes, since offsets are values that its methods return. Only a create's + // response sets created, so a handle built from a ref has none. + /// @nexus.doc "A handle on one Stream. Build it from a stream-ref, or get it from a create." + /// @nexus.type-parameter "body-t" + /// @nexus.definition python="temporalio.common.StreamDefinition" + /// typescript="StreamDefinition" typescript-import="@temporalio/common" + /// dotnet="Temporalio.Common.StreamDefinition" + /// @nexus.flatten-in-api param="stream" fields="id backend" keep=true + resource %stream { + constructor(%stream: stream-ref, created: option); + + /// @nexus.doc "Publish values, and return the offset of the last one." + /// @nexus.operation "append-records" + /// @nexus.varargs name="values" + /// @nexus.result-field "last-offset" + publish: func(records: list) -> append-records-response; + + /// @nexus.doc "Close the Stream. Readers drain what is left, then end." + /// @nexus.operation "close-stream" + /// @nexus.close failure="failure" + close: func(%result: option, failure: option); + + /// @nexus.doc "Read one page of Records. The poll iterator reads them all." + /// @nexus.operation "read-records" + read: func( + from-offset: option, + start: option, + max-records: option, + wait: option, + create-wait: option, + max-bytes: option, + ) -> read-records-response; + + /// @nexus.doc "Describe the Stream's status, newest offset and Retention." + /// @nexus.operation "describe-stream" + describe: func() -> describe-stream-response; + } + + // created is false when the Stream existed, whatever its Retention. + /// @nexus.proto "temporal.api.streamservice.v1.CreateStreamResponse" + type create-stream-result = own<%stream>; + + /// @nexus.operation name="CreateStream" + create-stream: func(request: create-stream-request) -> create-stream-result; + + /// @nexus.doc "Append one batch. Workflow code makes the call from a Local Activity." + /// @nexus.operation name="AppendRecords" + append-records: func(request: append-records-request) -> append-records-response; + + /// @nexus.operation name="CloseStream" + close-stream: func(request: close-stream-request) -> close-stream-response; + + // A WIT stream can't cross a Nexus call, so a read is a request and a response. The SDK's support + // helper builds the next request from a page for the poll iterator. + /// @nexus.doc + /// "A long poll. NOT_FOUND for an uncreated Stream. Workflow code reads from a Local Activity." + /// @nexus.operation name="ReadRecords" + /// @nexus.poll python="next_read_request" typescript="nextReadRequest" + /// dotnet="StreamPoll.NextReadRequest" items="records" python-resume="resume_read_request" + /// target="client" + read-records: func(request: read-records-request) -> read-records-response; + + /// @nexus.operation name="DescribeStream" + describe-stream: func(request: describe-stream-request) -> describe-stream-response; + + // No operation returns these. A failed call has one in its details. Each SDK raises the class + // of its reason, or the base class for a reason with no class. + /// @nexus.doc "Why a Stream call failed. Each reason has one gRPC status code." + /// @nexus.proto "temporal.api.enums.v1.StreamErrorReason" + /// @nexus.error details="stream-error-details" field="reason" name="stream-error" + /// python-extends="temporalio.exceptions.ApplicationError" + /// typescript-extends="ApplicationFailure" typescript-extends-import="@temporalio/common" + /// dotnet-extends="Temporalio.Exceptions.ApplicationFailureException" + enum stream-error-reason { + stream-error-reason-unspecified, + /// @nexus.error + stream-error-reason-not-found, + /// @nexus.error + stream-error-reason-expired, + /// @nexus.error + stream-error-reason-closed, + /// @nexus.error + stream-error-reason-fenced, + stream-error-reason-sequence, + /// @nexus.error + stream-error-reason-seq-divergent, + /// @nexus.error + stream-error-reason-not-owner, + stream-error-reason-format-too-new, + /// @nexus.error + stream-error-reason-offset, + /// @nexus.error + stream-error-reason-too-large, + stream-error-reason-invalid, + /// @nexus.error retryable=true + stream-error-reason-storage, + stream-error-reason-unknown-writer, + /// @nexus.error + stream-error-reason-unsupported, + } + + /// @nexus.doc "The details of a failed Stream call." + /// @nexus.proto "temporal.api.stream.v1.StreamErrorDetails" + record stream-error-details { + reason: option, + } +} From 2c8322fd242050898c83dbc755b19bf41057702c Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Sat, 31 Oct 2026 00:15:10 -0700 Subject: [PATCH 04/20] Renamed Supersession to Superseded, and the StreamRef fields to ref. The message now has the name of the read item that it is. A StreamRef field is named ref everywhere, as on CreateStreamRequest. The field numbers stay, so the wire format is the same. --- nexus/stream-service.wit | 18 +++++++++--------- temporal/api/stream/v1/message.proto | 9 +++++---- .../streamservice/v1/request_response.proto | 10 +++++----- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 955426248..7f56d3e92 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -123,8 +123,8 @@ interface stream-service { // or strictly between c and t. It also withdraws those at the started event id of c or of t with // a lower epoch than c or t. No task in that range can have completed. /// @nexus.doc "Which earlier Records a Superseded item withdraws." - /// @nexus.proto "temporal.api.stream.v1.Supersession" - record supersession { + /// @nexus.proto "temporal.api.stream.v1.Superseded" + record superseded { producer-id: string, /// @nexus.doc "Workflow only. Started event ids of failed or timed out Workflow Tasks." failed-tasks: list, @@ -173,7 +173,7 @@ interface stream-service { /// @nexus.proto "temporal.api.stream.v1.StreamRecord.item" variant stream-item { data(stream-data), - superseded(supersession), + superseded(superseded), close(stream-close), } @@ -240,7 +240,7 @@ interface stream-service { /// "Append. FAILED_PRECONDITION if closed, fenced or format too new. NOT_FOUND if absent." /// @nexus.proto "temporal.api.streamservice.v1.AppendRecordsRequest" record append-records-request { - %stream: stream-ref, + %ref: stream-ref, records: list, /// @nexus.doc "Local Activity only. The run's failed or timed out Workflow Tasks." /// @nexus.api-omit target="workflow" @@ -291,7 +291,7 @@ interface stream-service { /// @nexus.doc "Close a Stream. Readers drain what is left, then end. Later appends are refused." /// @nexus.proto "temporal.api.streamservice.v1.CloseStreamRequest" record close-stream-request { - %stream: stream-ref, + %ref: stream-ref, // Native only. The SDK Stores don't read it. /// @nexus.omit failed-tasks: placeholder, @@ -331,7 +331,7 @@ interface stream-service { /// @nexus.doc "Read Records from an offset." /// @nexus.proto "temporal.api.streamservice.v1.ReadRecordsRequest" record read-records-request { - %stream: stream-ref, + %ref: stream-ref, /// @nexus.doc "Start offset. Empty uses start. OUT_OF_RANGE past the newest offset + 1." from-offset: option, /// @nexus.doc "Where to start when from-offset is empty. Unspecified is the beginning." @@ -374,7 +374,7 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.DescribeStreamRequest" record describe-stream-request { - %stream: stream-ref, + %ref: stream-ref, /// @nexus.omit namespace: placeholder, } @@ -394,9 +394,9 @@ interface stream-service { /// @nexus.definition python="temporalio.common.StreamDefinition" /// typescript="StreamDefinition" typescript-import="@temporalio/common" /// dotnet="Temporalio.Common.StreamDefinition" - /// @nexus.flatten-in-api param="stream" fields="id backend" keep=true + /// @nexus.flatten-in-api param="ref" fields="id backend" keep=true resource %stream { - constructor(%stream: stream-ref, created: option); + constructor(%ref: stream-ref, created: option); /// @nexus.doc "Publish values, and return the offset of the last one." /// @nexus.operation "append-records" diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index e4984081e..1051537e4 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -70,7 +70,7 @@ message AppendRecord { } // Experimental. Might change in the future. -message Supersession { +message Superseded { temporal.api.enums.v1.StreamProducerKind producer_kind = 1; string producer_id = 2; ProducerEpoch epoch = 3; @@ -112,9 +112,10 @@ message StreamRecord { // A read item is a Record, a Superseded item or the close. oneof item { StreamData data = 2; - // The first batch of a higher epoch carries it. It withdraws the earlier Records that its - // facts name, and a reader drops them. - Supersession superseded = 3; + // The Store keeps only the withdrawal facts of the first batch of a higher epoch. A read + // builds this item from them. It withdraws the earlier Records that the facts name, and a + // reader drops them. + Superseded superseded = 3; StreamClose close = 4; } } diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index a2bfeba35..e8dcf8ef6 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -56,7 +56,7 @@ message CreateStreamResponse { reserved 3; reserved "status"; // The ref, with the owner of a Workflow-owned Stream. - temporal.api.stream.v1.StreamRef stream = 1; + temporal.api.stream.v1.StreamRef ref = 1; bool created = 2; } @@ -65,7 +65,7 @@ message CreateStreamResponse { // (detail stream_format_too_new). NOT_FOUND if absent. message AppendRecordsRequest { string namespace = 1; - temporal.api.stream.v1.StreamRef stream = 2; + temporal.api.stream.v1.StreamRef ref = 2; // Required outside a Workflow. In a Workflow the transport sets it. temporal.api.stream.v1.Producer producer = 3; // The batch number in this epoch. With the Producer and epoch it is the dedupe key. Zero takes @@ -96,7 +96,7 @@ message AppendRecordsResponse { // Experimental. Might change in the future. message CloseStreamRequest { string namespace = 1; - temporal.api.stream.v1.StreamRef stream = 2; + temporal.api.stream.v1.StreamRef ref = 2; temporal.api.stream.v1.Producer producer = 3; temporal.api.common.v1.Payload result = 4; // The close status comes from it. No failure is COMPLETED. A failure is CANCELED when it has @@ -121,7 +121,7 @@ message CloseStreamResponse { // Experimental. Might change in the future. message ReadRecordsRequest { string namespace = 1; - temporal.api.stream.v1.StreamRef stream = 2; + temporal.api.stream.v1.StreamRef ref = 2; // Start at this offset. Zero uses start. OUT_OF_RANGE if past the newest offset + 1. // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: "from" and "to" mark the ends of a range of offsets. --) @@ -175,7 +175,7 @@ message ReadRecordsResponse { // Experimental. Might change in the future. message DescribeStreamRequest { string namespace = 1; - temporal.api.stream.v1.StreamRef stream = 2; + temporal.api.stream.v1.StreamRef ref = 2; } // Experimental. Might change in the future. From a1160cf558c650142c11db75bb547105fb167c65 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Sat, 31 Oct 2026 00:26:51 -0700 Subject: [PATCH 05/20] Renamed StreamRef to StreamHandle, and its fields to handle. --- nexus/stream-service.wit | 28 +++++++++---------- temporal/api/stream/v1/message.proto | 4 +-- .../streamservice/v1/request_response.proto | 22 +++++++-------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 7f56d3e92..db80e259c 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -32,11 +32,11 @@ interface stream-service { // A Stream that a Workflow creates has the id ::. Each part is // percent-encoded. So a caller that knows the first run id can build the id with no call. /// @nexus.doc "Identifies one Stream. The id is opaque to callers." - /// @nexus.proto "temporal.api.stream.v1.StreamRef" - record stream-ref { + /// @nexus.proto "temporal.api.stream.v1.StreamHandle" + record stream-handle { /// @nexus.doc "The Stream id, 1 to 4096 bytes." id: string, - /// @nexus.doc "The owning Workflow run chain or Standalone Activity. Empty on a client ref." + /// @nexus.doc "The owning Workflow run chain or Standalone Activity. Empty for a client." owner: option, /// @nexus.doc "Registered backend name, empty for the default. The Store never reads it." backend: option, @@ -213,9 +213,9 @@ interface stream-service { /// dotnet="StreamWorkflowContext.Owner()" /// @nexus.api-omit target="client" owner: option, - /// @nexus.doc "The ref, whose id is stream-id. A create with an owner needs the ref's owner." + /// @nexus.doc "The handle, whose id is stream-id. With an owner, it needs the handle's owner." /// @nexus.flatten-in-api fields="backend" fill="id=stream-id" - %ref: option, + handle: option, // Native only. The SDK Stores don't read it. /// @nexus.omit close-with-run: placeholder, @@ -240,7 +240,7 @@ interface stream-service { /// "Append. FAILED_PRECONDITION if closed, fenced or format too new. NOT_FOUND if absent." /// @nexus.proto "temporal.api.streamservice.v1.AppendRecordsRequest" record append-records-request { - %ref: stream-ref, + handle: stream-handle, records: list, /// @nexus.doc "Local Activity only. The run's failed or timed out Workflow Tasks." /// @nexus.api-omit target="workflow" @@ -291,7 +291,7 @@ interface stream-service { /// @nexus.doc "Close a Stream. Readers drain what is left, then end. Later appends are refused." /// @nexus.proto "temporal.api.streamservice.v1.CloseStreamRequest" record close-stream-request { - %ref: stream-ref, + handle: stream-handle, // Native only. The SDK Stores don't read it. /// @nexus.omit failed-tasks: placeholder, @@ -331,7 +331,7 @@ interface stream-service { /// @nexus.doc "Read Records from an offset." /// @nexus.proto "temporal.api.streamservice.v1.ReadRecordsRequest" record read-records-request { - %ref: stream-ref, + handle: stream-handle, /// @nexus.doc "Start offset. Empty uses start. OUT_OF_RANGE past the newest offset + 1." from-offset: option, /// @nexus.doc "Where to start when from-offset is empty. Unspecified is the beginning." @@ -374,7 +374,7 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.DescribeStreamRequest" record describe-stream-request { - %ref: stream-ref, + handle: stream-handle, /// @nexus.omit namespace: placeholder, } @@ -387,16 +387,16 @@ interface stream-service { retention: option, } - // A handle never changes, since offsets are values that its methods return. Only a create's - // response sets created, so a handle built from a ref has none. - /// @nexus.doc "A handle on one Stream. Build it from a stream-ref, or get it from a create." + // A stream never changes, since offsets are values that its methods return. Only a create's + // response sets created, so a stream built from a handle has none. + /// @nexus.doc "One Stream. Build it from a stream-handle, or get it from a create." /// @nexus.type-parameter "body-t" /// @nexus.definition python="temporalio.common.StreamDefinition" /// typescript="StreamDefinition" typescript-import="@temporalio/common" /// dotnet="Temporalio.Common.StreamDefinition" - /// @nexus.flatten-in-api param="ref" fields="id backend" keep=true + /// @nexus.flatten-in-api param="handle" fields="id backend" keep=true resource %stream { - constructor(%ref: stream-ref, created: option); + constructor(handle: stream-handle, created: option); /// @nexus.doc "Publish values, and return the offset of the last one." /// @nexus.operation "append-records" diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 1051537e4..f38e79981 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -17,7 +17,7 @@ import "temporal/api/failure/v1/message.proto"; // Experimental. Might change in the future. // Identifies one Stream. The id is opaque to callers. -message StreamRef { +message StreamHandle { string id = 1; // Unset for a client's Stream. For a Workflow, business_id is the Workflow ID and run_id is // the first execution run id of its run chain. For a Standalone Activity, business_id is the @@ -25,7 +25,7 @@ message StreamRef { // name its owner. temporal.api.common.v1.Execution owner = 2; // The name of the backend that holds the Stream, as the Worker and client config register - // it, so every process routes a ref it receives. Empty is the default backend. The Store + // it, so every process routes a handle it receives. Empty is the default backend. The Store // never reads it. string backend = 3; } diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index e8dcf8ef6..13c2b1fe4 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -36,16 +36,16 @@ message CreateStreamRequest { // Workflow only. The previous run on a retry run's first create, to withdraw its Records. string withdraw_run = 4; // The Workflow run that owns the Stream. Workflow kind, with its run_id and epoch. It needs a - // ref whose owner names the run's chain. + // handle whose owner names the run's chain. temporal.api.stream.v1.Producer owner = 6; // Only the server reads it. The server closes the Stream with the outcome of the owner's run - // chain, terminate and timeout included. It needs owner, and a ref with a Workflow owner. + // chain, terminate and timeout included. It needs owner, and a handle with a Workflow owner. // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: "with" ties the close to the owner's run chain. --) bool close_with_run = 7; - // The ref of the Stream, with stream_id as its id. A Workflow-owned Stream sets the ref's - // owner, which names the owner's run chain. - temporal.api.stream.v1.StreamRef ref = 8; + // The handle of the Stream, with stream_id as its id. A Workflow-owned Stream sets the + // handle's owner, which names the owner's run chain. + temporal.api.stream.v1.StreamHandle handle = 8; // Only the server reads it. The server checks that the token is the Activity's current // attempt, so a caller can't put a Stream into a Standalone Activity it isn't. bytes task_token = 9; @@ -55,8 +55,8 @@ message CreateStreamRequest { message CreateStreamResponse { reserved 3; reserved "status"; - // The ref, with the owner of a Workflow-owned Stream. - temporal.api.stream.v1.StreamRef ref = 1; + // The handle, with the owner of a Workflow-owned Stream. + temporal.api.stream.v1.StreamHandle handle = 1; bool created = 2; } @@ -65,7 +65,7 @@ message CreateStreamResponse { // (detail stream_format_too_new). NOT_FOUND if absent. message AppendRecordsRequest { string namespace = 1; - temporal.api.stream.v1.StreamRef ref = 2; + temporal.api.stream.v1.StreamHandle handle = 2; // Required outside a Workflow. In a Workflow the transport sets it. temporal.api.stream.v1.Producer producer = 3; // The batch number in this epoch. With the Producer and epoch it is the dedupe key. Zero takes @@ -96,7 +96,7 @@ message AppendRecordsResponse { // Experimental. Might change in the future. message CloseStreamRequest { string namespace = 1; - temporal.api.stream.v1.StreamRef ref = 2; + temporal.api.stream.v1.StreamHandle handle = 2; temporal.api.stream.v1.Producer producer = 3; temporal.api.common.v1.Payload result = 4; // The close status comes from it. No failure is COMPLETED. A failure is CANCELED when it has @@ -121,7 +121,7 @@ message CloseStreamResponse { // Experimental. Might change in the future. message ReadRecordsRequest { string namespace = 1; - temporal.api.stream.v1.StreamRef ref = 2; + temporal.api.stream.v1.StreamHandle handle = 2; // Start at this offset. Zero uses start. OUT_OF_RANGE if past the newest offset + 1. // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: "from" and "to" mark the ends of a range of offsets. --) @@ -175,7 +175,7 @@ message ReadRecordsResponse { // Experimental. Might change in the future. message DescribeStreamRequest { string namespace = 1; - temporal.api.stream.v1.StreamRef ref = 2; + temporal.api.stream.v1.StreamHandle handle = 2; } // Experimental. Might change in the future. From cd0fa6753d1b5c2b798d79072c7f0ff68a52027c Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Sat, 31 Oct 2026 00:47:25 -0700 Subject: [PATCH 06/20] Renamed the handle of a create request to stream_handle. --- nexus/stream-service.wit | 2 +- temporal/api/streamservice/v1/request_response.proto | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index db80e259c..9f6a80cd9 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -215,7 +215,7 @@ interface stream-service { owner: option, /// @nexus.doc "The handle, whose id is stream-id. With an owner, it needs the handle's owner." /// @nexus.flatten-in-api fields="backend" fill="id=stream-id" - handle: option, + stream-handle: option, // Native only. The SDK Stores don't read it. /// @nexus.omit close-with-run: placeholder, diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index 13c2b1fe4..badeb203b 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -45,7 +45,7 @@ message CreateStreamRequest { bool close_with_run = 7; // The handle of the Stream, with stream_id as its id. A Workflow-owned Stream sets the // handle's owner, which names the owner's run chain. - temporal.api.stream.v1.StreamHandle handle = 8; + temporal.api.stream.v1.StreamHandle stream_handle = 8; // Only the server reads it. The server checks that the token is the Activity's current // attempt, so a caller can't put a Stream into a Standalone Activity it isn't. bytes task_token = 9; From 5784c82b55b1e213f7d624269c04844bf505e9db Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Sat, 31 Oct 2026 00:55:09 -0700 Subject: [PATCH 07/20] Renamed Superseded to StreamSuperseded. --- nexus/stream-service.wit | 6 +++--- temporal/api/stream/v1/message.proto | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 9f6a80cd9..ad6a2aa96 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -123,8 +123,8 @@ interface stream-service { // or strictly between c and t. It also withdraws those at the started event id of c or of t with // a lower epoch than c or t. No task in that range can have completed. /// @nexus.doc "Which earlier Records a Superseded item withdraws." - /// @nexus.proto "temporal.api.stream.v1.Superseded" - record superseded { + /// @nexus.proto "temporal.api.stream.v1.StreamSuperseded" + record stream-superseded { producer-id: string, /// @nexus.doc "Workflow only. Started event ids of failed or timed out Workflow Tasks." failed-tasks: list, @@ -173,7 +173,7 @@ interface stream-service { /// @nexus.proto "temporal.api.stream.v1.StreamRecord.item" variant stream-item { data(stream-data), - superseded(superseded), + superseded(stream-superseded), close(stream-close), } diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index f38e79981..221770662 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -70,7 +70,7 @@ message AppendRecord { } // Experimental. Might change in the future. -message Superseded { +message StreamSuperseded { temporal.api.enums.v1.StreamProducerKind producer_kind = 1; string producer_id = 2; ProducerEpoch epoch = 3; @@ -115,7 +115,7 @@ message StreamRecord { // The Store keeps only the withdrawal facts of the first batch of a higher epoch. A read // builds this item from them. It withdraws the earlier Records that the facts name, and a // reader drops them. - Superseded superseded = 3; + StreamSuperseded superseded = 3; StreamClose close = 4; } } From 0b6d5ad0fa50a67bdbdb6b99b85d0f9469cd1e9d Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Sat, 31 Oct 2026 01:38:53 -0700 Subject: [PATCH 08/20] Renamed the target key of the WIT directives to targets. --- nexus/stream-service.wit | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index ad6a2aa96..602caa969 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -194,7 +194,7 @@ interface stream-service { record create-stream-request { /// @nexus.doc "The Stream id. Required, and never empty." /// @nexus.name python="id" typescript="id" dotnet="Id" - /// @nexus.source target="workflow" python="workflow_stream_id(definition)" + /// @nexus.source targets="workflow" python="workflow_stream_id(definition)" /// typescript="workflowStreamId(definition)" /// dotnet="StreamWorkflowContext.StreamId(definition)" stream-id: string, @@ -203,15 +203,15 @@ interface stream-service { // A wrong run would silently withdraw real Records, so it is never a parameter. The helper // gives the previous run in a retry run's Workflow, and nothing elsewhere. /// @nexus.doc "Retry run only. The previous run, whose Records this withdraws." - /// @nexus.source target="workflow" python="withdraw_run()" typescript="withdrawRun()" + /// @nexus.source targets="workflow" python="withdraw_run()" typescript="withdrawRun()" /// dotnet="StreamWorkflowContext.WithdrawRun()" - /// @nexus.source target="client" python="withdraw_run()" typescript="withdrawRun()" + /// @nexus.source targets="client" python="withdraw_run()" typescript="withdrawRun()" /// dotnet="StreamWorkflowContext.WithdrawRun()" withdraw-run: option, /// @nexus.doc "The Workflow run that owns the Stream. The Workflow transport sets it." - /// @nexus.source target="workflow" python="workflow_owner()" typescript="workflowOwner()" + /// @nexus.source targets="workflow" python="workflow_owner()" typescript="workflowOwner()" /// dotnet="StreamWorkflowContext.Owner()" - /// @nexus.api-omit target="client" + /// @nexus.api-omit targets="client" owner: option, /// @nexus.doc "The handle, whose id is stream-id. With an owner, it needs the handle's owner." /// @nexus.flatten-in-api fields="backend" fill="id=stream-id" @@ -243,15 +243,15 @@ interface stream-service { handle: stream-handle, records: list, /// @nexus.doc "Local Activity only. The run's failed or timed out Workflow Tasks." - /// @nexus.api-omit target="workflow" - /// @nexus.source target="client" python="local_activity_failed_tasks()" + /// @nexus.api-omit targets="workflow" + /// @nexus.source targets="client" python="local_activity_failed_tasks()" /// typescript="localActivityFailedTasks()" /// dotnet="StreamActivityContext.LocalActivityFailedTasks()" failed-tasks: list, /// @nexus.doc "Required outside a Workflow. In a Workflow the transport sets it." - /// @nexus.source target="workflow" python="workflow_producer()" + /// @nexus.source targets="workflow" python="workflow_producer()" /// typescript="workflowProducer()" dotnet="StreamWorkflowContext.Producer()" - /// @nexus.source target="client" python="client_producer()" typescript="clientProducer()" + /// @nexus.source targets="client" python="client_producer()" typescript="clientProducer()" /// dotnet="StreamActivityContext.Producer()" producer: option, // A call with no writer state, such as publish, sends no sequence. The Store then numbers the @@ -260,17 +260,17 @@ interface stream-service { /// "The batch number in this epoch. Empty takes the next one, and a retry writes it again." sequence: option, /// @nexus.doc "Local Activity only. The run's last completed Workflow Task." - /// @nexus.api-omit target="workflow" - /// @nexus.source target="client" python="local_activity_previous_completed_task()" + /// @nexus.api-omit targets="workflow" + /// @nexus.source targets="client" python="local_activity_previous_completed_task()" /// typescript="localActivityPreviousCompletedTask()" /// dotnet="StreamActivityContext.LocalActivityPreviousCompletedTask()" previous-completed-task: option, // A wrong run would silently withdraw real Records, so it is never a parameter. The helper // gives the previous run in a retry run's Workflow, and nothing elsewhere. /// @nexus.doc "Retry run only. The previous run, whose Records this withdraws." - /// @nexus.source target="workflow" python="withdraw_run()" typescript="withdrawRun()" + /// @nexus.source targets="workflow" python="withdraw_run()" typescript="withdrawRun()" /// dotnet="StreamWorkflowContext.WithdrawRun()" - /// @nexus.source target="client" python="withdraw_run()" typescript="withdrawRun()" + /// @nexus.source targets="client" python="withdraw_run()" typescript="withdrawRun()" /// dotnet="StreamWorkflowContext.WithdrawRun()" withdraw-run: option, /// @nexus.omit @@ -295,9 +295,9 @@ interface stream-service { // Native only. The SDK Stores don't read it. /// @nexus.omit failed-tasks: placeholder, - /// @nexus.source target="workflow" python="workflow_producer()" + /// @nexus.source targets="workflow" python="workflow_producer()" /// typescript="workflowProducer()" dotnet="StreamWorkflowContext.Producer()" - /// @nexus.source target="client" python="client_producer()" typescript="clientProducer()" + /// @nexus.source targets="client" python="client_producer()" typescript="clientProducer()" /// dotnet="StreamActivityContext.Producer()" producer: option, %result: option, @@ -345,7 +345,7 @@ interface stream-service { create-wait: option, // Replay needs a fixed range, so the Workflow API omits `last`. /// @nexus.doc "Read the newest this many Records, oldest first. No from-offset. No wait." - /// @nexus.api-omit target="workflow" + /// @nexus.api-omit targets="workflow" last: option, /// @nexus.doc "The maximum Record bytes in a page. A larger Record is still returned alone." max-bytes: option, @@ -446,7 +446,7 @@ interface stream-service { /// @nexus.operation name="ReadRecords" /// @nexus.poll python="next_read_request" typescript="nextReadRequest" /// dotnet="StreamPoll.NextReadRequest" items="records" python-resume="resume_read_request" - /// target="client" + /// targets="client" read-records: func(request: read-records-request) -> read-records-response; /// @nexus.operation name="DescribeStream" From 643d4ecb96a537fc00fcfbc885a6971cbd3a26fe Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Sat, 31 Oct 2026 06:13:07 -0700 Subject: [PATCH 09/20] Moved the nexgen pin, and added the owner source and enum fallbacks. --- Makefile | 2 +- nexus/stream-service.wit | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 4870f9433..4a7841c08 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ OAPI3_PATH := .components.schemas.Payload # The Stream WIT uses nexgen directives that no release has yet. Pin the fork commit that has them # until a release does. -NEX_GEN_REV := 526cf81c44620b6e85d2256e19604885300ed6fd +NEX_GEN_REV := 2670394236dde95fe6fd1c90543cbfc97848f5e9 NEX_GEN_ROOT := $(CURDIR)/$(STAMPDIR)/nexgen-$(NEX_GEN_REV) NEX_GEN ?= $(NEX_GEN_ROOT)/bin/nexgen diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 602caa969..c2973c405 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -44,6 +44,7 @@ interface stream-service { /// @nexus.proto "temporal.api.enums.v1.ExecutionType" enum execution-type { + /// @nexus.fallback execution-type-unspecified, execution-type-workflow, execution-type-activity, @@ -62,6 +63,7 @@ interface stream-service { /// @nexus.proto "temporal.api.enums.v1.StreamProducerKind" enum stream-producer-kind { + /// @nexus.fallback stream-producer-kind-unspecified, stream-producer-kind-workflow, stream-producer-kind-local-activity, @@ -141,6 +143,7 @@ interface stream-service { /// @nexus.doc "How a Stream closed. Only the owner's end on the server terminates or times out." /// @nexus.proto "temporal.api.enums.v1.StreamCloseStatus" enum stream-close-status { + /// @nexus.fallback stream-close-status-unspecified, stream-close-status-completed, stream-close-status-failed, @@ -215,6 +218,8 @@ interface stream-service { owner: option, /// @nexus.doc "The handle, whose id is stream-id. With an owner, it needs the handle's owner." /// @nexus.flatten-in-api fields="backend" fill="id=stream-id" + /// @nexus.source field="owner" targets="workflow" python="workflow_handle_owner()" + /// typescript="workflowHandleOwner()" dotnet="StreamWorkflowContext.HandleOwner()" stream-handle: option, // Native only. The SDK Stores don't read it. /// @nexus.omit @@ -228,6 +233,7 @@ interface stream-service { /// @nexus.proto "temporal.api.enums.v1.StreamStatus" enum stream-status { + /// @nexus.fallback stream-status-unspecified, stream-status-open, stream-status-closed, @@ -461,6 +467,7 @@ interface stream-service { /// typescript-extends="ApplicationFailure" typescript-extends-import="@temporalio/common" /// dotnet-extends="Temporalio.Exceptions.ApplicationFailureException" enum stream-error-reason { + /// @nexus.fallback stream-error-reason-unspecified, /// @nexus.error stream-error-reason-not-found, From 2df445516ec52d20bb185f6d9aee7f8da1bfe0dc Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Tue, 3 Nov 2026 02:09:44 -0700 Subject: [PATCH 10/20] Moved the nexgen pin to the fork chain on the current upstream. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 4a7841c08..091084483 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ OAPI3_PATH := .components.schemas.Payload # The Stream WIT uses nexgen directives that no release has yet. Pin the fork commit that has them # until a release does. -NEX_GEN_REV := 2670394236dde95fe6fd1c90543cbfc97848f5e9 +NEX_GEN_REV := f9578ddbd8dbc8a1a79320210d58d1b722cd542b NEX_GEN_ROOT := $(CURDIR)/$(STAMPDIR)/nexgen-$(NEX_GEN_REV) NEX_GEN ?= $(NEX_GEN_ROOT)/bin/nexgen From 87dc735e8e715b953d6f9816dacafe545c536460 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 4 Nov 2026 17:13:19 -0700 Subject: [PATCH 11/20] Made create strict and Workflow Streams standalone, with no definitions. A create fails if the Stream exists, unless its conflict policy is USE_EXISTING, and a request id keeps a retried create from failing as a conflict. An SDK creates only standalone Streams, so the Workflow API sets no owner, and a Workflow may close a standalone Stream. A call takes the payload type, so the WIT has no definition. --- nexus/stream-service.wit | 46 +++++++++++-------- temporal/api/enums/v1/stream.proto | 12 +++++ temporal/api/stream/v1/message.proto | 8 ++-- .../streamservice/v1/request_response.proto | 18 ++++++-- 4 files changed, 55 insertions(+), 29 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index c2973c405..570bb8253 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -19,7 +19,7 @@ interface stream-service { placeholder, }; - /// @nexus.doc "The value type of a Stream's Records. A Stream definition sets it." + /// @nexus.doc "The value type of a Stream's Records. A call's type argument sets it." /// @nexus.type-parameter type body-t = placeholder; @@ -29,14 +29,12 @@ interface stream-service { nanos: option, } - // A Stream that a Workflow creates has the id ::. Each part is - // percent-encoded. So a caller that knows the first run id can build the id with no call. /// @nexus.doc "Identifies one Stream. The id is opaque to callers." /// @nexus.proto "temporal.api.stream.v1.StreamHandle" record stream-handle { /// @nexus.doc "The Stream id, 1 to 4096 bytes." id: string, - /// @nexus.doc "The owning Workflow run chain or Standalone Activity. Empty for a client." + /// @nexus.doc "The run chain or Standalone Activity of a bound Stream. Empty if standalone." owner: option, /// @nexus.doc "Registered backend name, empty for the default. The Store never reads it." backend: option, @@ -192,14 +190,11 @@ interface stream-service { } /// @nexus.doc - /// "Create a Stream, or get the open one. A closed, expired or another owner's Stream fails." + /// "Create a Stream. If it exists, fail, or with use-existing get the open one." /// @nexus.proto "temporal.api.streamservice.v1.CreateStreamRequest" record create-stream-request { /// @nexus.doc "The Stream id. Required, and never empty." /// @nexus.name python="id" typescript="id" dotnet="Id" - /// @nexus.source targets="workflow" python="workflow_stream_id(definition)" - /// typescript="workflowStreamId(definition)" - /// dotnet="StreamWorkflowContext.StreamId(definition)" stream-id: string, /// @nexus.doc "The age after which a Record is dropped. Empty is the Store default." retention: option, @@ -211,16 +206,20 @@ interface stream-service { /// @nexus.source targets="client" python="withdraw_run()" typescript="withdrawRun()" /// dotnet="StreamWorkflowContext.WithdrawRun()" withdraw-run: option, - /// @nexus.doc "The Workflow run that owns the Stream. The Workflow transport sets it." - /// @nexus.source targets="workflow" python="workflow_owner()" typescript="workflowOwner()" - /// dotnet="StreamWorkflowContext.Owner()" - /// @nexus.api-omit targets="client" - owner: option, - /// @nexus.doc "The handle, whose id is stream-id. With an owner, it needs the handle's owner." + // A bound Stream's owner. An SDK creates only standalone Streams for now. + /// @nexus.omit + owner: placeholder, + /// @nexus.doc "The handle, whose id is stream-id." /// @nexus.flatten-in-api fields="backend" fill="id=stream-id" - /// @nexus.source field="owner" targets="workflow" python="workflow_handle_owner()" - /// typescript="workflowHandleOwner()" dotnet="StreamWorkflowContext.HandleOwner()" stream-handle: option, + /// @nexus.doc "What the create does when the Stream exists. Unspecified is fail." + id-conflict-policy: option, + // A retry of the create that made the Stream sends the same id, so it doesn't fail as a + // conflict. In a Workflow the helper gives a deterministic id. + /// @nexus.doc "Unique for each create call, and the same on its retries." + /// @nexus.source python="request_id()" typescript="requestId()" + /// dotnet="StreamWorkflowContext.RequestId()" + request-id: option, // Native only. The SDK Stores don't read it. /// @nexus.omit close-with-run: placeholder, @@ -231,6 +230,14 @@ interface stream-service { task-token: placeholder, } + /// @nexus.doc "What a create does when its Stream exists. Unspecified is fail." + /// @nexus.proto "temporal.api.enums.v1.StreamIdConflictPolicy" + enum stream-id-conflict-policy { + stream-id-conflict-policy-unspecified, + stream-id-conflict-policy-fail, + stream-id-conflict-policy-use-existing, + } + /// @nexus.proto "temporal.api.enums.v1.StreamStatus" enum stream-status { /// @nexus.fallback @@ -397,9 +404,6 @@ interface stream-service { // response sets created, so a stream built from a handle has none. /// @nexus.doc "One Stream. Build it from a stream-handle, or get it from a create." /// @nexus.type-parameter "body-t" - /// @nexus.definition python="temporalio.common.StreamDefinition" - /// typescript="StreamDefinition" typescript-import="@temporalio/common" - /// dotnet="Temporalio.Common.StreamDefinition" /// @nexus.flatten-in-api param="handle" fields="id backend" keep=true resource %stream { constructor(handle: stream-handle, created: option); @@ -431,7 +435,7 @@ interface stream-service { describe: func() -> describe-stream-response; } - // created is false when the Stream existed, whatever its Retention. + // With use-existing, created is false when the Stream existed, whatever its Retention. /// @nexus.proto "temporal.api.streamservice.v1.CreateStreamResponse" type create-stream-result = own<%stream>; @@ -493,6 +497,8 @@ interface stream-service { stream-error-reason-unknown-writer, /// @nexus.error stream-error-reason-unsupported, + /// @nexus.error + stream-error-reason-already-exists, } /// @nexus.doc "The details of a failed Stream call." diff --git a/temporal/api/enums/v1/stream.proto b/temporal/api/enums/v1/stream.proto index b64f7d5d4..6492ec975 100644 --- a/temporal/api/enums/v1/stream.proto +++ b/temporal/api/enums/v1/stream.proto @@ -51,6 +51,16 @@ enum StreamReadStart { STREAM_READ_START_END = 2; } +// Experimental. Might change in the future. +// What a create does when its Stream exists. Unspecified is FAIL, as for a Workflow start. +enum StreamIdConflictPolicy { + STREAM_ID_CONFLICT_POLICY_UNSPECIFIED = 0; + // The create fails with ALREADY_EXISTS. + STREAM_ID_CONFLICT_POLICY_FAIL = 1; + // The create gets the open Stream, which keeps its own Retention. + STREAM_ID_CONFLICT_POLICY_USE_EXISTING = 2; +} + // Experimental. Might change in the future. // Why a Stream call failed. Each reason has one gRPC status code. enum StreamErrorReason { @@ -87,4 +97,6 @@ enum StreamErrorReason { STREAM_ERROR_REASON_UNKNOWN_WRITER = 13; // The Store lacks a capability that the call needs. UNIMPLEMENTED. STREAM_ERROR_REASON_UNSUPPORTED = 14; + // A create with the FAIL conflict policy names a Stream that exists. ALREADY_EXISTS. + STREAM_ERROR_REASON_ALREADY_EXISTS = 15; } diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 221770662..faf80c88c 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -19,10 +19,10 @@ import "temporal/api/failure/v1/message.proto"; // Identifies one Stream. The id is opaque to callers. message StreamHandle { string id = 1; - // Unset for a client's Stream. For a Workflow, business_id is the Workflow ID and run_id is - // the first execution run id of its run chain. For a Standalone Activity, business_id is the - // activity ID and run_id is its run id. A create and a close of a Workflow-owned Stream must - // name its owner. + // Unset for a standalone Stream. For a Stream bound to a Workflow, business_id is the Workflow + // ID and run_id is the first execution run id of its run chain. For a Standalone Activity, + // business_id is the activity ID and run_id is its run id. A create and a close of a + // Workflow-owned Stream must name its owner. temporal.api.common.v1.Execution owner = 2; // The name of the backend that holds the Stream, as the Worker and client config register // it, so every process routes a handle it receives. Empty is the default backend. The Store diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index badeb203b..222b7bb0c 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -20,9 +20,10 @@ import "temporal/api/failure/v1/message.proto"; import "temporal/api/stream/v1/message.proto"; // Experimental. Might change in the future. -// Creates the Stream, or gets it when it exists. Then created is false, and the Stream keeps its -// own Retention. It fails with CLOSED on a closed Stream, EXPIRED on an expired one, and NOT_OWNER -// on one with another owner. +// Creates the Stream. When the Stream exists, FAIL fails with ALREADY_EXISTS, and USE_EXISTING +// gets it with created false and its own Retention. A retry of the create that made the Stream, +// with the same request_id, gets created true. Each policy fails with CLOSED on a closed Stream, +// EXPIRED on an expired one, and NOT_OWNER on one with another owner. // (-- api-linter: core::0133=disabled // aip.dev/not-precedent: The request names a Stream by namespace and stream_id. --) message CreateStreamRequest { @@ -35,8 +36,8 @@ message CreateStreamRequest { google.protobuf.Duration retention = 3; // Workflow only. The previous run on a retry run's first create, to withdraw its Records. string withdraw_run = 4; - // The Workflow run that owns the Stream. Workflow kind, with its run_id and epoch. It needs a - // handle whose owner names the run's chain. + // The Workflow run that owns a bound Stream. Workflow kind, with its run_id and epoch. It needs + // a handle whose owner names the run's chain. Unset for a standalone Stream. temporal.api.stream.v1.Producer owner = 6; // Only the server reads it. The server closes the Stream with the outcome of the owner's run // chain, terminate and timeout included. It needs owner, and a handle with a Workflow owner. @@ -49,6 +50,11 @@ message CreateStreamRequest { // Only the server reads it. The server checks that the token is the Activity's current // attempt, so a caller can't put a Stream into a Standalone Activity it isn't. bytes task_token = 9; + // What the create does when the Stream exists. Unspecified is FAIL. + temporal.api.enums.v1.StreamIdConflictPolicy id_conflict_policy = 10; + // Unique for each create call, and the same on its retries. The Store keeps the request_id of + // the create that made the Stream, so a retry after a lost answer doesn't get ALREADY_EXISTS. + string request_id = 11; } // Experimental. Might change in the future. @@ -94,6 +100,8 @@ message AppendRecordsResponse { } // Experimental. Might change in the future. +// Only the owner's run chain closes a bound Stream. Only a Workflow or a client closes a +// standalone Stream. message CloseStreamRequest { string namespace = 1; temporal.api.stream.v1.StreamHandle handle = 2; From beaa64c2e8da2b780dcd8501db900b5c2a725373 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 4 Nov 2026 17:24:07 -0700 Subject: [PATCH 12/20] Took the review of the Stream protos from temporalio/sdk-rust#1733. Each message and field says what it is for. A create names its Stream only by its handle, the protos keep no reserved fields, and SEQUENCE and OFFSET are SEQUENCE_TOO_LOW and OFFSET_TOO_HIGH. --- nexus/stream-service.wit | 21 ++-- temporal/api/enums/v1/stream.proto | 13 +- temporal/api/stream/v1/message.proto | 53 +++++--- .../streamservice/v1/request_response.proto | 117 ++++++++++-------- 4 files changed, 115 insertions(+), 89 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 570bb8253..ca5e75aa2 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -193,9 +193,11 @@ interface stream-service { /// "Create a Stream. If it exists, fail, or with use-existing get the open one." /// @nexus.proto "temporal.api.streamservice.v1.CreateStreamRequest" record create-stream-request { - /// @nexus.doc "The Stream id. Required, and never empty." - /// @nexus.name python="id" typescript="id" dotnet="Id" - stream-id: string, + // Not named handle, so the created stream takes its handle from the response, which carries + // the owner. + /// @nexus.doc "The handle of the Stream to create. Its id names the Stream." + /// @nexus.flatten-in-api fields="id backend" + stream-handle: stream-handle, /// @nexus.doc "The age after which a Record is dropped. Empty is the Store default." retention: option, // A wrong run would silently withdraw real Records, so it is never a parameter. The helper @@ -209,9 +211,6 @@ interface stream-service { // A bound Stream's owner. An SDK creates only standalone Streams for now. /// @nexus.omit owner: placeholder, - /// @nexus.doc "The handle, whose id is stream-id." - /// @nexus.flatten-in-api fields="backend" fill="id=stream-id" - stream-handle: option, /// @nexus.doc "What the create does when the Stream exists. Unspecified is fail." id-conflict-policy: option, // A retry of the create that made the Stream sends the same id, so it doesn't fail as a @@ -247,10 +246,8 @@ interface stream-service { stream-status-expired, } - // The meta key of each Stream has a format version v. Lua refuses a write to a Stream with a - // higher v with FAILED_PRECONDITION and the detail stream_format_too_new. /// @nexus.doc - /// "Append. FAILED_PRECONDITION if closed, fenced or format too new. NOT_FOUND if absent." + /// "Append one batch. The Store keeps a resent batch once, by Producer, epoch and sequence." /// @nexus.proto "temporal.api.streamservice.v1.AppendRecordsRequest" record append-records-request { handle: stream-handle, @@ -345,7 +342,7 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.ReadRecordsRequest" record read-records-request { handle: stream-handle, - /// @nexus.doc "Start offset. Empty uses start. OUT_OF_RANGE past the newest offset + 1." + /// @nexus.doc "Start offset. Empty uses start. An offset past the newest one + 1 fails." from-offset: option, /// @nexus.doc "Where to start when from-offset is empty. Unspecified is the beginning." start: option, @@ -481,14 +478,14 @@ interface stream-service { stream-error-reason-closed, /// @nexus.error stream-error-reason-fenced, - stream-error-reason-sequence, + stream-error-reason-sequence-too-low, /// @nexus.error stream-error-reason-seq-divergent, /// @nexus.error stream-error-reason-not-owner, stream-error-reason-format-too-new, /// @nexus.error - stream-error-reason-offset, + stream-error-reason-offset-too-high, /// @nexus.error stream-error-reason-too-large, stream-error-reason-invalid, diff --git a/temporal/api/enums/v1/stream.proto b/temporal/api/enums/v1/stream.proto index 6492ec975..f86cce2c0 100644 --- a/temporal/api/enums/v1/stream.proto +++ b/temporal/api/enums/v1/stream.proto @@ -10,6 +10,8 @@ option ruby_package = "Temporalio::Api::Enums::V1"; option csharp_namespace = "Temporalio.Api.Enums.V1"; // Experimental. Might change in the future. +// The kind of code that wrote a batch. The kind decides how the Store orders the Producer's +// epochs, which earlier Records a higher epoch withdraws, and if the Producer can close the Stream. enum StreamProducerKind { STREAM_PRODUCER_KIND_UNSPECIFIED = 0; STREAM_PRODUCER_KIND_WORKFLOW = 1; @@ -35,6 +37,7 @@ enum StreamCloseStatus { } // Experimental. Might change in the future. +// The state of a Stream, as a describe gives it. // (-- api-linter: core::0216::synonyms=disabled // aip.dev/not-precedent: Named consistently with WorkflowExecutionStatus. --) enum StreamStatus { @@ -45,6 +48,7 @@ enum StreamStatus { } // Experimental. Might change in the future. +// Where a read starts when it has no from_offset. enum StreamReadStart { STREAM_READ_START_UNSPECIFIED = 0; STREAM_READ_START_BEGINNING = 1; @@ -62,7 +66,8 @@ enum StreamIdConflictPolicy { } // Experimental. Might change in the future. -// Why a Stream call failed. Each reason has one gRPC status code. +// Why a Stream call failed. Each reason has one gRPC status code. An SDK raises its own error +// class for each reason that code can act on, and a base class for the others. enum StreamErrorReason { STREAM_ERROR_REASON_UNSPECIFIED = 0; // Nobody created the Stream, even after `create_wait`. NOT_FOUND. @@ -75,7 +80,7 @@ enum StreamErrorReason { // Workflow retry withdrew its run. FAILED_PRECONDITION. STREAM_ERROR_REASON_FENCED = 4; // The batch has a lower `sequence` than the last one of its epoch. FAILED_PRECONDITION. - STREAM_ERROR_REASON_SEQUENCE = 5; + STREAM_ERROR_REASON_SEQUENCE_TOO_LOW = 5; // The batch has the `sequence` of the last one of its epoch, and other content. // FAILED_PRECONDITION. STREAM_ERROR_REASON_SEQ_DIVERGENT = 6; @@ -86,12 +91,12 @@ enum StreamErrorReason { STREAM_ERROR_REASON_FORMAT_TOO_NEW = 8; // The offset is past the newest offset + 1, for example after a failover lost the newest // Records. A read from it works again once the Stream has Records up to it. OUT_OF_RANGE. - STREAM_ERROR_REASON_OFFSET = 9; + STREAM_ERROR_REASON_OFFSET_TOO_HIGH = 9; // A Record or a batch is over the Store's limit. INVALID_ARGUMENT. STREAM_ERROR_REASON_TOO_LARGE = 10; // The request is malformed. INVALID_ARGUMENT. STREAM_ERROR_REASON_INVALID = 11; - // The Store failed or did not answer. UNAVAILABLE. + // The Store failed, did not answer, or gave back data that isn't valid. UNAVAILABLE. STREAM_ERROR_REASON_STORAGE = 12; // No writer has the id. The SDK's in-process writer only. NOT_FOUND. STREAM_ERROR_REASON_UNKNOWN_WRITER = 13; diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index faf80c88c..b6fb61b78 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -31,24 +31,33 @@ message StreamHandle { } // Experimental. Might change in the future. -// The ordered attempt of a Producer. Compared in this order: task_started_event_id, -// task_attempt, task_started_time, attempt_scheduled_time, attempt. A field that a kind does -// not use is empty and compares as zero. A timestamp compares seconds, then nanos. +// The ordered attempt of a Producer. A higher epoch supersedes the Records of a lower epoch of the +// same Producer. Epochs compare field by field, in the order of the field numbers. Each kind sets +// only its own fields. A field that a kind does not use is empty and compares as zero. message ProducerEpoch { - // The Workflow Task of a Workflow, or the one that dispatched a Local Activity. + // Workflow and Local Activity. The started event id of the Workflow Task of a Workflow, or of + // the Workflow Task that dispatched a Local Activity. int64 task_started_event_id = 1; + // Workflow and Local Activity. The attempt of that Workflow Task. int32 task_attempt = 2; - // Tells a reused started event id apart, for example after a dropped speculative task. - google.protobuf.Timestamp task_started_time = 5; - // When the server scheduled an Activity's attempt. A reset makes it later. - google.protobuf.Timestamp attempt_scheduled_time = 3; - // Activity, Local Activity and client attempt. - int32 attempt = 4; + // Workflow and Local Activity. When that Workflow Task attempt started. It tells a reused + // started event id apart, for example after a dropped speculative task. + google.protobuf.Timestamp task_started_time = 3; + // Activity and Standalone Activity. When the server scheduled this attempt. An Activity reset + // starts the attempt count again, so only this time orders the attempts across a reset. A + // Local Activity or a client has no attempt that the server schedules. + google.protobuf.Timestamp attempt_scheduled_time = 4; + // Activity, Standalone Activity, Local Activity and client. The Producer's own attempt. For a + // Local Activity it counts the retries of the Local Activity, and task_attempt counts the + // attempts of the Workflow Task that dispatched it. + int32 attempt = 5; } // Experimental. Might change in the future. +// The code that wrote a batch. Its id stays the same across attempts, and its epoch orders them. message Producer { temporal.api.enums.v1.StreamProducerKind kind = 1; + // Stable across attempts, such as `wf:` for the Workflow Producer of a run. string id = 2; ProducerEpoch epoch = 3; // The run of the Producer, so nobody parses the id. Empty for a client. @@ -56,6 +65,8 @@ message Producer { } // Experimental. Might change in the future. +// One Workflow Task attempt of a run. The withdrawal facts name the run's last completed task +// with it. message WorkflowTaskEpoch { int64 started_event_id = 1; int32 attempt = 2; @@ -63,19 +74,25 @@ message WorkflowTaskEpoch { } // Experimental. Might change in the future. +// One Record of a batch to append. The Store adds the batch's Producer and an offset. message AppendRecord { - reserved 1; - reserved "topic"; - temporal.api.common.v1.Payload body = 2; + temporal.api.common.v1.Payload body = 1; } // Experimental. Might change in the future. +// Which earlier Records a retried Producer withdraws. The Store keeps these facts for the first +// batch of a higher epoch, or for a retry run's first write, and a read builds a Superseded item +// from them. A reader drops the earlier Records that the facts name. message StreamSuperseded { + // The kind of the Producer that withdraws. temporal.api.enums.v1.StreamProducerKind producer_kind = 1; + // The id of the Producer that withdraws. string producer_id = 2; + // The epoch of the Producer that withdraws. Its lower epochs are withdrawn. ProducerEpoch epoch = 3; - // Workflow only. + // Workflow only. The run's last completed Workflow Task. WorkflowTaskEpoch previous_completed_task = 4; + // Workflow only. The started event ids of the run's failed or timed out Workflow Tasks. repeated int64 failed_tasks = 5; // Workflow retry only. The previous run. Every Record whose Producer carries it is withdrawn. string withdraw_run = 6; @@ -86,10 +103,8 @@ message StreamSuperseded { // Experimental. Might change in the future. // One Record that a Producer appended. message StreamData { - reserved 1; - reserved "topic"; - Producer producer = 2; - temporal.api.common.v1.Payload body = 3; + Producer producer = 1; + temporal.api.common.v1.Payload body = 2; } // Experimental. Might change in the future. @@ -99,7 +114,7 @@ message StreamClose { Producer producer = 1; temporal.api.common.v1.Payload result = 2; temporal.api.failure.v1.Failure failure = 3; - // The result or the failure stays next to it. + // How the Stream closed, so a reader doesn't parse the failure. temporal.api.enums.v1.StreamCloseStatus status = 4; } diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index 222b7bb0c..a5a59c75d 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -10,7 +10,7 @@ option ruby_package = "Temporalio::Api::StreamService::V1"; option csharp_namespace = "Temporalio.Api.StreamService.V1"; // The messages of the Stream service. An SDK Store (Redis or memory) and the server answer the -// same five operations with them. A field that only the server reads says so. +// same five operations with them. A field that is only read by the server says so. import "google/protobuf/duration.proto"; @@ -25,50 +25,48 @@ import "temporal/api/stream/v1/message.proto"; // with the same request_id, gets created true. Each policy fails with CLOSED on a closed Stream, // EXPIRED on an expired one, and NOT_OWNER on one with another owner. // (-- api-linter: core::0133=disabled -// aip.dev/not-precedent: The request names a Stream by namespace and stream_id. --) +// aip.dev/not-precedent: The request names a Stream by namespace and handle. --) message CreateStreamRequest { - reserved 5; - reserved "auto_close"; string namespace = 1; - // Required, and never empty. - string stream_id = 2; + // The handle of the Stream to create. Its id names the Stream, and is never empty. A bound + // Stream's handle has the owner's run chain as its owner. + temporal.api.stream.v1.StreamHandle stream_handle = 2; // The age after which a Record is dropped. Empty is the Store default. google.protobuf.Duration retention = 3; // Workflow only. The previous run on a retry run's first create, to withdraw its Records. string withdraw_run = 4; // The Workflow run that owns a bound Stream. Workflow kind, with its run_id and epoch. It needs // a handle whose owner names the run's chain. Unset for a standalone Stream. - temporal.api.stream.v1.Producer owner = 6; - // Only the server reads it. The server closes the Stream with the outcome of the owner's run - // chain, terminate and timeout included. It needs owner, and a handle with a Workflow owner. + temporal.api.stream.v1.Producer owner = 5; + // Only read by the server. When set, the server closes the Stream when the owner's run chain + // ends, also on a terminate or a timeout. It needs owner, and a handle with a Workflow owner. // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: "with" ties the close to the owner's run chain. --) - bool close_with_run = 7; - // The handle of the Stream, with stream_id as its id. A Workflow-owned Stream sets the - // handle's owner, which names the owner's run chain. - temporal.api.stream.v1.StreamHandle stream_handle = 8; - // Only the server reads it. The server checks that the token is the Activity's current - // attempt, so a caller can't put a Stream into a Standalone Activity it isn't. - bytes task_token = 9; + bool close_with_run = 6; + // Only read by the server, for a Stream bound to a Standalone Activity. The server checks that + // the token is the Activity's current attempt, so a caller can't bind a Stream to a Standalone + // Activity that it isn't. The server knows the run of a Workflow's call, and a standalone + // Stream has no owner, so neither needs a token. + bytes task_token = 7; // What the create does when the Stream exists. Unspecified is FAIL. - temporal.api.enums.v1.StreamIdConflictPolicy id_conflict_policy = 10; + temporal.api.enums.v1.StreamIdConflictPolicy id_conflict_policy = 8; // Unique for each create call, and the same on its retries. The Store keeps the request_id of // the create that made the Stream, so a retry after a lost answer doesn't get ALREADY_EXISTS. - string request_id = 11; + string request_id = 9; } // Experimental. Might change in the future. +// The Stream that a create made or got. message CreateStreamResponse { - reserved 3; - reserved "status"; - // The handle, with the owner of a Workflow-owned Stream. + // The handle, with the owner of a bound Stream. temporal.api.stream.v1.StreamHandle handle = 1; + // False when USE_EXISTING got a Stream that existed. bool created = 2; } // Experimental. Might change in the future. -// FAILED_PRECONDITION if closed, fenced, or if the Stream's meta has a higher format version v -// (detail stream_format_too_new). NOT_FOUND if absent. +// Appends one batch of Records as one Producer. The Store writes a resent batch, with the same +// Producer, epoch and sequence, only once. message AppendRecordsRequest { string namespace = 1; temporal.api.stream.v1.StreamHandle handle = 2; @@ -78,118 +76,129 @@ message AppendRecordsRequest { // the next number of the epoch. Such a batch has no dedupe, so a retry writes it again. int64 sequence = 4; repeated temporal.api.stream.v1.AppendRecord records = 5; - // The run's last completed Workflow Task (started_event_id 0 before the first) and its failed - // tasks, from a Local Activity's start. The batch withdraws the run's Local Activity Records - // whose task never completed. + // The run's last completed Workflow Task, from a Local Activity's start. Its started_event_id + // is 0 before the first. The batch withdraws the run's Local Activity Records whose Workflow + // Task never completed. temporal.api.stream.v1.WorkflowTaskEpoch previous_completed_task = 6; + // The started event ids of the run's failed or timed out Workflow Tasks, from a Local + // Activity's start. The batch withdraws the run's Local Activity Records of these tasks. repeated int64 failed_tasks = 7; // Local Activity only. The previous run on a retry run's first batch, to withdraw its Records. string withdraw_run = 8; - // Only the server reads it, on a Workflow Producer's batch. The batch withdraws no Workflow + // Only read by the server, on a Workflow Producer's batch. The batch withdraws no Workflow // Task and keeps no task facts, but withdraw_run still applies. bool defer_withdrawal = 9; - // Only the server reads it. The server checks the Producer against the Activity's current + // Only read by the server. The server checks the Producer against the Activity's current // attempt, so nobody else writes as that Activity. bytes task_token = 10; } // Experimental. Might change in the future. +// Where the batch's Records are in the Stream. message AppendRecordsResponse { int64 first_offset = 1; int64 last_offset = 2; } // Experimental. Might change in the future. -// Only the owner's run chain closes a bound Stream. Only a Workflow or a client closes a -// standalone Stream. +// Closes a Stream. Readers drain what is left, then end, and later appends fail with CLOSED. Only +// the owner's run chain closes a bound Stream. Only a Workflow or a client closes a standalone +// Stream. message CloseStreamRequest { string namespace = 1; temporal.api.stream.v1.StreamHandle handle = 2; + // The Producer that closes the Stream. temporal.api.stream.v1.Producer producer = 3; temporal.api.common.v1.Payload result = 4; // The close status comes from it. No failure is COMPLETED. A failure is CANCELED when it has // canceled_failure_info, or when it is an Activity, child Workflow or Nexus Operation failure // whose cause has canceled_failure_info. Any other failure is FAILED. temporal.api.failure.v1.Failure failure = 5; - // Only the server reads these. They are the run's task facts, as on the append. + // Only read by the server. The run's last completed Workflow Task, as on the append. temporal.api.stream.v1.WorkflowTaskEpoch previous_completed_task = 6; + // Only read by the server. The run's failed or timed out Workflow Tasks, as on the append. repeated int64 failed_tasks = 7; - // Only the server reads it. The previous run on a retry run's close, to withdraw its Records. + // Only read by the server. The previous run on a retry run's close, to withdraw its Records. string withdraw_run = 8; - reserved 9; - reserved "status"; - // Only the server reads it. It checks the token as on the append. - bytes task_token = 10; + // Only read by the server. It checks the token as on the append. + bytes task_token = 9; } // Experimental. Might change in the future. +// A close answers when the Store has the close. It gives nothing back. message CloseStreamResponse { } // Experimental. Might change in the future. +// Reads one page of items from an offset. At the end of the Stream, the read waits for a new +// Record, so readers long poll. message ReadRecordsRequest { string namespace = 1; temporal.api.stream.v1.StreamHandle handle = 2; - // Start at this offset. Zero uses start. OUT_OF_RANGE if past the newest offset + 1. + // Start at this offset. Zero uses start. It fails with OFFSET_TOO_HIGH if it is past the newest + // offset + 1. // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: "from" and "to" mark the ends of a range of offsets. --) int64 from_offset = 3; + // Where to start when from_offset is zero. Unspecified is the beginning. temporal.api.enums.v1.StreamReadStart start = 4; - reserved 5, 10; - reserved "topics", "topic_prefixes"; // The most items in one page, Superseded items included. Zero is 1000. A Superseded item and // the Record after it stay in one page, so a page of one Record can hold two items. - int32 max_records = 6; - google.protobuf.Duration wait = 7; - // How long to wait for a Stream that nobody created yet. Empty fails at once with - // NOT_FOUND. At most 60 s. - google.protobuf.Duration create_wait = 8; + int32 max_records = 5; + // How long the read waits for a new Record before it answers with an empty page. Empty answers + // at once, and a wait longer than 60 s waits 60 s. + google.protobuf.Duration wait = 6; + // How long to wait for a Stream that nobody created yet. Empty fails at once with NOT_FOUND. + // At most 60 s. + google.protobuf.Duration create_wait = 7; // The exclusive end of the read. The read scans no entry at or after it, the close Record // included, so a replay reads the same range. Zero means no end. Only the server serves it, // and a Store refuses it. // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: "from" and "to" mark the ends of a range of offsets. --) - int64 to_offset = 11; + int64 to_offset = 8; // Park as a normal read, then answer with no Records and next_offset = from_offset, when a // Record at or after from_offset exists, when the Stream closes (done), or when wait ends. Its // wait may be days, since a parked read adds nothing to History while it waits. Only the // server serves it, and a Store refuses it. - bool wake_only = 12; + bool wake_only = 9; // Read the newest `last` Records, oldest first. It needs no from_offset and no start. Not with // wake_only, and not in Workflow code, where replay needs a fixed range. - int32 last = 13; + int32 last = 10; // The most Record bytes in one page, so a page fits the caller's limit, such as a History // marker. A larger Record still comes alone, so the read always moves on. Zero is no cap. - int64 max_bytes = 14; + int64 max_bytes = 11; // The lowest offset this run read on replay. A reset run copies the earlier run's reads into - // its History, so its floor must cover them too, not only its first live page. Only the - // server reads it. + // its History, so its floor must cover them too, not only its first live page. Only read by + // the server. // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: The floor is the lowest from_offset of the run's reads. --) - int64 floor_from = 15; + int64 floor_from = 12; } // Experimental. Might change in the future. +// One page of a read. message ReadRecordsResponse { repeated temporal.api.stream.v1.StreamRecord records = 1; // The from_offset of the next read, which is the newest offset read + 1. int64 next_offset = 2; + // True after the close. No later read gives an item. bool done = 3; // This reader's floor keeps more than half of the Stream's byte cap. The writer fails when the - // cap fills, and only the reader's continue-as-new frees it. Only the server sets it. + // cap fills, and only the reader's continue-as-new frees it. Only set by the server. bool continue_as_new_suggested = 4; } // Experimental. Might change in the future. +// Gets the status, the newest offset and the Retention of a Stream. message DescribeStreamRequest { string namespace = 1; temporal.api.stream.v1.StreamHandle handle = 2; } // Experimental. Might change in the future. +// The state of a Stream. message DescribeStreamResponse { - reserved 4; - reserved "auto_close"; temporal.api.enums.v1.StreamStatus status = 1; // The newest offset, of a Record, a Superseded item or the close. Zero when the Stream has // none. From 7b38c0916919919e7ff5022f43b7e1ccc940a6d7 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 4 Nov 2026 17:39:12 -0700 Subject: [PATCH 13/20] Made the epoch a oneof whose case is the Producer's kind. Each kind has its own epoch fields, and the case picks the withdrawal rule, so StreamProducerKind is gone. The protos match temporalio/sdk-rust#1733. --- nexus/stream-service.wit | 96 +++++++++------- temporal/api/enums/v1/stream.proto | 12 -- temporal/api/stream/v1/message.proto | 103 +++++++++++------- .../streamservice/v1/request_response.proto | 4 +- 4 files changed, 119 insertions(+), 96 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index ca5e75aa2..e7877a25c 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -59,56 +59,67 @@ interface stream-service { run-id: option, } - /// @nexus.proto "temporal.api.enums.v1.StreamProducerKind" - enum stream-producer-kind { - /// @nexus.fallback - stream-producer-kind-unspecified, - stream-producer-kind-workflow, - stream-producer-kind-local-activity, - stream-producer-kind-activity, - stream-producer-kind-client, - stream-producer-kind-standalone-activity, - } - - // Epochs compare field by field, in the order of the fields. A field that a kind does not use is - // empty and compares as zero. A timestamp compares seconds, then nanos. A Workflow epoch has - // task-started-event-id, task-attempt and task-started-time. A Local Activity epoch has the same - // three of the dispatching Workflow Task, then attempt. An Activity epoch has - // attempt-scheduled-time, then attempt. A client epoch has attempt. - /// @nexus.doc "The ordered attempt of a Producer. A higher epoch supersedes a lower one." - /// @nexus.proto "temporal.api.stream.v1.ProducerEpoch" - record producer-epoch { - /// @nexus.doc "The started event id of the Workflow Task." - task-started-event-id: option, - /// @nexus.doc "The attempt of the Workflow Task." - task-attempt: option, + /// @nexus.doc "A Workflow Task attempt of the run." + /// @nexus.proto "temporal.api.stream.v1.WorkflowTaskEpoch" + record workflow-task-epoch { + started-event-id: option, + attempt: option, /// @nexus.doc "When the Workflow Task attempt started. It tells a reused started id apart." - task-started-time: option, - /// @nexus.doc "When the server scheduled this Activity attempt. A reset makes it later." + started-time: option, + } + + /// @nexus.doc "A Local Activity attempt. Its Workflow Task orders it first." + /// @nexus.proto "temporal.api.stream.v1.LocalActivityEpoch" + record local-activity-epoch { + /// @nexus.doc "The Workflow Task attempt that dispatched the Local Activity." + task: option, + /// @nexus.doc "The Local Activity's own attempt in that Workflow Task." + attempt: option, + } + + /// @nexus.doc "An Activity or Standalone Activity attempt." + /// @nexus.proto "temporal.api.stream.v1.ActivityEpoch" + record activity-epoch { + /// @nexus.doc "When the server scheduled this attempt. A reset makes it later." attempt-scheduled-time: option, - /// @nexus.doc "The Activity, Local Activity or client attempt." attempt: option, } + /// @nexus.doc "A client attempt, which the client picks." + /// @nexus.proto "temporal.api.stream.v1.ClientEpoch" + record client-epoch { + attempt: option, + } + + // The case is the kind of the Producer, so a kind sets only its own fields. + /// @nexus.doc "The epoch of each kind of Producer." + /// @nexus.proto "temporal.api.stream.v1.ProducerEpoch.kind" + variant producer-epoch-kind { + workflow(workflow-task-epoch), + local-activity(local-activity-epoch), + activity(activity-epoch), + standalone-activity(activity-epoch), + client(client-epoch), + } + + /// @nexus.doc "The ordered attempt of a Producer. A higher epoch supersedes a lower one." + /// @nexus.proto "temporal.api.stream.v1.ProducerEpoch" + record producer-epoch { + // A newer server can send a kind that this SDK doesn't know. Then the kind is empty. + kind: option, + } + /// @nexus.doc "The Producer of a batch." /// @nexus.proto "temporal.api.stream.v1.Producer" record producer { - /// @nexus.doc "Stable across attempts. The proposal gives the form per kind." + /// @nexus.doc "Stable across attempts, such as wf: for a Workflow run." id: string, - kind: option, + /// @nexus.doc "Required. Its case gives the kind of the Producer." epoch: option, /// @nexus.doc "The run of the Producer, so nobody parses the id. Empty for a client." run-id: option, } - /// @nexus.doc "A Workflow Task attempt of the run." - /// @nexus.proto "temporal.api.stream.v1.WorkflowTaskEpoch" - record workflow-task-epoch { - started-event-id: option, - attempt: option, - started-time: option, - } - /// @nexus.doc "One Record to append." /// @nexus.proto "temporal.api.stream.v1.AppendRecord" /// @nexus.unwrap @@ -117,18 +128,19 @@ interface stream-service { body: body-t, } - // An Activity or Local Activity item withdraws the Records of its Producer with a lower epoch. A - // Workflow item, with this task t in epoch and the previous completed task c, withdraws the - // Records of the run and of its Local Activities whose task-started-event-id is in failed-tasks - // or strictly between c and t. It also withdraws those at the started event id of c or of t with - // a lower epoch than c or t. No task in that range can have completed. + // A Local Activity, Activity or Standalone Activity item withdraws the Records of its Producer + // with a lower epoch. A Workflow item, with its task t and the previous completed task c, + // withdraws the Records of the run and of its Local Activities whose Workflow Task is in + // failed-tasks or strictly between c and t. It also withdraws those at the started event id of c + // or of t with a lower epoch than c or t. No task in that range can have completed. A client item + // withdraws nothing. /// @nexus.doc "Which earlier Records a Superseded item withdraws." /// @nexus.proto "temporal.api.stream.v1.StreamSuperseded" record stream-superseded { producer-id: string, /// @nexus.doc "Workflow only. Started event ids of failed or timed out Workflow Tasks." failed-tasks: list, - producer-kind: option, + /// @nexus.doc "The withdrawing Producer's epoch, whose case picks the rule." epoch: option, /// @nexus.doc "Workflow only. The previous completed Workflow Task of the run." previous-completed-task: option, diff --git a/temporal/api/enums/v1/stream.proto b/temporal/api/enums/v1/stream.proto index f86cce2c0..d7412f325 100644 --- a/temporal/api/enums/v1/stream.proto +++ b/temporal/api/enums/v1/stream.proto @@ -9,18 +9,6 @@ option java_outer_classname = "StreamProto"; option ruby_package = "Temporalio::Api::Enums::V1"; option csharp_namespace = "Temporalio.Api.Enums.V1"; -// Experimental. Might change in the future. -// The kind of code that wrote a batch. The kind decides how the Store orders the Producer's -// epochs, which earlier Records a higher epoch withdraws, and if the Producer can close the Stream. -enum StreamProducerKind { - STREAM_PRODUCER_KIND_UNSPECIFIED = 0; - STREAM_PRODUCER_KIND_WORKFLOW = 1; - STREAM_PRODUCER_KIND_LOCAL_ACTIVITY = 2; - STREAM_PRODUCER_KIND_ACTIVITY = 3; - STREAM_PRODUCER_KIND_CLIENT = 4; - STREAM_PRODUCER_KIND_STANDALONE_ACTIVITY = 5; -} - // Experimental. Might change in the future. // How a Stream closed, so a reader tells a terminated or timed out owner from a failed one // without parsing the failure. diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index b6fb61b78..1d30a7935 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -32,47 +32,71 @@ message StreamHandle { // Experimental. Might change in the future. // The ordered attempt of a Producer. A higher epoch supersedes the Records of a lower epoch of the -// same Producer. Epochs compare field by field, in the order of the field numbers. Each kind sets -// only its own fields. A field that a kind does not use is empty and compares as zero. +// same Producer. The case is the kind of the Producer. It decides how the Store orders the epochs, +// and which earlier Records a higher epoch withdraws. message ProducerEpoch { - // Workflow and Local Activity. The started event id of the Workflow Task of a Workflow, or of - // the Workflow Task that dispatched a Local Activity. - int64 task_started_event_id = 1; - // Workflow and Local Activity. The attempt of that Workflow Task. - int32 task_attempt = 2; - // Workflow and Local Activity. When that Workflow Task attempt started. It tells a reused - // started event id apart, for example after a dropped speculative task. - google.protobuf.Timestamp task_started_time = 3; - // Activity and Standalone Activity. When the server scheduled this attempt. An Activity reset - // starts the attempt count again, so only this time orders the attempts across a reset. A - // Local Activity or a client has no attempt that the server schedules. - google.protobuf.Timestamp attempt_scheduled_time = 4; - // Activity, Standalone Activity, Local Activity and client. The Producer's own attempt. For a - // Local Activity it counts the retries of the Local Activity, and task_attempt counts the - // attempts of the Workflow Task that dispatched it. - int32 attempt = 5; -} - -// Experimental. Might change in the future. -// The code that wrote a batch. Its id stays the same across attempts, and its epoch orders them. -message Producer { - temporal.api.enums.v1.StreamProducerKind kind = 1; - // Stable across attempts, such as `wf:` for the Workflow Producer of a run. - string id = 2; - ProducerEpoch epoch = 3; - // The run of the Producer, so nobody parses the id. Empty for a client. - string run_id = 4; + oneof kind { + // A Workflow run, which writes in its Workflow Task. A higher epoch withdraws the Records + // of the run's Workflow Tasks that never completed, also those of their Local Activities. + WorkflowTaskEpoch workflow = 1; + // A Local Activity. A higher epoch withdraws the Producer's lower epochs. + LocalActivityEpoch local_activity = 2; + // An Activity of a Workflow. A higher epoch withdraws the Producer's lower epochs. + ActivityEpoch activity = 3; + // A Standalone Activity. A higher epoch withdraws the Producer's lower epochs. + ActivityEpoch standalone_activity = 4; + // A client. A higher epoch only fences the lower ones, since a restarted client's earlier + // Records are real messages, not the Records of a failed attempt. + ClientEpoch client = 5; + } } // Experimental. Might change in the future. -// One Workflow Task attempt of a run. The withdrawal facts name the run's last completed task -// with it. +// One Workflow Task attempt of a run. It orders by started_event_id, then attempt, then +// started_time. message WorkflowTaskEpoch { int64 started_event_id = 1; int32 attempt = 2; + // Tells a reused started event id apart, for example after a dropped speculative task. google.protobuf.Timestamp started_time = 3; } +// Experimental. Might change in the future. +// The attempt of a Local Activity. It orders by task, then attempt, so a Local Activity of a +// later Workflow Task supersedes one of an earlier task. +message LocalActivityEpoch { + // The Workflow Task attempt that dispatched the Local Activity. + WorkflowTaskEpoch task = 1; + // The Local Activity's own attempt in that Workflow Task. + int32 attempt = 2; +} + +// Experimental. Might change in the future. +// The attempt of an Activity. It orders by attempt_scheduled_time, then attempt. +message ActivityEpoch { + // When the server scheduled this attempt. An Activity reset starts the attempt count again, + // so only this time orders the attempts across a reset. + google.protobuf.Timestamp attempt_scheduled_time = 1; + int32 attempt = 2; +} + +// Experimental. Might change in the future. +// The attempt of a client Producer, which the client picks. +message ClientEpoch { + int32 attempt = 1; +} + +// Experimental. Might change in the future. +// The code that wrote a batch. Its id stays the same across attempts, and its epoch orders them. +message Producer { + // Stable across attempts, such as `wf:` for the Workflow Producer of a run. + string id = 1; + // Required. Its case gives the kind of the Producer. + ProducerEpoch epoch = 2; + // The run of the Producer, so nobody parses the id. Empty for a client. + string run_id = 3; +} + // Experimental. Might change in the future. // One Record of a batch to append. The Store adds the batch's Producer and an offset. message AppendRecord { @@ -84,20 +108,19 @@ message AppendRecord { // batch of a higher epoch, or for a retry run's first write, and a read builds a Superseded item // from them. A reader drops the earlier Records that the facts name. message StreamSuperseded { - // The kind of the Producer that withdraws. - temporal.api.enums.v1.StreamProducerKind producer_kind = 1; // The id of the Producer that withdraws. - string producer_id = 2; - // The epoch of the Producer that withdraws. Its lower epochs are withdrawn. - ProducerEpoch epoch = 3; + string producer_id = 1; + // The epoch of the Producer that withdraws, whose case picks the rule. Empty when only + // withdraw_run applies. + ProducerEpoch epoch = 2; // Workflow only. The run's last completed Workflow Task. - WorkflowTaskEpoch previous_completed_task = 4; + WorkflowTaskEpoch previous_completed_task = 3; // Workflow only. The started event ids of the run's failed or timed out Workflow Tasks. - repeated int64 failed_tasks = 5; + repeated int64 failed_tasks = 4; // Workflow retry only. The previous run. Every Record whose Producer carries it is withdrawn. - string withdraw_run = 6; + string withdraw_run = 5; // The run of the Producer, so nobody parses the id. Empty for a client. - string run_id = 7; + string run_id = 6; } // Experimental. Might change in the future. diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index a5a59c75d..c2fb28490 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -35,8 +35,8 @@ message CreateStreamRequest { google.protobuf.Duration retention = 3; // Workflow only. The previous run on a retry run's first create, to withdraw its Records. string withdraw_run = 4; - // The Workflow run that owns a bound Stream. Workflow kind, with its run_id and epoch. It needs - // a handle whose owner names the run's chain. Unset for a standalone Stream. + // The Workflow run that owns a bound Stream, with its run_id and a Workflow epoch. It needs a + // handle whose owner names the run's chain. Unset for a standalone Stream. temporal.api.stream.v1.Producer owner = 5; // Only read by the server. When set, the server closes the Stream when the owner's run chain // ends, also on a terminate or a timeout. It needs owner, and a handle with a Workflow owner. From d00cb7c637145f3df6fe3ad4dd793db72a1b7df5 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 4 Nov 2026 18:25:56 -0700 Subject: [PATCH 14/20] Made the docs of the payload type and the enums plain comments. nexgen accepts @nexus.doc only on records, fields, variants, resources and functions, and it renders no enum doc. --- nexus/stream-service.wit | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index e7877a25c..08a381fae 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -19,7 +19,7 @@ interface stream-service { placeholder, }; - /// @nexus.doc "The value type of a Stream's Records. A call's type argument sets it." + /// The value type of a Stream's Records. A call's type argument sets it. /// @nexus.type-parameter type body-t = placeholder; @@ -150,7 +150,7 @@ interface stream-service { run-id: option, } - /// @nexus.doc "How a Stream closed. Only the owner's end on the server terminates or times out." + /// How a Stream closed. Only the owner's end on the server terminates or times out. /// @nexus.proto "temporal.api.enums.v1.StreamCloseStatus" enum stream-close-status { /// @nexus.fallback @@ -241,7 +241,7 @@ interface stream-service { task-token: placeholder, } - /// @nexus.doc "What a create does when its Stream exists. Unspecified is fail." + /// What a create does when its Stream exists. Unspecified is fail. /// @nexus.proto "temporal.api.enums.v1.StreamIdConflictPolicy" enum stream-id-conflict-policy { stream-id-conflict-policy-unspecified, @@ -473,7 +473,7 @@ interface stream-service { // No operation returns these. A failed call has one in its details. Each SDK raises the class // of its reason, or the base class for a reason with no class. - /// @nexus.doc "Why a Stream call failed. Each reason has one gRPC status code." + /// Why a Stream call failed. Each reason has one gRPC status code. /// @nexus.proto "temporal.api.enums.v1.StreamErrorReason" /// @nexus.error details="stream-error-details" field="reason" name="stream-error" /// python-extends="temporalio.exceptions.ApplicationError" From cabddc7572d0277fecf495cbb912bf7876f812c8 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 4 Nov 2026 20:42:11 -0700 Subject: [PATCH 15/20] Stopped using name as a verb in the Stream comments. The protos match temporalio/sdk-rust#1733 again. --- nexus/stream-service.wit | 4 ++-- temporal/api/enums/v1/stream.proto | 4 ++-- temporal/api/stream/v1/message.proto | 8 ++++---- temporal/api/streamservice/v1/request_response.proto | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 08a381fae..e81ef92b1 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -191,7 +191,7 @@ interface stream-service { } // An offset is ordered and unique in one Stream, and sparse. Offsets have gaps, so they don't - // count Records. A read names its Stream, so the read checks the offset against that Stream. + // count Records. A read gives its Stream, so the read checks the offset against that Stream. /// @nexus.doc "One item that a read gives." /// @nexus.proto "temporal.api.stream.v1.StreamRecord" record stream-record { @@ -207,7 +207,7 @@ interface stream-service { record create-stream-request { // Not named handle, so the created stream takes its handle from the response, which carries // the owner. - /// @nexus.doc "The handle of the Stream to create. Its id names the Stream." + /// @nexus.doc "The handle of the Stream to create. Its id is the Stream id." /// @nexus.flatten-in-api fields="id backend" stream-handle: stream-handle, /// @nexus.doc "The age after which a Record is dropped. Empty is the Store default." diff --git a/temporal/api/enums/v1/stream.proto b/temporal/api/enums/v1/stream.proto index d7412f325..a0bab0b4e 100644 --- a/temporal/api/enums/v1/stream.proto +++ b/temporal/api/enums/v1/stream.proto @@ -72,7 +72,7 @@ enum StreamErrorReason { // The batch has the `sequence` of the last one of its epoch, and other content. // FAILED_PRECONDITION. STREAM_ERROR_REASON_SEQ_DIVERGENT = 6; - // A create names another owner than the Stream has, or the owner doesn't allow this close. + // A create gives another owner than the Stream has, or the owner doesn't allow this close. // FAILED_PRECONDITION. STREAM_ERROR_REASON_NOT_OWNER = 7; // A newer format version wrote the Stream. FAILED_PRECONDITION. @@ -90,6 +90,6 @@ enum StreamErrorReason { STREAM_ERROR_REASON_UNKNOWN_WRITER = 13; // The Store lacks a capability that the call needs. UNIMPLEMENTED. STREAM_ERROR_REASON_UNSUPPORTED = 14; - // A create with the FAIL conflict policy names a Stream that exists. ALREADY_EXISTS. + // A create with the FAIL conflict policy is for a Stream that exists. ALREADY_EXISTS. STREAM_ERROR_REASON_ALREADY_EXISTS = 15; } diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 1d30a7935..97a103c96 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -22,7 +22,7 @@ message StreamHandle { // Unset for a standalone Stream. For a Stream bound to a Workflow, business_id is the Workflow // ID and run_id is the first execution run id of its run chain. For a Standalone Activity, // business_id is the activity ID and run_id is its run id. A create and a close of a - // Workflow-owned Stream must name its owner. + // Workflow-owned Stream must carry its owner. temporal.api.common.v1.Execution owner = 2; // The name of the backend that holds the Stream, as the Worker and client config register // it, so every process routes a handle it receives. Empty is the default backend. The Store @@ -106,7 +106,7 @@ message AppendRecord { // Experimental. Might change in the future. // Which earlier Records a retried Producer withdraws. The Store keeps these facts for the first // batch of a higher epoch, or for a retry run's first write, and a read builds a Superseded item -// from them. A reader drops the earlier Records that the facts name. +// from them. A reader drops the earlier Records that the facts match. message StreamSuperseded { // The id of the Producer that withdraws. string producer_id = 1; @@ -143,7 +143,7 @@ message StreamClose { // Experimental. Might change in the future. // An offset is ordered and unique in one Stream, and it is sparse. Offsets have gaps, so they -// don't count Records. Zero is never an offset. A read names its Stream, so the read checks the +// don't count Records. Zero is never an offset. A read gives its Stream, so the read checks the // offset against that Stream. message StreamRecord { int64 offset = 1; @@ -151,7 +151,7 @@ message StreamRecord { oneof item { StreamData data = 2; // The Store keeps only the withdrawal facts of the first batch of a higher epoch. A read - // builds this item from them. It withdraws the earlier Records that the facts name, and a + // builds this item from them. It withdraws the earlier Records that the facts match, and a // reader drops them. StreamSuperseded superseded = 3; StreamClose close = 4; diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index c2fb28490..c357bb614 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -25,10 +25,10 @@ import "temporal/api/stream/v1/message.proto"; // with the same request_id, gets created true. Each policy fails with CLOSED on a closed Stream, // EXPIRED on an expired one, and NOT_OWNER on one with another owner. // (-- api-linter: core::0133=disabled -// aip.dev/not-precedent: The request names a Stream by namespace and handle. --) +// aip.dev/not-precedent: The request identifies a Stream by namespace and handle. --) message CreateStreamRequest { string namespace = 1; - // The handle of the Stream to create. Its id names the Stream, and is never empty. A bound + // The handle of the Stream to create. Its id is the Stream id, and is never empty. A bound // Stream's handle has the owner's run chain as its owner. temporal.api.stream.v1.StreamHandle stream_handle = 2; // The age after which a Record is dropped. Empty is the Store default. @@ -36,7 +36,7 @@ message CreateStreamRequest { // Workflow only. The previous run on a retry run's first create, to withdraw its Records. string withdraw_run = 4; // The Workflow run that owns a bound Stream, with its run_id and a Workflow epoch. It needs a - // handle whose owner names the run's chain. Unset for a standalone Stream. + // handle whose owner is the run's chain. Unset for a standalone Stream. temporal.api.stream.v1.Producer owner = 5; // Only read by the server. When set, the server closes the Stream when the owner's run chain // ends, also on a terminate or a timeout. It needs owner, and a handle with a Workflow owner. From fffc801c95117b81063e7a78496e0990d1a28edb Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 4 Nov 2026 20:44:03 -0700 Subject: [PATCH 16/20] Reworded the comment on the create's handle field. --- nexus/stream-service.wit | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index e81ef92b1..c289cf390 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -205,8 +205,8 @@ interface stream-service { /// "Create a Stream. If it exists, fail, or with use-existing get the open one." /// @nexus.proto "temporal.api.streamservice.v1.CreateStreamRequest" record create-stream-request { - // Not named handle, so the created stream takes its handle from the response, which carries - // the owner. + // Its field name isn't handle, so the created stream takes its handle from the response, which + // carries the owner. /// @nexus.doc "The handle of the Stream to create. Its id is the Stream id." /// @nexus.flatten-in-api fields="id backend" stream-handle: stream-handle, From e8eefe864384028210f0414eb71d1b96b8febe43 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 4 Nov 2026 22:29:39 -0700 Subject: [PATCH 17/20] Split the storage reason, and made a close write no Record. A corrupt Store isn't retryable, unlike an unavailable one. A close carries no value, so a read gives no close item and the close takes no result or failure. The protos match temporalio/sdk-rust#1733. --- nexus/stream-service.wit | 45 ++++--------------- temporal/api/enums/v1/stream.proto | 22 +++------ temporal/api/stream/v1/message.proto | 15 +------ .../streamservice/v1/request_response.proto | 33 ++++++-------- 4 files changed, 27 insertions(+), 88 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index c289cf390..4fd53e670 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -14,8 +14,6 @@ world system { interface stream-service { use nexus:temporal-types/model@1.0.0.{ duration, - failure, - payload, placeholder, }; @@ -150,18 +148,6 @@ interface stream-service { run-id: option, } - /// How a Stream closed. Only the owner's end on the server terminates or times out. - /// @nexus.proto "temporal.api.enums.v1.StreamCloseStatus" - enum stream-close-status { - /// @nexus.fallback - stream-close-status-unspecified, - stream-close-status-completed, - stream-close-status-failed, - stream-close-status-canceled, - stream-close-status-terminated, - stream-close-status-timed-out, - } - /// @nexus.doc "One Record that a Producer appended." /// @nexus.proto "temporal.api.stream.v1.StreamData" record stream-data { @@ -170,24 +156,12 @@ interface stream-service { producer: option, } - /// @nexus.doc "The close of a Stream. A read gives it last." - /// @nexus.proto "temporal.api.stream.v1.StreamClose" - record stream-close { - /// @nexus.doc "The Producer that closed the Stream." - producer: option, - %result: option, - failure: option, - /// @nexus.doc "Set together with the result or the failure." - status: option, - } - // The read builds a Superseded item from a flagged batch. - /// @nexus.doc "A read item: a Record, a Superseded item or the close." + /// @nexus.doc "A read item: a Record or a Superseded item. A close writes no item." /// @nexus.proto "temporal.api.stream.v1.StreamRecord.item" variant stream-item { data(stream-data), superseded(stream-superseded), - close(stream-close), } // An offset is ordered and unique in one Stream, and sparse. Offsets have gaps, so they don't @@ -310,6 +284,7 @@ interface stream-service { last-offset: option, } + // A close carries no value, so code that has a last value publishes it first. /// @nexus.doc "Close a Stream. Readers drain what is left, then end. Later appends are refused." /// @nexus.proto "temporal.api.streamservice.v1.CloseStreamRequest" record close-stream-request { @@ -322,11 +297,6 @@ interface stream-service { /// @nexus.source targets="client" python="client_producer()" typescript="clientProducer()" /// dotnet="StreamActivityContext.Producer()" producer: option, - %result: option, - // Each SDK gives a canceled Activity, child Workflow or Nexus Operation as a failure of that - // call whose cause is the cancel. So such a failure also cancels. - /// @nexus.doc "Empty completes the Stream. A cancel failure cancels it. Any other fails it." - failure: option, // Native only. The SDK Stores don't read it. /// @nexus.omit previous-completed-task: placeholder, @@ -387,7 +357,7 @@ interface stream-service { records: list, /// @nexus.doc "The from-offset of the next read, which is the newest offset read + 1." next-offset: option, - /// @nexus.doc "True after the close Record. No later read gives a Record." + /// @nexus.doc "True when the Stream is closed and this read gave its last item." done: option, // Native only. The SDK Stores never set it. /// @nexus.omit @@ -404,7 +374,7 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.DescribeStreamResponse" record describe-stream-response { status: option, - /// @nexus.doc "The newest offset, of a Record, a Superseded item or the close. Empty for none." + /// @nexus.doc "The newest offset, of a Record or a Superseded item. Empty for none." last-offset: option, retention: option, } @@ -425,8 +395,8 @@ interface stream-service { /// @nexus.doc "Close the Stream. Readers drain what is left, then end." /// @nexus.operation "close-stream" - /// @nexus.close failure="failure" - close: func(%result: option, failure: option); + /// @nexus.close + close: func(); /// @nexus.doc "Read one page of Records. The poll iterator reads them all." /// @nexus.operation "read-records" @@ -502,12 +472,13 @@ interface stream-service { stream-error-reason-too-large, stream-error-reason-invalid, /// @nexus.error retryable=true - stream-error-reason-storage, + stream-error-reason-storage-unavailable, stream-error-reason-unknown-writer, /// @nexus.error stream-error-reason-unsupported, /// @nexus.error stream-error-reason-already-exists, + stream-error-reason-storage-corrupt, } /// @nexus.doc "The details of a failed Stream call." diff --git a/temporal/api/enums/v1/stream.proto b/temporal/api/enums/v1/stream.proto index a0bab0b4e..1a258f97d 100644 --- a/temporal/api/enums/v1/stream.proto +++ b/temporal/api/enums/v1/stream.proto @@ -9,21 +9,6 @@ option java_outer_classname = "StreamProto"; option ruby_package = "Temporalio::Api::Enums::V1"; option csharp_namespace = "Temporalio.Api.Enums.V1"; -// Experimental. Might change in the future. -// How a Stream closed, so a reader tells a terminated or timed out owner from a failed one -// without parsing the failure. -// (-- api-linter: core::0216::synonyms=disabled -// aip.dev/not-precedent: Named consistently with WorkflowExecutionStatus. --) -enum StreamCloseStatus { - STREAM_CLOSE_STATUS_UNSPECIFIED = 0; - STREAM_CLOSE_STATUS_COMPLETED = 1; - STREAM_CLOSE_STATUS_FAILED = 2; - STREAM_CLOSE_STATUS_CANCELED = 3; - // TERMINATED and TIMED_OUT come only from the owner's end, never from a caller. - STREAM_CLOSE_STATUS_TERMINATED = 4; - STREAM_CLOSE_STATUS_TIMED_OUT = 5; -} - // Experimental. Might change in the future. // The state of a Stream, as a describe gives it. // (-- api-linter: core::0216::synonyms=disabled @@ -84,12 +69,15 @@ enum StreamErrorReason { STREAM_ERROR_REASON_TOO_LARGE = 10; // The request is malformed. INVALID_ARGUMENT. STREAM_ERROR_REASON_INVALID = 11; - // The Store failed, did not answer, or gave back data that isn't valid. UNAVAILABLE. - STREAM_ERROR_REASON_STORAGE = 12; + // The Store failed or did not answer. A retry can succeed. UNAVAILABLE. + STREAM_ERROR_REASON_STORAGE_UNAVAILABLE = 12; // No writer has the id. The SDK's in-process writer only. NOT_FOUND. STREAM_ERROR_REASON_UNKNOWN_WRITER = 13; // The Store lacks a capability that the call needs. UNIMPLEMENTED. STREAM_ERROR_REASON_UNSUPPORTED = 14; // A create with the FAIL conflict policy is for a Stream that exists. ALREADY_EXISTS. STREAM_ERROR_REASON_ALREADY_EXISTS = 15; + // The Store gave back data that isn't valid, such as a stored value with the wrong length. A + // retry reads the same data. DATA_LOSS. + STREAM_ERROR_REASON_STORAGE_CORRUPT = 16; } diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 97a103c96..7caa72223 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -13,7 +13,6 @@ import "google/protobuf/timestamp.proto"; import "temporal/api/common/v1/message.proto"; import "temporal/api/enums/v1/stream.proto"; -import "temporal/api/failure/v1/message.proto"; // Experimental. Might change in the future. // Identifies one Stream. The id is opaque to callers. @@ -130,31 +129,19 @@ message StreamData { temporal.api.common.v1.Payload body = 2; } -// Experimental. Might change in the future. -// The close of a Stream. A Stream has at most one, and a read returns it last. -message StreamClose { - // The Producer that closed the Stream. - Producer producer = 1; - temporal.api.common.v1.Payload result = 2; - temporal.api.failure.v1.Failure failure = 3; - // How the Stream closed, so a reader doesn't parse the failure. - temporal.api.enums.v1.StreamCloseStatus status = 4; -} - // Experimental. Might change in the future. // An offset is ordered and unique in one Stream, and it is sparse. Offsets have gaps, so they // don't count Records. Zero is never an offset. A read gives its Stream, so the read checks the // offset against that Stream. message StreamRecord { int64 offset = 1; - // A read item is a Record, a Superseded item or the close. + // A read item is a Record or a Superseded item. A close writes no item. oneof item { StreamData data = 2; // The Store keeps only the withdrawal facts of the first batch of a higher epoch. A read // builds this item from them. It withdraws the earlier Records that the facts match, and a // reader drops them. StreamSuperseded superseded = 3; - StreamClose close = 4; } } diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index c357bb614..33036356b 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -14,9 +14,7 @@ option csharp_namespace = "Temporalio.Api.StreamService.V1"; import "google/protobuf/duration.proto"; -import "temporal/api/common/v1/message.proto"; import "temporal/api/enums/v1/stream.proto"; -import "temporal/api/failure/v1/message.proto"; import "temporal/api/stream/v1/message.proto"; // Experimental. Might change in the future. @@ -101,27 +99,23 @@ message AppendRecordsResponse { } // Experimental. Might change in the future. -// Closes a Stream. Readers drain what is left, then end, and later appends fail with CLOSED. Only -// the owner's run chain closes a bound Stream. Only a Workflow or a client closes a standalone -// Stream. +// Closes a Stream. Readers drain what is left, then end, and later appends fail with CLOSED. A +// close writes no Record and carries no value, so code that has a last value publishes it first. +// Only the owner's run chain closes a bound Stream. Only a Workflow or a client closes a +// standalone Stream. message CloseStreamRequest { string namespace = 1; temporal.api.stream.v1.StreamHandle handle = 2; // The Producer that closes the Stream. temporal.api.stream.v1.Producer producer = 3; - temporal.api.common.v1.Payload result = 4; - // The close status comes from it. No failure is COMPLETED. A failure is CANCELED when it has - // canceled_failure_info, or when it is an Activity, child Workflow or Nexus Operation failure - // whose cause has canceled_failure_info. Any other failure is FAILED. - temporal.api.failure.v1.Failure failure = 5; // Only read by the server. The run's last completed Workflow Task, as on the append. - temporal.api.stream.v1.WorkflowTaskEpoch previous_completed_task = 6; + temporal.api.stream.v1.WorkflowTaskEpoch previous_completed_task = 4; // Only read by the server. The run's failed or timed out Workflow Tasks, as on the append. - repeated int64 failed_tasks = 7; + repeated int64 failed_tasks = 5; // Only read by the server. The previous run on a retry run's close, to withdraw its Records. - string withdraw_run = 8; + string withdraw_run = 6; // Only read by the server. It checks the token as on the append. - bytes task_token = 9; + bytes task_token = 7; } // Experimental. Might change in the future. @@ -151,9 +145,8 @@ message ReadRecordsRequest { // How long to wait for a Stream that nobody created yet. Empty fails at once with NOT_FOUND. // At most 60 s. google.protobuf.Duration create_wait = 7; - // The exclusive end of the read. The read scans no entry at or after it, the close Record - // included, so a replay reads the same range. Zero means no end. Only the server serves it, - // and a Store refuses it. + // The exclusive end of the read. The read scans no entry at or after it, so a replay reads the + // same range. Zero means no end. Only the server serves it, and a Store refuses it. // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: "from" and "to" mark the ends of a range of offsets. --) int64 to_offset = 8; @@ -182,7 +175,8 @@ message ReadRecordsResponse { repeated temporal.api.stream.v1.StreamRecord records = 1; // The from_offset of the next read, which is the newest offset read + 1. int64 next_offset = 2; - // True after the close. No later read gives an item. + // True when the Stream is closed and this read gave its last item. A read of a closed Stream + // with no item left answers at once. bool done = 3; // This reader's floor keeps more than half of the Stream's byte cap. The writer fails when the // cap fills, and only the reader's continue-as-new frees it. Only set by the server. @@ -200,8 +194,7 @@ message DescribeStreamRequest { // The state of a Stream. message DescribeStreamResponse { temporal.api.enums.v1.StreamStatus status = 1; - // The newest offset, of a Record, a Superseded item or the close. Zero when the Stream has - // none. + // The newest offset, of a Record or a Superseded item. Zero when the Stream has none. int64 last_offset = 2; google.protobuf.Duration retention = 3; } From e4e2c477a66ac4744d0636b706118998a3872eff Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 4 Nov 2026 22:38:12 -0700 Subject: [PATCH 18/20] Renamed StreamHandle to StreamRef, and its fields to ref. A ref is the data that code passes, and the stream resource, the object with calls, is the handle, as the API review decided. The protos match temporalio/sdk-rust#1733. --- nexus/stream-service.wit | 26 +++++++++---------- temporal/api/stream/v1/message.proto | 4 +-- .../streamservice/v1/request_response.proto | 24 ++++++++--------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 4fd53e670..e95d8d2d3 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -28,8 +28,8 @@ interface stream-service { } /// @nexus.doc "Identifies one Stream. The id is opaque to callers." - /// @nexus.proto "temporal.api.stream.v1.StreamHandle" - record stream-handle { + /// @nexus.proto "temporal.api.stream.v1.StreamRef" + record stream-ref { /// @nexus.doc "The Stream id, 1 to 4096 bytes." id: string, /// @nexus.doc "The run chain or Standalone Activity of a bound Stream. Empty if standalone." @@ -179,11 +179,11 @@ interface stream-service { /// "Create a Stream. If it exists, fail, or with use-existing get the open one." /// @nexus.proto "temporal.api.streamservice.v1.CreateStreamRequest" record create-stream-request { - // Its field name isn't handle, so the created stream takes its handle from the response, which + // Its field name isn't ref, so the created stream takes its ref from the response, which // carries the owner. - /// @nexus.doc "The handle of the Stream to create. Its id is the Stream id." + /// @nexus.doc "The ref of the Stream to create. Its id is the Stream id." /// @nexus.flatten-in-api fields="id backend" - stream-handle: stream-handle, + stream-ref: stream-ref, /// @nexus.doc "The age after which a Record is dropped. Empty is the Store default." retention: option, // A wrong run would silently withdraw real Records, so it is never a parameter. The helper @@ -236,7 +236,7 @@ interface stream-service { /// "Append one batch. The Store keeps a resent batch once, by Producer, epoch and sequence." /// @nexus.proto "temporal.api.streamservice.v1.AppendRecordsRequest" record append-records-request { - handle: stream-handle, + ref: stream-ref, records: list, /// @nexus.doc "Local Activity only. The run's failed or timed out Workflow Tasks." /// @nexus.api-omit targets="workflow" @@ -288,7 +288,7 @@ interface stream-service { /// @nexus.doc "Close a Stream. Readers drain what is left, then end. Later appends are refused." /// @nexus.proto "temporal.api.streamservice.v1.CloseStreamRequest" record close-stream-request { - handle: stream-handle, + ref: stream-ref, // Native only. The SDK Stores don't read it. /// @nexus.omit failed-tasks: placeholder, @@ -323,7 +323,7 @@ interface stream-service { /// @nexus.doc "Read Records from an offset." /// @nexus.proto "temporal.api.streamservice.v1.ReadRecordsRequest" record read-records-request { - handle: stream-handle, + ref: stream-ref, /// @nexus.doc "Start offset. Empty uses start. An offset past the newest one + 1 fails." from-offset: option, /// @nexus.doc "Where to start when from-offset is empty. Unspecified is the beginning." @@ -366,7 +366,7 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.DescribeStreamRequest" record describe-stream-request { - handle: stream-handle, + ref: stream-ref, /// @nexus.omit namespace: placeholder, } @@ -380,12 +380,12 @@ interface stream-service { } // A stream never changes, since offsets are values that its methods return. Only a create's - // response sets created, so a stream built from a handle has none. - /// @nexus.doc "One Stream. Build it from a stream-handle, or get it from a create." + // response sets created, so a stream built from a ref has none. + /// @nexus.doc "One Stream. Build it from a stream-ref, or get it from a create." /// @nexus.type-parameter "body-t" - /// @nexus.flatten-in-api param="handle" fields="id backend" keep=true + /// @nexus.flatten-in-api param="ref" fields="id backend" keep=true resource %stream { - constructor(handle: stream-handle, created: option); + constructor(ref: stream-ref, created: option); /// @nexus.doc "Publish values, and return the offset of the last one." /// @nexus.operation "append-records" diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 7caa72223..6886c4112 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -16,7 +16,7 @@ import "temporal/api/enums/v1/stream.proto"; // Experimental. Might change in the future. // Identifies one Stream. The id is opaque to callers. -message StreamHandle { +message StreamRef { string id = 1; // Unset for a standalone Stream. For a Stream bound to a Workflow, business_id is the Workflow // ID and run_id is the first execution run id of its run chain. For a Standalone Activity, @@ -24,7 +24,7 @@ message StreamHandle { // Workflow-owned Stream must carry its owner. temporal.api.common.v1.Execution owner = 2; // The name of the backend that holds the Stream, as the Worker and client config register - // it, so every process routes a handle it receives. Empty is the default backend. The Store + // it, so every process routes a ref it receives. Empty is the default backend. The Store // never reads it. string backend = 3; } diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index 33036356b..ae3fd41e1 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -23,21 +23,21 @@ import "temporal/api/stream/v1/message.proto"; // with the same request_id, gets created true. Each policy fails with CLOSED on a closed Stream, // EXPIRED on an expired one, and NOT_OWNER on one with another owner. // (-- api-linter: core::0133=disabled -// aip.dev/not-precedent: The request identifies a Stream by namespace and handle. --) +// aip.dev/not-precedent: The request identifies a Stream by namespace and ref. --) message CreateStreamRequest { string namespace = 1; - // The handle of the Stream to create. Its id is the Stream id, and is never empty. A bound - // Stream's handle has the owner's run chain as its owner. - temporal.api.stream.v1.StreamHandle stream_handle = 2; + // The ref of the Stream to create. Its id is the Stream id, and is never empty. A bound + // Stream's ref has the owner's run chain as its owner. + temporal.api.stream.v1.StreamRef stream_ref = 2; // The age after which a Record is dropped. Empty is the Store default. google.protobuf.Duration retention = 3; // Workflow only. The previous run on a retry run's first create, to withdraw its Records. string withdraw_run = 4; // The Workflow run that owns a bound Stream, with its run_id and a Workflow epoch. It needs a - // handle whose owner is the run's chain. Unset for a standalone Stream. + // ref whose owner is the run's chain. Unset for a standalone Stream. temporal.api.stream.v1.Producer owner = 5; // Only read by the server. When set, the server closes the Stream when the owner's run chain - // ends, also on a terminate or a timeout. It needs owner, and a handle with a Workflow owner. + // ends, also on a terminate or a timeout. It needs owner, and a ref with a Workflow owner. // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: "with" ties the close to the owner's run chain. --) bool close_with_run = 6; @@ -56,8 +56,8 @@ message CreateStreamRequest { // Experimental. Might change in the future. // The Stream that a create made or got. message CreateStreamResponse { - // The handle, with the owner of a bound Stream. - temporal.api.stream.v1.StreamHandle handle = 1; + // The ref, with the owner of a bound Stream. + temporal.api.stream.v1.StreamRef ref = 1; // False when USE_EXISTING got a Stream that existed. bool created = 2; } @@ -67,7 +67,7 @@ message CreateStreamResponse { // Producer, epoch and sequence, only once. message AppendRecordsRequest { string namespace = 1; - temporal.api.stream.v1.StreamHandle handle = 2; + temporal.api.stream.v1.StreamRef ref = 2; // Required outside a Workflow. In a Workflow the transport sets it. temporal.api.stream.v1.Producer producer = 3; // The batch number in this epoch. With the Producer and epoch it is the dedupe key. Zero takes @@ -105,7 +105,7 @@ message AppendRecordsResponse { // standalone Stream. message CloseStreamRequest { string namespace = 1; - temporal.api.stream.v1.StreamHandle handle = 2; + temporal.api.stream.v1.StreamRef ref = 2; // The Producer that closes the Stream. temporal.api.stream.v1.Producer producer = 3; // Only read by the server. The run's last completed Workflow Task, as on the append. @@ -128,7 +128,7 @@ message CloseStreamResponse { // Record, so readers long poll. message ReadRecordsRequest { string namespace = 1; - temporal.api.stream.v1.StreamHandle handle = 2; + temporal.api.stream.v1.StreamRef ref = 2; // Start at this offset. Zero uses start. It fails with OFFSET_TOO_HIGH if it is past the newest // offset + 1. // (-- api-linter: core::0140::prepositions=disabled @@ -187,7 +187,7 @@ message ReadRecordsResponse { // Gets the status, the newest offset and the Retention of a Stream. message DescribeStreamRequest { string namespace = 1; - temporal.api.stream.v1.StreamHandle handle = 2; + temporal.api.stream.v1.StreamRef ref = 2; } // Experimental. Might change in the future. From 1f726629db28fc8fae7bad22afe037f18963f054 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 5 Nov 2026 10:34:11 -0700 Subject: [PATCH 19/20] Moved the Producer and withdrawal fields of a write into StreamWriter. An append, a close and a writer carry the same fields, and a Superseded item gives them back, so one message keeps them in step. --- nexus/stream-service.wit | 87 +++++++------------ temporal/api/stream/v1/message.proto | 32 ++++--- .../streamservice/v1/request_response.proto | 30 ++----- 3 files changed, 61 insertions(+), 88 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index e95d8d2d3..62df1a747 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -126,26 +126,30 @@ interface stream-service { body: body-t, } - // A Local Activity, Activity or Standalone Activity item withdraws the Records of its Producer - // with a lower epoch. A Workflow item, with its task t and the previous completed task c, + // A Local Activity, Activity or Standalone Activity write withdraws the Records of its Producer + // with a lower epoch. A Workflow write, with its task t and the previous completed task c, // withdraws the Records of the run and of its Local Activities whose Workflow Task is in // failed-tasks or strictly between c and t. It also withdraws those at the started event id of c - // or of t with a lower epoch than c or t. No task in that range can have completed. A client item - // withdraws nothing. + // or of t with a lower epoch than c or t. No task in that range can have completed. A client + // write withdraws nothing. + /// @nexus.doc "The Producer of a write, and which earlier Records the write withdraws." + /// @nexus.proto "temporal.api.stream.v1.StreamWriter" + record stream-writer { + /// @nexus.doc "Required on a write. Its epoch's case picks the withdrawal rule." + producer: option, + /// @nexus.doc "Workflow and Local Activity only. The run's last completed Workflow Task." + previous-completed-task: option, + /// @nexus.doc "Workflow and Local Activity only. The run's failed or timed out Workflow Tasks." + failed-tasks: list, + /// @nexus.doc "Retry run only. The previous run, all of whose Records are withdrawn." + withdraw-run: option, + } + /// @nexus.doc "Which earlier Records a Superseded item withdraws." /// @nexus.proto "temporal.api.stream.v1.StreamSuperseded" record stream-superseded { - producer-id: string, - /// @nexus.doc "Workflow only. Started event ids of failed or timed out Workflow Tasks." - failed-tasks: list, - /// @nexus.doc "The withdrawing Producer's epoch, whose case picks the rule." - epoch: option, - /// @nexus.doc "Workflow only. The previous completed Workflow Task of the run." - previous-completed-task: option, - /// @nexus.doc "Workflow retry only. The previous run, all of whose Records are withdrawn." - withdraw-run: option, - /// @nexus.doc "The withdrawing Producer's run, so no Reader parses ids. Empty for a client." - run-id: option, + /// @nexus.doc "The writer that withdraws. Its epoch is empty when only withdraw-run applies." + writer: option, } /// @nexus.doc "One Record that a Producer appended." @@ -238,37 +242,19 @@ interface stream-service { record append-records-request { ref: stream-ref, records: list, - /// @nexus.doc "Local Activity only. The run's failed or timed out Workflow Tasks." - /// @nexus.api-omit targets="workflow" - /// @nexus.source targets="client" python="local_activity_failed_tasks()" - /// typescript="localActivityFailedTasks()" - /// dotnet="StreamActivityContext.LocalActivityFailedTasks()" - failed-tasks: list, + // A wrong run or task would silently withdraw real Records, so the writer is never a + // parameter. The helper of each context fills only the fields that apply where it runs. /// @nexus.doc "Required outside a Workflow. In a Workflow the transport sets it." - /// @nexus.source targets="workflow" python="workflow_producer()" - /// typescript="workflowProducer()" dotnet="StreamWorkflowContext.Producer()" - /// @nexus.source targets="client" python="client_producer()" typescript="clientProducer()" - /// dotnet="StreamActivityContext.Producer()" - producer: option, + /// @nexus.source targets="workflow" python="workflow_writer()" + /// typescript="workflowWriter()" dotnet="StreamWorkflowContext.Writer()" + /// @nexus.source targets="client" python="client_writer()" typescript="clientWriter()" + /// dotnet="StreamActivityContext.Writer()" + writer: option, // A call with no writer state, such as publish, sends no sequence. The Store then numbers the // batch, so nothing dedupes it. /// @nexus.doc /// "The batch number in this epoch. Empty takes the next one, and a retry writes it again." sequence: option, - /// @nexus.doc "Local Activity only. The run's last completed Workflow Task." - /// @nexus.api-omit targets="workflow" - /// @nexus.source targets="client" python="local_activity_previous_completed_task()" - /// typescript="localActivityPreviousCompletedTask()" - /// dotnet="StreamActivityContext.LocalActivityPreviousCompletedTask()" - previous-completed-task: option, - // A wrong run would silently withdraw real Records, so it is never a parameter. The helper - // gives the previous run in a retry run's Workflow, and nothing elsewhere. - /// @nexus.doc "Retry run only. The previous run, whose Records this withdraws." - /// @nexus.source targets="workflow" python="withdraw_run()" typescript="withdrawRun()" - /// dotnet="StreamWorkflowContext.WithdrawRun()" - /// @nexus.source targets="client" python="withdraw_run()" typescript="withdrawRun()" - /// dotnet="StreamWorkflowContext.WithdrawRun()" - withdraw-run: option, /// @nexus.omit namespace: placeholder, // Native only. The SDK Stores don't read them. @@ -289,22 +275,15 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.CloseStreamRequest" record close-stream-request { ref: stream-ref, - // Native only. The SDK Stores don't read it. - /// @nexus.omit - failed-tasks: placeholder, - /// @nexus.source targets="workflow" python="workflow_producer()" - /// typescript="workflowProducer()" dotnet="StreamWorkflowContext.Producer()" - /// @nexus.source targets="client" python="client_producer()" typescript="clientProducer()" - /// dotnet="StreamActivityContext.Producer()" - producer: option, - // Native only. The SDK Stores don't read it. - /// @nexus.omit - previous-completed-task: placeholder, + // The SDK Stores read only the Producer. The server also reads the withdrawal fields. + /// @nexus.source targets="workflow" python="workflow_writer()" + /// typescript="workflowWriter()" dotnet="StreamWorkflowContext.Writer()" + /// @nexus.source targets="client" python="client_writer()" typescript="clientWriter()" + /// dotnet="StreamActivityContext.Writer()" + writer: option, /// @nexus.omit namespace: placeholder, - // Native only. The SDK Stores don't read them. - /// @nexus.omit - withdraw-run: placeholder, + // Native only. The SDK Stores don't read it. /// @nexus.omit task-token: placeholder, } diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 6886c4112..63bca5f6c 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -96,6 +96,24 @@ message Producer { string run_id = 3; } +// Experimental. Might change in the future. +// The Producer of a write, and which earlier Records the write withdraws. An append, a close and +// a writer carry the same fields, so the Store orders and withdraws each write the same way. +message StreamWriter { + // Required on a write. Its epoch's case picks the withdrawal rule. + Producer producer = 1; + // Workflow and Local Activity only. The run's last completed Workflow Task, from a Local + // Activity's start. Its started_event_id is 0 before the first. The write withdraws the run's + // Local Activity Records whose Workflow Task never completed. + WorkflowTaskEpoch previous_completed_task = 2; + // Workflow and Local Activity only. The started event ids of the run's failed or timed out + // Workflow Tasks. The write withdraws the run's Local Activity Records of these tasks. + repeated int64 failed_tasks = 3; + // Retry run only. The previous run, on the retry run's first write. Every Record whose + // Producer carries it is withdrawn. + string withdraw_run = 4; +} + // Experimental. Might change in the future. // One Record of a batch to append. The Store adds the batch's Producer and an offset. message AppendRecord { @@ -107,19 +125,9 @@ message AppendRecord { // batch of a higher epoch, or for a retry run's first write, and a read builds a Superseded item // from them. A reader drops the earlier Records that the facts match. message StreamSuperseded { - // The id of the Producer that withdraws. - string producer_id = 1; - // The epoch of the Producer that withdraws, whose case picks the rule. Empty when only + // The writer of the batch that withdraws. Its Producer's epoch is empty when only // withdraw_run applies. - ProducerEpoch epoch = 2; - // Workflow only. The run's last completed Workflow Task. - WorkflowTaskEpoch previous_completed_task = 3; - // Workflow only. The started event ids of the run's failed or timed out Workflow Tasks. - repeated int64 failed_tasks = 4; - // Workflow retry only. The previous run. Every Record whose Producer carries it is withdrawn. - string withdraw_run = 5; - // The run of the Producer, so nobody parses the id. Empty for a client. - string run_id = 6; + StreamWriter writer = 1; } // Experimental. Might change in the future. diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index ae3fd41e1..cdd61456e 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -69,26 +69,17 @@ message AppendRecordsRequest { string namespace = 1; temporal.api.stream.v1.StreamRef ref = 2; // Required outside a Workflow. In a Workflow the transport sets it. - temporal.api.stream.v1.Producer producer = 3; + temporal.api.stream.v1.StreamWriter writer = 3; // The batch number in this epoch. With the Producer and epoch it is the dedupe key. Zero takes // the next number of the epoch. Such a batch has no dedupe, so a retry writes it again. int64 sequence = 4; repeated temporal.api.stream.v1.AppendRecord records = 5; - // The run's last completed Workflow Task, from a Local Activity's start. Its started_event_id - // is 0 before the first. The batch withdraws the run's Local Activity Records whose Workflow - // Task never completed. - temporal.api.stream.v1.WorkflowTaskEpoch previous_completed_task = 6; - // The started event ids of the run's failed or timed out Workflow Tasks, from a Local - // Activity's start. The batch withdraws the run's Local Activity Records of these tasks. - repeated int64 failed_tasks = 7; - // Local Activity only. The previous run on a retry run's first batch, to withdraw its Records. - string withdraw_run = 8; // Only read by the server, on a Workflow Producer's batch. The batch withdraws no Workflow - // Task and keeps no task facts, but withdraw_run still applies. - bool defer_withdrawal = 9; + // Task and keeps no task facts, but the writer's withdraw_run still applies. + bool defer_withdrawal = 6; // Only read by the server. The server checks the Producer against the Activity's current // attempt, so nobody else writes as that Activity. - bytes task_token = 10; + bytes task_token = 7; } // Experimental. Might change in the future. @@ -106,16 +97,11 @@ message AppendRecordsResponse { message CloseStreamRequest { string namespace = 1; temporal.api.stream.v1.StreamRef ref = 2; - // The Producer that closes the Stream. - temporal.api.stream.v1.Producer producer = 3; - // Only read by the server. The run's last completed Workflow Task, as on the append. - temporal.api.stream.v1.WorkflowTaskEpoch previous_completed_task = 4; - // Only read by the server. The run's failed or timed out Workflow Tasks, as on the append. - repeated int64 failed_tasks = 5; - // Only read by the server. The previous run on a retry run's close, to withdraw its Records. - string withdraw_run = 6; + // The writer that closes the Stream. An SDK Store reads only its Producer, and the server + // also reads its withdrawal fields. + temporal.api.stream.v1.StreamWriter writer = 3; // Only read by the server. It checks the token as on the append. - bytes task_token = 7; + bytes task_token = 4; } // Experimental. Might change in the future. From e6ed62263bba9ab8e3dae7d8d7c02576ad6e9349 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 5 Nov 2026 14:37:12 -0700 Subject: [PATCH 20/20] Dropped the resource's type parameter, which nexgen now infers. A resource that uses body-t is generic in it, so the stream resource needs no annotation. --- nexus/stream-service.wit | 1 - 1 file changed, 1 deletion(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 62df1a747..68bc13d1b 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -361,7 +361,6 @@ interface stream-service { // A stream never changes, since offsets are values that its methods return. Only a create's // response sets created, so a stream built from a ref has none. /// @nexus.doc "One Stream. Build it from a stream-ref, or get it from a create." - /// @nexus.type-parameter "body-t" /// @nexus.flatten-in-api param="ref" fields="id backend" keep=true resource %stream { constructor(ref: stream-ref, created: option);