From 44519d8a0f4e16a455b032b044e9326bf4141509 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Sun, 25 Oct 2026 14:25:33 -0700 Subject: [PATCH 01/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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); From 9e2fc93bebd9db91069d58e459d75cb004e0f227 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 6 Nov 2026 00:52:36 -0700 Subject: [PATCH 21/56] Took the final nexgen directives, and dropped the WIT's error types. The SDKs write the Stream error classes by hand, so the WIT no longer needs the error reason and its details. --- nexus/stream-service.wit | 56 ++++------------------------------------ 1 file changed, 5 insertions(+), 51 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 68bc13d1b..1114bb745 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -120,7 +120,7 @@ interface stream-service { /// @nexus.doc "One Record to append." /// @nexus.proto "temporal.api.stream.v1.AppendRecord" - /// @nexus.unwrap + /// @nexus.flatten-in-api record append-record { /// @nexus.doc "The value." body: body-t, @@ -186,7 +186,7 @@ interface stream-service { // Its field name isn't ref, so the created stream takes its ref from the response, which // carries the owner. /// @nexus.doc "The ref of the Stream to create. Its id is the Stream id." - /// @nexus.flatten-in-api fields="id backend" + /// @nexus.flatten-in-api stream-ref: stream-ref, /// @nexus.doc "The age after which a Record is dropped. Empty is the Store default." retention: option, @@ -361,14 +361,13 @@ 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.flatten-in-api param="ref" fields="id backend" keep=true + /// @nexus.flatten-in-api param="ref" resource %stream { constructor(ref: 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" + /// @nexus.varargs "records" publish: func(records: list) -> append-records-response; /// @nexus.doc "Close the Stream. Readers drain what is left, then end." @@ -400,6 +399,7 @@ interface stream-service { 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.output-transform python-type="int" python="_support.last_offset(result)" /// @nexus.operation name="AppendRecords" append-records: func(request: append-records-request) -> append-records-response; @@ -418,50 +418,4 @@ interface stream-service { /// @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. - /// 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 { - /// @nexus.fallback - 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-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-too-high, - /// @nexus.error - stream-error-reason-too-large, - stream-error-reason-invalid, - /// @nexus.error retryable=true - 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." - /// @nexus.proto "temporal.api.stream.v1.StreamErrorDetails" - record stream-error-details { - reason: option, - } } From 6bdefacc0ae69da40c4ea00e61dfedb592b9e77c Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 6 Nov 2026 01:51:35 -0700 Subject: [PATCH 22/56] Renamed the Stream resource's publish method to append. The API review settled on append and read, so the WIT names the write the same way. --- 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 1114bb745..e3e79eaeb 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -250,7 +250,7 @@ interface stream-service { /// @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 + // A call with no writer state, such as append, 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." @@ -270,7 +270,7 @@ interface stream-service { last-offset: option, } - // A close carries no value, so code that has a last value publishes it first. + // A close carries no value, so code that has a last value appends 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 { @@ -365,10 +365,10 @@ interface stream-service { resource %stream { constructor(ref: stream-ref, created: option); - /// @nexus.doc "Publish values, and return the offset of the last one." + /// @nexus.doc "Append values, and return the offset of the last one." /// @nexus.operation "append-records" /// @nexus.varargs "records" - publish: func(records: list) -> append-records-response; + append: func(records: list) -> append-records-response; /// @nexus.doc "Close the Stream. Readers drain what is left, then end." /// @nexus.operation "close-stream" From 75031b353342dea335751d32cc45ed1f7eea1616 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Sat, 7 Nov 2026 18:53:34 -0700 Subject: [PATCH 23/56] Added the Stream close read item and its status enum. StreamRecord's item oneof gains a StreamClose arm so a read returns the close last. StreamCloseStatus separates a Producer's CLOSED from the server's owner-end and idle closes. --- temporal/api/enums/v1/stream.proto | 17 +++++++++++++++++ temporal/api/stream/v1/message.proto | 15 ++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/temporal/api/enums/v1/stream.proto b/temporal/api/enums/v1/stream.proto index 1a258f97d..7d6af8514 100644 --- a/temporal/api/enums/v1/stream.proto +++ b/temporal/api/enums/v1/stream.proto @@ -20,6 +20,23 @@ enum StreamStatus { STREAM_STATUS_EXPIRED = 3; } +// Experimental. Might change in the future. +// How a Stream closed. CLOSED comes from a Producer that called close(). The OWNER_ values and +// IDLE come from the server (native Streams) when the owner ends or the Stream goes idle. A Brook +// SDK store only emits CLOSED. +// (-- api-linter: core::0216::synonyms=disabled +// aip.dev/not-precedent: Named consistently with WorkflowExecutionStatus. --) +enum StreamCloseStatus { + STREAM_CLOSE_STATUS_UNSPECIFIED = 0; + STREAM_CLOSE_STATUS_CLOSED = 1; + STREAM_CLOSE_STATUS_OWNER_COMPLETED = 2; + STREAM_CLOSE_STATUS_OWNER_FAILED = 3; + STREAM_CLOSE_STATUS_OWNER_CANCELED = 4; + STREAM_CLOSE_STATUS_OWNER_TERMINATED = 5; + STREAM_CLOSE_STATUS_OWNER_TIMED_OUT = 6; + STREAM_CLOSE_STATUS_IDLE = 7; +} + // Experimental. Might change in the future. // Where a read starts when it has no from_offset. enum StreamReadStart { diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 63bca5f6c..5bb93084d 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -13,6 +13,7 @@ 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. @@ -137,19 +138,31 @@ 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; + // Which of result or failure is set, and why the Stream closed. + 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 or a Superseded item. A close writes no item. + // A read item is a Record, a Superseded item or the close. 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; } } From 20b14a61a316b398c70f61dc0bdeebf9359d9b78 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Sun, 8 Nov 2026 02:41:50 -0700 Subject: [PATCH 24/56] Removed the owner field and the withdrawal machinery from Streams. Also put the Producer kind into a oneof, so these match the SDK's vendored protos. --- temporal/api/enums/v1/stream.proto | 17 +-- temporal/api/stream/v1/message.proto | 112 ++++++++---------- .../streamservice/v1/request_response.proto | 35 ++---- 3 files changed, 61 insertions(+), 103 deletions(-) diff --git a/temporal/api/enums/v1/stream.proto b/temporal/api/enums/v1/stream.proto index 7d6af8514..98104ca9e 100644 --- a/temporal/api/enums/v1/stream.proto +++ b/temporal/api/enums/v1/stream.proto @@ -21,20 +21,13 @@ enum StreamStatus { } // Experimental. Might change in the future. -// How a Stream closed. CLOSED comes from a Producer that called close(). The OWNER_ values and -// IDLE come from the server (native Streams) when the owner ends or the Stream goes idle. A Brook -// SDK store only emits CLOSED. +// How a Stream closed. CLOSED comes from a Producer that called close(). Native Streams add their +// own close statuses later. // (-- api-linter: core::0216::synonyms=disabled // aip.dev/not-precedent: Named consistently with WorkflowExecutionStatus. --) enum StreamCloseStatus { STREAM_CLOSE_STATUS_UNSPECIFIED = 0; STREAM_CLOSE_STATUS_CLOSED = 1; - STREAM_CLOSE_STATUS_OWNER_COMPLETED = 2; - STREAM_CLOSE_STATUS_OWNER_FAILED = 3; - STREAM_CLOSE_STATUS_OWNER_CANCELED = 4; - STREAM_CLOSE_STATUS_OWNER_TERMINATED = 5; - STREAM_CLOSE_STATUS_OWNER_TIMED_OUT = 6; - STREAM_CLOSE_STATUS_IDLE = 7; } // Experimental. Might change in the future. @@ -66,16 +59,14 @@ enum StreamErrorReason { 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. + // The Producer's epoch is lower than the newest one. 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_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; - // A create gives another owner than the Stream has, or the owner doesn't allow this close. - // FAILED_PRECONDITION. + // The caller may not create or close this Stream. FAILED_PRECONDITION. STREAM_ERROR_REASON_NOT_OWNER = 7; // A newer format version wrote the Stream. FAILED_PRECONDITION. STREAM_ERROR_REASON_FORMAT_TOO_NEW = 8; diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 5bb93084d..fcd2d8560 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -13,44 +13,17 @@ 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 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 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 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. A higher epoch supersedes the Records of a lower epoch of the -// 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 { - 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. It orders by started_event_id, then attempt, then // started_time. @@ -87,32 +60,59 @@ message ClientEpoch { } // Experimental. Might change in the future. -// The code that wrote a batch. Its id stays the same across attempts, and its epoch orders them. +// A Workflow run Producer. It writes in its Workflow Task. A higher epoch supersedes the Records of +// the run's Workflow Tasks that never completed, with those of their Local Activities. +message WorkflowProducer { + // The first execution run id of the run chain. + string run_id = 1; + WorkflowTaskEpoch epoch = 2; +} + +// Experimental. Might change in the future. +// A Local Activity Producer. A higher epoch supersedes the Producer's lower epochs. +message LocalActivityProducer { + string run_id = 1; + LocalActivityEpoch epoch = 2; +} + +// Experimental. Might change in the future. +// An Activity Producer, for an Activity of a Workflow or a Standalone Activity. A higher epoch +// supersedes the Producer's lower epochs. +message ActivityProducer { + string run_id = 1; + ActivityEpoch epoch = 2; +} + +// Experimental. Might change in the future. +// A client Producer. 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. A client has no run. +message ClientProducer { + ClientEpoch epoch = 1; +} + +// Experimental. Might change in the future. +// The code that wrote a batch. Its id stays the same across attempts, and the kind's case orders +// its epochs and picks which earlier Records a higher epoch supersedes. A run-based kind carries its +// run, so a client can't. 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; + // Required. The case is the kind of the Producer. + oneof kind { + WorkflowProducer workflow = 2; + LocalActivityProducer local_activity = 3; + ActivityProducer activity = 4; + ActivityProducer standalone_activity = 5; + ClientProducer client = 6; + } } // 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. +// The Producer of a write. An append and a close carry the same writer, so the Store orders each +// write the same way. message StreamWriter { - // Required on a write. Its epoch's case picks the withdrawal rule. + // Required on a write. 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. @@ -121,16 +121,6 @@ message AppendRecord { 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 match. -message StreamSuperseded { - // The writer of the batch that withdraws. Its Producer's epoch is empty when only - // withdraw_run applies. - StreamWriter writer = 1; -} - // Experimental. Might change in the future. // One Record that a Producer appended. message StreamData { @@ -143,9 +133,9 @@ message StreamData { 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; - // Which of result or failure is set, and why the Stream closed. + reserved 2, 3; + reserved "result", "failure"; + // Why the Stream closed. temporal.api.enums.v1.StreamCloseStatus status = 4; } @@ -155,13 +145,9 @@ message StreamClose { // 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 the close. 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 cdd61456e..e9296dbab 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -20,31 +20,19 @@ import "temporal/api/stream/v1/message.proto"; // Experimental. Might change in the future. // Creates the Stream. When the Stream exists, FAIL fails with ALREADY_EXISTS, and USE_EXISTING // gets it with created false 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. +// with the same request_id, gets created true. Each policy fails with CLOSED on a closed Stream +// and EXPIRED on an expired one. // (-- api-linter: core::0133=disabled // aip.dev/not-precedent: The request identifies a Stream by namespace and ref. --) message CreateStreamRequest { string namespace = 1; - // 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. + // The ref of the Stream to create. Its id is the Stream id, and is never empty. 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 - // 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 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; // 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. + // Activity that it isn't. bytes task_token = 7; // What the create does when the Stream exists. Unspecified is FAIL. temporal.api.enums.v1.StreamIdConflictPolicy id_conflict_policy = 8; @@ -56,7 +44,7 @@ message CreateStreamRequest { // Experimental. Might change in the future. // The Stream that a create made or got. message CreateStreamResponse { - // The ref, with the owner of a bound Stream. + // The ref of the Stream. temporal.api.stream.v1.StreamRef ref = 1; // False when USE_EXISTING got a Stream that existed. bool created = 2; @@ -74,9 +62,6 @@ 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; - // Only read by the server, on a Workflow Producer's batch. The batch withdraws no Workflow - // 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 = 7; @@ -92,13 +77,10 @@ message AppendRecordsResponse { // Experimental. Might change in the future. // Closes a Stream. Readers drain what is left, then end, and later appends fail with CLOSED. A // close writes no Record and carries no value, so code that has a last value publishes it first. -// 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.StreamRef ref = 2; - // The writer that closes the Stream. An SDK Store reads only its Producer, and the server - // also reads its withdrawal fields. + // The writer that closes the Stream. A Store reads only its Producer. temporal.api.stream.v1.StreamWriter writer = 3; // Only read by the server. It checks the token as on the append. bytes task_token = 4; @@ -122,8 +104,7 @@ message ReadRecordsRequest { int64 from_offset = 3; // Where to start when from_offset is zero. Unspecified is the beginning. temporal.api.enums.v1.StreamReadStart start = 4; - // 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. + // The most Records in one page. Zero is 1000. 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. @@ -180,7 +161,7 @@ message DescribeStreamRequest { // The state of a Stream. message DescribeStreamResponse { temporal.api.enums.v1.StreamStatus status = 1; - // The newest offset, of a Record or a Superseded item. Zero when the Stream has none. + // The newest offset, of a Record. Zero when the Stream has none. int64 last_offset = 2; google.protobuf.Duration retention = 3; } From d6671e3ffb746806caad30977ff1a71b516edcdb Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Sun, 8 Nov 2026 23:14:45 -0700 Subject: [PATCH 25/56] Synced the Stream service WIT with the owner and withdrawal removal. --- nexus/stream-service.wit | 183 ++++++++++++++++++++------------------- 1 file changed, 93 insertions(+), 90 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index e3e79eaeb..536ff875a 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -32,31 +32,10 @@ interface stream-service { 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." - 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 { - /// @nexus.fallback - 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.doc "A Workflow Task attempt of the run." /// @nexus.proto "temporal.api.stream.v1.WorkflowTaskEpoch" record workflow-task-epoch { @@ -89,33 +68,53 @@ interface stream-service { attempt: option, } + /// @nexus.doc "A Workflow run Producer. A higher epoch supersedes the run's orphaned Records." + /// @nexus.proto "temporal.api.stream.v1.WorkflowProducer" + record workflow-producer { + /// @nexus.doc "The first execution run id of the run chain." + run-id: option, + epoch: option, + } + + /// @nexus.doc "A Local Activity Producer. A higher epoch supersedes the Producer's lower epochs." + /// @nexus.proto "temporal.api.stream.v1.LocalActivityProducer" + record local-activity-producer { + run-id: option, + epoch: option, + } + + /// @nexus.doc "An Activity Producer. A higher epoch supersedes the Producer's lower epochs." + /// @nexus.proto "temporal.api.stream.v1.ActivityProducer" + record activity-producer { + run-id: option, + epoch: option, + } + + /// @nexus.doc "A client Producer. A higher epoch only fences the lower ones." + /// @nexus.proto "temporal.api.stream.v1.ClientProducer" + record client-producer { + epoch: 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 kind of a Producer, with its run and epoch." + /// @nexus.proto "temporal.api.stream.v1.Producer.kind" + variant producer-kind { + workflow(workflow-producer), + local-activity(local-activity-producer), + activity(activity-producer), + standalone-activity(activity-producer), + client(client-producer), } - /// @nexus.doc "The Producer of a batch." + /// @nexus.doc "The code that wrote a batch. Its id is stable across attempts." /// @nexus.proto "temporal.api.stream.v1.Producer" record producer { /// @nexus.doc "Stable across attempts, such as wf: for a Workflow run." id: string, - /// @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, + // A newer server can send a kind that this SDK doesn't know. Then the kind is empty. + /// @nexus.doc "Required. The case is the kind of the Producer." + kind: option, } /// @nexus.doc "One Record to append." @@ -126,30 +125,11 @@ interface stream-service { body: body-t, } - // 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 - // write withdraws nothing. - /// @nexus.doc "The Producer of a write, and which earlier Records the write withdraws." + /// @nexus.doc "The Producer of a write. An append and a close carry the same writer." /// @nexus.proto "temporal.api.stream.v1.StreamWriter" record stream-writer { - /// @nexus.doc "Required on a write. Its epoch's case picks the withdrawal rule." + /// @nexus.doc "Required on a write." 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 { - /// @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." @@ -160,12 +140,28 @@ interface stream-service { producer: option, } - // The read builds a Superseded item from a flagged batch. - /// @nexus.doc "A read item: a Record or a Superseded item. A close writes no item." + /// How a Stream closed. A Producer's close gives CLOSED. Native Streams add their own later. + /// @nexus.proto "temporal.api.enums.v1.StreamCloseStatus" + enum stream-close-status { + /// @nexus.fallback + stream-close-status-unspecified, + stream-close-status-closed, + } + + /// @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, + /// @nexus.doc "Why the Stream closed." + status: option, + } + + /// @nexus.doc "A read item: a Record or the close." /// @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 @@ -183,24 +179,12 @@ 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 ref, so the created stream takes its ref from the response, which - // carries the owner. + // Its field name isn't ref, so the created Stream takes its ref from the response. /// @nexus.doc "The ref of the Stream to create. Its id is the Stream id." /// @nexus.flatten-in-api 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 - // 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, - // A bound Stream's owner. An SDK creates only standalone Streams for now. - /// @nexus.omit - owner: placeholder, /// @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 @@ -209,9 +193,6 @@ interface stream-service { /// @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, /// @nexus.omit namespace: placeholder, // Native only. The SDK Stores don't read it. @@ -242,8 +223,8 @@ interface stream-service { record append-records-request { ref: stream-ref, records: 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. + // The writer carries the Producer, which only the context helper sets right, so it is never a + // parameter. The helper of each context fills the Producer that applies where it runs. /// @nexus.doc "Required outside a Workflow. In a Workflow the transport sets it." /// @nexus.source targets="workflow" python="workflow_writer()" /// typescript="workflowWriter()" dotnet="StreamWorkflowContext.Writer()" @@ -257,9 +238,7 @@ interface stream-service { sequence: option, /// @nexus.omit namespace: placeholder, - // Native only. The SDK Stores don't read them. - /// @nexus.omit - defer-withdrawal: placeholder, + // Native only. The SDK Stores don't read it. /// @nexus.omit task-token: placeholder, } @@ -275,7 +254,7 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.CloseStreamRequest" record close-stream-request { ref: stream-ref, - // The SDK Stores read only the Producer. The server also reads the withdrawal fields. + // The SDK Stores read only the Producer. /// @nexus.source targets="workflow" python="workflow_writer()" /// typescript="workflowWriter()" dotnet="StreamWorkflowContext.Writer()" /// @nexus.source targets="client" python="client_writer()" typescript="clientWriter()" @@ -307,8 +286,7 @@ interface stream-service { 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." + /// @nexus.doc "About this many items per page, 1 to 1000." max-records: option, /// @nexus.doc "How long to wait for a new Record before an empty answer. At most 60 s." wait: option, @@ -353,7 +331,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 or a Superseded item. Empty for none." + /// @nexus.doc "The newest offset, of a Record. Empty for none." last-offset: option, retention: option, } @@ -418,4 +396,29 @@ interface stream-service { /// @nexus.operation name="DescribeStream" describe-stream: func(request: describe-stream-request) -> describe-stream-response; + /// @nexus.proto "temporal.api.enums.v1.StreamErrorReason" + enum stream-error-reason { + stream-error-reason-unspecified, + stream-error-reason-not-found, + stream-error-reason-expired, + stream-error-reason-closed, + stream-error-reason-fenced, + stream-error-reason-sequence-too-low, + stream-error-reason-seq-divergent, + stream-error-reason-not-owner, + stream-error-reason-format-too-new, + stream-error-reason-offset-too-high, + stream-error-reason-too-large, + stream-error-reason-invalid, + stream-error-reason-storage-unavailable, + stream-error-reason-unknown-writer, + stream-error-reason-unsupported, + stream-error-reason-already-exists, + stream-error-reason-storage-corrupt, + } + + /// @nexus.proto "temporal.api.stream.v1.StreamErrorDetails" + record stream-error-details { + reason: option, + } } From d54483e75c91c8c83e7d8bb33bf6ebd96872ff9a Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Tue, 10 Nov 2026 10:46:35 -0700 Subject: [PATCH 26/56] Made the Stream offset an opaque bytes value. An offset is ordered opaque bytes on the wire, so a client can't depend on an int and the server can change the encoding later. The WIT names the type `stream-offset`, and an append returns bytes. --- nexus/stream-service.wit | 19 +++++++++++-------- temporal/api/stream/v1/message.proto | 2 +- .../streamservice/v1/request_response.proto | 12 ++++++------ 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 536ff875a..6a9926194 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -21,6 +21,9 @@ interface stream-service { /// @nexus.type-parameter type body-t = placeholder; + /// An opaque, ordered position of a Record in one Stream. It is proto `bytes`. + type stream-offset = list; + /// @nexus.proto "google.protobuf.Timestamp" record timestamp { seconds: option, @@ -172,7 +175,7 @@ interface stream-service { // 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, + offset: option, } /// @nexus.doc @@ -245,8 +248,8 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.AppendRecordsResponse" record append-records-response { - first-offset: option, - last-offset: option, + first-offset: option, + last-offset: option, } // A close carries no value, so code that has a last value appends it first. @@ -283,7 +286,7 @@ interface stream-service { record read-records-request { ref: stream-ref, /// @nexus.doc "Start offset. Empty uses start. An offset past the newest one + 1 fails." - from-offset: option, + 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." @@ -313,7 +316,7 @@ interface stream-service { 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, + next-offset: option, /// @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. @@ -332,7 +335,7 @@ interface stream-service { record describe-stream-response { status: option, /// @nexus.doc "The newest offset, of a Record. Empty for none." - last-offset: option, + last-offset: option, retention: option, } @@ -356,7 +359,7 @@ interface stream-service { /// @nexus.doc "Read one page of Records. The poll iterator reads them all." /// @nexus.operation "read-records" read: func( - from-offset: option, + from-offset: option, start: option, max-records: option, wait: option, @@ -377,7 +380,7 @@ interface stream-service { 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.output-transform python-type="int" python="_support.last_offset(result)" + /// @nexus.output-transform python-type="bytes" python="_support.last_offset(result)" /// @nexus.operation name="AppendRecords" append-records: func(request: append-records-request) -> append-records-response; diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index fcd2d8560..607c3a529 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -144,7 +144,7 @@ message StreamClose { // 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; + bytes offset = 1; // A read item is a Record or the close. oneof item { StreamData data = 2; diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index e9296dbab..3984e1676 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -70,8 +70,8 @@ message AppendRecordsRequest { // 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; + bytes first_offset = 1; + bytes last_offset = 2; } // Experimental. Might change in the future. @@ -101,7 +101,7 @@ message ReadRecordsRequest { // 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; + bytes from_offset = 3; // Where to start when from_offset is zero. Unspecified is the beginning. temporal.api.enums.v1.StreamReadStart start = 4; // The most Records in one page. Zero is 1000. @@ -116,7 +116,7 @@ message ReadRecordsRequest { // 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; + bytes 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 @@ -141,7 +141,7 @@ message ReadRecordsRequest { 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; + bytes next_offset = 2; // 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; @@ -162,6 +162,6 @@ message DescribeStreamRequest { message DescribeStreamResponse { temporal.api.enums.v1.StreamStatus status = 1; // The newest offset, of a Record. Zero when the Stream has none. - int64 last_offset = 2; + bytes last_offset = 2; google.protobuf.Duration retention = 3; } From cda8be972109f7945ef4efee610a6734a48f79df Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Tue, 10 Nov 2026 14:38:05 -0700 Subject: [PATCH 27/56] Pinned a nexgen that accepts the `stream-offset` alias. The WIT types the `bytes` offset fields with a named `list` alias, and the pinned nexgen refused that in `add-message`. The new pin is the old one plus that fix. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 091084483..f17d6c663 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 := f9578ddbd8dbc8a1a79320210d58d1b722cd542b +NEX_GEN_REV := ad5f375ef920b2817df4b994a3603070cf62c2f6 NEX_GEN_ROOT := $(CURDIR)/$(STAMPDIR)/nexgen-$(NEX_GEN_REV) NEX_GEN ?= $(NEX_GEN_ROOT)/bin/nexgen From c66e44043f5fe42a0efce18d1a10e437a20f65c7 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Tue, 10 Nov 2026 14:38:05 -0700 Subject: [PATCH 28/56] Made `floor_from` bytes and fixed the offset and Retention comments. `floor_from` is an offset, so it is opaque `bytes` like the others. The comments still described integer offsets, an inclusive `from_offset` and a Retention that drops Records by age. --- nexus/stream-service.wit | 6 +++--- temporal/api/stream/v1/message.proto | 2 +- .../streamservice/v1/request_response.proto | 19 ++++++++++--------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 6a9926194..500397607 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -186,7 +186,7 @@ interface stream-service { /// @nexus.doc "The ref of the Stream to create. Its id is the Stream id." /// @nexus.flatten-in-api stream-ref: stream-ref, - /// @nexus.doc "The age after which a Record is dropped. Empty is the Store default." + /// @nexus.doc "How long the Stream stays readable after its close. Empty is the Store default." retention: option, /// @nexus.doc "What the create does when the Stream exists. Unspecified is fail." id-conflict-policy: option, @@ -285,7 +285,7 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.ReadRecordsRequest" record read-records-request { ref: stream-ref, - /// @nexus.doc "Start offset. Empty uses start. An offset past the newest one + 1 fails." + /// @nexus.doc "Read after this offset. Empty uses start. An offset past the newest one fails." from-offset: option, /// @nexus.doc "Where to start when from-offset is empty. Unspecified is the beginning." start: option, @@ -315,7 +315,7 @@ interface stream-service { /// @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." + /// @nexus.doc "The from-offset of the next read, the offset of the newest Record read." next-offset: option, /// @nexus.doc "True when the Stream is closed and this read gave its last item." done: option, diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 607c3a529..849264037 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -141,7 +141,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 gives its Stream, so the read checks the +// don't count Records. An offset is never empty. A read gives its Stream, so the read checks the // offset against that Stream. message StreamRecord { bytes offset = 1; diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index 3984e1676..c3d764ef3 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -28,7 +28,7 @@ message CreateStreamRequest { string namespace = 1; // The ref of the Stream to create. Its id is the Stream id, and is never empty. temporal.api.stream.v1.StreamRef stream_ref = 2; - // The age after which a Record is dropped. Empty is the Store default. + // How long the Stream stays readable after its close. Empty is the Store default. google.protobuf.Duration retention = 3; // 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 @@ -97,12 +97,12 @@ message CloseStreamResponse { message ReadRecordsRequest { string namespace = 1; 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. + // Read after this offset. Empty uses start. It fails with OFFSET_TOO_HIGH if it is past the + // newest offset. // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: "from" and "to" mark the ends of a range of offsets. --) bytes from_offset = 3; - // Where to start when from_offset is zero. Unspecified is the beginning. + // Where to start when from_offset is empty. Unspecified is the beginning. temporal.api.enums.v1.StreamReadStart start = 4; // The most Records in one page. Zero is 1000. int32 max_records = 5; @@ -113,12 +113,12 @@ message ReadRecordsRequest { // 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, so a replay reads the - // same range. Zero means no end. Only the server serves it, and a Store refuses it. + // same range. Empty 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. --) bytes 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 + // Record 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 = 9; @@ -133,14 +133,15 @@ message ReadRecordsRequest { // 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 = 12; + bytes 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. + // The from_offset of the next read, which is the offset of the newest Record read. An empty + // page gives the request's from_offset. bytes next_offset = 2; // 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. @@ -161,7 +162,7 @@ message DescribeStreamRequest { // The state of a Stream. message DescribeStreamResponse { temporal.api.enums.v1.StreamStatus status = 1; - // The newest offset, of a Record. Zero when the Stream has none. + // The newest offset, of a Record. Empty when the Stream has none. bytes last_offset = 2; google.protobuf.Duration retention = 3; } From a267cddbbe322870b7ad8815e389f75266f72340 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Tue, 10 Nov 2026 14:48:30 -0700 Subject: [PATCH 29/56] Applied the review of the Stream messages. A create takes no Retention and no task token, and the Activity case of a Workflow is `workflow_activity`. The comments now describe the fence, the run of a Producer and the server-only read fields. --- nexus/stream-service.wit | 19 +++---- temporal/api/stream/v1/message.proto | 45 +++++++++------ .../streamservice/v1/request_response.proto | 55 +++++++++++-------- 3 files changed, 68 insertions(+), 51 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 500397607..483f49fbd 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -35,7 +35,7 @@ interface stream-service { record stream-ref { /// @nexus.doc "The Stream id, 1 to 4096 bytes." id: string, - /// @nexus.doc "Registered backend name, empty for the default. The Store never reads it." + /// @nexus.doc "Registered backend name, empty for the default. Only an SDK reads it." backend: option, } @@ -71,22 +71,22 @@ interface stream-service { attempt: option, } - /// @nexus.doc "A Workflow run Producer. A higher epoch supersedes the run's orphaned Records." + /// @nexus.doc "A Workflow run Producer. A higher epoch fences the lower ones." /// @nexus.proto "temporal.api.stream.v1.WorkflowProducer" record workflow-producer { - /// @nexus.doc "The first execution run id of the run chain." + /// @nexus.doc "The run id of the Workflow run, not of its chain." run-id: option, epoch: option, } - /// @nexus.doc "A Local Activity Producer. A higher epoch supersedes the Producer's lower epochs." + /// @nexus.doc "A Local Activity Producer. A higher epoch fences the lower ones." /// @nexus.proto "temporal.api.stream.v1.LocalActivityProducer" record local-activity-producer { run-id: option, epoch: option, } - /// @nexus.doc "An Activity Producer. A higher epoch supersedes the Producer's lower epochs." + /// @nexus.doc "An Activity Producer. A higher epoch fences the lower ones." /// @nexus.proto "temporal.api.stream.v1.ActivityProducer" record activity-producer { run-id: option, @@ -105,7 +105,7 @@ interface stream-service { variant producer-kind { workflow(workflow-producer), local-activity(local-activity-producer), - activity(activity-producer), + workflow-activity(activity-producer), standalone-activity(activity-producer), client(client-producer), } @@ -151,7 +151,7 @@ interface stream-service { stream-close-status-closed, } - /// @nexus.doc "The close of a Stream. A read gives it last." + /// @nexus.doc "The close of a Stream, as a read item. A close stores no Record." /// @nexus.proto "temporal.api.stream.v1.StreamClose" record stream-close { /// @nexus.doc "The Producer that closed the Stream." @@ -186,8 +186,6 @@ interface stream-service { /// @nexus.doc "The ref of the Stream to create. Its id is the Stream id." /// @nexus.flatten-in-api stream-ref: stream-ref, - /// @nexus.doc "How long the Stream stays readable after its close. Empty is the Store default." - retention: 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 @@ -198,9 +196,6 @@ interface stream-service { request-id: option, /// @nexus.omit namespace: placeholder, - // Native only. The SDK Stores don't read it. - /// @nexus.omit - task-token: placeholder, } /// What a create does when its Stream exists. Unspecified is fail. diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 849264037..03aa1bdbb 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -19,14 +19,15 @@ import "temporal/api/enums/v1/stream.proto"; message StreamRef { string id = 1; // 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. + // it, so every process routes a ref it receives. Empty is the default backend. Only an SDK + // reads it. A request carries the whole ref, and the server and a Store ignore this field. string backend = 3; } // Experimental. Might change in the future. // One Workflow Task attempt of a run. It orders by started_event_id, then attempt, then -// started_time. +// started_time. An epoch orders the writes of one Producer only, and a Producer is one run. A +// reset run and the next run of a chain are other Producers, so their epochs start again. message WorkflowTaskEpoch { int64 started_event_id = 1; int32 attempt = 2; @@ -35,8 +36,9 @@ message WorkflowTaskEpoch { } // 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. +// The attempt of a Local Activity. It orders by task, then attempt, so the same call in a later +// Workflow Task is a higher epoch. The Producer carries the run. Records of two runs have no +// order by epoch, only their order in the Stream. message LocalActivityEpoch { // The Workflow Task attempt that dispatched the Local Activity. WorkflowTaskEpoch task = 1; @@ -54,31 +56,35 @@ message ActivityEpoch { } // Experimental. Might change in the future. -// The attempt of a client Producer, which the client picks. +// The attempt of a client Producer, which the client picks. A client that keeps one Producer +// name across restarts stores this number and raises it at each start, so the new process +// fences the old one. A client with no name of its own is a new Producer in each process. message ClientEpoch { int32 attempt = 1; } // Experimental. Might change in the future. -// A Workflow run Producer. It writes in its Workflow Task. A higher epoch supersedes the Records of -// the run's Workflow Tasks that never completed, with those of their Local Activities. +// A Workflow run Producer. It writes in its Workflow Task. A higher epoch fences the lower ones. message WorkflowProducer { - // The first execution run id of the run chain. + // The run id of the Workflow run, not of its chain. Each run is its own Producer. string run_id = 1; WorkflowTaskEpoch epoch = 2; } // Experimental. Might change in the future. -// A Local Activity Producer. A higher epoch supersedes the Producer's lower epochs. +// A Local Activity Producer, one for each call of a run. A higher epoch fences the lower ones. message LocalActivityProducer { + // The run id of the Workflow run. string run_id = 1; LocalActivityEpoch epoch = 2; } // Experimental. Might change in the future. // An Activity Producer, for an Activity of a Workflow or a Standalone Activity. A higher epoch -// supersedes the Producer's lower epochs. +// fences the lower ones. Their Records are those of a failed attempt, which a reader may hide. message ActivityProducer { + // The run id of the Workflow run that scheduled the Activity. Empty for a Standalone + // Activity, whose own run id is in the Producer id. string run_id = 1; ActivityEpoch epoch = 2; } @@ -92,16 +98,21 @@ message ClientProducer { // Experimental. Might change in the future. // The code that wrote a batch. Its id stays the same across attempts, and the kind's case orders -// its epochs and picks which earlier Records a higher epoch supersedes. A run-based kind carries its -// run, so a client can't. +// its epochs. After a write of a higher epoch, a write of a lower epoch of the same Producer +// fails with FENCED. The epochs of two Producers never compare. A run-based kind carries its run, +// so a client can't. message Producer { - // Stable across attempts, such as `wf:` for the Workflow Producer of a run. + // Stable across attempts, such as `wf:` for the Workflow Producer of a run. It is + // unique in one Stream, and a Stream is in one namespace, so it carries no namespace. string id = 1; // Required. The case is the kind of the Producer. oneof kind { WorkflowProducer workflow = 2; LocalActivityProducer local_activity = 3; - ActivityProducer activity = 4; + // An Activity that a Workflow scheduled. + ActivityProducer workflow_activity = 4; + // An Activity with no Workflow. Both Activity cases order their epochs the same way. The + // case tells which identity the Producer has, since only the first has a Workflow run. ActivityProducer standalone_activity = 5; ClientProducer client = 6; } @@ -129,7 +140,9 @@ message StreamData { } // Experimental. Might change in the future. -// The close of a Stream. A Stream has at most one, and a read returns it last. +// The close of a Stream, as a read item. A close is its own call and stores no Record. A read +// builds this item from the close state of the Stream and returns it last, so a reader learns in +// order that the Stream ended, who closed it and why. message StreamClose { // The Producer that closed the Stream. Producer producer = 1; diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index c3d764ef3..911601a32 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -19,21 +19,18 @@ import "temporal/api/stream/v1/message.proto"; // Experimental. Might change in the future. // Creates the Stream. When the Stream exists, FAIL fails with ALREADY_EXISTS, and USE_EXISTING -// gets it with created false 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 -// and EXPIRED on an expired one. +// gets it with created false. A retry of the create that made the Stream, with the same +// request_id, gets created true. Each policy fails with CLOSED on a closed Stream and EXPIRED on +// an expired one. A create takes no Retention. The backend config holds it, as a namespace holds +// the Retention of its Workflows. // (-- api-linter: core::0133=disabled // aip.dev/not-precedent: The request identifies a Stream by namespace and ref. --) message CreateStreamRequest { string namespace = 1; // The ref of the Stream to create. Its id is the Stream id, and is never empty. temporal.api.stream.v1.StreamRef stream_ref = 2; - // How long the Stream stays readable after its close. Empty is the Store default. - google.protobuf.Duration retention = 3; - // 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. - bytes task_token = 7; + reserved 3, 7; + reserved "retention", "task_token"; // What the create does when the Stream exists. Unspecified is FAIL. 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 @@ -44,7 +41,8 @@ message CreateStreamRequest { // Experimental. Might change in the future. // The Stream that a create made or got. message CreateStreamResponse { - // The ref of the Stream. + // The ref of the Stream, with the name of the backend that holds it, also when the request + // left the name empty. The caller passes this ref on as data. temporal.api.stream.v1.StreamRef ref = 1; // False when USE_EXISTING got a Stream that existed. bool created = 2; @@ -62,8 +60,9 @@ 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; - // Only read by the server. The server checks the Producer against the Activity's current - // attempt, so nobody else writes as that Activity. + // Only read by the server, and opaque to every SDK and Store, which never parse it. It lets + // the server check that the caller is the task that the Producer names. It is an identity + // check, not a guard against a stale attempt, which the epoch fences. bytes task_token = 7; } @@ -117,13 +116,17 @@ message ReadRecordsRequest { // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: "from" and "to" mark the ends of a range of offsets. --) bytes to_offset = 8; - // Park as a normal read, then answer with no Records and next_offset = from_offset, when a - // Record 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. + // Asks only for a wake-up, for a reader that must not get Records in this answer, such as a + // Workflow whose Records stay out of History. The read waits as a normal read does. It then + // answers with no Records and next_offset = from_offset, when a Record after from_offset + // exists, when the Stream closes (done), or when wait ends. The reader then gets the Records + // with a normal read. The wait may be days, since a parked read adds nothing to History. + // Only the server serves it, and a Store refuses it. 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. + // Read the newest `last` Records, oldest first, such as for the first view of a chat. The + // read goes back from the newest Record, so it needs no count and no index of the Stream. It + // takes no from_offset and no start. Not with wake_only, and not in Workflow code, where + // replay needs a fixed range. 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. @@ -141,13 +144,19 @@ message ReadRecordsRequest { message ReadRecordsResponse { repeated temporal.api.stream.v1.StreamRecord records = 1; // The from_offset of the next read, which is the offset of the newest Record read. An empty - // page gives the request's from_offset. + // page gives the request's from_offset, and that is empty when the request had none. So it + // is always known, and an empty value means that the next read starts as this one did. bytes next_offset = 2; - // 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. + // True when the Stream is closed and this read gave its last item. The close item says the + // same on a page that has it. An answer with no item needs this flag, which is a wake_only + // read and a read after the close 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. + // Tells a Workflow reader to continue as new. The server keeps every Record from the lowest + // offset that a run read, since a replay of the run reads them again. That floor moves only + // when the run ends. This flag is true when the Records that this reader's floor keeps are + // 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. bool continue_as_new_suggested = 4; } From edb933d7ea43306a2885e8f6a657a95b03a53efd Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Tue, 10 Nov 2026 15:05:41 -0700 Subject: [PATCH 30/56] Said why the three read positions are fields and not a oneof. A oneof would be one variant parameter in each generated SDK call, and the Workflow API could not leave out `last` then. --- temporal/api/streamservice/v1/request_response.proto | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index 911601a32..1578cdf04 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -97,7 +97,9 @@ message ReadRecordsRequest { string namespace = 1; temporal.api.stream.v1.StreamRef ref = 2; // Read after this offset. Empty uses start. It fails with OFFSET_TOO_HIGH if it is past the - // newest offset. + // newest offset. A read starts in one of three ways, after from_offset, at start, or with the + // newest last Records, and a request sets at most one of them. They are three fields and not + // a oneof, so that the Workflow API can leave out last, which a replay can't serve. // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: "from" and "to" mark the ends of a range of offsets. --) bytes from_offset = 3; From 6de7df050ed16a7a7bd6fca8a2668431115e9d2b Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Tue, 10 Nov 2026 15:10:22 -0700 Subject: [PATCH 31/56] Put the read position in a oneof and removed `last`. A read starts after an offset or at one end of the Stream, and the oneof says that only one applies. The newest-N read is gone for now, so the Workflow API has nothing to leave out. --- nexus/stream-service.wit | 22 ++++++++-------- .../streamservice/v1/request_response.proto | 25 ++++++++----------- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 483f49fbd..c446b90eb 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -276,24 +276,25 @@ interface stream-service { stream-read-start-end, } - /// @nexus.doc "Read Records from an offset." + /// @nexus.doc "Where a read starts, after an offset, or at the beginning or the end." + /// @nexus.proto "temporal.api.streamservice.v1.ReadRecordsRequest.position" + variant read-position { + from-offset(stream-offset), + start(stream-read-start), + } + + /// @nexus.doc "Read Records from a position." /// @nexus.proto "temporal.api.streamservice.v1.ReadRecordsRequest" record read-records-request { ref: stream-ref, - /// @nexus.doc "Read after this offset. Empty uses start. An offset past the newest one fails." - from-offset: option, - /// @nexus.doc "Where to start when from-offset is empty. Unspecified is the beginning." - start: option, + /// @nexus.doc "Where the read starts. Unset is the beginning. A too high offset fails." + position: option, /// @nexus.doc "About this many items per page, 1 to 1000." 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 targets="workflow" - last: option, /// @nexus.doc "The maximum Record bytes in a page. A larger Record is still returned alone." max-bytes: option, /// @nexus.omit @@ -354,8 +355,7 @@ interface stream-service { /// @nexus.doc "Read one page of Records. The poll iterator reads them all." /// @nexus.operation "read-records" read: func( - from-offset: option, - start: option, + position: option, max-records: option, wait: option, create-wait: option, diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index 1578cdf04..c3316bdc4 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -96,15 +96,15 @@ message CloseStreamResponse { message ReadRecordsRequest { string namespace = 1; temporal.api.stream.v1.StreamRef ref = 2; - // Read after this offset. Empty uses start. It fails with OFFSET_TOO_HIGH if it is past the - // newest offset. A read starts in one of three ways, after from_offset, at start, or with the - // newest last Records, and a request sets at most one of them. They are three fields and not - // a oneof, so that the Workflow API can leave out last, which a replay can't serve. - // (-- api-linter: core::0140::prepositions=disabled - // aip.dev/not-precedent: "from" and "to" mark the ends of a range of offsets. --) - bytes from_offset = 3; - // Where to start when from_offset is empty. Unspecified is the beginning. - temporal.api.enums.v1.StreamReadStart start = 4; + // Where the read starts. Unset is the beginning of the Stream. + oneof position { + // Read after this offset. It fails with OFFSET_TOO_HIGH if it is past the newest offset. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" and "to" mark the ends of a range of offsets. --) + bytes from_offset = 3; + // Start at the beginning or at the end of the Stream. Unspecified is the beginning. + temporal.api.enums.v1.StreamReadStart start = 4; + } // The most Records in one page. Zero is 1000. int32 max_records = 5; // How long the read waits for a new Record before it answers with an empty page. Empty answers @@ -125,11 +125,8 @@ message ReadRecordsRequest { // with a normal read. The wait may be days, since a parked read adds nothing to History. // Only the server serves it, and a Store refuses it. bool wake_only = 9; - // Read the newest `last` Records, oldest first, such as for the first view of a chat. The - // read goes back from the newest Record, so it needs no count and no index of the Stream. It - // takes no from_offset and no start. Not with wake_only, and not in Workflow code, where - // replay needs a fixed range. - int32 last = 10; + reserved 10; + reserved "last"; // 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 = 11; From 7133a2740953a4d57a5ea45a69cc215d6075bef5 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Tue, 10 Nov 2026 15:22:08 -0700 Subject: [PATCH 32/56] Renamed the `next_offset` of a read to `last_offset`. It is the last offset that the read covered, and the next read starts after it. The old name came from the time when `from_offset` was inclusive. --- nexus/stream-service.wit | 4 ++-- temporal/api/streamservice/v1/request_response.proto | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index c446b90eb..6742b9dd6 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -311,8 +311,8 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.ReadRecordsResponse" record read-records-response { records: list, - /// @nexus.doc "The from-offset of the next read, the offset of the newest Record read." - next-offset: option, + /// @nexus.doc "The last offset that this read covered. The next read starts after it." + last-offset: option, /// @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. diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index c3316bdc4..dd1c68131 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -120,7 +120,7 @@ message ReadRecordsRequest { bytes to_offset = 8; // Asks only for a wake-up, for a reader that must not get Records in this answer, such as a // Workflow whose Records stay out of History. The read waits as a normal read does. It then - // answers with no Records and next_offset = from_offset, when a Record after from_offset + // answers with no Records and last_offset = from_offset, when a Record after from_offset // exists, when the Stream closes (done), or when wait ends. The reader then gets the Records // with a normal read. The wait may be days, since a parked read adds nothing to History. // Only the server serves it, and a Store refuses it. @@ -142,10 +142,11 @@ message ReadRecordsRequest { // 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 offset of the newest Record read. An empty - // page gives the request's from_offset, and that is empty when the request had none. So it - // is always known, and an empty value means that the next read starts as this one did. - bytes next_offset = 2; + // The last offset that this read covered, which is the offset of the newest Record of the + // page. The next read gives it as from_offset and starts after it. An empty page gives the + // request's from_offset, and that is empty when the request had none. So it is always + // known, and an empty value means that the next read starts as this one did. + bytes last_offset = 2; // True when the Stream is closed and this read gave its last item. The close item says the // same on a page that has it. An answer with no item needs this flag, which is a wake_only // read and a read after the close item. A read of a closed Stream with no item left answers From f0a6b346ac96979c5deb28e66953b21b2b676466 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Tue, 10 Nov 2026 23:13:08 -0700 Subject: [PATCH 33/56] Dropped the reserved fields from the Stream protos. The Stream API was never released, so no client sent the old field numbers. Each message numbers its fields in order. --- temporal/api/stream/v1/message.proto | 4 +--- temporal/api/streamservice/v1/request_response.proto | 12 ++++-------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 03aa1bdbb..2d864835f 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -146,10 +146,8 @@ message StreamData { message StreamClose { // The Producer that closed the Stream. Producer producer = 1; - reserved 2, 3; - reserved "result", "failure"; // Why the Stream closed. - temporal.api.enums.v1.StreamCloseStatus status = 4; + temporal.api.enums.v1.StreamCloseStatus status = 2; } // 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 dd1c68131..0a9b77b2c 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -29,13 +29,11 @@ message CreateStreamRequest { string namespace = 1; // The ref of the Stream to create. Its id is the Stream id, and is never empty. temporal.api.stream.v1.StreamRef stream_ref = 2; - reserved 3, 7; - reserved "retention", "task_token"; // What the create does when the Stream exists. Unspecified is FAIL. - temporal.api.enums.v1.StreamIdConflictPolicy id_conflict_policy = 8; + temporal.api.enums.v1.StreamIdConflictPolicy id_conflict_policy = 3; // 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 = 9; + string request_id = 4; } // Experimental. Might change in the future. @@ -125,17 +123,15 @@ message ReadRecordsRequest { // with a normal read. The wait may be days, since a parked read adds nothing to History. // Only the server serves it, and a Store refuses it. bool wake_only = 9; - reserved 10; - reserved "last"; // 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 = 11; + int64 max_bytes = 10; // 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 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. --) - bytes floor_from = 12; + bytes floor_from = 11; } // Experimental. Might change in the future. From b0b2657e9babfd88b1387646aecde3e8c755a9c0 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 11 Nov 2026 00:08:32 -0700 Subject: [PATCH 34/56] Fixed the stale comments of the Stream protos. A read's from_offset is exclusive, an SDK raises one error type, the open Stream TTL is the last-write TTL, and no backend is a default. --- temporal/api/enums/v1/stream.proto | 11 ++++++----- temporal/api/stream/v1/message.proto | 5 +++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/temporal/api/enums/v1/stream.proto b/temporal/api/enums/v1/stream.proto index 98104ca9e..f4130ce56 100644 --- a/temporal/api/enums/v1/stream.proto +++ b/temporal/api/enums/v1/stream.proto @@ -49,13 +49,14 @@ enum StreamIdConflictPolicy { } // Experimental. Might change in the future. -// 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. +// Why a Stream call failed. Each reason has one gRPC status code, and several reasons share a +// code. So code matches on the reason, and not on the status code. An SDK raises one error type +// that carries the reason. 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. + // The Stream's Retention or its last-write TTL passed. NOT_FOUND. STREAM_ERROR_REASON_EXPIRED = 2; // The Stream is closed. FAILED_PRECONDITION. STREAM_ERROR_REASON_CLOSED = 3; @@ -70,8 +71,8 @@ enum StreamErrorReason { 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. + // The offset is past the newest offset, for example after a failover lost the newest Records. + // A read from it works again once the Stream has Records up to it. OUT_OF_RANGE. STREAM_ERROR_REASON_OFFSET_TOO_HIGH = 9; // A Record or a batch is over the Store's limit. INVALID_ARGUMENT. STREAM_ERROR_REASON_TOO_LARGE = 10; diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 2d864835f..8ba391089 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -19,8 +19,9 @@ import "temporal/api/enums/v1/stream.proto"; message StreamRef { string id = 1; // 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. Only an SDK - // reads it. A request carries the whole ref, and the server and a Store ignore this field. + // it, so every process routes a ref it receives. With no name, the SDK picks the backend from + // its config. Only an SDK reads it. A request carries the whole ref, and the server and a + // Store ignore this field. string backend = 3; } From 0ac6b717242d502e991ef451e65a5a8406c81fb3 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 11 Nov 2026 00:35:04 -0700 Subject: [PATCH 35/56] Removed create_wait from the Stream read. The creator hands out a ref only after its create, so no reader waits for a create. A read of a Stream that nobody created fails at once with NOT_FOUND. --- nexus/stream-service.wit | 3 --- temporal/api/enums/v1/stream.proto | 2 +- temporal/api/streamservice/v1/request_response.proto | 11 ++++------- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 6742b9dd6..221a86977 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -293,8 +293,6 @@ interface stream-service { 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, /// @nexus.doc "The maximum Record bytes in a page. A larger Record is still returned alone." max-bytes: option, /// @nexus.omit @@ -358,7 +356,6 @@ interface stream-service { position: option, max-records: option, wait: option, - create-wait: option, max-bytes: option, ) -> read-records-response; diff --git a/temporal/api/enums/v1/stream.proto b/temporal/api/enums/v1/stream.proto index f4130ce56..35617465a 100644 --- a/temporal/api/enums/v1/stream.proto +++ b/temporal/api/enums/v1/stream.proto @@ -54,7 +54,7 @@ enum StreamIdConflictPolicy { // that carries the reason. enum StreamErrorReason { STREAM_ERROR_REASON_UNSPECIFIED = 0; - // Nobody created the Stream, even after `create_wait`. NOT_FOUND. + // Nobody created the Stream. NOT_FOUND. STREAM_ERROR_REASON_NOT_FOUND = 1; // The Stream's Retention or its last-write TTL passed. NOT_FOUND. STREAM_ERROR_REASON_EXPIRED = 2; diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index 0a9b77b2c..a7e73abce 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -108,30 +108,27 @@ message ReadRecordsRequest { // 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, so a replay reads the // same range. Empty 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. --) - bytes to_offset = 8; + bytes to_offset = 7; // Asks only for a wake-up, for a reader that must not get Records in this answer, such as a // Workflow whose Records stay out of History. The read waits as a normal read does. It then // answers with no Records and last_offset = from_offset, when a Record after from_offset // exists, when the Stream closes (done), or when wait ends. The reader then gets the Records // with a normal read. The wait may be days, since a parked read adds nothing to History. // Only the server serves it, and a Store refuses it. - bool wake_only = 9; + bool wake_only = 8; // 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 = 10; + int64 max_bytes = 9; // 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 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. --) - bytes floor_from = 11; + bytes floor_from = 10; } // Experimental. Might change in the future. From 3e902932c4447f1477772d6b3ac40c5a65cffa43 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 11 Nov 2026 00:52:05 -0700 Subject: [PATCH 36/56] Made the notification WIT's interface and alias docs plain comments. nexgen 0.2.12 and later reject `@nexus.doc` on an interface and on a type alias, so `make system-nexus-wit` failed on this file. --- nexus/notification-service.wit | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nexus/notification-service.wit b/nexus/notification-service.wit index 2b35b81c8..7029c037e 100644 --- a/nexus/notification-service.wit +++ b/nexus/notification-service.wit @@ -4,7 +4,7 @@ world system { export notification-service; } -/// @nexus.doc "Notification models for Nexus Handler Callbacks." +/// Notification models for Nexus Handler Callbacks. /// @nexus.experimental interface notification-service { use nexus:temporal-types/model@1.0.0.{ @@ -12,11 +12,11 @@ interface notification-service { placeholder, }; - /// @nexus.doc "Type of the user-supplied data associated with the source invocation." + /// Type of the user-supplied data associated with the source invocation. /// @nexus.type-parameter type source-context-t = placeholder; - /// @nexus.doc "Type of the successful output of the source operation." + /// Type of the successful output of the source operation. /// @nexus.type-parameter type output-t = placeholder; From 717709c6b1dd9c2cbf4b43fd9e2e34f4ebc5255d Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 11 Nov 2026 00:52:05 -0700 Subject: [PATCH 37/56] Pinned temporalio/nexgen#374 and took its Stream WIT. The Stream WIT is the sample WIT of that PR. It adds the `@nexus.api-omit` of nexgen#405 and `@nexus.generic` on `stream-ref`, and keeps what upstream nexgen can't read yet as TODO comments, with an offset as an inline `list`. The pinned commit needs Rust 1.95 or later. --- Makefile | 6 +++--- nexus/stream-service.wit | 28 +++++++++++++++++----------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index f17d6c663..d40563522 100644 --- a/Makefile +++ b/Makefile @@ -35,9 +35,9 @@ PROTO_PATHS = paths=source_relative:$(PROTO_OUT) OAPI_OUT := openapi OAPI3_PATH := .components.schemas.Payload -# The Stream WIT uses nexgen directives that no release has yet. Pin the fork commit that has them +# The Stream WIT uses nexgen directives that no release has yet. Pin a nexgen commit that has them # until a release does. -NEX_GEN_REV := ad5f375ef920b2817df4b994a3603070cf62c2f6 +NEX_GEN_REV := e63694c551178f1ba9caa039b5c252b27bc73665 NEX_GEN_ROOT := $(CURDIR)/$(STAMPDIR)/nexgen-$(NEX_GEN_REV) NEX_GEN ?= $(NEX_GEN_ROOT)/bin/nexgen @@ -181,7 +181,7 @@ system-nexus-wit-install: nex-gen-install: printf $(COLOR) "Install nexgen if missing..." 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 \ + --git https://github.com/temporalio/nexgen --rev $(NEX_GEN_REV) --locked --features advanced \ --root $(NEX_GEN_ROOT) nexgen ##### Clean ##### diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 221a86977..f943e0183 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -21,8 +21,8 @@ interface stream-service { /// @nexus.type-parameter type body-t = placeholder; - /// An opaque, ordered position of a Record in one Stream. It is proto `bytes`. - type stream-offset = list; + // An offset is an opaque, ordered position of a Record in one Stream. It is proto `bytes`. + // TODO: use `type stream-offset = list` once nexgen reads a named `list` as `bytes`. /// @nexus.proto "google.protobuf.Timestamp" record timestamp { @@ -32,6 +32,7 @@ interface stream-service { /// @nexus.doc "Identifies one Stream. The id is opaque to callers." /// @nexus.proto "temporal.api.stream.v1.StreamRef" + /// @nexus.generic "body-t" record stream-ref { /// @nexus.doc "The Stream id, 1 to 4096 bytes." id: string, @@ -175,7 +176,7 @@ interface stream-service { // 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, + offset: option>, } /// @nexus.doc @@ -243,8 +244,8 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.AppendRecordsResponse" record append-records-response { - first-offset: option, - last-offset: option, + first-offset: option>, + last-offset: option>, } // A close carries no value, so code that has a last value appends it first. @@ -279,7 +280,7 @@ interface stream-service { /// @nexus.doc "Where a read starts, after an offset, or at the beginning or the end." /// @nexus.proto "temporal.api.streamservice.v1.ReadRecordsRequest.position" variant read-position { - from-offset(stream-offset), + from-offset(list), start(stream-read-start), } @@ -310,7 +311,7 @@ interface stream-service { record read-records-response { records: list, /// @nexus.doc "The last offset that this read covered. The next read starts after it." - last-offset: option, + last-offset: option>, /// @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. @@ -329,14 +330,14 @@ interface stream-service { record describe-stream-response { status: option, /// @nexus.doc "The newest offset, of a Record. Empty for none." - last-offset: option, + last-offset: option>, retention: option, } // 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.flatten-in-api param="ref" + // TODO: implement @nexus.flatten-in-api param="ref". resource %stream { constructor(ref: stream-ref, created: option); @@ -368,15 +369,18 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.CreateStreamResponse" type create-stream-result = own<%stream>; + // The Workflow API has no other way to create a Stream, so this function stays public. /// @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.output-transform python-type="bytes" python="_support.last_offset(result)" /// @nexus.operation name="AppendRecords" + /// @nexus.api-omit append-records: func(request: append-records-request) -> append-records-response; /// @nexus.operation name="CloseStream" + /// @nexus.api-omit 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 @@ -384,12 +388,14 @@ interface stream-service { /// @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" + /// @nexus.poll python="next_read_request" items="records" python-resume="resume_read_request" /// targets="client" + /// @nexus.api-omit + // TODO: implement polling for typescript="nextReadRequest" dotnet="StreamPoll.NextReadRequest". read-records: func(request: read-records-request) -> read-records-response; /// @nexus.operation name="DescribeStream" + /// @nexus.api-omit describe-stream: func(request: describe-stream-request) -> describe-stream-response; /// @nexus.proto "temporal.api.enums.v1.StreamErrorReason" enum stream-error-reason { From 23fac0861ede04e037c1a4482735cc34f42345b1 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 11 Nov 2026 01:10:46 -0700 Subject: [PATCH 38/56] Reshaped the Stream writes and items, and marked required fields. The append and the close name the Producer directly, so StreamWriter is gone. Each read item carries its own offset. The WIT drops the options of the fields that the API requires, so callers can't leave them out. --- nexus/stream-service.wit | 76 +++++++++---------- temporal/api/stream/v1/message.proto | 54 +++++++------ .../streamservice/v1/request_response.proto | 17 +++-- 3 files changed, 79 insertions(+), 68 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index f943e0183..5ddbe527e 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -36,15 +36,16 @@ interface stream-service { record stream-ref { /// @nexus.doc "The Stream id, 1 to 4096 bytes." id: string, - /// @nexus.doc "Registered backend name, empty for the default. Only an SDK reads it." + /// @nexus.doc "Registered backend name. With none, the SDK picks one. Only an SDK reads it." backend: 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-event-id: s64, + /// @nexus.doc "1 or more." + attempt: s32, /// @nexus.doc "When the Workflow Task attempt started. It tells a reused started id apart." started-time: option, } @@ -53,51 +54,55 @@ interface stream-service { /// @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, + task: workflow-task-epoch, + /// @nexus.doc "The Local Activity's own attempt in that Workflow Task, 1 or more." + attempt: s32, } /// @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, - attempt: option, + attempt-scheduled-time: timestamp, + /// @nexus.doc "1 or more." + attempt: s32, } /// @nexus.doc "A client attempt, which the client picks." /// @nexus.proto "temporal.api.stream.v1.ClientEpoch" record client-epoch { - attempt: option, + /// @nexus.doc "1 or more." + attempt: s32, } /// @nexus.doc "A Workflow run Producer. A higher epoch fences the lower ones." /// @nexus.proto "temporal.api.stream.v1.WorkflowProducer" record workflow-producer { /// @nexus.doc "The run id of the Workflow run, not of its chain." - run-id: option, + run-id: string, + // A Workflow Producer only closes a Stream, and its close sends no epoch. epoch: option, } /// @nexus.doc "A Local Activity Producer. A higher epoch fences the lower ones." /// @nexus.proto "temporal.api.stream.v1.LocalActivityProducer" record local-activity-producer { - run-id: option, - epoch: option, + run-id: string, + epoch: local-activity-epoch, } /// @nexus.doc "An Activity Producer. A higher epoch fences the lower ones." /// @nexus.proto "temporal.api.stream.v1.ActivityProducer" record activity-producer { + /// @nexus.doc "The run of the Workflow that scheduled the Activity. Empty when standalone." run-id: option, - epoch: option, + epoch: activity-epoch, } /// @nexus.doc "A client Producer. A higher epoch only fences the lower ones." /// @nexus.proto "temporal.api.stream.v1.ClientProducer" record client-producer { - epoch: option, + epoch: client-epoch, } // The case is the kind of the Producer, so a kind sets only its own fields. @@ -129,19 +134,14 @@ interface stream-service { body: body-t, } - /// @nexus.doc "The Producer of a write. An append and a close carry the same writer." - /// @nexus.proto "temporal.api.stream.v1.StreamWriter" - record stream-writer { - /// @nexus.doc "Required on a write." - producer: option, - } - /// @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 place of this Record in the Stream. Ordered and unique, with gaps." + offset: list, } /// How a Stream closed. A Producer's close gives CLOSED. Native Streams add their own later. @@ -159,6 +159,8 @@ interface stream-service { producer: option, /// @nexus.doc "Why the Stream closed." status: option, + /// @nexus.doc "The offset after the newest Record. A resume from it has nothing to read." + offset: list, } /// @nexus.doc "A read item: a Record or the close." @@ -170,13 +172,11 @@ 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 gives its Stream, so the read checks the offset against that Stream. - /// @nexus.doc "One item that a read gives." + /// @nexus.doc "One item that a read gives. Each item carries its own offset." /// @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 @@ -222,14 +222,14 @@ interface stream-service { record append-records-request { ref: stream-ref, records: list, - // The writer carries the Producer, which only the context helper sets right, so it is never a - // parameter. The helper of each context fills the Producer that applies where it runs. - /// @nexus.doc "Required outside a Workflow. In a Workflow the transport sets it." - /// @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, + // Only the context helper sets the Producer right, so it is never a parameter. The helper of + // each context fills the Producer that applies where it runs. + /// @nexus.doc "The Producer of the batch. The helper of each context 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: producer, // A call with no writer state, such as append, sends no sequence. The Store then numbers the // batch, so nothing dedupes it. /// @nexus.doc @@ -253,12 +253,12 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.CloseStreamRequest" record close-stream-request { ref: stream-ref, - // The SDK Stores read only the Producer. - /// @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.doc "The Producer that closes the Stream. The helper of each context 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: producer, /// @nexus.omit namespace: placeholder, // Native only. The SDK Stores don't read it. diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 8ba391089..d401799a3 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -30,9 +30,12 @@ message StreamRef { // started_time. An epoch orders the writes of one Producer only, and a Producer is one run. A // reset run and the next run of a chain are other Producers, so their epochs start again. message WorkflowTaskEpoch { + // Required. int64 started_event_id = 1; + // Required. 1 or more. int32 attempt = 2; - // Tells a reused started event id apart, for example after a dropped speculative task. + // Tells a reused started event id apart, for example after a dropped speculative task. Core + // leaves it empty when the server gave the task no started time. google.protobuf.Timestamp started_time = 3; } @@ -41,18 +44,19 @@ message WorkflowTaskEpoch { // Workflow Task is a higher epoch. The Producer carries the run. Records of two runs have no // order by epoch, only their order in the Stream. message LocalActivityEpoch { - // The Workflow Task attempt that dispatched the Local Activity. + // Required. The Workflow Task attempt that dispatched the Local Activity. WorkflowTaskEpoch task = 1; - // The Local Activity's own attempt in that Workflow Task. + // Required. The Local Activity's own attempt in that Workflow Task, 1 or more. 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. + // Required. When the server scheduled this attempt. An Activity reset starts the attempt count + // again, so only this time orders the attempts across a reset. google.protobuf.Timestamp attempt_scheduled_time = 1; + // Required. 1 or more. int32 attempt = 2; } @@ -61,22 +65,25 @@ message ActivityEpoch { // name across restarts stores this number and raises it at each start, so the new process // fences the old one. A client with no name of its own is a new Producer in each process. message ClientEpoch { + // Required. 1 or more. int32 attempt = 1; } // Experimental. Might change in the future. // A Workflow run Producer. It writes in its Workflow Task. A higher epoch fences the lower ones. message WorkflowProducer { - // The run id of the Workflow run, not of its chain. Each run is its own Producer. + // Required. The run id of the Workflow run, not of its chain. Each run is its own Producer. string run_id = 1; + // A Workflow Producer only closes a Stream, and its close sends no epoch. WorkflowTaskEpoch epoch = 2; } // Experimental. Might change in the future. // A Local Activity Producer, one for each call of a run. A higher epoch fences the lower ones. message LocalActivityProducer { - // The run id of the Workflow run. + // Required. The run id of the Workflow run. string run_id = 1; + // Required. LocalActivityEpoch epoch = 2; } @@ -87,6 +94,7 @@ message ActivityProducer { // The run id of the Workflow run that scheduled the Activity. Empty for a Standalone // Activity, whose own run id is in the Producer id. string run_id = 1; + // Required. ActivityEpoch epoch = 2; } @@ -94,6 +102,7 @@ message ActivityProducer { // A client Producer. 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. A client has no run. message ClientProducer { + // Required. ClientEpoch epoch = 1; } @@ -103,8 +112,8 @@ message ClientProducer { // fails with FENCED. The epochs of two Producers never compare. A run-based kind carries its run, // so a client can't. message Producer { - // Stable across attempts, such as `wf:` for the Workflow Producer of a run. It is - // unique in one Stream, and a Stream is in one namespace, so it carries no namespace. + // Required. Stable across attempts, such as `wf:` for the Workflow Producer of a run. + // It is unique in one Stream, so it carries no namespace. string id = 1; // Required. The case is the kind of the Producer. oneof kind { @@ -119,14 +128,6 @@ message Producer { } } -// Experimental. Might change in the future. -// The Producer of a write. An append and a close carry the same writer, so the Store orders each -// write the same way. -message StreamWriter { - // Required on a write. - Producer producer = 1; -} - // 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 { @@ -138,6 +139,10 @@ message AppendRecord { message StreamData { Producer producer = 1; temporal.api.common.v1.Payload body = 2; + // Required. The Record's offset. An offset is ordered and unique in one Stream, and it is + // sparse. Offsets have gaps, so they don't count Records. An offset is never empty. A read + // gives its Stream, so the read checks the offset against that Stream. + bytes offset = 3; } // Experimental. Might change in the future. @@ -149,18 +154,19 @@ message StreamClose { Producer producer = 1; // Why the Stream closed. temporal.api.enums.v1.StreamCloseStatus status = 2; + // Required. The offset after the newest Record, so a reader that resumes from it has nothing + // left to read. + bytes offset = 3; } // 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. An offset is never empty. A read gives its Stream, so the read checks the -// offset against that Stream. +// One item that a read gives. Each item carries its own offset. message StreamRecord { - bytes offset = 1; - // A read item is a Record or the close. + // A read item is a Record or the close. A newer server may send a kind that an older SDK + // doesn't know. oneof item { - StreamData data = 2; - StreamClose close = 4; + StreamData data = 1; + StreamClose close = 2; } } diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index a7e73abce..11490d2fe 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -27,7 +27,7 @@ import "temporal/api/stream/v1/message.proto"; // aip.dev/not-precedent: The request identifies a Stream by namespace and ref. --) message CreateStreamRequest { string namespace = 1; - // The ref of the Stream to create. Its id is the Stream id, and is never empty. + // Required. The ref of the Stream to create. Its id is the Stream id, and is never empty. temporal.api.stream.v1.StreamRef stream_ref = 2; // What the create does when the Stream exists. Unspecified is FAIL. temporal.api.enums.v1.StreamIdConflictPolicy id_conflict_policy = 3; @@ -51,9 +51,11 @@ message CreateStreamResponse { // Producer, epoch and sequence, only once. message AppendRecordsRequest { string namespace = 1; + // Required. temporal.api.stream.v1.StreamRef ref = 2; - // Required outside a Workflow. In a Workflow the transport sets it. - temporal.api.stream.v1.StreamWriter writer = 3; + // Required. The Producer of the batch. An append and a close of one Producer carry the same + // Producer, so the Store orders each write the same way. + 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 // the next number of the epoch. Such a batch has no dedupe, so a retry writes it again. int64 sequence = 4; @@ -61,7 +63,7 @@ message AppendRecordsRequest { // Only read by the server, and opaque to every SDK and Store, which never parse it. It lets // the server check that the caller is the task that the Producer names. It is an identity // check, not a guard against a stale attempt, which the epoch fences. - bytes task_token = 7; + bytes task_token = 6; } // Experimental. Might change in the future. @@ -76,9 +78,10 @@ message AppendRecordsResponse { // close writes no Record and carries no value, so code that has a last value publishes it first. message CloseStreamRequest { string namespace = 1; + // Required. temporal.api.stream.v1.StreamRef ref = 2; - // The writer that closes the Stream. A Store reads only its Producer. - temporal.api.stream.v1.StreamWriter writer = 3; + // Required. The Producer that closes the Stream. + temporal.api.stream.v1.Producer producer = 3; // Only read by the server. It checks the token as on the append. bytes task_token = 4; } @@ -93,6 +96,7 @@ message CloseStreamResponse { // Record, so readers long poll. message ReadRecordsRequest { string namespace = 1; + // Required. temporal.api.stream.v1.StreamRef ref = 2; // Where the read starts. Unset is the beginning of the Stream. oneof position { @@ -157,6 +161,7 @@ message ReadRecordsResponse { // Gets the status, the newest offset and the Retention of a Stream. message DescribeStreamRequest { string namespace = 1; + // Required. temporal.api.stream.v1.StreamRef ref = 2; } From 4e9db3f7721d23efb790ef9bd0de42e1da90e58a Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 11 Nov 2026 01:30:22 -0700 Subject: [PATCH 39/56] Reworded the close offset and the request namespace comments. The close offset says only where the Stream ended. The namespace comments say that the SDK sets it and that only the server reads it. --- nexus/stream-service.wit | 7 ++++++- temporal/api/stream/v1/message.proto | 3 +-- temporal/api/streamservice/v1/request_response.proto | 10 ++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 5ddbe527e..129954778 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -159,7 +159,7 @@ interface stream-service { producer: option, /// @nexus.doc "Why the Stream closed." status: option, - /// @nexus.doc "The offset after the newest Record. A resume from it has nothing to read." + /// @nexus.doc "Where the Stream ended." offset: list, } @@ -195,6 +195,7 @@ interface stream-service { /// @nexus.source python="request_id()" typescript="requestId()" /// dotnet="StreamWorkflowContext.RequestId()" request-id: option, + // Native only. The SDK sets it, and the SDK Stores don't read it. /// @nexus.omit namespace: placeholder, } @@ -235,6 +236,7 @@ interface stream-service { /// @nexus.doc /// "The batch number in this epoch. Empty takes the next one, and a retry writes it again." sequence: option, + // Native only. The SDK sets it, and the SDK Stores don't read it. /// @nexus.omit namespace: placeholder, // Native only. The SDK Stores don't read it. @@ -259,6 +261,7 @@ interface stream-service { /// @nexus.source targets="client" python="client_producer()" typescript="clientProducer()" /// dotnet="StreamActivityContext.Producer()" producer: producer, + // Native only. The SDK sets it, and the SDK Stores don't read it. /// @nexus.omit namespace: placeholder, // Native only. The SDK Stores don't read it. @@ -296,6 +299,7 @@ interface stream-service { wait: option, /// @nexus.doc "The maximum Record bytes in a page. A larger Record is still returned alone." max-bytes: option, + // Native only. The SDK sets it, and the SDK Stores don't read it. /// @nexus.omit namespace: placeholder, // Native only. The SDK Stores refuse to-offset and wake-only. @@ -322,6 +326,7 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.DescribeStreamRequest" record describe-stream-request { ref: stream-ref, + // Native only. The SDK sets it, and the SDK Stores don't read it. /// @nexus.omit namespace: placeholder, } diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index d401799a3..b38453a86 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -154,8 +154,7 @@ message StreamClose { Producer producer = 1; // Why the Stream closed. temporal.api.enums.v1.StreamCloseStatus status = 2; - // Required. The offset after the newest Record, so a reader that resumes from it has nothing - // left to read. + // Required. Where the Stream ended. bytes offset = 3; } diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index 11490d2fe..48cc81a4b 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -26,6 +26,8 @@ import "temporal/api/stream/v1/message.proto"; // (-- api-linter: core::0133=disabled // aip.dev/not-precedent: The request identifies a Stream by namespace and ref. --) message CreateStreamRequest { + // The caller's namespace. Only read by the server. The SDK sets it, and the SDK Stores don't + // read it. string namespace = 1; // Required. The ref of the Stream to create. Its id is the Stream id, and is never empty. temporal.api.stream.v1.StreamRef stream_ref = 2; @@ -50,6 +52,8 @@ message CreateStreamResponse { // 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 { + // The caller's namespace. Only read by the server. The SDK sets it, and the SDK Stores don't + // read it. string namespace = 1; // Required. temporal.api.stream.v1.StreamRef ref = 2; @@ -77,6 +81,8 @@ message AppendRecordsResponse { // 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. message CloseStreamRequest { + // The caller's namespace. Only read by the server. The SDK sets it, and the SDK Stores don't + // read it. string namespace = 1; // Required. temporal.api.stream.v1.StreamRef ref = 2; @@ -95,6 +101,8 @@ message CloseStreamResponse { // 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 { + // The caller's namespace. Only read by the server. The SDK sets it, and the SDK Stores don't + // read it. string namespace = 1; // Required. temporal.api.stream.v1.StreamRef ref = 2; @@ -160,6 +168,8 @@ message ReadRecordsResponse { // Experimental. Might change in the future. // Gets the status, the newest offset and the Retention of a Stream. message DescribeStreamRequest { + // The caller's namespace. Only read by the server. The SDK sets it, and the SDK Stores don't + // read it. string namespace = 1; // Required. temporal.api.stream.v1.StreamRef ref = 2; From 5e7931b562cc3cae9901349e0623ddb5533dc0ac Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Wed, 11 Nov 2026 01:34:35 -0700 Subject: [PATCH 40/56] Moved the nexgen pin to the head of temporalio/nexgen#374. That head has the sample synced to this WIT. Its nexgen code is the same as at the earlier pin. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index d40563522..b9c8e70da 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 a nexgen commit that has them # until a release does. -NEX_GEN_REV := e63694c551178f1ba9caa039b5c252b27bc73665 +NEX_GEN_REV := bbf5c1595a703563ff373d507d6e8174144cdf7c NEX_GEN_ROOT := $(CURDIR)/$(STAMPDIR)/nexgen-$(NEX_GEN_REV) NEX_GEN ?= $(NEX_GEN_ROOT)/bin/nexgen From 6693d0dc07546fb4991d1c3c1502fedd6942f2ac Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 11 Nov 2026 01:51:24 -0700 Subject: [PATCH 41/56] Stated one fence and supersede rule for every Producer kind. A higher epoch fences the lower one, and a reader may hide its Records, for a client too. A client with no name of its own is a new Producer in each process. --- nexus/stream-service.wit | 10 +++++----- temporal/api/stream/v1/message.proto | 30 ++++++++++++++-------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 129954778..27e1bc18d 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -75,7 +75,7 @@ interface stream-service { attempt: s32, } - /// @nexus.doc "A Workflow run Producer. A higher epoch fences the lower ones." + /// @nexus.doc "A Workflow run Producer." /// @nexus.proto "temporal.api.stream.v1.WorkflowProducer" record workflow-producer { /// @nexus.doc "The run id of the Workflow run, not of its chain." @@ -84,14 +84,14 @@ interface stream-service { epoch: option, } - /// @nexus.doc "A Local Activity Producer. A higher epoch fences the lower ones." + /// @nexus.doc "A Local Activity Producer." /// @nexus.proto "temporal.api.stream.v1.LocalActivityProducer" record local-activity-producer { run-id: string, epoch: local-activity-epoch, } - /// @nexus.doc "An Activity Producer. A higher epoch fences the lower ones." + /// @nexus.doc "An Activity Producer." /// @nexus.proto "temporal.api.stream.v1.ActivityProducer" record activity-producer { /// @nexus.doc "The run of the Workflow that scheduled the Activity. Empty when standalone." @@ -99,7 +99,7 @@ interface stream-service { epoch: activity-epoch, } - /// @nexus.doc "A client Producer. A higher epoch only fences the lower ones." + /// @nexus.doc "A client Producer." /// @nexus.proto "temporal.api.stream.v1.ClientProducer" record client-producer { epoch: client-epoch, @@ -116,7 +116,7 @@ interface stream-service { client(client-producer), } - /// @nexus.doc "The code that wrote a batch. Its id is stable across attempts." + /// @nexus.doc "The code that wrote a batch. A higher epoch fences and supersedes a lower one." /// @nexus.proto "temporal.api.stream.v1.Producer" record producer { /// @nexus.doc "Stable across attempts, such as wf: for a Workflow run." diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index b38453a86..417d9c5af 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -27,8 +27,9 @@ message StreamRef { // Experimental. Might change in the future. // One Workflow Task attempt of a run. It orders by started_event_id, then attempt, then -// started_time. An epoch orders the writes of one Producer only, and a Producer is one run. A -// reset run and the next run of a chain are other Producers, so their epochs start again. +// started_time. An epoch orders the attempts of one Producer only, and a Producer belongs to one +// run. So a reset run and the next run of a chain have other Producers, and their epochs start +// again. message WorkflowTaskEpoch { // Required. int64 started_event_id = 1; @@ -61,16 +62,16 @@ message ActivityEpoch { } // Experimental. Might change in the future. -// The attempt of a client Producer, which the client picks. A client that keeps one Producer -// name across restarts stores this number and raises it at each start, so the new process -// fences the old one. A client with no name of its own is a new Producer in each process. +// The attempt of a client Producer, which the client picks. A client that runs the same work +// again under one Producer name stores this number and raises it at each start. A client with no +// name of its own is a new Producer in each process, so nothing fences or supersedes it. message ClientEpoch { // Required. 1 or more. int32 attempt = 1; } // Experimental. Might change in the future. -// A Workflow run Producer. It writes in its Workflow Task. A higher epoch fences the lower ones. +// A Workflow run Producer. It writes in its Workflow Task. message WorkflowProducer { // Required. The run id of the Workflow run, not of its chain. Each run is its own Producer. string run_id = 1; @@ -79,7 +80,7 @@ message WorkflowProducer { } // Experimental. Might change in the future. -// A Local Activity Producer, one for each call of a run. A higher epoch fences the lower ones. +// A Local Activity Producer, one for each call of a run. message LocalActivityProducer { // Required. The run id of the Workflow run. string run_id = 1; @@ -88,8 +89,7 @@ message LocalActivityProducer { } // Experimental. Might change in the future. -// An Activity Producer, for an Activity of a Workflow or a Standalone Activity. A higher epoch -// fences the lower ones. Their Records are those of a failed attempt, which a reader may hide. +// An Activity Producer, for an Activity of a Workflow or a Standalone Activity. message ActivityProducer { // The run id of the Workflow run that scheduled the Activity. Empty for a Standalone // Activity, whose own run id is in the Producer id. @@ -99,18 +99,18 @@ message ActivityProducer { } // Experimental. Might change in the future. -// A client Producer. 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. A client has no run. +// A client Producer. A client has no run. message ClientProducer { // Required. ClientEpoch epoch = 1; } // Experimental. Might change in the future. -// The code that wrote a batch. Its id stays the same across attempts, and the kind's case orders -// its epochs. After a write of a higher epoch, a write of a lower epoch of the same Producer -// fails with FENCED. The epochs of two Producers never compare. A run-based kind carries its run, -// so a client can't. +// The code that wrote a batch, which is one unit of work. Its id stays the same across attempts, +// and an epoch is one attempt. The kind's case orders its epochs. After a write of a higher epoch, +// a write of a lower epoch of the same Producer fails with FENCED, and a reader may hide the +// Records of the lower epoch. The rule is the same for every kind. The epochs of two Producers +// never compare. A run-based kind carries its run, so a client can't. message Producer { // Required. Stable across attempts, such as `wf:` for the Workflow Producer of a run. // It is unique in one Stream, so it carries no namespace. From 6da3cbc96feaadd8ad5d7d2f51974924b84d9fff Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 11 Nov 2026 02:31:39 -0700 Subject: [PATCH 42/56] Dropped the unknown writer reason and marked more required fields. An SDK writer is an object now, so no call names a writer by id. A closed writer fails as CLOSED. A Record needs a body, and a timestamp needs both of its fields. --- nexus/stream-service.wit | 5 ++--- temporal/api/enums/v1/stream.proto | 10 ++++------ temporal/api/stream/v1/message.proto | 1 + 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 27e1bc18d..a275f6b86 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -26,8 +26,8 @@ interface stream-service { /// @nexus.proto "google.protobuf.Timestamp" record timestamp { - seconds: option, - nanos: option, + seconds: s64, + nanos: s32, } /// @nexus.doc "Identifies one Stream. The id is opaque to callers." @@ -417,7 +417,6 @@ interface stream-service { stream-error-reason-too-large, stream-error-reason-invalid, stream-error-reason-storage-unavailable, - stream-error-reason-unknown-writer, stream-error-reason-unsupported, stream-error-reason-already-exists, stream-error-reason-storage-corrupt, diff --git a/temporal/api/enums/v1/stream.proto b/temporal/api/enums/v1/stream.proto index 35617465a..0045738b4 100644 --- a/temporal/api/enums/v1/stream.proto +++ b/temporal/api/enums/v1/stream.proto @@ -58,7 +58,7 @@ enum StreamErrorReason { STREAM_ERROR_REASON_NOT_FOUND = 1; // The Stream's Retention or its last-write TTL passed. NOT_FOUND. STREAM_ERROR_REASON_EXPIRED = 2; - // The Stream is closed. FAILED_PRECONDITION. + // The Stream is closed, or the SDK's writer is closed. FAILED_PRECONDITION. STREAM_ERROR_REASON_CLOSED = 3; // The Producer's epoch is lower than the newest one. FAILED_PRECONDITION. STREAM_ERROR_REASON_FENCED = 4; @@ -80,13 +80,11 @@ enum StreamErrorReason { STREAM_ERROR_REASON_INVALID = 11; // 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; + STREAM_ERROR_REASON_UNSUPPORTED = 13; // A create with the FAIL conflict policy is for a Stream that exists. ALREADY_EXISTS. - STREAM_ERROR_REASON_ALREADY_EXISTS = 15; + STREAM_ERROR_REASON_ALREADY_EXISTS = 14; // 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; + STREAM_ERROR_REASON_STORAGE_CORRUPT = 15; } diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 417d9c5af..6b969051d 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -131,6 +131,7 @@ message Producer { // 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 { + // Required. temporal.api.common.v1.Payload body = 1; } From 08b955e790897f9ff3e4567f3e157ba98b3f9b0b Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 11 Nov 2026 10:34:34 -0700 Subject: [PATCH 43/56] Said when a Workflow Producer writes and when it only closes. The two comments held for one kind of Store each. --- nexus/stream-service.wit | 3 ++- temporal/api/stream/v1/message.proto | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index a275f6b86..890d4c511 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -80,7 +80,8 @@ interface stream-service { record workflow-producer { /// @nexus.doc "The run id of the Workflow run, not of its chain." run-id: string, - // A Workflow Producer only closes a Stream, and its close sends no epoch. + // The Workflow Task of the write. A close on a Store runs after the Workflow Task and sends + // no epoch. epoch: option, } diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 6b969051d..7f8f44960 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -71,11 +71,14 @@ message ClientEpoch { } // Experimental. Might change in the future. -// A Workflow run Producer. It writes in its Workflow Task. +// A Workflow run Producer. On the server's Streams, a Workflow writes as this Producer in its +// Workflow Task. On a Store, a Workflow appends as a Local Activity Producer, and this Producer +// only closes the Stream. message WorkflowProducer { // Required. The run id of the Workflow run, not of its chain. Each run is its own Producer. string run_id = 1; - // A Workflow Producer only closes a Stream, and its close sends no epoch. + // The Workflow Task of the write. A close on a Store runs after the Workflow Task and sends + // no epoch. WorkflowTaskEpoch epoch = 2; } From 0764a6620715df9e80ad5a1b705ecf16d999bab9 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 01:41:59 -0700 Subject: [PATCH 44/56] Dropped the fields with no design yet and the close item of a read. A read gives only Records, tells of a close with done, and starts after an offset. A client has no epoch, and a describe gives no Retention, which is a backend setting. The fields that only the server reads come back with the design of Workflow reads on native Streams. --- nexus/stream-service.wit | 103 +++--------------- temporal/api/enums/v1/stream.proto | 18 --- temporal/api/stream/v1/message.proto | 59 +++------- .../streamservice/v1/request_response.proto | 71 ++++-------- 4 files changed, 51 insertions(+), 200 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 890d4c511..c6b8fc3de 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -68,13 +68,6 @@ interface stream-service { attempt: s32, } - /// @nexus.doc "A client attempt, which the client picks." - /// @nexus.proto "temporal.api.stream.v1.ClientEpoch" - record client-epoch { - /// @nexus.doc "1 or more." - attempt: s32, - } - /// @nexus.doc "A Workflow run Producer." /// @nexus.proto "temporal.api.stream.v1.WorkflowProducer" record workflow-producer { @@ -100,10 +93,9 @@ interface stream-service { epoch: activity-epoch, } - /// @nexus.doc "A client Producer." + /// @nexus.doc "A client Producer. It has no run and no epoch, so nothing fences it." /// @nexus.proto "temporal.api.stream.v1.ClientProducer" record client-producer { - epoch: client-epoch, } // The case is the kind of the Producer, so a kind sets only its own fields. @@ -135,9 +127,13 @@ interface stream-service { body: body-t, } - /// @nexus.doc "One Record that a Producer appended." - /// @nexus.proto "temporal.api.stream.v1.StreamData" - record stream-data { + // An offset is ordered and unique in one Stream, and sparse. Offsets have gaps, so they don't + // count Records. A read gives its Stream, so the read checks the offset against that Stream. + // A read gives only Records. A close stores no Record, so the read response tells of it with + // done. + /// @nexus.doc "One Record that a Producer appended, as a read gives it." + /// @nexus.proto "temporal.api.stream.v1.StreamRecord" + record stream-record { /// @nexus.doc "The value." body: body-t, producer: option, @@ -145,41 +141,6 @@ interface stream-service { offset: list, } - /// How a Stream closed. A Producer's close gives CLOSED. Native Streams add their own later. - /// @nexus.proto "temporal.api.enums.v1.StreamCloseStatus" - enum stream-close-status { - /// @nexus.fallback - stream-close-status-unspecified, - stream-close-status-closed, - } - - /// @nexus.doc "The close of a Stream, as a read item. A close stores no Record." - /// @nexus.proto "temporal.api.stream.v1.StreamClose" - record stream-close { - /// @nexus.doc "The Producer that closed the Stream." - producer: option, - /// @nexus.doc "Why the Stream closed." - status: option, - /// @nexus.doc "Where the Stream ended." - offset: list, - } - - /// @nexus.doc "A read item: a Record or the close." - /// @nexus.proto "temporal.api.stream.v1.StreamRecord.item" - variant stream-item { - data(stream-data), - 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 gives its Stream, so the read checks the offset against that Stream. - /// @nexus.doc "One item that a read gives. Each item carries its own offset." - /// @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 /// "Create a Stream. If it exists, fail, or with use-existing get the open one." /// @nexus.proto "temporal.api.streamservice.v1.CreateStreamRequest" @@ -240,9 +201,6 @@ interface stream-service { // Native only. The SDK sets it, and the SDK Stores don't read it. /// @nexus.omit namespace: placeholder, - // Native only. The SDK Stores don't read it. - /// @nexus.omit - task-token: placeholder, } /// @nexus.proto "temporal.api.streamservice.v1.AppendRecordsResponse" @@ -265,36 +223,20 @@ interface stream-service { // Native only. The SDK sets it, and the SDK Stores don't read it. /// @nexus.omit namespace: placeholder, - // Native only. The SDK Stores don't read it. - /// @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 "Where a read starts, after an offset, or at the beginning or the end." - /// @nexus.proto "temporal.api.streamservice.v1.ReadRecordsRequest.position" - variant read-position { - from-offset(list), - start(stream-read-start), - } - - /// @nexus.doc "Read Records from a position." + /// @nexus.doc "Read Records after an offset." /// @nexus.proto "temporal.api.streamservice.v1.ReadRecordsRequest" record read-records-request { ref: stream-ref, - /// @nexus.doc "Where the read starts. Unset is the beginning. A too high offset fails." - position: option, - /// @nexus.doc "About this many items per page, 1 to 1000." + // To read only new Records, a caller gets last-offset from a describe and reads after it. + /// @nexus.doc "Read after this offset. Empty is the beginning. A too high offset fails." + from-offset: option>, + /// @nexus.doc "About this many Records per page, 1 to 1000." max-records: option, /// @nexus.doc "How long to wait for a new Record before an empty answer. At most 60 s." wait: option, @@ -303,13 +245,6 @@ interface stream-service { // Native only. The SDK sets it, and the SDK Stores don't read it. /// @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" @@ -317,11 +252,8 @@ interface stream-service { records: list, /// @nexus.doc "The last offset that this read covered. The next read starts after it." last-offset: option>, - /// @nexus.doc "True when the Stream is closed and this read gave its last item." + /// @nexus.doc "True when the Stream is closed and this read gave its last 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" @@ -337,7 +269,6 @@ interface stream-service { status: option, /// @nexus.doc "The newest offset, of a Record. Empty for none." last-offset: option>, - retention: option, } // A stream never changes, since offsets are values that its methods return. Only a create's @@ -360,18 +291,18 @@ interface stream-service { /// @nexus.doc "Read one page of Records. The poll iterator reads them all." /// @nexus.operation "read-records" read: func( - position: option, + from-offset: option>, max-records: option, wait: option, max-bytes: option, ) -> read-records-response; - /// @nexus.doc "Describe the Stream's status, newest offset and Retention." + /// @nexus.doc "Describe the Stream's status and newest offset." /// @nexus.operation "describe-stream" describe: func() -> describe-stream-response; } - // With use-existing, created is false when the Stream existed, whatever its Retention. + // With use-existing, created is false when the Stream existed. /// @nexus.proto "temporal.api.streamservice.v1.CreateStreamResponse" type create-stream-result = own<%stream>; diff --git a/temporal/api/enums/v1/stream.proto b/temporal/api/enums/v1/stream.proto index 0045738b4..e784c937b 100644 --- a/temporal/api/enums/v1/stream.proto +++ b/temporal/api/enums/v1/stream.proto @@ -20,24 +20,6 @@ enum StreamStatus { STREAM_STATUS_EXPIRED = 3; } -// Experimental. Might change in the future. -// How a Stream closed. CLOSED comes from a Producer that called close(). Native Streams add their -// own close statuses later. -// (-- api-linter: core::0216::synonyms=disabled -// aip.dev/not-precedent: Named consistently with WorkflowExecutionStatus. --) -enum StreamCloseStatus { - STREAM_CLOSE_STATUS_UNSPECIFIED = 0; - STREAM_CLOSE_STATUS_CLOSED = 1; -} - -// 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; - 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 { diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index 7f8f44960..f17f05db4 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -15,13 +15,15 @@ import "temporal/api/common/v1/message.proto"; import "temporal/api/enums/v1/stream.proto"; // Experimental. Might change in the future. -// Identifies one Stream. The id is opaque to callers. +// Identifies one Stream. The id is opaque to callers. A Stream has no incarnations. A create of a +// closed or expired Stream fails, and it doesn't make a second Stream with the same id. message StreamRef { string id = 1; // 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. With no name, the SDK picks the backend from - // its config. Only an SDK reads it. A request carries the whole ref, and the server and a - // Store ignore this field. + // its config. Only an SDK sets and reads it, since only the SDK knows the name that its config + // gives a backend. A request carries the whole ref, and the server and a Store ignore this + // field. string backend = 3; } @@ -61,15 +63,6 @@ message ActivityEpoch { int32 attempt = 2; } -// Experimental. Might change in the future. -// The attempt of a client Producer, which the client picks. A client that runs the same work -// again under one Producer name stores this number and raises it at each start. A client with no -// name of its own is a new Producer in each process, so nothing fences or supersedes it. -message ClientEpoch { - // Required. 1 or more. - int32 attempt = 1; -} - // Experimental. Might change in the future. // A Workflow run Producer. On the server's Streams, a Workflow writes as this Producer in its // Workflow Task. On a Store, a Workflow appends as a Local Activity Producer, and this Producer @@ -102,21 +95,22 @@ message ActivityProducer { } // Experimental. Might change in the future. -// A client Producer. A client has no run. +// A client Producer. A client has no run and no epoch. So nothing fences a client's writes, and a +// reader never hides them. message ClientProducer { - // Required. - ClientEpoch epoch = 1; } // Experimental. Might change in the future. // The code that wrote a batch, which is one unit of work. Its id stays the same across attempts, // and an epoch is one attempt. The kind's case orders its epochs. After a write of a higher epoch, // a write of a lower epoch of the same Producer fails with FENCED, and a reader may hide the -// Records of the lower epoch. The rule is the same for every kind. The epochs of two Producers -// never compare. A run-based kind carries its run, so a client can't. +// Records of the lower epoch. The rule is the same for every kind that has an epoch. A client has +// none. The epochs of two Producers never compare. A new run has new Producers, so no write +// fences or supersedes the Records of an earlier run, such as the base run of a reset. message Producer { // Required. Stable across attempts, such as `wf:` for the Workflow Producer of a run. - // It is unique in one Stream, so it carries no namespace. + // It is unique in one Stream. The id of a run-based kind has its run id, so it stays unique + // when Producers of more than one namespace write to one Stream. string id = 1; // Required. The case is the kind of the Producer. oneof kind { @@ -139,8 +133,9 @@ message AppendRecord { } // Experimental. Might change in the future. -// One Record that a Producer appended. -message StreamData { +// One Record that a Producer appended, as a read gives it. A read gives only Records. A close +// stores no Record, so a read tells of it with the done flag of its response. +message StreamRecord { Producer producer = 1; temporal.api.common.v1.Payload body = 2; // Required. The Record's offset. An offset is ordered and unique in one Stream, and it is @@ -149,30 +144,6 @@ message StreamData { bytes offset = 3; } -// Experimental. Might change in the future. -// The close of a Stream, as a read item. A close is its own call and stores no Record. A read -// builds this item from the close state of the Stream and returns it last, so a reader learns in -// order that the Stream ended, who closed it and why. -message StreamClose { - // The Producer that closed the Stream. - Producer producer = 1; - // Why the Stream closed. - temporal.api.enums.v1.StreamCloseStatus status = 2; - // Required. Where the Stream ended. - bytes offset = 3; -} - -// Experimental. Might change in the future. -// One item that a read gives. Each item carries its own offset. -message StreamRecord { - // A read item is a Record or the close. A newer server may send a kind that an older SDK - // doesn't know. - oneof item { - StreamData data = 1; - StreamClose close = 2; - } -} - // 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. diff --git a/temporal/api/streamservice/v1/request_response.proto b/temporal/api/streamservice/v1/request_response.proto index 48cc81a4b..75d14bffe 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -41,8 +41,9 @@ message CreateStreamRequest { // Experimental. Might change in the future. // The Stream that a create made or got. message CreateStreamResponse { - // The ref of the Stream, with the name of the backend that holds it, also when the request - // left the name empty. The caller passes this ref on as data. + // The ref of the Stream, which the caller passes on as data. The SDK sets the name of the + // backend that it picked, also when the request left the name empty. The server gives the ref + // back as the request had it, since it doesn't know that name. temporal.api.stream.v1.StreamRef ref = 1; // False when USE_EXISTING got a Stream that existed. bool created = 2; @@ -64,10 +65,6 @@ 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; - // Only read by the server, and opaque to every SDK and Store, which never parse it. It lets - // the server check that the caller is the task that the Producer names. It is an identity - // check, not a guard against a stale attempt, which the epoch fences. - bytes task_token = 6; } // Experimental. Might change in the future. @@ -88,8 +85,6 @@ message CloseStreamRequest { temporal.api.stream.v1.StreamRef ref = 2; // Required. The Producer that closes the Stream. temporal.api.stream.v1.Producer producer = 3; - // Only read by the server. It checks the token as on the append. - bytes task_token = 4; } // Experimental. Might change in the future. @@ -98,7 +93,7 @@ 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 +// Reads one page of Records after an offset. At the end of the Stream, the read waits for a new // Record, so readers long poll. message ReadRecordsRequest { // The caller's namespace. Only read by the server. The SDK sets it, and the SDK Stores don't @@ -106,41 +101,20 @@ message ReadRecordsRequest { string namespace = 1; // Required. temporal.api.stream.v1.StreamRef ref = 2; - // Where the read starts. Unset is the beginning of the Stream. - oneof position { - // Read after this offset. It fails with OFFSET_TOO_HIGH if it is past the newest offset. - // (-- api-linter: core::0140::prepositions=disabled - // aip.dev/not-precedent: "from" and "to" mark the ends of a range of offsets. --) - bytes from_offset = 3; - // Start at the beginning or at the end of the Stream. Unspecified is the beginning. - temporal.api.enums.v1.StreamReadStart start = 4; - } + // Read after this offset. It fails with OFFSET_TOO_HIGH if it is past the newest offset. + // Leave empty to read from the beginning. To read only new Records, a caller gets last_offset + // from a describe and reads after it. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" marks the start of a range of offsets. --) + bytes from_offset = 3; // The most Records in one page. Zero is 1000. - int32 max_records = 5; + int32 max_records = 4; // 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; - // The exclusive end of the read. The read scans no entry at or after it, so a replay reads the - // same range. Empty 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. --) - bytes to_offset = 7; - // Asks only for a wake-up, for a reader that must not get Records in this answer, such as a - // Workflow whose Records stay out of History. The read waits as a normal read does. It then - // answers with no Records and last_offset = from_offset, when a Record after from_offset - // exists, when the Stream closes (done), or when wait ends. The reader then gets the Records - // with a normal read. The wait may be days, since a parked read adds nothing to History. - // Only the server serves it, and a Store refuses it. - bool wake_only = 8; + google.protobuf.Duration wait = 5; // 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 = 9; - // 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 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. --) - bytes floor_from = 10; + int64 max_bytes = 6; } // Experimental. Might change in the future. @@ -152,21 +126,14 @@ message ReadRecordsResponse { // request's from_offset, and that is empty when the request had none. So it is always // known, and an empty value means that the next read starts as this one did. bytes last_offset = 2; - // True when the Stream is closed and this read gave its last item. The close item says the - // same on a page that has it. An answer with no item needs this flag, which is a wake_only - // read and a read after the close item. A read of a closed Stream with no item left answers - // at once. + // True when the Stream is closed and this read gave its last Record. It is the only sign of a + // close in a read, since a close stores no Record. A read of a closed Stream with no Record + // left answers at once with done. bool done = 3; - // Tells a Workflow reader to continue as new. The server keeps every Record from the lowest - // offset that a run read, since a replay of the run reads them again. That floor moves only - // when the run ends. This flag is true when the Records that this reader's floor keeps are - // 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. - bool continue_as_new_suggested = 4; } // Experimental. Might change in the future. -// Gets the status, the newest offset and the Retention of a Stream. +// Gets the status and the newest offset of a Stream. message DescribeStreamRequest { // The caller's namespace. Only read by the server. The SDK sets it, and the SDK Stores don't // read it. @@ -176,10 +143,10 @@ message DescribeStreamRequest { } // Experimental. Might change in the future. -// The state of a Stream. +// The state of a Stream. It has no Retention, since the Retention is a setting of the backend and +// not of one Stream. message DescribeStreamResponse { temporal.api.enums.v1.StreamStatus status = 1; // The newest offset, of a Record. Empty when the Stream has none. bytes last_offset = 2; - google.protobuf.Duration retention = 3; } From c86a5e090ce645349bc33e2cd6c1d0bfd39c3f61 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 01:50:52 -0700 Subject: [PATCH 45/56] Made done a plain bool in the read response of the Stream WIT. False and not set mean the same for this flag, so an option gave the SDK a third state with no meaning. --- nexus/stream-service.wit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index c6b8fc3de..461e02769 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -253,7 +253,7 @@ interface stream-service { /// @nexus.doc "The last offset that this read covered. The next read starts after it." last-offset: option>, /// @nexus.doc "True when the Stream is closed and this read gave its last Record." - done: option, + done: bool, } /// @nexus.proto "temporal.api.streamservice.v1.DescribeStreamRequest" From cc12c5eb149e6c81ffd4ecb664d2f1eca1a50652 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 02:24:06 -0700 Subject: [PATCH 46/56] Moved the nexgen pin to the head of temporalio/nexgen#407. That revision makes a list parameter of a resource method the bytes of its proto field, which the read's from-offset needs. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b9c8e70da..e721e98af 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 a nexgen commit that has them # until a release does. -NEX_GEN_REV := bbf5c1595a703563ff373d507d6e8174144cdf7c +NEX_GEN_REV := 8270746827e9706df9b050455f311995cf385459 NEX_GEN_ROOT := $(CURDIR)/$(STAMPDIR)/nexgen-$(NEX_GEN_REV) NEX_GEN ?= $(NEX_GEN_ROOT)/bin/nexgen From 88d729452c56774aa152ee135dbbbe81e49d110a Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 20:23:15 -0700 Subject: [PATCH 47/56] Gave the Stream's append and close an optional Producer in the WIT. A caller can pass its own Producer, and the default helper picks one by where the code runs. An append gives back its response, so the API can grow without a breaking change. --- nexus/stream-service.wit | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 461e02769..05cd72453 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -185,12 +185,12 @@ interface stream-service { record append-records-request { ref: stream-ref, records: list, - // Only the context helper sets the Producer right, so it is never a parameter. The helper of - // each context fills the Producer that applies where it runs. - /// @nexus.doc "The Producer of the batch. The helper of each context sets it." + // A call can pass its own Producer. With none, the helper of each context fills the Producer + // that applies where it runs. + /// @nexus.doc "The Producer of the batch. With none, the helper of each context sets it." /// @nexus.source targets="workflow" python="workflow_producer()" /// typescript="workflowProducer()" dotnet="StreamWorkflowContext.Producer()" - /// @nexus.source targets="client" python="client_producer()" typescript="clientProducer()" + /// @nexus.source targets="client" python="default_producer()" typescript="defaultProducer()" /// dotnet="StreamActivityContext.Producer()" producer: producer, // A call with no writer state, such as append, sends no sequence. The Store then numbers the @@ -214,10 +214,10 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.CloseStreamRequest" record close-stream-request { ref: stream-ref, - /// @nexus.doc "The Producer that closes the Stream. The helper of each context sets it." + /// @nexus.doc "The Producer that closes the Stream. With none, the helper of each context sets it." /// @nexus.source targets="workflow" python="workflow_producer()" /// typescript="workflowProducer()" dotnet="StreamWorkflowContext.Producer()" - /// @nexus.source targets="client" python="client_producer()" typescript="clientProducer()" + /// @nexus.source targets="client" python="default_producer()" typescript="defaultProducer()" /// dotnet="StreamActivityContext.Producer()" producer: producer, // Native only. The SDK sets it, and the SDK Stores don't read it. @@ -278,15 +278,18 @@ interface stream-service { resource %stream { constructor(ref: stream-ref, created: option); - /// @nexus.doc "Append values, and return the offset of the last one." + /// @nexus.doc "Append values, and return the offsets of the batch." /// @nexus.operation "append-records" /// @nexus.varargs "records" - append: func(records: list) -> append-records-response; + append: func( + records: list, + producer: option, + ) -> append-records-response; /// @nexus.doc "Close the Stream. Readers drain what is left, then end." /// @nexus.operation "close-stream" /// @nexus.close - close: func(); + close: func(producer: option); /// @nexus.doc "Read one page of Records. The poll iterator reads them all." /// @nexus.operation "read-records" @@ -311,7 +314,6 @@ interface stream-service { 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.output-transform python-type="bytes" python="_support.last_offset(result)" /// @nexus.operation name="AppendRecords" /// @nexus.api-omit append-records: func(request: append-records-request) -> append-records-response; From cdbb81dbd7272bb370c66d13f20f076c40dca33a Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 12 Nov 2026 20:32:49 -0700 Subject: [PATCH 48/56] Renamed the backend of a Stream ref to store. The SDKs and the docs say Store for the thing that holds a Stream, so the ref's field says it too. --- nexus/stream-service.wit | 7 ++++--- temporal/api/stream/v1/message.proto | 10 +++++----- temporal/api/streamservice/v1/request_response.proto | 6 +++--- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 05cd72453..c2f77059e 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -36,8 +36,8 @@ interface stream-service { record stream-ref { /// @nexus.doc "The Stream id, 1 to 4096 bytes." id: string, - /// @nexus.doc "Registered backend name. With none, the SDK picks one. Only an SDK reads it." - backend: option, + /// @nexus.doc "Registered Store name. With none, the SDK picks one. Only an SDK reads it." + store: option, } /// @nexus.doc "A Workflow Task attempt of the run." @@ -214,7 +214,8 @@ interface stream-service { /// @nexus.proto "temporal.api.streamservice.v1.CloseStreamRequest" record close-stream-request { ref: stream-ref, - /// @nexus.doc "The Producer that closes the Stream. With none, the helper of each context sets it." + /// @nexus.doc + /// "The Producer that closes the Stream. With none, the helper of each context sets it." /// @nexus.source targets="workflow" python="workflow_producer()" /// typescript="workflowProducer()" dotnet="StreamWorkflowContext.Producer()" /// @nexus.source targets="client" python="default_producer()" typescript="defaultProducer()" diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index f17f05db4..b278a1037 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -19,12 +19,12 @@ import "temporal/api/enums/v1/stream.proto"; // closed or expired Stream fails, and it doesn't make a second Stream with the same id. message StreamRef { string id = 1; - // 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. With no name, the SDK picks the backend from - // its config. Only an SDK sets and reads it, since only the SDK knows the name that its config - // gives a backend. A request carries the whole ref, and the server and a Store ignore this + // The name of the Store that holds the Stream, as the Worker and client config register it, + // so every process routes a ref it receives. With no name, the SDK picks the Store from its + // config. Only an SDK sets and reads it, since only the SDK knows the name that its config + // gives a Store. A request carries the whole ref, and the server and a Store ignore this // field. - string backend = 3; + string store = 3; } // 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 75d14bffe..4d23665da 100644 --- a/temporal/api/streamservice/v1/request_response.proto +++ b/temporal/api/streamservice/v1/request_response.proto @@ -21,7 +21,7 @@ import "temporal/api/stream/v1/message.proto"; // Creates the Stream. When the Stream exists, FAIL fails with ALREADY_EXISTS, and USE_EXISTING // gets it with created false. A retry of the create that made the Stream, with the same // request_id, gets created true. Each policy fails with CLOSED on a closed Stream and EXPIRED on -// an expired one. A create takes no Retention. The backend config holds it, as a namespace holds +// an expired one. A create takes no Retention. The Store config holds it, as a namespace holds // the Retention of its Workflows. // (-- api-linter: core::0133=disabled // aip.dev/not-precedent: The request identifies a Stream by namespace and ref. --) @@ -42,7 +42,7 @@ message CreateStreamRequest { // The Stream that a create made or got. message CreateStreamResponse { // The ref of the Stream, which the caller passes on as data. The SDK sets the name of the - // backend that it picked, also when the request left the name empty. The server gives the ref + // Store that it picked, also when the request left the name empty. The server gives the ref // back as the request had it, since it doesn't know that name. temporal.api.stream.v1.StreamRef ref = 1; // False when USE_EXISTING got a Stream that existed. @@ -143,7 +143,7 @@ message DescribeStreamRequest { } // Experimental. Might change in the future. -// The state of a Stream. It has no Retention, since the Retention is a setting of the backend and +// The state of a Stream. It has no Retention, since the Retention is a setting of the Store and // not of one Stream. message DescribeStreamResponse { temporal.api.enums.v1.StreamStatus status = 1; From 6a6d45d8e9afaeaf47f228ace06690c23e2ace05 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 00:47:22 -0700 Subject: [PATCH 49/56] Let a caller number an append in the Stream WIT. The resource's `append` takes an optional `sequence`, so a caller that numbers its batches gets each one written once. Two comments no longer name an SDK writer. --- nexus/stream-service.wit | 5 +++-- temporal/api/enums/v1/stream.proto | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index c2f77059e..79b5fb883 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -193,8 +193,8 @@ interface stream-service { /// @nexus.source targets="client" python="default_producer()" typescript="defaultProducer()" /// dotnet="StreamActivityContext.Producer()" producer: producer, - // A call with no writer state, such as append, sends no sequence. The Store then numbers the - // batch, so nothing dedupes it. + // A call that passes no sequence sends none. 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, @@ -285,6 +285,7 @@ interface stream-service { append: func( records: list, producer: option, + sequence: option, ) -> append-records-response; /// @nexus.doc "Close the Stream. Readers drain what is left, then end." diff --git a/temporal/api/enums/v1/stream.proto b/temporal/api/enums/v1/stream.proto index e784c937b..20d87ba48 100644 --- a/temporal/api/enums/v1/stream.proto +++ b/temporal/api/enums/v1/stream.proto @@ -40,7 +40,7 @@ enum StreamErrorReason { STREAM_ERROR_REASON_NOT_FOUND = 1; // The Stream's Retention or its last-write TTL passed. NOT_FOUND. STREAM_ERROR_REASON_EXPIRED = 2; - // The Stream is closed, or the SDK's writer is closed. FAILED_PRECONDITION. + // The Stream is closed. FAILED_PRECONDITION. STREAM_ERROR_REASON_CLOSED = 3; // The Producer's epoch is lower than the newest one. FAILED_PRECONDITION. STREAM_ERROR_REASON_FENCED = 4; From 849243a9de9f55e37823df28d9a6a37bd9c781fc Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 01:04:26 -0700 Subject: [PATCH 50/56] Said how offsets compare, and what a Workflow writes with on a Store. Two offsets of one Stream compare bytewise, so the server can order them too. On a Store, Workflow code writes through a Local Activity, so the Workflow Producer comments no longer describe a close there. --- nexus/stream-service.wit | 6 +++--- temporal/api/stream/v1/message.proto | 13 +++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index 79b5fb883..a0c0ded73 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -21,7 +21,8 @@ interface stream-service { /// @nexus.type-parameter type body-t = placeholder; - // An offset is an opaque, ordered position of a Record in one Stream. It is proto `bytes`. + // An offset is an opaque, ordered position of a Record in one Stream. It is proto `bytes`, and + // two offsets of one Stream compare bytewise in the order of their Records. // TODO: use `type stream-offset = list` once nexgen reads a named `list` as `bytes`. /// @nexus.proto "google.protobuf.Timestamp" @@ -73,8 +74,7 @@ interface stream-service { record workflow-producer { /// @nexus.doc "The run id of the Workflow run, not of its chain." run-id: string, - // The Workflow Task of the write. A close on a Store runs after the Workflow Task and sends - // no epoch. + // The Workflow Task of the write. epoch: option, } diff --git a/temporal/api/stream/v1/message.proto b/temporal/api/stream/v1/message.proto index b278a1037..376f883d6 100644 --- a/temporal/api/stream/v1/message.proto +++ b/temporal/api/stream/v1/message.proto @@ -65,13 +65,12 @@ message ActivityEpoch { // Experimental. Might change in the future. // A Workflow run Producer. On the server's Streams, a Workflow writes as this Producer in its -// Workflow Task. On a Store, a Workflow appends as a Local Activity Producer, and this Producer -// only closes the Stream. +// Workflow Task. On a Store, Workflow code writes through a Local Activity, so its writes carry a +// Local Activity Producer. message WorkflowProducer { // Required. The run id of the Workflow run, not of its chain. Each run is its own Producer. string run_id = 1; - // The Workflow Task of the write. A close on a Store runs after the Workflow Task and sends - // no epoch. + // The Workflow Task of the write. WorkflowTaskEpoch epoch = 2; } @@ -139,8 +138,10 @@ message StreamRecord { Producer producer = 1; temporal.api.common.v1.Payload body = 2; // Required. The Record's offset. An offset is ordered and unique in one Stream, and it is - // sparse. Offsets have gaps, so they don't count Records. An offset is never empty. A read - // gives its Stream, so the read checks the offset against that Stream. + // sparse. Offsets have gaps, so they don't count Records. Two offsets of one Stream compare + // bytewise, as unsigned bytes, in the order of their Records. So a reader, a Store and the + // server order them without decoding them. An offset is never empty. A read gives its + // Stream, so the read checks the offset against that Stream. bytes offset = 3; } From a82faf82fd5eb9930016694b941da2878487af21 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 14:08:19 -0700 Subject: [PATCH 51/56] Left append, close and read out of the Workflow Stream in the WIT. Workflow code has no built-in append, close or read in this release, so the Workflow target omits the three methods and the client target keeps them. The two `producer` parameters take their value from `@nexus.default`. The nexgen pin moves to `main`, which has both rules. --- Makefile | 2 +- nexus/stream-service.wit | 36 ++++++++++++++++++++++-------------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index e721e98af..ebaddc72f 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 a nexgen commit that has them # until a release does. -NEX_GEN_REV := 8270746827e9706df9b050455f311995cf385459 +NEX_GEN_REV := 9653a0f5a845df0c5059554f1b5813db6a74cd1a 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 a0c0ded73..e4401d856 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -21,8 +21,11 @@ interface stream-service { /// @nexus.type-parameter type body-t = placeholder; - // An offset is an opaque, ordered position of a Record in one Stream. It is proto `bytes`, and - // two offsets of one Stream compare bytewise in the order of their Records. + // The WIT has no named offset type yet, so this comment tells what each `list` offset holds. + // An offset is the opaque position of a Record in one Stream, and it is proto `bytes`. A caller + // never parses it. It only gives it back to a read, or compares two offsets of one Stream. They + // compare bytewise in the order of their Records, so a caller can tell which one is newer + // without a call to the Store. // TODO: use `type stream-offset = list` once nexgen reads a named `list` as `bytes`. /// @nexus.proto "google.protobuf.Timestamp" @@ -185,13 +188,13 @@ interface stream-service { record append-records-request { ref: stream-ref, records: list, - // A call can pass its own Producer. With none, the helper of each context fills the Producer - // that applies where it runs. - /// @nexus.doc "The Producer of the batch. With none, the helper of each context sets it." - /// @nexus.source targets="workflow" python="workflow_producer()" - /// typescript="workflowProducer()" dotnet="StreamWorkflowContext.Producer()" - /// @nexus.source targets="client" python="default_producer()" typescript="defaultProducer()" - /// dotnet="StreamActivityContext.Producer()" + // A call can pass its own Producer. A call that passes none gets the default, which the + // helper of each context picks for where it runs. + /// @nexus.doc "The Producer of the batch. Defaults to the Producer of the calling context." + /// @nexus.default targets="workflow" python="_support.workflow_producer()" + /// typescript="support.workflowProducer()" dotnet="StreamWorkflowContext.Producer()" + /// @nexus.default targets="client" python="_support.default_producer()" + /// typescript="support.defaultProducer()" dotnet="StreamActivityContext.Producer()" producer: producer, // A call that passes no sequence sends none. The Store then numbers the batch, so nothing // dedupes it. @@ -215,11 +218,11 @@ interface stream-service { record close-stream-request { ref: stream-ref, /// @nexus.doc - /// "The Producer that closes the Stream. With none, the helper of each context sets it." - /// @nexus.source targets="workflow" python="workflow_producer()" - /// typescript="workflowProducer()" dotnet="StreamWorkflowContext.Producer()" - /// @nexus.source targets="client" python="default_producer()" typescript="defaultProducer()" - /// dotnet="StreamActivityContext.Producer()" + /// "The Producer that closes the Stream. Defaults to the Producer of the calling context." + /// @nexus.default targets="workflow" python="_support.workflow_producer()" + /// typescript="support.workflowProducer()" dotnet="StreamWorkflowContext.Producer()" + /// @nexus.default targets="client" python="_support.default_producer()" + /// typescript="support.defaultProducer()" dotnet="StreamActivityContext.Producer()" producer: producer, // Native only. The SDK sets it, and the SDK Stores don't read it. /// @nexus.omit @@ -279,9 +282,12 @@ interface stream-service { resource %stream { constructor(ref: stream-ref, created: option); + // In this release Workflow code can't append to a Stream, close it or read it. So the Workflow + // target omits `append`, `close` and `read`, and the client target keeps all three. /// @nexus.doc "Append values, and return the offsets of the batch." /// @nexus.operation "append-records" /// @nexus.varargs "records" + /// @nexus.api-omit targets="workflow" append: func( records: list, producer: option, @@ -291,10 +297,12 @@ interface stream-service { /// @nexus.doc "Close the Stream. Readers drain what is left, then end." /// @nexus.operation "close-stream" /// @nexus.close + /// @nexus.api-omit targets="workflow" close: func(producer: option); /// @nexus.doc "Read one page of Records. The poll iterator reads them all." /// @nexus.operation "read-records" + /// @nexus.api-omit targets="workflow" read: func( from-offset: option>, max-records: option, From 21c53fe9397b74e0fcb10a688a1e539471df879f Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 13 Nov 2026 18:51:58 -0700 Subject: [PATCH 52/56] Put append and close back on the Workflow Stream in the WIT. Workflow code appends through a Local Activity and closes through an Activity, so the Workflow target has both methods again. It still omits read. --- nexus/stream-service.wit | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index e4401d856..ed7449882 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -282,12 +282,9 @@ interface stream-service { resource %stream { constructor(ref: stream-ref, created: option); - // In this release Workflow code can't append to a Stream, close it or read it. So the Workflow - // target omits `append`, `close` and `read`, and the client target keeps all three. /// @nexus.doc "Append values, and return the offsets of the batch." /// @nexus.operation "append-records" /// @nexus.varargs "records" - /// @nexus.api-omit targets="workflow" append: func( records: list, producer: option, @@ -297,9 +294,10 @@ interface stream-service { /// @nexus.doc "Close the Stream. Readers drain what is left, then end." /// @nexus.operation "close-stream" /// @nexus.close - /// @nexus.api-omit targets="workflow" close: func(producer: option); + // In this release Workflow code can't read a Stream. So the Workflow target omits `read`, and + // the client target keeps it. /// @nexus.doc "Read one page of Records. The poll iterator reads them all." /// @nexus.operation "read-records" /// @nexus.api-omit targets="workflow" From 59e7ea7ad63d9dab48b538982cd4b3752fb4ff04 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Sat, 14 Nov 2026 02:03:45 -0700 Subject: [PATCH 53/56] Pinned the nexgen fork commit that has `@nexus.helper`. The next commit adds three methods to the Stream WIT that use the directive, and no commit of nexgen `main` has it yet. --- Makefile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index ebaddc72f..a03fa405d 100644 --- a/Makefile +++ b/Makefile @@ -36,8 +36,9 @@ OAPI_OUT := openapi OAPI3_PATH := .components.schemas.Payload # The Stream WIT uses nexgen directives that no release has yet. Pin a nexgen commit that has them -# until a release does. -NEX_GEN_REV := 9653a0f5a845df0c5059554f1b5813db6a74cd1a +# until a release does. `@nexus.helper` is not in nexgen's `main` yet, so the commit is on a fork. +NEX_GEN_REPO := https://github.com/moetemp/nexgen +NEX_GEN_REV := 4eab992027a449192a18ee3b3ab190a23e5ae174 NEX_GEN_ROOT := $(CURDIR)/$(STAMPDIR)/nexgen-$(NEX_GEN_REV) NEX_GEN ?= $(NEX_GEN_ROOT)/bin/nexgen @@ -181,7 +182,7 @@ system-nexus-wit-install: nex-gen-install: printf $(COLOR) "Install nexgen if missing..." command -v $(NEX_GEN) >/dev/null || CARGO_NET_GIT_FETCH_WITH_CLI=true cargo install \ - --git https://github.com/temporalio/nexgen --rev $(NEX_GEN_REV) --locked --features advanced \ + --git $(NEX_GEN_REPO) --rev $(NEX_GEN_REV) --locked --features advanced \ --root $(NEX_GEN_ROOT) nexgen ##### Clean ##### From 7db9c131e887bf4b6cc27b0bd30baa2ee7409674 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Sat, 14 Nov 2026 02:03:45 -0700 Subject: [PATCH 54/56] Added take, filter and reduce to the Stream in the WIT. Client, Activity and Workflow code read a Stream with these three methods. The SDK implements each one, so no operation binds them, and the Workflow Stream still has no page read. --- nexus/stream-service.wit | 92 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index ed7449882..def79a37d 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -21,6 +21,10 @@ interface stream-service { /// @nexus.type-parameter type body-t = placeholder; + /// The value that `reduce` folds the Records into. A call's initial value sets it. + /// @nexus.type-parameter + type value-t = placeholder; + // The WIT has no named offset type yet, so this comment tells what each `list` offset holds. // An offset is the opaque position of a Record in one Stream, and it is proto `bytes`. A caller // never parses it. It only gives it back to a read, or compares two offsets of one Stream. They @@ -144,6 +148,52 @@ interface stream-service { offset: list, } + /// Why a read of many pages ended. + enum stream-read-stop { + match, + done, + max-records, + max-bytes, + idle, + timeout, + } + + // The SDK's support package builds this from many pages of one call. It has no proto message, + // since it never goes on the wire. + /// @nexus.doc "What one read of many pages gave." + record stream-read { + records: list, + /// @nexus.doc "Where the scan stopped. Give it as the from-offset of the next call." + last-offset: option>, + stop: stream-read-stop, + done: bool, + } + + // A function of the caller stays in the process, so this signature is no operation. + record-predicate: func(%record: stream-record) -> bool; + + /// @nexus.function signature="record-predicate" + type read-predicate = placeholder; + + /// @nexus.doc "What one fold of many pages gave." + record stream-reduce { + value: value-t, + count: s32, + last-offset: option>, + stop: stream-read-stop, + done: bool, + } + + fold-function: func(value: value-t, %record: stream-record) -> value-t; + + fold-predicate: func(value: value-t, %record: stream-record) -> bool; + + /// @nexus.function signature="fold-function" + type fold = placeholder; + + /// @nexus.function signature="fold-predicate" + type fold-until = placeholder; + /// @nexus.doc /// "Create a Stream. If it exists, fail, or with use-existing get the open one." /// @nexus.proto "temporal.api.streamservice.v1.CreateStreamRequest" @@ -296,8 +346,8 @@ interface stream-service { /// @nexus.close close: func(producer: option); - // In this release Workflow code can't read a Stream. So the Workflow target omits `read`, and - // the client target keeps it. + // Workflow code reads with `take`, `filter` and `reduce`, not with a page read. So the + // Workflow target omits `read`, and the client target keeps it. /// @nexus.doc "Read one page of Records. The poll iterator reads them all." /// @nexus.operation "read-records" /// @nexus.api-omit targets="workflow" @@ -311,6 +361,44 @@ interface stream-service { /// @nexus.doc "Describe the Stream's status and newest offset." /// @nexus.operation "describe-stream" describe: func() -> describe-stream-response; + + // The SDK's support package reads many pages in one call, so no operation binds these + // methods. It finds out where it runs, and Workflow code reads in one Local Activity. + /// @nexus.doc "Read every Record up to a stop." + /// @nexus.helper python="take_stream" + take: func( + timeout: duration, + until: option, + from-offset: option>, + idle-timeout: option, + max-records: option, + max-bytes: option, + ) -> stream-read; + + /// @nexus.doc "Read the Records that the predicate keeps, up to a stop." + /// @nexus.helper python="filter_stream" + filter: func( + predicate: read-predicate, + timeout: duration, + until: option, + from-offset: option>, + idle-timeout: option, + max-records: option, + max-bytes: option, + ) -> stream-read; + + /// @nexus.doc "Fold the Records into one value, up to a stop." + /// @nexus.helper python="reduce_stream" + reduce: func( + function: fold, + initial: value-t, + timeout: duration, + until: option, + from-offset: option>, + idle-timeout: option, + max-records: option, + max-bytes: option, + ) -> stream-reduce; } // With use-existing, created is false when the Stream existed. From 1246a4a66b864f584ee765b7f241f9fc1699181e Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Sat, 14 Nov 2026 13:22:44 -0700 Subject: [PATCH 55/56] Pinned the nexgen fork commit that has `@nexus.guard`. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a03fa405d..74d877ece 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ OAPI3_PATH := .components.schemas.Payload # The Stream WIT uses nexgen directives that no release has yet. Pin a nexgen commit that has them # until a release does. `@nexus.helper` is not in nexgen's `main` yet, so the commit is on a fork. NEX_GEN_REPO := https://github.com/moetemp/nexgen -NEX_GEN_REV := 4eab992027a449192a18ee3b3ab190a23e5ae174 +NEX_GEN_REV := 9055618618e580b68d61c7d7e52d8f25aea3824c NEX_GEN_ROOT := $(CURDIR)/$(STAMPDIR)/nexgen-$(NEX_GEN_REV) NEX_GEN ?= $(NEX_GEN_ROOT)/bin/nexgen From 57dd40705d4c976e604566960451ac0a80a9ab31 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Sat, 14 Nov 2026 13:22:44 -0700 Subject: [PATCH 56/56] Added the method guard to the Stream in the WIT. The SDK's check runs when code takes a method of a Stream, so a call that nothing awaits fails too. --- nexus/stream-service.wit | 1 + 1 file changed, 1 insertion(+) diff --git a/nexus/stream-service.wit b/nexus/stream-service.wit index def79a37d..79bb6a627 100644 --- a/nexus/stream-service.wit +++ b/nexus/stream-service.wit @@ -328,6 +328,7 @@ 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.guard python="check_stream_call" // TODO: implement @nexus.flatten-in-api param="ref". resource %stream { constructor(ref: stream-ref, created: option);