From 85b71d7ecd4f2bf677fa1cee17f3fbc1ab10f1b9 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Fri, 28 Aug 2026 11:42:13 -0700 Subject: [PATCH 01/27] Update api_upstream --- crates/protos/protos/api_upstream/Makefile | 8 ++-- crates/protos/protos/api_upstream/buf.lock | 19 +++------- crates/protos/protos/api_upstream/buf.yaml | 29 +++++++-------- .../nexus/deps/nexus-temporal-types/model.wit | 37 ++++++++++++------- .../api_upstream/nexus/workflow-service.wit | 4 +- 5 files changed, 49 insertions(+), 48 deletions(-) diff --git a/crates/protos/protos/api_upstream/Makefile b/crates/protos/protos/api_upstream/Makefile index d43b05677..f6fcf899d 100644 --- a/crates/protos/protos/api_upstream/Makefile +++ b/crates/protos/protos/api_upstream/Makefile @@ -97,7 +97,7 @@ api-linter-install: buf-install: printf $(COLOR) "Install/update buf..." - go install github.com/bufbuild/buf/cmd/buf@v1.27.0 + go install github.com/bufbuild/buf/cmd/buf@v1.49.0 ##### Sync external proto dependencies ##### sync-nexus-annotations: @@ -112,12 +112,12 @@ api-linter: $(STAMPDIR): mkdir $@ -$(STAMPDIR)/buf-mod-prune: $(STAMPDIR) buf.yaml +$(STAMPDIR)/buf-dep-prune: $(STAMPDIR) buf.yaml printf $(COLOR) "Pruning buf module" - buf mod prune + buf dep prune touch $@ -buf-lint: $(STAMPDIR)/buf-mod-prune +buf-lint: $(STAMPDIR)/buf-dep-prune printf $(COLOR) "Run buf linter..." (cd $(PROTO_ROOT) && buf lint) diff --git a/crates/protos/protos/api_upstream/buf.lock b/crates/protos/protos/api_upstream/buf.lock index f43352bf2..e0559987a 100644 --- a/crates/protos/protos/api_upstream/buf.lock +++ b/crates/protos/protos/api_upstream/buf.lock @@ -1,18 +1,9 @@ # Generated by buf. DO NOT EDIT. -version: v1 +version: v2 deps: - - remote: buf.build - owner: googleapis - repository: googleapis + - name: buf.build/googleapis/googleapis commit: 004180b77378443887d3b55cabc00384 - digest: shake256:d26c7c2fd95f0873761af33ca4a0c0d92c8577122b6feb74eb3b0a57ebe47a98ab24a209a0e91945ac4c77204e9da0c2de0020b2cedc27bdbcdea6c431eec69b - - remote: buf.build - owner: grpc-ecosystem - repository: grpc-gateway - commit: 6467306b4f624747aaf6266762ee7a1c - digest: shake256:833d648b99b9d2c18b6882ef41aaeb113e76fc38de20dda810c588d133846e6593b4da71b388bcd921b1c7ab41c7acf8f106663d7301ae9e82ceab22cf64b1b7 - - remote: buf.build - owner: temporalio - repository: nexus-annotations + digest: b5:e8f475fe3330f31f5fd86ac689093bcd274e19611a09db91f41d637cb9197881ce89882b94d13a58738e53c91c6e4bae7dc1feba85f590164c975a89e25115dc + - name: buf.build/temporalio/nexus-annotations commit: 599b78404fbe4e78b833d527a1d0da40 - digest: shake256:1f41ef11ccbf31d7318b0fe1915550ba6567c99dc94694d60b117fc1ffc756290ba9766c58b403986f079e2b861b42538e5f8cf0495f744cd390d223b81854ca + digest: b5:feb0298a2e7e60058a5dee533e166e152bd0c3b9f776170946fba80737722022a39a65b132af1028d150d2c5bc52990694f60fbcf89fbb9693f7a6b7803d9203 diff --git a/crates/protos/protos/api_upstream/buf.yaml b/crates/protos/protos/api_upstream/buf.yaml index 2f2fa5389..d00ec17a7 100644 --- a/crates/protos/protos/api_upstream/buf.yaml +++ b/crates/protos/protos/api_upstream/buf.yaml @@ -1,24 +1,23 @@ -version: v1 -name: buf.build/temporalio/api +version: v2 +modules: + - path: . + name: buf.build/temporalio/api + excludes: + # Vendored for api-linter (can't read the BSR); excluded so buf sees them once. + - google + - nexusannotations deps: - - buf.build/grpc-ecosystem/grpc-gateway - buf.build/googleapis/googleapis - buf.build/temporalio/nexus-annotations -build: - excludes: - # Buf won't accept a local dependency on the google protos but we need them - # to run api-linter, so just tell buf it ignore it - - google - # Same for nexusannotations - local copy for api-linter, BSR dep for buf - - nexusannotations -breaking: +lint: use: - - WIRE_JSON + - STANDARD ignore: + - cmd - google -lint: + disallow_comment_ignores: true +breaking: use: - - DEFAULT + - WIRE_JSON ignore: - google - - cmd diff --git a/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit b/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit index 91de3e838..9909d34c6 100644 --- a/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit +++ b/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit @@ -9,15 +9,28 @@ interface model { /// python="typing.Any" /// typescript="common.Payload" /// dotnet="object?" + /// dotnet-from="ProtoExtensions.FromPayload" /// dotnet-to="ProtoExtensions.ToPayload" /// typescript-import="@temporalio/common" type payload = placeholder; /// @nexus.proto "temporal.api.common.v1.Payloads" /// typescript-import="@temporalio/proto" - /// @nexus.type dotnet="IReadOnlyCollection" dotnet-to="ProtoExtensions.ToPayloads" + /// @nexus.type dotnet="IReadOnlyCollection" dotnet-from="ProtoExtensions.FromPayloads" dotnet-to="ProtoExtensions.ToPayloads" type payloads = list; + /// Temporal failure represented by the target SDK's native exception/error type. + /// The SDK failure converter owns the recursive cause and failure-info structure. + /// @nexus.proto "temporal.api.failure.v1.Failure" typescript-import="@temporalio/proto" + /// @nexus.type + /// python="BaseException" + /// typescript="Error" + /// go="error" + /// dotnet="System.Exception" + /// dotnet-from="ProtoExtensions.FromFailureProto" + /// dotnet-to="ProtoExtensions.ToFailureProto" + type failure = placeholder; + /// Callable result annotation for workflow functions. /// @nexus.type /// python="collections.abc.Awaitable[WorkflowResult]" @@ -50,6 +63,7 @@ interface model { /// python="str" /// typescript="string" /// dotnet="string" + /// dotnet-from="ProtoExtensions.FromWorkflowTypeProto" /// dotnet-to="ProtoExtensions.ToWorkflowTypeProto" type workflow-type = placeholder; @@ -83,6 +97,7 @@ interface model { /// python="temporalio.common.RetryPolicy" /// typescript="common.RetryPolicy" /// dotnet="Temporalio.Common.RetryPolicy" + /// dotnet-from="ProtoExtensions.FromRetryPolicyProto" /// typescript-import="@temporalio/common" type retry-policy = placeholder; @@ -91,11 +106,12 @@ interface model { /// python="str" /// typescript="string" /// dotnet="string" + /// dotnet-from="ProtoExtensions.FromTaskQueueProto" /// dotnet-to="ProtoExtensions.ToTaskQueueProto" type task-queue = placeholder; /// @nexus.proto "temporal.api.common.v1.Memo" typescript-import="@temporalio/proto" - /// @nexus.type python="collections.abc.Mapping[str, typing.Any]" typescript="Record" dotnet="IReadOnlyDictionary" + /// @nexus.type python="collections.abc.Mapping[str, typing.Any]" typescript="Record" dotnet="IReadOnlyDictionary" dotnet-from="ProtoExtensions.FromMemoProto" type memo = placeholder; /// @nexus.proto "temporal.api.common.v1.Header" typescript-import="@temporalio/proto" @@ -114,6 +130,7 @@ interface model { /// python="temporalio.common.TypedSearchAttributes" /// typescript="common.TypedSearchAttributes" /// dotnet="Temporalio.Common.SearchAttributeCollection" + /// dotnet-from="ProtoExtensions.FromSearchAttributesProto" /// typescript-import="@temporalio/common" type search-attributes = placeholder; @@ -122,6 +139,7 @@ interface model { /// python="temporalio.common.Priority" /// typescript="common.Priority" /// dotnet="Temporalio.Common.Priority" + /// dotnet-from="ProtoExtensions.FromPriorityProto" /// typescript-import="@temporalio/common" type priority = placeholder; @@ -130,6 +148,7 @@ interface model { /// python="temporalio.common.VersioningOverride" /// typescript="common.VersioningOverride" /// dotnet="Temporalio.Common.VersioningOverride" + /// dotnet-from="ProtoExtensions.FromVersioningOverrideProto" /// typescript-import="@temporalio/common" type versioning-override = placeholder; @@ -138,6 +157,7 @@ interface model { /// python="datetime.timedelta" /// typescript="common.Duration" /// dotnet="System.TimeSpan" + /// dotnet-from="ProtoExtensions.FromDurationProto" /// typescript-import="@temporalio/common" type duration = placeholder; @@ -147,12 +167,7 @@ interface model { /// typescript="common.WorkflowIdReusePolicy" /// dotnet="Temporalio.Api.Enums.V1.WorkflowIdReusePolicy" /// typescript-import="@temporalio/common" - enum workflow-id-reuse-policy { - allow-duplicate, - allow-duplicate-failed-only, - reject-duplicate, - terminate-if-running, - } + type workflow-id-reuse-policy = placeholder; /// @nexus.proto "temporal.api.enums.v1.WorkflowIdConflictPolicy" typescript-import="@temporalio/proto" /// @nexus.type @@ -160,11 +175,7 @@ interface model { /// typescript="common.WorkflowIdConflictPolicy" /// dotnet="Temporalio.Api.Enums.V1.WorkflowIdConflictPolicy" /// typescript-import="@temporalio/common" - enum workflow-id-conflict-policy { - fail, - use-existing, - terminate-existing, - } + type workflow-id-conflict-policy = placeholder; /// @nexus.proto "temporal.api.sdk.v1.UserMetadata" typescript-import="@temporalio/proto" /// @nexus.flatten-in-api diff --git a/crates/protos/protos/api_upstream/nexus/workflow-service.wit b/crates/protos/protos/api_upstream/nexus/workflow-service.wit index 5ae3814e6..190aae88c 100644 --- a/crates/protos/protos/api_upstream/nexus/workflow-service.wit +++ b/crates/protos/protos/api_upstream/nexus/workflow-service.wit @@ -86,7 +86,7 @@ interface workflow-service { /// @nexus.proto-field "workflow_start_delay" start-delay: option, user-metadata: option, - /// @nexus.source python="workflow_namespace" typescript="workflowNamespace" dotnet="TemporalWorkflowContext.WorkflowNamespace" + /// @nexus.source python="workflow_namespace()" typescript="workflowNamespace()" go="workflow.GetInfo(ctx).Namespace" dotnet="TemporalWorkflowContext.WorkflowNamespace()" namespace: string, /// @nexus.omit control: placeholder, @@ -122,7 +122,7 @@ interface workflow-service { /// dotnet-type="Temporalio.Workflows.ExternalWorkflowHandle" /// dotnet="Temporalio.Workflows.Workflow.GetExternalWorkflowHandle(request.Id, result.RunId)" /// @nexus.operation name="SignalWithStartWorkflowExecution" - /// @nexus.serialization-context python="signal_with_start_workflow_serialization_context" + /// @nexus.serialization-context python="signal_with_start_workflow_serialization_context" dotnet="WorkflowServiceSerializationContexts.SignalWithStartWorkflow" /// @nexus.experimental signal-with-start-workflow: func( request: signal-with-start-workflow-request, From 5209468cb61533f95b78c9f9d5561d55400e2d25 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 21 Sep 2026 18:01:41 -0700 Subject: [PATCH 02/27] Carried the stream protos in the Core's api tree. This branch is the Core commit upstream sdk-python pins, plus the api branch's stream diff applied onto that Core's own api tree, so a Python branch that vendors the stream protos can regenerate them from its pin. The protos crate lists the new package and the two payload fields; nothing else changes. --- crates/common/build.rs | 5 ++ crates/protos/build.rs | 1 + .../temporal/api/command/v1/message.proto | 34 ++++++++ .../temporal/api/enums/v1/command_type.proto | 2 + .../temporal/api/enums/v1/event_type.proto | 8 ++ .../temporal/api/enums/v1/failed_cause.proto | 8 ++ .../temporal/api/history/v1/message.proto | 37 +++++++++ .../temporal/api/stream/v1/message.proto | 82 +++++++++++++++++++ .../workflowservice/v1/request_response.proto | 5 ++ crates/protos/src/protos/mod.rs | 19 +++++ 10 files changed, 201 insertions(+) create mode 100644 crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto diff --git a/crates/common/build.rs b/crates/common/build.rs index 2e4a378de..1baae9de8 100644 --- a/crates/common/build.rs +++ b/crates/common/build.rs @@ -820,6 +820,11 @@ const NOT_VALIDATED_FIELDS: &[&str] = &[ "temporal.api.workflowservice.v1.StartWorkflowExecutionRequest.continued_failure", "temporal.api.workflowservice.v1.StartWorkflowExecutionRequest.last_completion_result", "temporal.api.workflowservice.v1.TerminateWorkflowExecutionRequest.details", + // Stream records: the blob limit is a per-event limit, and these bodies never reach an + // event. The server bounds the batch by message count (MaxMessagesPerBatch) instead, which + // is not a payload size the SDK can mirror. + "temporal.api.stream.v1.StreamRecord.body", + "temporal.api.stream.v1.StreamRecord.metadata", // Dedicated, non-fetchable limits (not blob/memo, not in DescribeNamespace): UserMetadata // (nexus-start only); Nexus EndpointSpec.description (maxDescriptionSize; cloud variant cloud-only). "temporal.api.sdk.v1.UserMetadata.details", diff --git a/crates/protos/build.rs b/crates/protos/build.rs index 509ba9259..7365df524 100644 --- a/crates/protos/build.rs +++ b/crates/protos/build.rs @@ -36,6 +36,7 @@ const SERDE_DERIVE_PREFIXES: &[&str] = &[ ".temporal.api.rules", ".temporal.api.schedule", ".temporal.api.sdk", + ".temporal.api.stream", ".temporal.api.taskqueue", ".temporal.api.testservice", ".temporal.api.update", diff --git a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto index ee839115b..7d10e441b 100644 --- a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto @@ -14,6 +14,7 @@ import "google/protobuf/duration.proto"; import "temporal/api/enums/v1/workflow.proto"; import "temporal/api/enums/v1/command_type.proto"; import "temporal/api/common/v1/message.proto"; +import "temporal/api/stream/v1/message.proto"; import "temporal/api/failure/v1/message.proto"; import "temporal/api/taskqueue/v1/message.proto"; import "temporal/api/workflow/v1/message.proto"; @@ -324,5 +325,38 @@ message Command { ScheduleNexusOperationCommandAttributes schedule_nexus_operation_command_attributes = 18; RequestCancelNexusOperationCommandAttributes request_cancel_nexus_operation_command_attributes = 19; + AppendStreamRecordsCommandAttributes append_stream_records_command_attributes = 20; + SubscribeStreamCommandAttributes subscribe_stream_command_attributes = 21; } } + +// Appends records to a stream the Workflow owns. Applied inside the Workflow +// Task's own commit. Produces one `WorkflowStreamRecordsAppended` event +// carrying the offset range and none of the payload; it schedules no further +// work. +message AppendStreamRecordsCommandAttributes { + // Empty means the Workflow's default output stream. + string stream_id = 1; + // Stored in order. The server sets `producer_id` to empty on each record, + // because the owning Workflow is the producer here. + repeated temporal.api.stream.v1.StreamRecord records = 2; +} + +// Subscribe this Workflow to a stream, so later Workflow Tasks carry the ranges +// it has not consumed yet. +// +// The stream's addressing is resolved by the server rather than supplied here. +// A Workflow cannot look it up without doing I/O, and a value it carried would +// be a reading rather than a fact, so it could differ on replay. +message SubscribeStreamCommandAttributes { + // Stream to consume. A stream in another execution is addressed by its id; + // one this Workflow owns is addressed by the name it was published under. + // The server resolves an owned name first and falls back to a standalone + // id, so a Workflow that owns a stream under this name cannot reach a + // standalone stream with the same id. + string stream_id = 1; + // Where to start. Negative means from wherever the stream is when the + // subscription is registered, which the server resolves and records so + // replay does not resolve it again. + int64 start_offset = 2; +} diff --git a/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto b/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto index 067d95391..967169b19 100644 --- a/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto +++ b/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto @@ -29,4 +29,6 @@ enum CommandType { COMMAND_TYPE_MODIFY_WORKFLOW_PROPERTIES = 16; COMMAND_TYPE_SCHEDULE_NEXUS_OPERATION = 17; COMMAND_TYPE_REQUEST_CANCEL_NEXUS_OPERATION = 18; + COMMAND_TYPE_APPEND_STREAM_RECORDS = 19; + COMMAND_TYPE_SUBSCRIBE_STREAM = 20; } diff --git a/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto b/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto index b879f51e8..a386b1db7 100644 --- a/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto +++ b/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto @@ -175,4 +175,12 @@ enum EventType { EVENT_TYPE_WORKFLOW_EXECUTION_UNPAUSED = 59; // An event that indicates time skipping advanced time or was disabled automatically after a bound was reached. EVENT_TYPE_WORKFLOW_EXECUTION_TIME_SKIPPING_TRANSITIONED = 60; + // A Workflow subscribed to a stream. Recorded once per subscription, not + // per record: the offsets a task consumed ride WorkflowTaskCompleted and + // the payloads never enter History at all. + EVENT_TYPE_WORKFLOW_STREAM_SUBSCRIBED = 61; + // A Workflow appended a batch of records to a stream. Recorded per + // batch, and carrying only the offset range it landed at: the bodies go to + // the stream's own log, never into History. + EVENT_TYPE_WORKFLOW_STREAM_RECORDS_APPENDED = 62; } diff --git a/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto b/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto index 81cbde73e..274e533d5 100644 --- a/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto +++ b/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto @@ -90,6 +90,14 @@ enum WorkflowTaskFailedCause { WORKFLOW_TASK_FAILED_CAUSE_WORKFLOW_PAUSE_REQUESTED_BEFORE_TASK_STARTED = 39; // A workflow task failed because the request exceeded a size limit. WORKFLOW_TASK_FAILED_CAUSE_REQUEST_TOO_LARGE = 40; + // A workflow task completed with an invalid AppendStreamRecords command. + WORKFLOW_TASK_FAILED_CAUSE_BAD_APPEND_STREAM_RECORDS_ATTRIBUTES = 41; + // A workflow task completed with an invalid SubscribeStream command. + WORKFLOW_TASK_FAILED_CAUSE_BAD_SUBSCRIBE_STREAM_ATTRIBUTES = 42; + // A workflow task could not be started because a stream range it consumed and recorded in + // History can no longer be served, for example after truncation or because it exceeds the + // replay bound. Check the workflow task failure message for more information. + WORKFLOW_TASK_FAILED_CAUSE_STREAM_RANGE_UNAVAILABLE = 43; } enum StartChildWorkflowExecutionFailedCause { diff --git a/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto index 0211c6f55..8deca8d15 100644 --- a/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto @@ -17,6 +17,7 @@ import "temporal/api/enums/v1/failed_cause.proto"; import "temporal/api/enums/v1/update.proto"; import "temporal/api/enums/v1/workflow.proto"; import "temporal/api/common/v1/message.proto"; +import "temporal/api/stream/v1/message.proto"; import "temporal/api/deployment/v1/message.proto"; import "temporal/api/failure/v1/message.proto"; import "temporal/api/taskqueue/v1/message.proto"; @@ -379,6 +380,13 @@ message WorkflowTaskCompletedEventAttributes { // The Worker Deployment Version that completed this task. Must be set if `versioning_behavior` // is set. This value updates workflow execution's `versioning_info.deployment_version`. temporal.api.deployment.v1.WorkerDeploymentVersion deployment_version = 11; + + // Offset ranges this Workflow Task consumed from streams it subscribes to. + // Recorded on every task where a subscription is active, including when it + // observed nothing: an empty range is a fact replay must reproduce, and + // omitting it would let replay deliver records the Workflow did not have. + // Numbered 20 to leave 14 through 19 free for fields added on the main line. + repeated temporal.api.stream.v1.StreamRange consumed_stream_ranges = 20; } message WorkflowTaskTimedOutEventAttributes { @@ -953,6 +961,33 @@ message ActivityPropertiesModifiedExternallyEventAttributes { temporal.api.common.v1.RetryPolicy new_retry_policy = 2; } +message WorkflowStreamSubscribedEventAttributes { + // The WorkflowTaskCompleted event of the task whose command created this + // subscription. + int64 workflow_task_completed_event_id = 1; + // Stream the Workflow subscribed to. + string stream_id = 2; + // The offset the subscription actually starts from. Resolved by the server + // when the subscription is registered and recorded here, so replay reads + // the resolved value rather than resolving it again against a stream that + // has since moved. + int64 start_offset = 3; +} + +message WorkflowStreamRecordsAppendedEventAttributes { + // The WorkflowTaskCompleted event of the task whose command appended this + // batch. + int64 workflow_task_completed_event_id = 1; + // Stream the Workflow appended to. + string stream_id = 2; + // Offset the first record of the batch landed at. + int64 first_offset = 3; + // How many records the batch held. With first_offset this names the range + // without carrying any of it, which is what keeps this event a fixed size + // no matter how large the batch or its payloads are. + int64 record_count = 4; +} + message WorkflowExecutionUpdateAcceptedEventAttributes { // The instance ID of the update protocol that generated this event. string protocol_instance_id = 1; @@ -1276,6 +1311,8 @@ message HistoryEvent { WorkflowExecutionPausedEventAttributes workflow_execution_paused_event_attributes = 63; WorkflowExecutionUnpausedEventAttributes workflow_execution_unpaused_event_attributes = 64; WorkflowExecutionTimeSkippingTransitionedEventAttributes workflow_execution_time_skipping_transitioned_event_attributes = 65; + WorkflowStreamSubscribedEventAttributes workflow_stream_subscribed_event_attributes = 66; + WorkflowStreamRecordsAppendedEventAttributes workflow_stream_records_appended_event_attributes = 67; } } diff --git a/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto new file mode 100644 index 000000000..9f0c88dfb --- /dev/null +++ b/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto @@ -0,0 +1,82 @@ +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 "temporal/api/common/v1/message.proto"; + +// One entry in a stream. The record is the wire format: stores keep it +// serialized as is and readers in every language decode the same bytes. +message StreamRecord { + // The value the producer published, stored as sent. A payload codec + // applies here as it does to any other payload. + temporal.api.common.v1.Payload body = 1; + // Producer-supplied provenance, stored as sent. + map metadata = 2; + // Producer-supplied grouping label, stored as sent. + string topic = 3; + // How to read this record. Unspecified is read as DATA. + StreamRecordKind kind = 4; + // Who wrote the record. Empty when the owning Workflow did. + string producer_id = 5; + // The producer's attempt. Readers treat a later attempt by the same + // producer as superseding what the earlier one wrote. + int64 attempt = 6; + // The producer's position within its attempt, or -1 when unnumbered. + // Stored as sent; the server does not assign, validate or order by it. + int64 sequence = 7; +} + +// A contiguous range of a stream delivered to a Workflow Task, along with the +// offsets it covers. The offsets are what History records; the records +// themselves are never written to History. +message StreamSlice { + string stream_id = 1; + // Run id of the execution that owns the stream. Set on both a slice for the + // task being started and a re-supplied one. + string run_id = 2; + // Inclusive. + int64 from_offset = 3; + // Exclusive. Equal to from_offset when the subscription observed nothing, + // which is a fact replay has to reproduce rather than an absence of one. + int64 to_offset = 4; + repeated StreamRecord records = 5; + // The WorkflowTaskCompleted event whose consumed_stream_ranges recorded + // this range. Set only when the server is re-supplying a range for a task + // being replayed; a slice for the task now being started leaves it unset, + // because the event closing that task does not exist yet. + // + // Replay needs this because a Workflow Task response carries one slice set + // while a cache miss replays every prior task, so the ranges have to be + // matched to the events that recorded them rather than to the response. + int64 workflow_task_completed_event_id = 6; +} + +// The offsets a Workflow Task consumed, without the payloads. Recorded on +// WorkflowTaskCompleted so History grows with Workflow Tasks rather than with +// records. +message StreamRange { + string stream_id = 1; + // Inclusive. + int64 from_offset = 2; + // Exclusive. + int64 to_offset = 3; +} + +// What a record means to a reader. Kept on the record itself so every store +// and every language reads it the same way without a private envelope. +enum StreamRecordKind { + // Read as DATA. + STREAM_RECORD_KIND_UNSPECIFIED = 0; + // A value the producer published; `body` carries it. + STREAM_RECORD_KIND_DATA = 1; + // The producer named by `producer_id` writes nothing more on `topic`. + // Says nothing about that producer's outcome and does not end the stream. + STREAM_RECORD_KIND_FINISH = 2; +} diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto index c3dd95769..b396de5b4 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto @@ -24,6 +24,7 @@ import "temporal/api/enums/v1/activity.proto"; import "temporal/api/enums/v1/nexus.proto"; import "temporal/api/activity/v1/message.proto"; import "temporal/api/common/v1/message.proto"; +import "temporal/api/stream/v1/message.proto"; import "temporal/api/history/v1/message.proto"; import "temporal/api/workflow/v1/message.proto"; import "temporal/api/command/v1/message.proto"; @@ -383,6 +384,10 @@ message PollWorkflowTaskQueueResponse { // 3. If every group has some pending polls, assign the next poll to a group randomly // according to the weights. temporal.api.taskqueue.v1.PollerGroupsInfo poller_groups_info = 19; + + // Stream data attached to this task. Delivered out of band so the payloads + // never enter History; only the offset ranges are recorded there. + repeated temporal.api.stream.v1.StreamSlice stream_slices = 20; } message RespondWorkflowTaskCompletedRequest { diff --git a/crates/protos/src/protos/mod.rs b/crates/protos/src/protos/mod.rs index 4bbfa5ac5..cf1d67db4 100644 --- a/crates/protos/src/protos/mod.rs +++ b/crates/protos/src/protos/mod.rs @@ -1825,6 +1825,12 @@ pub mod temporal { CommandType::ScheduleActivityTask } Attributes::StartTimerCommandAttributes(_) => CommandType::StartTimer, + Attributes::AppendStreamRecordsCommandAttributes(_) => { + CommandType::AppendStreamRecords + } + Attributes::SubscribeStreamCommandAttributes(_) => { + CommandType::SubscribeStream + } Attributes::CompleteWorkflowExecutionCommandAttributes(_) => { CommandType::CompleteWorkflowExecution } @@ -2337,6 +2343,8 @@ pub mod temporal { | EventType::TimerStarted | EventType::UpsertWorkflowSearchAttributes | EventType::WorkflowPropertiesModified + | EventType::WorkflowStreamSubscribed + | EventType::WorkflowStreamRecordsAppended | EventType::NexusOperationScheduled | EventType::NexusOperationCancelRequested | EventType::WorkflowExecutionCanceled @@ -2436,6 +2444,10 @@ pub mod temporal { // mark any new event types as ignorable or not. if let Some(a) = self.attributes.as_ref() { match a { + Attributes::WorkflowStreamSubscribedEventAttributes(_) => false, + Attributes::WorkflowStreamRecordsAppendedEventAttributes(_) => { + false + } Attributes::WorkflowExecutionStartedEventAttributes(_) => false, Attributes::WorkflowExecutionCompletedEventAttributes(_) => false, Attributes::WorkflowExecutionFailedEventAttributes(_) => false, @@ -2523,6 +2535,8 @@ pub mod temporal { pub fn event_type(&self) -> EventType { // I just absolutely _love_ this match self { + Attributes::WorkflowStreamSubscribedEventAttributes(_) => { EventType::WorkflowStreamSubscribed } + Attributes::WorkflowStreamRecordsAppendedEventAttributes(_) => { EventType::WorkflowStreamRecordsAppended } Attributes::WorkflowExecutionStartedEventAttributes(_) => { EventType::WorkflowExecutionStarted } Attributes::WorkflowExecutionCompletedEventAttributes(_) => { EventType::WorkflowExecutionCompleted } Attributes::WorkflowExecutionFailedEventAttributes(_) => { EventType::WorkflowExecutionFailed } @@ -2640,6 +2654,11 @@ pub mod temporal { tonic::include_proto!("temporal.api.sdk.v1"); } } + pub mod stream { + pub mod v1 { + tonic::include_proto!("temporal.api.stream.v1"); + } + } pub mod taskqueue { pub mod v1 { tonic::include_proto!("temporal.api.taskqueue.v1"); From 98d96215ee1432149e185228703c09a1e5a6b1fd Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 25 Sep 2026 07:59:33 -0700 Subject: [PATCH 03/27] Matched the nexus model WIT to upstream for the workflow-id policies. The nexgen the Python SDK drives rejects @nexus.type on a native declaration, so generation failed for every consumer. Upstream declares both policies as placeholders, which keeps the annotations valid. --- .../nexus/deps/nexus-temporal-types/model.wit | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit b/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit index 91de3e838..b6e6e5e8f 100644 --- a/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit +++ b/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit @@ -147,12 +147,7 @@ interface model { /// typescript="common.WorkflowIdReusePolicy" /// dotnet="Temporalio.Api.Enums.V1.WorkflowIdReusePolicy" /// typescript-import="@temporalio/common" - enum workflow-id-reuse-policy { - allow-duplicate, - allow-duplicate-failed-only, - reject-duplicate, - terminate-if-running, - } + type workflow-id-reuse-policy = placeholder; /// @nexus.proto "temporal.api.enums.v1.WorkflowIdConflictPolicy" typescript-import="@temporalio/proto" /// @nexus.type @@ -160,11 +155,7 @@ interface model { /// typescript="common.WorkflowIdConflictPolicy" /// dotnet="Temporalio.Api.Enums.V1.WorkflowIdConflictPolicy" /// typescript-import="@temporalio/common" - enum workflow-id-conflict-policy { - fail, - use-existing, - terminate-existing, - } + type workflow-id-conflict-policy = placeholder; /// @nexus.proto "temporal.api.sdk.v1.UserMetadata" typescript-import="@temporalio/proto" /// @nexus.flatten-in-api From bc0c1c9183eac08e8b3122339d82899db1588593 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 25 Sep 2026 07:59:33 -0700 Subject: [PATCH 04/27] Honored the per-runner timeout for the integ test matrix. The matrix asks for 40 minutes on `macos-intel`, but the job never read that value, so the runner stayed on the 25-minute default it exceeds. --- .github/workflows/per-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/per-pr.yml b/.github/workflows/per-pr.yml index 11325303a..89126c4cc 100644 --- a/.github/workflows/per-pr.yml +++ b/.github/workflows/per-pr.yml @@ -123,7 +123,7 @@ jobs: integ-tests: name: Integ tests - timeout-minutes: ${{ github.ref == 'refs/heads/main' && 30 || 25 }} + timeout-minutes: ${{ matrix.timeoutMinutes || (github.ref == 'refs/heads/main' && 30 || 25) }} strategy: fail-fast: false matrix: From 9163b11053bb0421f79c2dca5a3b1f9a28d71249 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 25 Sep 2026 07:59:40 -0700 Subject: [PATCH 05/27] Vendored the api's stream protos into Core. The api adds a stream family: the record, the two workflow commands with their events, and the slices a poll response carries. Core needs those messages before anything can use them. The record bodies are exempt from the blob-size validation because they never reach an event; the server bounds a batch by message count instead. --- crates/common/build.rs | 5 ++ crates/protos/build.rs | 1 + .../temporal/api/command/v1/message.proto | 34 ++++++++ .../temporal/api/enums/v1/command_type.proto | 2 + .../temporal/api/enums/v1/event_type.proto | 8 ++ .../temporal/api/enums/v1/failed_cause.proto | 8 ++ .../temporal/api/history/v1/message.proto | 37 +++++++++ .../temporal/api/stream/v1/message.proto | 82 +++++++++++++++++++ .../workflowservice/v1/request_response.proto | 5 ++ crates/protos/src/protos/mod.rs | 19 +++++ 10 files changed, 201 insertions(+) create mode 100644 crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto diff --git a/crates/common/build.rs b/crates/common/build.rs index 2e4a378de..1baae9de8 100644 --- a/crates/common/build.rs +++ b/crates/common/build.rs @@ -820,6 +820,11 @@ const NOT_VALIDATED_FIELDS: &[&str] = &[ "temporal.api.workflowservice.v1.StartWorkflowExecutionRequest.continued_failure", "temporal.api.workflowservice.v1.StartWorkflowExecutionRequest.last_completion_result", "temporal.api.workflowservice.v1.TerminateWorkflowExecutionRequest.details", + // Stream records: the blob limit is a per-event limit, and these bodies never reach an + // event. The server bounds the batch by message count (MaxMessagesPerBatch) instead, which + // is not a payload size the SDK can mirror. + "temporal.api.stream.v1.StreamRecord.body", + "temporal.api.stream.v1.StreamRecord.metadata", // Dedicated, non-fetchable limits (not blob/memo, not in DescribeNamespace): UserMetadata // (nexus-start only); Nexus EndpointSpec.description (maxDescriptionSize; cloud variant cloud-only). "temporal.api.sdk.v1.UserMetadata.details", diff --git a/crates/protos/build.rs b/crates/protos/build.rs index 509ba9259..7365df524 100644 --- a/crates/protos/build.rs +++ b/crates/protos/build.rs @@ -36,6 +36,7 @@ const SERDE_DERIVE_PREFIXES: &[&str] = &[ ".temporal.api.rules", ".temporal.api.schedule", ".temporal.api.sdk", + ".temporal.api.stream", ".temporal.api.taskqueue", ".temporal.api.testservice", ".temporal.api.update", diff --git a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto index ee839115b..7d10e441b 100644 --- a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto @@ -14,6 +14,7 @@ import "google/protobuf/duration.proto"; import "temporal/api/enums/v1/workflow.proto"; import "temporal/api/enums/v1/command_type.proto"; import "temporal/api/common/v1/message.proto"; +import "temporal/api/stream/v1/message.proto"; import "temporal/api/failure/v1/message.proto"; import "temporal/api/taskqueue/v1/message.proto"; import "temporal/api/workflow/v1/message.proto"; @@ -324,5 +325,38 @@ message Command { ScheduleNexusOperationCommandAttributes schedule_nexus_operation_command_attributes = 18; RequestCancelNexusOperationCommandAttributes request_cancel_nexus_operation_command_attributes = 19; + AppendStreamRecordsCommandAttributes append_stream_records_command_attributes = 20; + SubscribeStreamCommandAttributes subscribe_stream_command_attributes = 21; } } + +// Appends records to a stream the Workflow owns. Applied inside the Workflow +// Task's own commit. Produces one `WorkflowStreamRecordsAppended` event +// carrying the offset range and none of the payload; it schedules no further +// work. +message AppendStreamRecordsCommandAttributes { + // Empty means the Workflow's default output stream. + string stream_id = 1; + // Stored in order. The server sets `producer_id` to empty on each record, + // because the owning Workflow is the producer here. + repeated temporal.api.stream.v1.StreamRecord records = 2; +} + +// Subscribe this Workflow to a stream, so later Workflow Tasks carry the ranges +// it has not consumed yet. +// +// The stream's addressing is resolved by the server rather than supplied here. +// A Workflow cannot look it up without doing I/O, and a value it carried would +// be a reading rather than a fact, so it could differ on replay. +message SubscribeStreamCommandAttributes { + // Stream to consume. A stream in another execution is addressed by its id; + // one this Workflow owns is addressed by the name it was published under. + // The server resolves an owned name first and falls back to a standalone + // id, so a Workflow that owns a stream under this name cannot reach a + // standalone stream with the same id. + string stream_id = 1; + // Where to start. Negative means from wherever the stream is when the + // subscription is registered, which the server resolves and records so + // replay does not resolve it again. + int64 start_offset = 2; +} diff --git a/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto b/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto index 067d95391..967169b19 100644 --- a/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto +++ b/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto @@ -29,4 +29,6 @@ enum CommandType { COMMAND_TYPE_MODIFY_WORKFLOW_PROPERTIES = 16; COMMAND_TYPE_SCHEDULE_NEXUS_OPERATION = 17; COMMAND_TYPE_REQUEST_CANCEL_NEXUS_OPERATION = 18; + COMMAND_TYPE_APPEND_STREAM_RECORDS = 19; + COMMAND_TYPE_SUBSCRIBE_STREAM = 20; } diff --git a/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto b/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto index b879f51e8..a386b1db7 100644 --- a/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto +++ b/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto @@ -175,4 +175,12 @@ enum EventType { EVENT_TYPE_WORKFLOW_EXECUTION_UNPAUSED = 59; // An event that indicates time skipping advanced time or was disabled automatically after a bound was reached. EVENT_TYPE_WORKFLOW_EXECUTION_TIME_SKIPPING_TRANSITIONED = 60; + // A Workflow subscribed to a stream. Recorded once per subscription, not + // per record: the offsets a task consumed ride WorkflowTaskCompleted and + // the payloads never enter History at all. + EVENT_TYPE_WORKFLOW_STREAM_SUBSCRIBED = 61; + // A Workflow appended a batch of records to a stream. Recorded per + // batch, and carrying only the offset range it landed at: the bodies go to + // the stream's own log, never into History. + EVENT_TYPE_WORKFLOW_STREAM_RECORDS_APPENDED = 62; } diff --git a/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto b/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto index 81cbde73e..274e533d5 100644 --- a/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto +++ b/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto @@ -90,6 +90,14 @@ enum WorkflowTaskFailedCause { WORKFLOW_TASK_FAILED_CAUSE_WORKFLOW_PAUSE_REQUESTED_BEFORE_TASK_STARTED = 39; // A workflow task failed because the request exceeded a size limit. WORKFLOW_TASK_FAILED_CAUSE_REQUEST_TOO_LARGE = 40; + // A workflow task completed with an invalid AppendStreamRecords command. + WORKFLOW_TASK_FAILED_CAUSE_BAD_APPEND_STREAM_RECORDS_ATTRIBUTES = 41; + // A workflow task completed with an invalid SubscribeStream command. + WORKFLOW_TASK_FAILED_CAUSE_BAD_SUBSCRIBE_STREAM_ATTRIBUTES = 42; + // A workflow task could not be started because a stream range it consumed and recorded in + // History can no longer be served, for example after truncation or because it exceeds the + // replay bound. Check the workflow task failure message for more information. + WORKFLOW_TASK_FAILED_CAUSE_STREAM_RANGE_UNAVAILABLE = 43; } enum StartChildWorkflowExecutionFailedCause { diff --git a/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto index 0211c6f55..8deca8d15 100644 --- a/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto @@ -17,6 +17,7 @@ import "temporal/api/enums/v1/failed_cause.proto"; import "temporal/api/enums/v1/update.proto"; import "temporal/api/enums/v1/workflow.proto"; import "temporal/api/common/v1/message.proto"; +import "temporal/api/stream/v1/message.proto"; import "temporal/api/deployment/v1/message.proto"; import "temporal/api/failure/v1/message.proto"; import "temporal/api/taskqueue/v1/message.proto"; @@ -379,6 +380,13 @@ message WorkflowTaskCompletedEventAttributes { // The Worker Deployment Version that completed this task. Must be set if `versioning_behavior` // is set. This value updates workflow execution's `versioning_info.deployment_version`. temporal.api.deployment.v1.WorkerDeploymentVersion deployment_version = 11; + + // Offset ranges this Workflow Task consumed from streams it subscribes to. + // Recorded on every task where a subscription is active, including when it + // observed nothing: an empty range is a fact replay must reproduce, and + // omitting it would let replay deliver records the Workflow did not have. + // Numbered 20 to leave 14 through 19 free for fields added on the main line. + repeated temporal.api.stream.v1.StreamRange consumed_stream_ranges = 20; } message WorkflowTaskTimedOutEventAttributes { @@ -953,6 +961,33 @@ message ActivityPropertiesModifiedExternallyEventAttributes { temporal.api.common.v1.RetryPolicy new_retry_policy = 2; } +message WorkflowStreamSubscribedEventAttributes { + // The WorkflowTaskCompleted event of the task whose command created this + // subscription. + int64 workflow_task_completed_event_id = 1; + // Stream the Workflow subscribed to. + string stream_id = 2; + // The offset the subscription actually starts from. Resolved by the server + // when the subscription is registered and recorded here, so replay reads + // the resolved value rather than resolving it again against a stream that + // has since moved. + int64 start_offset = 3; +} + +message WorkflowStreamRecordsAppendedEventAttributes { + // The WorkflowTaskCompleted event of the task whose command appended this + // batch. + int64 workflow_task_completed_event_id = 1; + // Stream the Workflow appended to. + string stream_id = 2; + // Offset the first record of the batch landed at. + int64 first_offset = 3; + // How many records the batch held. With first_offset this names the range + // without carrying any of it, which is what keeps this event a fixed size + // no matter how large the batch or its payloads are. + int64 record_count = 4; +} + message WorkflowExecutionUpdateAcceptedEventAttributes { // The instance ID of the update protocol that generated this event. string protocol_instance_id = 1; @@ -1276,6 +1311,8 @@ message HistoryEvent { WorkflowExecutionPausedEventAttributes workflow_execution_paused_event_attributes = 63; WorkflowExecutionUnpausedEventAttributes workflow_execution_unpaused_event_attributes = 64; WorkflowExecutionTimeSkippingTransitionedEventAttributes workflow_execution_time_skipping_transitioned_event_attributes = 65; + WorkflowStreamSubscribedEventAttributes workflow_stream_subscribed_event_attributes = 66; + WorkflowStreamRecordsAppendedEventAttributes workflow_stream_records_appended_event_attributes = 67; } } diff --git a/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto new file mode 100644 index 000000000..9f0c88dfb --- /dev/null +++ b/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto @@ -0,0 +1,82 @@ +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 "temporal/api/common/v1/message.proto"; + +// One entry in a stream. The record is the wire format: stores keep it +// serialized as is and readers in every language decode the same bytes. +message StreamRecord { + // The value the producer published, stored as sent. A payload codec + // applies here as it does to any other payload. + temporal.api.common.v1.Payload body = 1; + // Producer-supplied provenance, stored as sent. + map metadata = 2; + // Producer-supplied grouping label, stored as sent. + string topic = 3; + // How to read this record. Unspecified is read as DATA. + StreamRecordKind kind = 4; + // Who wrote the record. Empty when the owning Workflow did. + string producer_id = 5; + // The producer's attempt. Readers treat a later attempt by the same + // producer as superseding what the earlier one wrote. + int64 attempt = 6; + // The producer's position within its attempt, or -1 when unnumbered. + // Stored as sent; the server does not assign, validate or order by it. + int64 sequence = 7; +} + +// A contiguous range of a stream delivered to a Workflow Task, along with the +// offsets it covers. The offsets are what History records; the records +// themselves are never written to History. +message StreamSlice { + string stream_id = 1; + // Run id of the execution that owns the stream. Set on both a slice for the + // task being started and a re-supplied one. + string run_id = 2; + // Inclusive. + int64 from_offset = 3; + // Exclusive. Equal to from_offset when the subscription observed nothing, + // which is a fact replay has to reproduce rather than an absence of one. + int64 to_offset = 4; + repeated StreamRecord records = 5; + // The WorkflowTaskCompleted event whose consumed_stream_ranges recorded + // this range. Set only when the server is re-supplying a range for a task + // being replayed; a slice for the task now being started leaves it unset, + // because the event closing that task does not exist yet. + // + // Replay needs this because a Workflow Task response carries one slice set + // while a cache miss replays every prior task, so the ranges have to be + // matched to the events that recorded them rather than to the response. + int64 workflow_task_completed_event_id = 6; +} + +// The offsets a Workflow Task consumed, without the payloads. Recorded on +// WorkflowTaskCompleted so History grows with Workflow Tasks rather than with +// records. +message StreamRange { + string stream_id = 1; + // Inclusive. + int64 from_offset = 2; + // Exclusive. + int64 to_offset = 3; +} + +// What a record means to a reader. Kept on the record itself so every store +// and every language reads it the same way without a private envelope. +enum StreamRecordKind { + // Read as DATA. + STREAM_RECORD_KIND_UNSPECIFIED = 0; + // A value the producer published; `body` carries it. + STREAM_RECORD_KIND_DATA = 1; + // The producer named by `producer_id` writes nothing more on `topic`. + // Says nothing about that producer's outcome and does not end the stream. + STREAM_RECORD_KIND_FINISH = 2; +} diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto index c3dd95769..b396de5b4 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto @@ -24,6 +24,7 @@ import "temporal/api/enums/v1/activity.proto"; import "temporal/api/enums/v1/nexus.proto"; import "temporal/api/activity/v1/message.proto"; import "temporal/api/common/v1/message.proto"; +import "temporal/api/stream/v1/message.proto"; import "temporal/api/history/v1/message.proto"; import "temporal/api/workflow/v1/message.proto"; import "temporal/api/command/v1/message.proto"; @@ -383,6 +384,10 @@ message PollWorkflowTaskQueueResponse { // 3. If every group has some pending polls, assign the next poll to a group randomly // according to the weights. temporal.api.taskqueue.v1.PollerGroupsInfo poller_groups_info = 19; + + // Stream data attached to this task. Delivered out of band so the payloads + // never enter History; only the offset ranges are recorded there. + repeated temporal.api.stream.v1.StreamSlice stream_slices = 20; } message RespondWorkflowTaskCompletedRequest { diff --git a/crates/protos/src/protos/mod.rs b/crates/protos/src/protos/mod.rs index 4bbfa5ac5..e272b3863 100644 --- a/crates/protos/src/protos/mod.rs +++ b/crates/protos/src/protos/mod.rs @@ -1825,6 +1825,12 @@ pub mod temporal { CommandType::ScheduleActivityTask } Attributes::StartTimerCommandAttributes(_) => CommandType::StartTimer, + Attributes::SubscribeStreamCommandAttributes(_) => { + CommandType::SubscribeStream + } + Attributes::AppendStreamRecordsCommandAttributes(_) => { + CommandType::AppendStreamRecords + } Attributes::CompleteWorkflowExecutionCommandAttributes(_) => { CommandType::CompleteWorkflowExecution } @@ -2337,6 +2343,8 @@ pub mod temporal { | EventType::TimerStarted | EventType::UpsertWorkflowSearchAttributes | EventType::WorkflowPropertiesModified + | EventType::WorkflowStreamSubscribed + | EventType::WorkflowStreamRecordsAppended | EventType::NexusOperationScheduled | EventType::NexusOperationCancelRequested | EventType::WorkflowExecutionCanceled @@ -2436,6 +2444,10 @@ pub mod temporal { // mark any new event types as ignorable or not. if let Some(a) = self.attributes.as_ref() { match a { + Attributes::WorkflowStreamSubscribedEventAttributes(_) => false, + Attributes::WorkflowStreamRecordsAppendedEventAttributes(_) => { + false + } Attributes::WorkflowExecutionStartedEventAttributes(_) => false, Attributes::WorkflowExecutionCompletedEventAttributes(_) => false, Attributes::WorkflowExecutionFailedEventAttributes(_) => false, @@ -2523,6 +2535,8 @@ pub mod temporal { pub fn event_type(&self) -> EventType { // I just absolutely _love_ this match self { + Attributes::WorkflowStreamSubscribedEventAttributes(_) => { EventType::WorkflowStreamSubscribed } + Attributes::WorkflowStreamRecordsAppendedEventAttributes(_) => { EventType::WorkflowStreamRecordsAppended } Attributes::WorkflowExecutionStartedEventAttributes(_) => { EventType::WorkflowExecutionStarted } Attributes::WorkflowExecutionCompletedEventAttributes(_) => { EventType::WorkflowExecutionCompleted } Attributes::WorkflowExecutionFailedEventAttributes(_) => { EventType::WorkflowExecutionFailed } @@ -2640,6 +2654,11 @@ pub mod temporal { tonic::include_proto!("temporal.api.sdk.v1"); } } + pub mod stream { + pub mod v1 { + tonic::include_proto!("temporal.api.stream.v1"); + } + } pub mod taskqueue { pub mod v1 { tonic::include_proto!("temporal.api.taskqueue.v1"); From 7603658f6ae0cd777cbdf8c47fc6a242221e0dcd Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 25 Sep 2026 07:59:58 -0700 Subject: [PATCH 06/27] Added the stream delivery job to the bridge protos. History records the offsets a task consumed and never the payloads, so the server sends the bytes on the poll response and Core hands them to lang as their own job. The Rust SDKs have no stream API, so they fail loudly on this job rather than ignoring it: the server has already recorded the range as consumed and will not send it again, so dropping it would lose data silently. --- .../workflow_activation.proto | 26 +++++++++++++++++++ crates/protos/src/protos/mod.rs | 7 +++++ crates/sdk/src/workflow_future.rs | 9 +++++++ crates/workflow/src/runtime/instance.rs | 14 ++++++++++ 4 files changed, 56 insertions(+) diff --git a/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto b/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto index 097994ac6..8106089e7 100644 --- a/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto +++ b/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto @@ -13,6 +13,7 @@ import "google/protobuf/empty.proto"; import "temporal/api/failure/v1/message.proto"; import "temporal/api/update/v1/message.proto"; import "temporal/api/common/v1/message.proto"; +import "temporal/api/stream/v1/message.proto"; import "temporal/api/enums/v1/workflow.proto"; import "temporal/sdk/core/activity_result/activity_result.proto"; import "temporal/sdk/core/child_workflow/child_workflow.proto"; @@ -139,6 +140,12 @@ message WorkflowActivationJob { ResolveNexusOperationStart resolve_nexus_operation_start = 15; // A nexus operation resolved. ResolveNexusOperation resolve_nexus_operation = 16; + // 17 to 20 are taken by the external stream jobs, which are developed + // alongside this one and share this message. The number below is fixed + // with that family and must not be reused. + // + // A range of a stream the workflow subscribed to. + DeliverStreamRecords deliver_stream_records = 21; // Remove the workflow identified by the [WorkflowActivation] containing this job from the // cache after performing the activation. It is guaranteed that this will be the only job // in the activation if present. @@ -146,6 +153,25 @@ message WorkflowActivationJob { } } +// Hand a workflow the next range of a stream it subscribed to. +// +// The range is delivered once, on the task the server decided it belongs to, +// and the offsets it covered are recorded in History rather than the payloads. +// On replay the server re-supplies the same range by reading the stream again, +// so this job appears at the same point with the same contents both times. +// +// An empty range is still delivered: a task where the subscription saw nothing +// is a fact replay has to reproduce, not an absence of one. +message DeliverStreamRecords { + // Id of the stream this range came from. + string stream_id = 1; + // Inclusive. + int64 from_offset = 2; + // Exclusive. Equal to from_offset when the subscription saw nothing. + int64 to_offset = 3; + repeated temporal.api.stream.v1.StreamRecord records = 4; +} + // Initialize a new workflow message InitializeWorkflow { // The identifier the lang-specific sdk uses to execute workflow code diff --git a/crates/protos/src/protos/mod.rs b/crates/protos/src/protos/mod.rs index e272b3863..4af74a5f4 100644 --- a/crates/protos/src/protos/mod.rs +++ b/crates/protos/src/protos/mod.rs @@ -1277,6 +1277,13 @@ pub mod coresdk { workflow_activation_job::Variant::ResolveNexusOperation(_) => { write!(f, "ResolveNexusOperation") } + workflow_activation_job::Variant::DeliverStreamRecords(d) => { + write!( + f, + "DeliverStreamRecords({}, {}..{})", + d.stream_id, d.from_offset, d.to_offset + ) + } } } } diff --git a/crates/sdk/src/workflow_future.rs b/crates/sdk/src/workflow_future.rs index d6920870d..6e7f421f0 100644 --- a/crates/sdk/src/workflow_future.rs +++ b/crates/sdk/src/workflow_future.rs @@ -340,6 +340,15 @@ impl WorkflowFuture { .context("Nexus operation must have result")?; push_polled_context!(ActivationJobContext::Passive); } + Variant::DeliverStreamRecords(slice) => { + // No stream API in this SDK. Bailing rather than ignoring: + // the server has recorded this range as consumed and will + // not send it again, so dropping it loses data silently. + bail!( + "received stream records for {}, which this SDK cannot deliver", + slice.stream_id + ); + } Variant::RemoveFromCache(_) => { unreachable!("Cache removal should happen higher up"); } diff --git a/crates/workflow/src/runtime/instance.rs b/crates/workflow/src/runtime/instance.rs index c3417ef31..8af164913 100644 --- a/crates/workflow/src/runtime/instance.rs +++ b/crates/workflow/src/runtime/instance.rs @@ -1093,6 +1093,20 @@ where self.apply_resolution(resolution); ActivationJobResult::None } + Some(ActivationVariant::DeliverStreamRecords(slice)) => { + // The Rust workflow runtime has no stream API yet. Failing + // is the only safe answer: the server has already recorded + // this range as consumed, so dropping it would leave the + // workflow permanently behind data it will never be sent + // again. + return Err(Box::new(Failure { + message: format!( + "received stream records for {}, which this SDK cannot deliver", + slice.stream_id + ), + ..Default::default() + })); + } Some(ActivationVariant::RemoveFromCache(_)) => ActivationJobResult::None, None => { return Err(Box::new(Failure { From 2e05ed42508992625d9e9fd93acf3a74495923b0 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 25 Sep 2026 15:32:01 -0700 Subject: [PATCH 07/27] Carried the review round's stream proto changes. The Python branch that vendors these regenerates from this pin, so the pin has to hold the same field names the api branch now publishes. --- .../temporal/api/command/v1/message.proto | 31 ++++++++++++------- .../temporal/api/history/v1/message.proto | 29 +++++++++++------ .../temporal/api/stream/v1/message.proto | 28 ++++++++++++++--- 3 files changed, 64 insertions(+), 24 deletions(-) diff --git a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto index 7d10e441b..890e79e62 100644 --- a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto @@ -335,8 +335,11 @@ message Command { // carrying the offset range and none of the payload; it schedules no further // work. message AppendStreamRecordsCommandAttributes { - // Empty means the Workflow's default output stream. - string stream_id = 1; + // Name of a stream this Workflow owns, scoped to the Workflow. Created on + // first use. Empty means the Workflow's default output stream. A Workflow + // cannot append to a stream in another execution, so this is never the id + // of a standalone stream. + string stream_name = 1; // Stored in order. The server sets `producer_id` to empty on each record, // because the owning Workflow is the producer here. repeated temporal.api.stream.v1.StreamRecord records = 2; @@ -349,14 +352,20 @@ message AppendStreamRecordsCommandAttributes { // A Workflow cannot look it up without doing I/O, and a value it carried would // be a reading rather than a fact, so it could differ on replay. message SubscribeStreamCommandAttributes { - // Stream to consume. A stream in another execution is addressed by its id; - // one this Workflow owns is addressed by the name it was published under. - // The server resolves an owned name first and falls back to a standalone - // id, so a Workflow that owns a stream under this name cannot reach a - // standalone stream with the same id. - string stream_id = 1; - // Where to start. Negative means from wherever the stream is when the - // subscription is registered, which the server resolves and records so - // replay does not resolve it again. + // Stream to consume, named either way round: a stream this Workflow owns + // by the name it appends under, a stream in another execution by its id. + // The server tries them in that order, so a Workflow that owns a stream + // under this name cannot reach a standalone stream with the same id. When + // neither exists the Workflow gets a stream of its own by that name, which + // is how a reader subscribes before the first record is written. + string stream_name_or_id = 1; + // Where to start, as an absolute offset. Any negative value means the head + // of the stream as of registration, and they all mean the same thing. The + // server resolves it and records the result, so replay does not resolve it + // again. + // + // There is no way to ask for the earliest readable offset of a stream that + // has been truncated. Reading it from DescribeStream and passing it here + // races with further truncation. int64 start_offset = 2; } diff --git a/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto index 8deca8d15..377ea4bbd 100644 --- a/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto @@ -385,8 +385,11 @@ message WorkflowTaskCompletedEventAttributes { // Recorded on every task where a subscription is active, including when it // observed nothing: an empty range is a fact replay must reproduce, and // omitting it would let replay deliver records the Workflow did not have. - // Numbered 20 to leave 14 through 19 free for fields added on the main line. repeated temporal.api.stream.v1.StreamRange consumed_stream_ranges = 20; + + // Held for fields added on the main line, so a rebase does not land one of + // them on a number this fork already writes. + reserved 14 to 19; } message WorkflowTaskTimedOutEventAttributes { @@ -965,7 +968,9 @@ message WorkflowStreamSubscribedEventAttributes { // The WorkflowTaskCompleted event of the task whose command created this // subscription. int64 workflow_task_completed_event_id = 1; - // Stream the Workflow subscribed to. + // The stream the Workflow subscribed to, as the command addressed it: + // either the name of a stream this Workflow owns or the id of one in + // another execution. string stream_id = 2; // The offset the subscription actually starts from. Resolved by the server // when the subscription is registered and recorded here, so replay reads @@ -978,14 +983,20 @@ message WorkflowStreamRecordsAppendedEventAttributes { // The WorkflowTaskCompleted event of the task whose command appended this // batch. int64 workflow_task_completed_event_id = 1; - // Stream the Workflow appended to. + // Name of the stream the Workflow appended to. string stream_id = 2; - // Offset the first record of the batch landed at. - int64 first_offset = 3; - // How many records the batch held. With first_offset this names the range - // without carrying any of it, which is what keeps this event a fixed size - // no matter how large the batch or its payloads are. - int64 record_count = 4; + // Inclusive. Same range vocabulary as StreamRange and StreamSlice, so a + // reader does not have to remember which of the three counts and which + // bounds. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" and "to" name a half-open offset range. --) + int64 from_offset = 3; + // Exclusive. With from_offset this names the range without carrying any of + // it, which is what keeps this event a fixed size no matter how large the + // batch or its payloads are. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" and "to" name a half-open offset range. --) + int64 to_offset = 4; } message WorkflowExecutionUpdateAcceptedEventAttributes { diff --git a/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto index 9f0c88dfb..868c9fce6 100644 --- a/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto @@ -14,8 +14,13 @@ import "temporal/api/common/v1/message.proto"; // One entry in a stream. The record is the wire format: stores keep it // serialized as is and readers in every language decode the same bytes. message StreamRecord { - // The value the producer published, stored as sent. A payload codec - // applies here as it does to any other payload. + // The value the producer published, stored as sent. + // + // A payload codec applies on the paths this API owns: the append command on + // RespondWorkflowTaskCompleted, and the slices on PollWorkflowTaskQueue. + // Records a producer writes or reads through the stream service take a + // different path, whose messages are not part of this API yet and so are + // outside what a codec-applying proxy walks. temporal.api.common.v1.Payload body = 1; // Producer-supplied provenance, stored as sent. map metadata = 2; @@ -28,8 +33,9 @@ message StreamRecord { // The producer's attempt. Readers treat a later attempt by the same // producer as superseding what the earlier one wrote. int64 attempt = 6; - // The producer's position within its attempt, or -1 when unnumbered. - // Stored as sent; the server does not assign, validate or order by it. + // The producer's position within its attempt, zero when it does not number + // its records. Stored as sent; the server does not assign, validate or + // order by it, and the stream's own offsets are what order a read. int64 sequence = 7; } @@ -37,14 +43,21 @@ message StreamRecord { // offsets it covers. The offsets are what History records; the records // themselves are never written to History. message StreamSlice { + // The stream, as the subscribing command addressed it: either the name of + // a stream the consuming Workflow owns or the id of one in another + // execution. string stream_id = 1; // Run id of the execution that owns the stream. Set on both a slice for the // task being started and a re-supplied one. string run_id = 2; // Inclusive. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" and "to" name a half-open offset range. --) int64 from_offset = 3; // Exclusive. Equal to from_offset when the subscription observed nothing, // which is a fact replay has to reproduce rather than an absence of one. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" and "to" name a half-open offset range. --) int64 to_offset = 4; repeated StreamRecord records = 5; // The WorkflowTaskCompleted event whose consumed_stream_ranges recorded @@ -62,10 +75,17 @@ message StreamSlice { // WorkflowTaskCompleted so History grows with Workflow Tasks rather than with // records. message StreamRange { + // The stream, as the subscribing command addressed it: either the name of + // a stream the consuming Workflow owns or the id of one in another + // execution. string stream_id = 1; // Inclusive. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" and "to" name a half-open offset range. --) int64 from_offset = 2; // Exclusive. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" and "to" name a half-open offset range. --) int64 to_offset = 3; } From 56750a598ee269f7cc6f07d62d6106203a9ace5b Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 25 Sep 2026 15:34:30 -0700 Subject: [PATCH 08/27] Renamed the stream command and event fields. The append event carries the exclusive end offset instead of a count, so the three range-carrying messages read the same way. The command fields say name rather than id, which is what a Workflow actually addresses. --- .../temporal/api/command/v1/message.proto | 31 ++++++++++++------- .../temporal/api/history/v1/message.proto | 29 +++++++++++------ .../temporal/api/stream/v1/message.proto | 28 ++++++++++++++--- 3 files changed, 64 insertions(+), 24 deletions(-) diff --git a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto index 7d10e441b..890e79e62 100644 --- a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto @@ -335,8 +335,11 @@ message Command { // carrying the offset range and none of the payload; it schedules no further // work. message AppendStreamRecordsCommandAttributes { - // Empty means the Workflow's default output stream. - string stream_id = 1; + // Name of a stream this Workflow owns, scoped to the Workflow. Created on + // first use. Empty means the Workflow's default output stream. A Workflow + // cannot append to a stream in another execution, so this is never the id + // of a standalone stream. + string stream_name = 1; // Stored in order. The server sets `producer_id` to empty on each record, // because the owning Workflow is the producer here. repeated temporal.api.stream.v1.StreamRecord records = 2; @@ -349,14 +352,20 @@ message AppendStreamRecordsCommandAttributes { // A Workflow cannot look it up without doing I/O, and a value it carried would // be a reading rather than a fact, so it could differ on replay. message SubscribeStreamCommandAttributes { - // Stream to consume. A stream in another execution is addressed by its id; - // one this Workflow owns is addressed by the name it was published under. - // The server resolves an owned name first and falls back to a standalone - // id, so a Workflow that owns a stream under this name cannot reach a - // standalone stream with the same id. - string stream_id = 1; - // Where to start. Negative means from wherever the stream is when the - // subscription is registered, which the server resolves and records so - // replay does not resolve it again. + // Stream to consume, named either way round: a stream this Workflow owns + // by the name it appends under, a stream in another execution by its id. + // The server tries them in that order, so a Workflow that owns a stream + // under this name cannot reach a standalone stream with the same id. When + // neither exists the Workflow gets a stream of its own by that name, which + // is how a reader subscribes before the first record is written. + string stream_name_or_id = 1; + // Where to start, as an absolute offset. Any negative value means the head + // of the stream as of registration, and they all mean the same thing. The + // server resolves it and records the result, so replay does not resolve it + // again. + // + // There is no way to ask for the earliest readable offset of a stream that + // has been truncated. Reading it from DescribeStream and passing it here + // races with further truncation. int64 start_offset = 2; } diff --git a/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto index 8deca8d15..377ea4bbd 100644 --- a/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto @@ -385,8 +385,11 @@ message WorkflowTaskCompletedEventAttributes { // Recorded on every task where a subscription is active, including when it // observed nothing: an empty range is a fact replay must reproduce, and // omitting it would let replay deliver records the Workflow did not have. - // Numbered 20 to leave 14 through 19 free for fields added on the main line. repeated temporal.api.stream.v1.StreamRange consumed_stream_ranges = 20; + + // Held for fields added on the main line, so a rebase does not land one of + // them on a number this fork already writes. + reserved 14 to 19; } message WorkflowTaskTimedOutEventAttributes { @@ -965,7 +968,9 @@ message WorkflowStreamSubscribedEventAttributes { // The WorkflowTaskCompleted event of the task whose command created this // subscription. int64 workflow_task_completed_event_id = 1; - // Stream the Workflow subscribed to. + // The stream the Workflow subscribed to, as the command addressed it: + // either the name of a stream this Workflow owns or the id of one in + // another execution. string stream_id = 2; // The offset the subscription actually starts from. Resolved by the server // when the subscription is registered and recorded here, so replay reads @@ -978,14 +983,20 @@ message WorkflowStreamRecordsAppendedEventAttributes { // The WorkflowTaskCompleted event of the task whose command appended this // batch. int64 workflow_task_completed_event_id = 1; - // Stream the Workflow appended to. + // Name of the stream the Workflow appended to. string stream_id = 2; - // Offset the first record of the batch landed at. - int64 first_offset = 3; - // How many records the batch held. With first_offset this names the range - // without carrying any of it, which is what keeps this event a fixed size - // no matter how large the batch or its payloads are. - int64 record_count = 4; + // Inclusive. Same range vocabulary as StreamRange and StreamSlice, so a + // reader does not have to remember which of the three counts and which + // bounds. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" and "to" name a half-open offset range. --) + int64 from_offset = 3; + // Exclusive. With from_offset this names the range without carrying any of + // it, which is what keeps this event a fixed size no matter how large the + // batch or its payloads are. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" and "to" name a half-open offset range. --) + int64 to_offset = 4; } message WorkflowExecutionUpdateAcceptedEventAttributes { diff --git a/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto index 9f0c88dfb..868c9fce6 100644 --- a/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto @@ -14,8 +14,13 @@ import "temporal/api/common/v1/message.proto"; // One entry in a stream. The record is the wire format: stores keep it // serialized as is and readers in every language decode the same bytes. message StreamRecord { - // The value the producer published, stored as sent. A payload codec - // applies here as it does to any other payload. + // The value the producer published, stored as sent. + // + // A payload codec applies on the paths this API owns: the append command on + // RespondWorkflowTaskCompleted, and the slices on PollWorkflowTaskQueue. + // Records a producer writes or reads through the stream service take a + // different path, whose messages are not part of this API yet and so are + // outside what a codec-applying proxy walks. temporal.api.common.v1.Payload body = 1; // Producer-supplied provenance, stored as sent. map metadata = 2; @@ -28,8 +33,9 @@ message StreamRecord { // The producer's attempt. Readers treat a later attempt by the same // producer as superseding what the earlier one wrote. int64 attempt = 6; - // The producer's position within its attempt, or -1 when unnumbered. - // Stored as sent; the server does not assign, validate or order by it. + // The producer's position within its attempt, zero when it does not number + // its records. Stored as sent; the server does not assign, validate or + // order by it, and the stream's own offsets are what order a read. int64 sequence = 7; } @@ -37,14 +43,21 @@ message StreamRecord { // offsets it covers. The offsets are what History records; the records // themselves are never written to History. message StreamSlice { + // The stream, as the subscribing command addressed it: either the name of + // a stream the consuming Workflow owns or the id of one in another + // execution. string stream_id = 1; // Run id of the execution that owns the stream. Set on both a slice for the // task being started and a re-supplied one. string run_id = 2; // Inclusive. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" and "to" name a half-open offset range. --) int64 from_offset = 3; // Exclusive. Equal to from_offset when the subscription observed nothing, // which is a fact replay has to reproduce rather than an absence of one. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" and "to" name a half-open offset range. --) int64 to_offset = 4; repeated StreamRecord records = 5; // The WorkflowTaskCompleted event whose consumed_stream_ranges recorded @@ -62,10 +75,17 @@ message StreamSlice { // WorkflowTaskCompleted so History grows with Workflow Tasks rather than with // records. message StreamRange { + // The stream, as the subscribing command addressed it: either the name of + // a stream the consuming Workflow owns or the id of one in another + // execution. string stream_id = 1; // Inclusive. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" and "to" name a half-open offset range. --) int64 from_offset = 2; // Exclusive. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "from" and "to" name a half-open offset range. --) int64 to_offset = 3; } From b3c75b56f2a7656850734e9d265e78df50a8a1ba Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 28 Sep 2026 16:27:29 -0700 Subject: [PATCH 09/27] Vendored the api's subscribe start position. The api adds a StreamStartPosition message and a start_position field on the subscribe command, at a new field number, so a Workflow can ask for the earliest record, the tail or the last N. --- .../temporal/api/command/v1/message.proto | 16 ++++++++-------- .../temporal/api/stream/v1/message.proto | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto index 890e79e62..256af4403 100644 --- a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto @@ -359,13 +359,13 @@ message SubscribeStreamCommandAttributes { // neither exists the Workflow gets a stream of its own by that name, which // is how a reader subscribes before the first record is written. string stream_name_or_id = 1; - // Where to start, as an absolute offset. Any negative value means the head - // of the stream as of registration, and they all mean the same thing. The - // server resolves it and records the result, so replay does not resolve it - // again. - // - // There is no way to ask for the earliest readable offset of a stream that - // has been truncated. Reading it from DescribeStream and passing it here - // races with further truncation. + // Where to start, as an absolute offset. Read only when `start_position` + // is unset. A negative value is refused: the head of the stream is asked + // for with `start_position.tail`. int64 start_offset = 2; + // Where to start. The server resolves it once, when it registers the + // subscription, and records the resolved absolute offset on the subscribed + // event, so replay does not resolve it again. Setting it together with a + // non-zero `start_offset` fails the command. + temporal.api.stream.v1.StreamStartPosition start_position = 3; } diff --git a/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto index 868c9fce6..e6a8fab2d 100644 --- a/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto @@ -89,6 +89,25 @@ message StreamRange { int64 to_offset = 3; } +// Where a new subscription or read begins. The server resolves it against the +// stream as it stands in the same transaction that registers the reader, so +// the result does not race with appends or truncation, and records the +// resolved absolute offset. +message StreamStartPosition { + oneof position { + // Absolute and inclusive. Refused when below the stream's floor. + int64 offset = 1; + // The last N records the stream holds, or all of them when it holds + // fewer. Counts records of every kind. Must be positive. + int64 last_n = 2; + // The oldest record the stream still holds. Must be true. + bool earliest = 3; + // Only records appended after registration: the stream's head offset. + // Must be true. + bool tail = 4; + } +} + // What a record means to a reader. Kept on the record itself so every store // and every language reads it the same way without a private envelope. enum StreamRecordKind { From 0b6992495d9a81631e5098041b2122e5a8bd699b Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Mon, 28 Sep 2026 16:31:09 -0700 Subject: [PATCH 10/27] Carried the subscribe start position in the Core's api tree. --- .../temporal/api/command/v1/message.proto | 16 ++++++++-------- .../temporal/api/stream/v1/message.proto | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto index 890e79e62..256af4403 100644 --- a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto @@ -359,13 +359,13 @@ message SubscribeStreamCommandAttributes { // neither exists the Workflow gets a stream of its own by that name, which // is how a reader subscribes before the first record is written. string stream_name_or_id = 1; - // Where to start, as an absolute offset. Any negative value means the head - // of the stream as of registration, and they all mean the same thing. The - // server resolves it and records the result, so replay does not resolve it - // again. - // - // There is no way to ask for the earliest readable offset of a stream that - // has been truncated. Reading it from DescribeStream and passing it here - // races with further truncation. + // Where to start, as an absolute offset. Read only when `start_position` + // is unset. A negative value is refused: the head of the stream is asked + // for with `start_position.tail`. int64 start_offset = 2; + // Where to start. The server resolves it once, when it registers the + // subscription, and records the resolved absolute offset on the subscribed + // event, so replay does not resolve it again. Setting it together with a + // non-zero `start_offset` fails the command. + temporal.api.stream.v1.StreamStartPosition start_position = 3; } diff --git a/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto index 868c9fce6..e6a8fab2d 100644 --- a/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto @@ -89,6 +89,25 @@ message StreamRange { int64 to_offset = 3; } +// Where a new subscription or read begins. The server resolves it against the +// stream as it stands in the same transaction that registers the reader, so +// the result does not race with appends or truncation, and records the +// resolved absolute offset. +message StreamStartPosition { + oneof position { + // Absolute and inclusive. Refused when below the stream's floor. + int64 offset = 1; + // The last N records the stream holds, or all of them when it holds + // fewer. Counts records of every kind. Must be positive. + int64 last_n = 2; + // The oldest record the stream still holds. Must be true. + bool earliest = 3; + // Only records appended after registration: the stream's head offset. + // Must be true. + bool tail = 4; + } +} + // What a record means to a reader. Kept on the record itself so every store // and every language reads it the same way without a private envelope. enum StreamRecordKind { From 1410ad5997a701ef73bf8ca69f709ac2a3dcc228 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 10:54:48 -0700 Subject: [PATCH 11/27] Carried the wake protos in the Core's api tree. The vendored tree sits on an older upstream base, so this applies the api commit's wake diff rather than copying whole files. --- .../temporal/api/workflow/v1/message.proto | 23 ++++++++++++++++ .../workflowservice/v1/request_response.proto | 27 +++++++++++++++++++ .../api/workflowservice/v1/service.proto | 16 +++++++++++ 3 files changed, 66 insertions(+) diff --git a/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto index 1ed33fa4c..b4220bc0d 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto @@ -760,3 +760,26 @@ message WorkflowExecutionPauseInfo { // The reason for pausing the workflow execution. string reason = 3; } + +// A wake tells a running Workflow Execution that something it consumes has +// moved, so it should run a Workflow Task and read from its own cursor. It is +// a reason to run, not data: nothing is recorded in History and no record +// travels with it. A store Temporal does not host sends one after the store +// has acknowledged a write. Wakes with the same source fold: the server keeps +// the highest counter it has seen per execution and source, so a repeated, +// reordered or late wake changes nothing, and a burst of writes costs one task. +message Wake { + // What moved, as the sender names it. The server folds wakes by this value + // and never interprets it. For a stream, the provider formats the stream's + // identity, owner and topic, into it. + string source = 1; + // Where the source stands after the write that caused this wake, in the + // store's own terms. Opaque to the server and handed to the Workflow as + // received. A later wake's position replaces an earlier one's. + bytes position = 2; + // Orders wakes from one source. Only the source's store can order its + // positions, so the sender derives this from the position and the server + // compares nothing else. A wake whose counter is not above the one the + // server already holds is folded. + int64 counter = 3; +} diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto index b396de5b4..e8984162d 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto @@ -388,6 +388,12 @@ message PollWorkflowTaskQueueResponse { // Stream data attached to this task. Delivered out of band so the payloads // never enter History; only the offset ranges are recorded there. repeated temporal.api.stream.v1.StreamSlice stream_slices = 20; + + // Wakes pending for this execution when the task started, folded by + // source. Not recorded in History and never re-supplied on replay: the + // Workflow treats one as a reason to read its source now and records what + // it read itself. + repeated temporal.api.workflow.v1.Wake wakes = 21; } message RespondWorkflowTaskCompletedRequest { @@ -879,6 +885,27 @@ message SignalWorkflowExecutionResponse { temporal.api.common.v1.Link link = 1; } +message WakeWorkflowExecutionRequest { + string namespace = 1; + // The Workflow to wake. `run_id` is optional. When set, the wake goes to + // the current run of the chain that run belongs to, so a sender that + // learned a run id before a continue-as-new still reaches the consumer, + // and it is refused with NotFound once that chain has ended. When unset, + // the wake goes to the current run under the Workflow Id, whichever chain. + temporal.api.common.v1.WorkflowExecution workflow_execution = 2; + temporal.api.workflow.v1.Wake wake = 3; + // The identity of the sender, for metrics and logs. + string identity = 4; +} + +message WakeWorkflowExecutionResponse { + // The run the wake was stored on. + string run_id = 1; + // True when the server already held this counter or a higher one for the + // source, so the wake changed nothing. + bool folded = 2; +} + message SignalWithStartWorkflowExecutionRequest { string namespace = 1; string workflow_id = 2; diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto index 34f6a73f6..5404601c4 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto @@ -483,6 +483,22 @@ service WorkflowService { }; } + // WakeWorkflowExecution asks a running Workflow Execution to run a Workflow + // Task because a source it consumes has moved. Unlike a Signal it records + // no event, carries no payload, and folds with other wakes for the same + // source, so a burst of writes costs one task. The Workflow learns the + // source and its position from the task and reads the source itself. + rpc WakeWorkflowExecution (WakeWorkflowExecutionRequest) returns (WakeWorkflowExecutionResponse) { + option (google.api.http) = { + post: "/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/wake" + body: "*" + additional_bindings { + post: "/api/v1/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/wake" + body: "*" + } + }; + } + // SignalWithStartWorkflowExecution is used to ensure a signal is sent to a workflow, even if // it isn't yet started. // From 0ab38103b7241ce71614a7bd20c82ffc95f029d9 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 11:04:26 -0700 Subject: [PATCH 12/27] Exposed the wake call on the raw workflow client. --- crates/client/src/grpc.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/client/src/grpc.rs b/crates/client/src/grpc.rs index 1c3d5b0a6..f803371a1 100644 --- a/crates/client/src/grpc.rs +++ b/crates/client/src/grpc.rs @@ -955,6 +955,15 @@ proxier! { r.extensions_mut().insert(labels); } ); + ( + wake_workflow_execution, + WakeWorkflowExecutionRequest, + WakeWorkflowExecutionResponse, + |r| { + let labels = namespaced_request!(r); + r.extensions_mut().insert(labels); + } + ); ( signal_with_start_workflow_execution, SignalWithStartWorkflowExecutionRequest, From 209ca4b28715be6931728366c2b69daca0b93aab Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 11:09:25 -0700 Subject: [PATCH 13/27] Revert "Exposed the wake call on the raw workflow client." This reverts commit 0ab38103b7241ce71614a7bd20c82ffc95f029d9. --- crates/client/src/grpc.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/crates/client/src/grpc.rs b/crates/client/src/grpc.rs index f803371a1..1c3d5b0a6 100644 --- a/crates/client/src/grpc.rs +++ b/crates/client/src/grpc.rs @@ -955,15 +955,6 @@ proxier! { r.extensions_mut().insert(labels); } ); - ( - wake_workflow_execution, - WakeWorkflowExecutionRequest, - WakeWorkflowExecutionResponse, - |r| { - let labels = namespaced_request!(r); - r.extensions_mut().insert(labels); - } - ); ( signal_with_start_workflow_execution, SignalWithStartWorkflowExecutionRequest, From 27690d34fa9344d4f57d0ecd5b5ba6f8ae401a18 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 11:10:36 -0700 Subject: [PATCH 14/27] Exposed the wake call on the raw workflow client. --- crates/client/src/grpc.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/client/src/grpc.rs b/crates/client/src/grpc.rs index 0ec015c02..8b224aa06 100644 --- a/crates/client/src/grpc.rs +++ b/crates/client/src/grpc.rs @@ -955,6 +955,15 @@ proxier! { r.extensions_mut().insert(labels); } ); + ( + wake_workflow_execution, + WakeWorkflowExecutionRequest, + WakeWorkflowExecutionResponse, + |r| { + let labels = namespaced_request!(r); + r.extensions_mut().insert(labels); + } + ); ( signal_with_start_workflow_execution, SignalWithStartWorkflowExecutionRequest, From 3fafca0b13c23b61565231a7810ba0749e559793 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 11:12:30 -0700 Subject: [PATCH 15/27] Carried the wake's fold-rule wording in the Core's api tree. --- .../temporal/api/workflow/v1/message.proto | 18 ++++++++++-------- .../workflowservice/v1/request_response.proto | 5 +++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto index b4220bc0d..276a587bf 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto @@ -765,9 +765,10 @@ message WorkflowExecutionPauseInfo { // moved, so it should run a Workflow Task and read from its own cursor. It is // a reason to run, not data: nothing is recorded in History and no record // travels with it. A store Temporal does not host sends one after the store -// has acknowledged a write. Wakes with the same source fold: the server keeps -// the highest counter it has seen per execution and source, so a repeated, -// reordered or late wake changes nothing, and a burst of writes costs one task. +// has acknowledged a write. Wakes for the same source fold while one is +// pending and no task has received it yet, so a burst of writes costs one +// task. Once a task has been handed the wake, a new wake for the source is +// accepted again, because only the receiver knows what it read. message Wake { // What moved, as the sender names it. The server folds wakes by this value // and never interprets it. For a stream, the provider formats the stream's @@ -775,11 +776,12 @@ message Wake { string source = 1; // Where the source stands after the write that caused this wake, in the // store's own terms. Opaque to the server and handed to the Workflow as - // received. A later wake's position replaces an earlier one's. + // received. Among wakes folded together, the position with the highest + // counter is the one delivered. bytes position = 2; - // Orders wakes from one source. Only the source's store can order its - // positions, so the sender derives this from the position and the server - // compares nothing else. A wake whose counter is not above the one the - // server already holds is folded. + // Orders wakes from one source, so that folding keeps the latest position. + // Only the source's store can order its positions, so the sender derives + // this from the position. It is not a durable identity: a wake sent after + // a task received an equal counter is accepted again. int64 counter = 3; } diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto index e8984162d..08421a371 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto @@ -901,8 +901,9 @@ message WakeWorkflowExecutionRequest { message WakeWorkflowExecutionResponse { // The run the wake was stored on. string run_id = 1; - // True when the server already held this counter or a higher one for the - // source, so the wake changed nothing. + // True when a wake for the source was already pending and no task had + // received it yet, so this one changed nothing but possibly the position + // and no new task results from it. bool folded = 2; } From ae087c690a9c091528887b7989b42f4c26b8f467 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 12:12:59 -0700 Subject: [PATCH 16/27] Dispatched the wake call in the C bridge. --- crates/sdk-core-c-bridge/src/client.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/sdk-core-c-bridge/src/client.rs b/crates/sdk-core-c-bridge/src/client.rs index 580874fd3..6d7634f56 100644 --- a/crates/sdk-core-c-bridge/src/client.rs +++ b/crates/sdk-core-c-bridge/src/client.rs @@ -1107,6 +1107,9 @@ async fn call_workflow_service( validate_worker_deployment_version_compute_config ) } + "WakeWorkflowExecution" => { + rpc_call_on_trait!(client, call, WorkflowService, wake_workflow_execution) + } rpc => Err(anyhow::anyhow!("Unknown RPC call {rpc}")), } } From 8085667b0915651c84f88715dda8cc55f6cb97e2 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 17:26:58 -0700 Subject: [PATCH 17/27] Carried the notification channel protos and client calls in the Core's api tree. Applies the api commit's diff to the vendored tree, exposes the channel calls on the raw client and the C bridge, and classifies Notification.metadata for payload limits. The C bridge also gains the wake call it was missing. --- crates/client/src/grpc.rs | 45 +++++++++++ crates/common/build.rs | 3 + crates/protos/build.rs | 1 + .../temporal/api/command/v1/message.proto | 9 +++ .../temporal/api/enums/v1/command_type.proto | 1 + .../temporal/api/enums/v1/event_type.proto | 3 + .../temporal/api/history/v1/message.proto | 14 ++++ .../api/notification/v1/message.proto | 55 ++++++++++++++ .../temporal/api/workflow/v1/message.proto | 4 + .../workflowservice/v1/request_response.proto | 74 +++++++++++++++++++ .../api/workflowservice/v1/service.proto | 69 +++++++++++++++++ crates/protos/src/protos/mod.rs | 13 ++++ crates/sdk-core-c-bridge/src/client.rs | 18 +++++ 13 files changed, 309 insertions(+) create mode 100644 crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto diff --git a/crates/client/src/grpc.rs b/crates/client/src/grpc.rs index 8b224aa06..7565c4c0e 100644 --- a/crates/client/src/grpc.rs +++ b/crates/client/src/grpc.rs @@ -964,6 +964,51 @@ proxier! { r.extensions_mut().insert(labels); } ); + ( + notify_channel, + NotifyChannelRequest, + NotifyChannelResponse, + |r| { + let labels = namespaced_request!(r); + r.extensions_mut().insert(labels); + } + ); + ( + register_channel_listener, + RegisterChannelListenerRequest, + RegisterChannelListenerResponse, + |r| { + let labels = namespaced_request!(r); + r.extensions_mut().insert(labels); + } + ); + ( + unregister_channel_listener, + UnregisterChannelListenerRequest, + UnregisterChannelListenerResponse, + |r| { + let labels = namespaced_request!(r); + r.extensions_mut().insert(labels); + } + ); + ( + poll_channel, + PollChannelRequest, + PollChannelResponse, + |r| { + let labels = namespaced_request!(r); + r.extensions_mut().insert(labels); + } + ); + ( + describe_channel, + DescribeChannelRequest, + DescribeChannelResponse, + |r| { + let labels = namespaced_request!(r); + r.extensions_mut().insert(labels); + } + ); ( signal_with_start_workflow_execution, SignalWithStartWorkflowExecutionRequest, diff --git a/crates/common/build.rs b/crates/common/build.rs index 1baae9de8..ecf894587 100644 --- a/crates/common/build.rs +++ b/crates/common/build.rs @@ -825,6 +825,9 @@ const NOT_VALIDATED_FIELDS: &[&str] = &[ // is not a payload size the SDK can mirror. "temporal.api.stream.v1.StreamRecord.body", "temporal.api.stream.v1.StreamRecord.metadata", + // Notification metadata: the server bounds it with its own notification size limit, not + // the blob limit, so the SDK has nothing to mirror. + "temporal.api.notification.v1.Notification.metadata", // Dedicated, non-fetchable limits (not blob/memo, not in DescribeNamespace): UserMetadata // (nexus-start only); Nexus EndpointSpec.description (maxDescriptionSize; cloud variant cloud-only). "temporal.api.sdk.v1.UserMetadata.details", diff --git a/crates/protos/build.rs b/crates/protos/build.rs index 7365df524..5ca388c9f 100644 --- a/crates/protos/build.rs +++ b/crates/protos/build.rs @@ -29,6 +29,7 @@ const SERDE_DERIVE_PREFIXES: &[&str] = &[ ".temporal.api.namespace", ".temporal.api.nexus", ".temporal.api.nexusservices", + ".temporal.api.notification", ".temporal.api.operatorservice", ".temporal.api.protocol", ".temporal.api.query", diff --git a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto index 256af4403..8e17b93f1 100644 --- a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto @@ -327,6 +327,7 @@ message Command { RequestCancelNexusOperationCommandAttributes request_cancel_nexus_operation_command_attributes = 19; AppendStreamRecordsCommandAttributes append_stream_records_command_attributes = 20; SubscribeStreamCommandAttributes subscribe_stream_command_attributes = 21; + SubscribeNotificationChannelCommandAttributes subscribe_notification_channel_command_attributes = 22; } } @@ -369,3 +370,11 @@ message SubscribeStreamCommandAttributes { // non-zero `start_offset` fails the command. temporal.api.stream.v1.StreamStartPosition start_position = 3; } + +// Makes the Workflow a listener of a notification channel for this run. The +// next notifications on the channel arrive on the scheduled event of a Workflow +// Task. The subscription ends with the run, and a successor subscribes again. +message SubscribeNotificationChannelCommandAttributes { + // The channel to listen on, as the writers name it. + string channel = 1; +} diff --git a/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto b/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto index 967169b19..b8eada084 100644 --- a/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto +++ b/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto @@ -31,4 +31,5 @@ enum CommandType { COMMAND_TYPE_REQUEST_CANCEL_NEXUS_OPERATION = 18; COMMAND_TYPE_APPEND_STREAM_RECORDS = 19; COMMAND_TYPE_SUBSCRIBE_STREAM = 20; + COMMAND_TYPE_SUBSCRIBE_NOTIFICATION_CHANNEL = 21; } diff --git a/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto b/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto index a386b1db7..f70de73fe 100644 --- a/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto +++ b/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto @@ -183,4 +183,7 @@ enum EventType { // batch, and carrying only the offset range it landed at: the bodies go to // the stream's own log, never into History. EVENT_TYPE_WORKFLOW_STREAM_RECORDS_APPENDED = 62; + // A Workflow became a listener of a notification channel for its run. + // The notifications themselves ride the WorkflowTaskScheduled event. + EVENT_TYPE_WORKFLOW_NOTIFICATION_CHANNEL_SUBSCRIBED = 63; } diff --git a/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto index 377ea4bbd..3ce67b03f 100644 --- a/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto @@ -18,6 +18,7 @@ import "temporal/api/enums/v1/update.proto"; import "temporal/api/enums/v1/workflow.proto"; import "temporal/api/common/v1/message.proto"; import "temporal/api/stream/v1/message.proto"; +import "temporal/api/notification/v1/message.proto"; import "temporal/api/deployment/v1/message.proto"; import "temporal/api/failure/v1/message.proto"; import "temporal/api/taskqueue/v1/message.proto"; @@ -303,6 +304,10 @@ message WorkflowTaskScheduledEventAttributes { google.protobuf.Duration start_to_close_timeout = 2; // Starting at 1, how many attempts there have been to complete this task int32 attempt = 3; + // Notifications for channels this Workflow listens to, folded per channel + // since the last task was scheduled. In History so a Workflow may act on + // them deterministically and replay sees the same. + repeated temporal.api.notification.v1.Notification notifications = 4; } message WorkflowTaskStartedEventAttributes { @@ -979,6 +984,14 @@ message WorkflowStreamSubscribedEventAttributes { int64 start_offset = 3; } +message WorkflowNotificationChannelSubscribedEventAttributes { + // The WorkflowTaskCompleted event of the task whose command created this + // subscription. + int64 workflow_task_completed_event_id = 1; + // The channel the Workflow listens on for the rest of this run. + string channel = 2; +} + message WorkflowStreamRecordsAppendedEventAttributes { // The WorkflowTaskCompleted event of the task whose command appended this // batch. @@ -1324,6 +1337,7 @@ message HistoryEvent { WorkflowExecutionTimeSkippingTransitionedEventAttributes workflow_execution_time_skipping_transitioned_event_attributes = 65; WorkflowStreamSubscribedEventAttributes workflow_stream_subscribed_event_attributes = 66; WorkflowStreamRecordsAppendedEventAttributes workflow_stream_records_appended_event_attributes = 67; + WorkflowNotificationChannelSubscribedEventAttributes workflow_notification_channel_subscribed_event_attributes = 68; } } diff --git a/crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto new file mode 100644 index 000000000..63e1b7716 --- /dev/null +++ b/crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto @@ -0,0 +1,55 @@ +syntax = "proto3"; + +package temporal.api.notification.v1; + +option go_package = "go.temporal.io/api/notification/v1;notification"; +option java_package = "io.temporal.api.notification.v1"; +option java_multiple_files = true; +option java_outer_classname = "MessageProto"; +option ruby_package = "Temporalio::Api::Notification::V1"; +option csharp_namespace = "Temporalio.Api.Notification.V1"; + +import "google/protobuf/timestamp.proto"; + +import "temporal/api/common/v1/message.proto"; + +// A notification tells the listeners of a channel that a source they consume +// has moved. It is not data: the listener reads the source itself. A channel +// is named by the writer and its listeners; for a stream, the provider formats +// the stream's identity into the name. Writers never learn who listens. The +// server folds notifications per listener while one is pending and no task has +// been scheduled for it, keeping the one with the highest counter. +message Notification { + // The channel the writer notified. Listeners register on the same name. + string channel = 1; + // Where the source stands after the write that caused this notification, + // in the writer's terms. Opaque to the server. + bytes position = 2; + // Orders notifications from one channel's writers. The writer derives it + // from the position, since only the source can order its positions. Among + // notifications folded together, the one with the highest counter is kept. + int64 counter = 3; + // Details for the listener, such as which topic moved. Bounded in size and + // carried as payloads, so a codec applies as to any payload. + map metadata = 4; +} + +// A listener of a channel: a Workflow Execution woken with a Workflow Task, or +// a callback the server invokes with each notification. +message ChannelListener { + // Assigned by the server when the listener registers. + string listener_id = 1; + oneof listener { + WorkflowListener workflow = 2; + temporal.api.common.v1.Callback callback = 3; + } + google.protobuf.Timestamp registered_time = 4; +} + +// A Workflow Execution listening on a channel. +message WorkflowListener { + string workflow_id = 1; + // The run that subscribed. The server follows a continue-as-new to the + // chain's current run when it delivers. + string run_id = 2; +} diff --git a/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto index 276a587bf..0c414794e 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto @@ -769,6 +769,10 @@ message WorkflowExecutionPauseInfo { // pending and no task has received it yet, so a burst of writes costs one // task. Once a task has been handed the wake, a new wake for the source is // accepted again, because only the receiver knows what it read. +// +// Superseded by the notification channel (`NotifyChannel` and the +// `SubscribeNotificationChannel` command). Kept for one round and slated for +// removal. message Wake { // What moved, as the sender names it. The server folds wakes by this value // and never interprets it. For a stream, the provider formats the stream's diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto index 08421a371..c0af15c8d 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto @@ -25,6 +25,7 @@ import "temporal/api/enums/v1/nexus.proto"; import "temporal/api/activity/v1/message.proto"; import "temporal/api/common/v1/message.proto"; import "temporal/api/stream/v1/message.proto"; +import "temporal/api/notification/v1/message.proto"; import "temporal/api/history/v1/message.proto"; import "temporal/api/workflow/v1/message.proto"; import "temporal/api/command/v1/message.proto"; @@ -907,6 +908,79 @@ message WakeWorkflowExecutionResponse { bool folded = 2; } +message NotifyChannelRequest { + string namespace = 1; + temporal.api.notification.v1.Notification notification = 2; + // The identity of the writer, for metrics and logs. + string identity = 3; + // Used to de-dupe a retried notification. + string request_id = 4; +} + +message NotifyChannelResponse { + // Listeners registered when the notification was accepted. Zero means the + // notification was retained for pollers and woke nobody. + int32 listener_count = 1; +} + +message RegisterChannelListenerRequest { + string namespace = 1; + string channel = 2; + // Invoked with each notification on the channel. + temporal.api.common.v1.Callback callback = 3; + // Used to de-dupe a retried registration. + string request_id = 4; + // The identity of the caller, for metrics and logs. + string identity = 5; +} + +message RegisterChannelListenerResponse { + string listener_id = 1; +} + +message UnregisterChannelListenerRequest { + string namespace = 1; + string channel = 2; + string listener_id = 3; + // The identity of the caller, for metrics and logs. + string identity = 4; +} + +message UnregisterChannelListenerResponse { +} + +message PollChannelRequest { + string namespace = 1; + string channel = 2; + // Only notifications with a counter above this one are returned. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "after" names the exclusive lower bound. --) + int64 after_counter = 3; + // How long to wait for a notification when none is retained above + // `after_counter`. + google.protobuf.Duration wait = 4; + // At most this many notifications are returned. Zero means the server's + // default. + int32 max_notifications = 5; +} + +message PollChannelResponse { + repeated temporal.api.notification.v1.Notification notifications = 1; +} + +message DescribeChannelRequest { + string namespace = 1; + string channel = 2; +} + +message DescribeChannelResponse { + repeated temporal.api.notification.v1.ChannelListener listeners = 1; + // The notification with the highest counter the channel retains. + temporal.api.notification.v1.Notification latest = 2; + // How many notifications the channel retains for pollers. + int32 retained_count = 3; +} + message SignalWithStartWorkflowExecutionRequest { string namespace = 1; string workflow_id = 2; diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto index 5404601c4..3a873811a 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto @@ -488,6 +488,9 @@ service WorkflowService { // no event, carries no payload, and folds with other wakes for the same // source, so a burst of writes costs one task. The Workflow learns the // source and its position from the task and reads the source itself. + // + // Superseded by the notification channel (`NotifyChannel` and the + // `SubscribeNotificationChannel` command). Kept for one round and slated for removal. rpc WakeWorkflowExecution (WakeWorkflowExecutionRequest) returns (WakeWorkflowExecutionResponse) { option (google.api.http) = { post: "/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/wake" @@ -499,6 +502,72 @@ service WorkflowService { }; } + // NotifyChannel tells every listener of a channel that a source they consume + // has moved. The writer names no addressee and never learns who listens. The + // server wakes each listener: a Workflow with a Workflow Task, a callback by + // invoking it. Nothing goes to History except the notifications a woken + // Workflow Task carries on its scheduled event. + rpc NotifyChannel (NotifyChannelRequest) returns (NotifyChannelResponse) { + option (google.api.http) = { + post: "/namespaces/{namespace}/channels/{notification.channel}/notify" + body: "*" + additional_bindings { + post: "/api/v1/namespaces/{namespace}/channels/{notification.channel}/notify" + body: "*" + } + }; + } + + // RegisterChannelListener registers a callback as a listener of a channel. A + // Workflow registers itself with the `SubscribeNotificationChannel` command + // instead. + rpc RegisterChannelListener (RegisterChannelListenerRequest) returns (RegisterChannelListenerResponse) { + option (google.api.http) = { + post: "/namespaces/{namespace}/channels/{channel}/listeners" + body: "*" + additional_bindings { + post: "/api/v1/namespaces/{namespace}/channels/{channel}/listeners" + body: "*" + } + }; + } + + // UnregisterChannelListener removes a listener from a channel. + // + // (-- api-linter: core::0136::http-method=disabled + // aip.dev/not-precedent: Removing a listener is a delete of that listener. --) + rpc UnregisterChannelListener (UnregisterChannelListenerRequest) returns (UnregisterChannelListenerResponse) { + option (google.api.http) = { + delete: "/namespaces/{namespace}/channels/{channel}/listeners/{listener_id}" + additional_bindings { + delete: "/api/v1/namespaces/{namespace}/channels/{channel}/listeners/{listener_id}" + } + }; + } + + // PollChannel is a long poll for clients. It returns the retained + // notifications of a channel with a counter above `after_counter`, waiting + // up to `wait` for one when none is retained yet. + rpc PollChannel (PollChannelRequest) returns (PollChannelResponse) { + option (google.api.http) = { + get: "/namespaces/{namespace}/channels/{channel}/notifications" + additional_bindings { + get: "/api/v1/namespaces/{namespace}/channels/{channel}/notifications" + } + }; + } + + // DescribeChannel returns the listeners of a channel and its latest + // notification. + rpc DescribeChannel (DescribeChannelRequest) returns (DescribeChannelResponse) { + option (google.api.http) = { + get: "/namespaces/{namespace}/channels/{channel}" + additional_bindings { + get: "/api/v1/namespaces/{namespace}/channels/{channel}" + } + }; + } + // SignalWithStartWorkflowExecution is used to ensure a signal is sent to a workflow, even if // it isn't yet started. // diff --git a/crates/protos/src/protos/mod.rs b/crates/protos/src/protos/mod.rs index cf1d67db4..1a7363d7b 100644 --- a/crates/protos/src/protos/mod.rs +++ b/crates/protos/src/protos/mod.rs @@ -1831,6 +1831,9 @@ pub mod temporal { Attributes::SubscribeStreamCommandAttributes(_) => { CommandType::SubscribeStream } + Attributes::SubscribeNotificationChannelCommandAttributes(_) => { + CommandType::SubscribeNotificationChannel + } Attributes::CompleteWorkflowExecutionCommandAttributes(_) => { CommandType::CompleteWorkflowExecution } @@ -2345,6 +2348,7 @@ pub mod temporal { | EventType::WorkflowPropertiesModified | EventType::WorkflowStreamSubscribed | EventType::WorkflowStreamRecordsAppended + | EventType::WorkflowNotificationChannelSubscribed | EventType::NexusOperationScheduled | EventType::NexusOperationCancelRequested | EventType::WorkflowExecutionCanceled @@ -2448,6 +2452,9 @@ pub mod temporal { Attributes::WorkflowStreamRecordsAppendedEventAttributes(_) => { false } + Attributes::WorkflowNotificationChannelSubscribedEventAttributes(_) => { + false + } Attributes::WorkflowExecutionStartedEventAttributes(_) => false, Attributes::WorkflowExecutionCompletedEventAttributes(_) => false, Attributes::WorkflowExecutionFailedEventAttributes(_) => false, @@ -2537,6 +2544,7 @@ pub mod temporal { match self { Attributes::WorkflowStreamSubscribedEventAttributes(_) => { EventType::WorkflowStreamSubscribed } Attributes::WorkflowStreamRecordsAppendedEventAttributes(_) => { EventType::WorkflowStreamRecordsAppended } + Attributes::WorkflowNotificationChannelSubscribedEventAttributes(_) => { EventType::WorkflowNotificationChannelSubscribed } Attributes::WorkflowExecutionStartedEventAttributes(_) => { EventType::WorkflowExecutionStarted } Attributes::WorkflowExecutionCompletedEventAttributes(_) => { EventType::WorkflowExecutionCompleted } Attributes::WorkflowExecutionFailedEventAttributes(_) => { EventType::WorkflowExecutionFailed } @@ -2608,6 +2616,11 @@ pub mod temporal { tonic::include_proto!("temporal.api.namespace.v1"); } } + pub mod notification { + pub mod v1 { + tonic::include_proto!("temporal.api.notification.v1"); + } + } pub mod operatorservice { pub mod v1 { tonic::include_proto!("temporal.api.operatorservice.v1"); diff --git a/crates/sdk-core-c-bridge/src/client.rs b/crates/sdk-core-c-bridge/src/client.rs index 3379ac5ed..75ae21a9f 100644 --- a/crates/sdk-core-c-bridge/src/client.rs +++ b/crates/sdk-core-c-bridge/src/client.rs @@ -669,6 +669,9 @@ async fn call_workflow_service( "DescribeBatchOperation" => { rpc_call_on_trait!(client, call, WorkflowService, describe_batch_operation) } + "DescribeChannel" => { + rpc_call_on_trait!(client, call, WorkflowService, describe_channel) + } "DescribeDeployment" => { rpc_call_on_trait!(client, call, WorkflowService, describe_deployment) } @@ -802,6 +805,9 @@ async fn call_workflow_service( "ListWorkflowRules" => { rpc_call_on_trait!(client, call, WorkflowService, list_workflow_rules) } + "NotifyChannel" => { + rpc_call_on_trait!(client, call, WorkflowService, notify_channel) + } "PatchSchedule" => rpc_call_on_trait!(client, call, WorkflowService, patch_schedule), "PauseActivity" => rpc_call_on_trait!(client, call, WorkflowService, pause_activity), "PauseActivityExecution" => { @@ -810,6 +816,9 @@ async fn call_workflow_service( "PauseWorkflowExecution" => { rpc_call_on_trait!(client, call, WorkflowService, pause_workflow_execution) } + "PollChannel" => { + rpc_call_on_trait!(client, call, WorkflowService, poll_channel) + } "PollActivityExecution" => { rpc_call_on_trait!(client, call, WorkflowService, poll_activity_execution) } @@ -862,6 +871,9 @@ async fn call_workflow_service( "RecordWorkerHeartbeat" => { rpc_call_on_trait!(client, call, WorkflowService, record_worker_heartbeat) } + "RegisterChannelListener" => { + rpc_call_on_trait!(client, call, WorkflowService, register_channel_listener) + } "RegisterNamespace" => { rpc_call_on_trait!(client, call, WorkflowService, register_namespace) } @@ -1030,6 +1042,9 @@ async fn call_workflow_service( "TriggerWorkflowRule" => { rpc_call_on_trait!(client, call, WorkflowService, trigger_workflow_rule) } + "UnregisterChannelListener" => { + rpc_call_on_trait!(client, call, WorkflowService, unregister_channel_listener) + } "UnpauseActivity" => { rpc_call_on_trait!(client, call, WorkflowService, unpause_activity) } @@ -1107,6 +1122,9 @@ async fn call_workflow_service( validate_worker_deployment_version_compute_config ) } + "WakeWorkflowExecution" => { + rpc_call_on_trait!(client, call, WorkflowService, wake_workflow_execution) + } rpc => Err(anyhow::anyhow!("Unknown RPC call {rpc}")), } } From 5aef7fb85853a8d52acf9a0d524ecc529353e078 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 17:34:11 -0700 Subject: [PATCH 18/27] Carried the wrapped notification channel lines in the Core's api tree. --- .../api_upstream/temporal/api/command/v1/message.proto | 3 ++- .../api_upstream/temporal/api/history/v1/message.proto | 3 ++- .../temporal/api/workflowservice/v1/service.proto | 6 ++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto index 8e17b93f1..38bdc0b35 100644 --- a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto @@ -327,7 +327,8 @@ message Command { RequestCancelNexusOperationCommandAttributes request_cancel_nexus_operation_command_attributes = 19; AppendStreamRecordsCommandAttributes append_stream_records_command_attributes = 20; SubscribeStreamCommandAttributes subscribe_stream_command_attributes = 21; - SubscribeNotificationChannelCommandAttributes subscribe_notification_channel_command_attributes = 22; + SubscribeNotificationChannelCommandAttributes + subscribe_notification_channel_command_attributes = 22; } } diff --git a/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto index 3ce67b03f..9eb2d07dc 100644 --- a/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto @@ -1337,7 +1337,8 @@ message HistoryEvent { WorkflowExecutionTimeSkippingTransitionedEventAttributes workflow_execution_time_skipping_transitioned_event_attributes = 65; WorkflowStreamSubscribedEventAttributes workflow_stream_subscribed_event_attributes = 66; WorkflowStreamRecordsAppendedEventAttributes workflow_stream_records_appended_event_attributes = 67; - WorkflowNotificationChannelSubscribedEventAttributes workflow_notification_channel_subscribed_event_attributes = 68; + WorkflowNotificationChannelSubscribedEventAttributes + workflow_notification_channel_subscribed_event_attributes = 68; } } diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto index 3a873811a..e3abf2800 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto @@ -521,7 +521,8 @@ service WorkflowService { // RegisterChannelListener registers a callback as a listener of a channel. A // Workflow registers itself with the `SubscribeNotificationChannel` command // instead. - rpc RegisterChannelListener (RegisterChannelListenerRequest) returns (RegisterChannelListenerResponse) { + rpc RegisterChannelListener (RegisterChannelListenerRequest) + returns (RegisterChannelListenerResponse) { option (google.api.http) = { post: "/namespaces/{namespace}/channels/{channel}/listeners" body: "*" @@ -536,7 +537,8 @@ service WorkflowService { // // (-- api-linter: core::0136::http-method=disabled // aip.dev/not-precedent: Removing a listener is a delete of that listener. --) - rpc UnregisterChannelListener (UnregisterChannelListenerRequest) returns (UnregisterChannelListenerResponse) { + rpc UnregisterChannelListener (UnregisterChannelListenerRequest) + returns (UnregisterChannelListenerResponse) { option (google.api.http) = { delete: "/namespaces/{namespace}/channels/{channel}/listeners/{listener_id}" additional_bindings { From 43f2bd189059383ab8f28c3028c076f408a5c811 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 17:57:25 -0700 Subject: [PATCH 19/27] Carried the channel command and job protos for the Python bridge. --- .../workflow_activation/workflow_activation.proto | 14 ++++++++++++++ .../core/workflow_commands/workflow_commands.proto | 12 ++++++++++++ crates/protos/src/protos/mod.rs | 9 +++++++++ 3 files changed, 35 insertions(+) diff --git a/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto b/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto index 097994ac6..179bcf5e0 100644 --- a/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto +++ b/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto @@ -14,6 +14,7 @@ import "temporal/api/failure/v1/message.proto"; import "temporal/api/update/v1/message.proto"; import "temporal/api/common/v1/message.proto"; import "temporal/api/enums/v1/workflow.proto"; +import "temporal/api/notification/v1/message.proto"; import "temporal/sdk/core/activity_result/activity_result.proto"; import "temporal/sdk/core/child_workflow/child_workflow.proto"; import "temporal/sdk/core/common/common.proto"; @@ -139,6 +140,10 @@ message WorkflowActivationJob { ResolveNexusOperationStart resolve_nexus_operation_start = 15; // A nexus operation resolved. ResolveNexusOperation resolve_nexus_operation = 16; + // 17 to 21 are taken by the stream jobs, which share this message. + // + // Notifications from the channels the workflow subscribed to. + NotificationsReceived notifications_received = 22; // Remove the workflow identified by the [WorkflowActivation] containing this job from the // cache after performing the activation. It is guaranteed that this will be the only job // in the activation if present. @@ -146,6 +151,15 @@ message WorkflowActivationJob { } } +// Hand a workflow the notifications the server folded for its channels. +// +// They come from the scheduled event of the Workflow Task this activation +// belongs to. History is the record, so a replay yields the same job with the +// same notifications at the same point. +message NotificationsReceived { + repeated temporal.api.notification.v1.Notification notifications = 1; +} + // Initialize a new workflow message InitializeWorkflow { // The identifier the lang-specific sdk uses to execute workflow code diff --git a/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto b/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto index 03e172216..a183e0f05 100644 --- a/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto +++ b/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto @@ -54,9 +54,21 @@ message WorkflowCommand { UpdateResponse update_response = 20; ScheduleNexusOperation schedule_nexus_operation = 21; RequestCancelNexusOperation request_cancel_nexus_operation = 22; + // 23 to 30 are taken by the stream commands, which share this message. + SubscribeNotificationChannel subscribe_notification_channel = 31; } } +// Subscribe this workflow to a notification channel, so the scheduled event of +// each later Workflow Task carries the notifications folded for it. +// +// The notifications live in History rather than arriving by a side channel, so +// a replay reads the same ones the live run saw. +message SubscribeNotificationChannel { + // Name of the channel, scoped to the namespace. + string channel = 1; +} + message StartTimer { // Lang's incremental sequence number, used as the operation identifier uint32 seq = 1; diff --git a/crates/protos/src/protos/mod.rs b/crates/protos/src/protos/mod.rs index 1a7363d7b..9a240e0be 100644 --- a/crates/protos/src/protos/mod.rs +++ b/crates/protos/src/protos/mod.rs @@ -1277,6 +1277,9 @@ pub mod coresdk { workflow_activation_job::Variant::ResolveNexusOperation(_) => { write!(f, "ResolveNexusOperation") } + workflow_activation_job::Variant::NotificationsReceived(n) => { + write!(f, "NotificationsReceived({})", n.notifications.len()) + } } } } @@ -1477,6 +1480,12 @@ pub mod coresdk { use crate::protos::temporal::api::{common::v1::Payloads, enums::v1::QueryResultType}; use std::fmt::{Display, Formatter}; + impl Display for SubscribeNotificationChannel { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "SubscribeNotificationChannel({})", self.channel) + } + } + impl Display for WorkflowCommand { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match &self.variant { From b481c8112008a85ff30f5c230a8389d0e5478cb5 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 17:57:43 -0700 Subject: [PATCH 20/27] Carried the subscribe-notification-channel failed cause in the Core's api tree. --- .../api_upstream/temporal/api/enums/v1/failed_cause.proto | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto b/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto index 274e533d5..e11048302 100644 --- a/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto +++ b/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto @@ -98,6 +98,9 @@ enum WorkflowTaskFailedCause { // History can no longer be served, for example after truncation or because it exceeds the // replay bound. Check the workflow task failure message for more information. WORKFLOW_TASK_FAILED_CAUSE_STREAM_RANGE_UNAVAILABLE = 43; + // A SubscribeNotificationChannel command named an empty or too-long channel, or hit a + // subscription or listener limit. + WORKFLOW_TASK_FAILED_CAUSE_BAD_SUBSCRIBE_NOTIFICATION_CHANNEL_ATTRIBUTES = 44; } enum StartChildWorkflowExecutionFailedCause { From a9a025b8172618a15777da64fbfc259d1c6b73f9 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 18:22:21 -0700 Subject: [PATCH 21/27] Compiled the channel command and job without handling them on the protos pin. --- crates/sdk-core/src/worker/workflow/mod.rs | 9 +++++++-- crates/sdk/src/workflow_future.rs | 6 ++++++ crates/workflow/src/runtime/instance.rs | 5 +++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/sdk-core/src/worker/workflow/mod.rs b/crates/sdk-core/src/worker/workflow/mod.rs index b0da392a3..be73d5d94 100644 --- a/crates/sdk-core/src/worker/workflow/mod.rs +++ b/crates/sdk-core/src/worker/workflow/mod.rs @@ -1352,7 +1352,7 @@ fn validate_completion( .collect::, EmptyWorkflowCommandErr>>() .map_err(|_| CompleteWfError::MalformedWorkflowCompletion { reason: "At least one workflow command in the completion contained \ - an empty variant" + an empty or unsupported variant" .to_owned(), run_id: completion.run_id.clone(), })?; @@ -1434,7 +1434,7 @@ impl LocalResolution { } #[derive(thiserror::Error, Debug, derive_more::From)] -#[error("Lang provided workflow command with empty variant")] +#[error("Lang provided workflow command with an empty or unsupported variant")] struct EmptyWorkflowCommandErr; /// [DrivenWorkflow]s respond with these when called, to indicate what they want to do next. @@ -1588,6 +1588,11 @@ impl TryFrom for WFCommand { workflow_command::Variant::RequestCancelNexusOperation(s) => { WFCommandVariant::RequestCancelNexusOperation(s) } + // This layer carries the protos only. Dropping the command would let the + // workflow go on as if subscribed while the server never heard of it. + workflow_command::Variant::SubscribeNotificationChannel(_) => { + return Err(EmptyWorkflowCommandErr); + } }; Ok(Self { variant, diff --git a/crates/sdk/src/workflow_future.rs b/crates/sdk/src/workflow_future.rs index d6920870d..efb4a47ea 100644 --- a/crates/sdk/src/workflow_future.rs +++ b/crates/sdk/src/workflow_future.rs @@ -340,6 +340,12 @@ impl WorkflowFuture { .context("Nexus operation must have result")?; push_polled_context!(ActivationJobContext::Passive); } + Variant::NotificationsReceived(_) => { + // No channel API in this SDK, so nothing here subscribed, and a + // notification carries no data a workflow could lose by this. + debug!("Channel notifications received and ignored"); + push_polled_context!(ActivationJobContext::Passive); + } Variant::RemoveFromCache(_) => { unreachable!("Cache removal should happen higher up"); } diff --git a/crates/workflow/src/runtime/instance.rs b/crates/workflow/src/runtime/instance.rs index c3417ef31..d8ff291ce 100644 --- a/crates/workflow/src/runtime/instance.rs +++ b/crates/workflow/src/runtime/instance.rs @@ -1093,6 +1093,11 @@ where self.apply_resolution(resolution); ActivationJobResult::None } + Some(ActivationVariant::NotificationsReceived(_)) => { + // This runtime cannot subscribe to a channel, and a notification + // carries no data the workflow could lose by ignoring it. + ActivationJobResult::None + } Some(ActivationVariant::RemoveFromCache(_)) => ActivationJobResult::None, None => { return Err(Box::new(Failure { From 23307d81c8ba314c9eca2c9eb1e307d2d272eb1c Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 22:11:29 -0700 Subject: [PATCH 22/27] Dropped the point-to-point wake from the Core's api tree and client. Applies api 9cd8b40 to the vendored tree and removes the raw-client proxier and the C-bridge dispatch entry that the Python bridge generator built on. --- crates/client/src/grpc.rs | 9 ------ .../temporal/api/workflow/v1/message.proto | 29 ------------------- .../workflowservice/v1/request_response.proto | 29 ++----------------- .../api/workflowservice/v1/service.proto | 19 ------------ crates/sdk-core-c-bridge/src/client.rs | 3 -- 5 files changed, 2 insertions(+), 87 deletions(-) diff --git a/crates/client/src/grpc.rs b/crates/client/src/grpc.rs index 7565c4c0e..a7f46422f 100644 --- a/crates/client/src/grpc.rs +++ b/crates/client/src/grpc.rs @@ -955,15 +955,6 @@ proxier! { r.extensions_mut().insert(labels); } ); - ( - wake_workflow_execution, - WakeWorkflowExecutionRequest, - WakeWorkflowExecutionResponse, - |r| { - let labels = namespaced_request!(r); - r.extensions_mut().insert(labels); - } - ); ( notify_channel, NotifyChannelRequest, diff --git a/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto index 0c414794e..1ed33fa4c 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto @@ -760,32 +760,3 @@ message WorkflowExecutionPauseInfo { // The reason for pausing the workflow execution. string reason = 3; } - -// A wake tells a running Workflow Execution that something it consumes has -// moved, so it should run a Workflow Task and read from its own cursor. It is -// a reason to run, not data: nothing is recorded in History and no record -// travels with it. A store Temporal does not host sends one after the store -// has acknowledged a write. Wakes for the same source fold while one is -// pending and no task has received it yet, so a burst of writes costs one -// task. Once a task has been handed the wake, a new wake for the source is -// accepted again, because only the receiver knows what it read. -// -// Superseded by the notification channel (`NotifyChannel` and the -// `SubscribeNotificationChannel` command). Kept for one round and slated for -// removal. -message Wake { - // What moved, as the sender names it. The server folds wakes by this value - // and never interprets it. For a stream, the provider formats the stream's - // identity, owner and topic, into it. - string source = 1; - // Where the source stands after the write that caused this wake, in the - // store's own terms. Opaque to the server and handed to the Workflow as - // received. Among wakes folded together, the position with the highest - // counter is the one delivered. - bytes position = 2; - // Orders wakes from one source, so that folding keeps the latest position. - // Only the source's store can order its positions, so the sender derives - // this from the position. It is not a durable identity: a wake sent after - // a task received an equal counter is accepted again. - int64 counter = 3; -} diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto index c0af15c8d..57d4327cd 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto @@ -390,11 +390,8 @@ message PollWorkflowTaskQueueResponse { // never enter History; only the offset ranges are recorded there. repeated temporal.api.stream.v1.StreamSlice stream_slices = 20; - // Wakes pending for this execution when the task started, folded by - // source. Not recorded in History and never re-supplied on replay: the - // Workflow treats one as a reason to read its source now and records what - // it read itself. - repeated temporal.api.workflow.v1.Wake wakes = 21; + // Used once by a repeated field this fork has since removed. + reserved 21; } message RespondWorkflowTaskCompletedRequest { @@ -886,28 +883,6 @@ message SignalWorkflowExecutionResponse { temporal.api.common.v1.Link link = 1; } -message WakeWorkflowExecutionRequest { - string namespace = 1; - // The Workflow to wake. `run_id` is optional. When set, the wake goes to - // the current run of the chain that run belongs to, so a sender that - // learned a run id before a continue-as-new still reaches the consumer, - // and it is refused with NotFound once that chain has ended. When unset, - // the wake goes to the current run under the Workflow Id, whichever chain. - temporal.api.common.v1.WorkflowExecution workflow_execution = 2; - temporal.api.workflow.v1.Wake wake = 3; - // The identity of the sender, for metrics and logs. - string identity = 4; -} - -message WakeWorkflowExecutionResponse { - // The run the wake was stored on. - string run_id = 1; - // True when a wake for the source was already pending and no task had - // received it yet, so this one changed nothing but possibly the position - // and no new task results from it. - bool folded = 2; -} - message NotifyChannelRequest { string namespace = 1; temporal.api.notification.v1.Notification notification = 2; diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto index e3abf2800..1f55b34c0 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto @@ -483,25 +483,6 @@ service WorkflowService { }; } - // WakeWorkflowExecution asks a running Workflow Execution to run a Workflow - // Task because a source it consumes has moved. Unlike a Signal it records - // no event, carries no payload, and folds with other wakes for the same - // source, so a burst of writes costs one task. The Workflow learns the - // source and its position from the task and reads the source itself. - // - // Superseded by the notification channel (`NotifyChannel` and the - // `SubscribeNotificationChannel` command). Kept for one round and slated for removal. - rpc WakeWorkflowExecution (WakeWorkflowExecutionRequest) returns (WakeWorkflowExecutionResponse) { - option (google.api.http) = { - post: "/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/wake" - body: "*" - additional_bindings { - post: "/api/v1/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/wake" - body: "*" - } - }; - } - // NotifyChannel tells every listener of a channel that a source they consume // has moved. The writer names no addressee and never learns who listens. The // server wakes each listener: a Workflow with a Workflow Task, a callback by diff --git a/crates/sdk-core-c-bridge/src/client.rs b/crates/sdk-core-c-bridge/src/client.rs index 75ae21a9f..9776327c5 100644 --- a/crates/sdk-core-c-bridge/src/client.rs +++ b/crates/sdk-core-c-bridge/src/client.rs @@ -1122,9 +1122,6 @@ async fn call_workflow_service( validate_worker_deployment_version_compute_config ) } - "WakeWorkflowExecution" => { - rpc_call_on_trait!(client, call, WorkflowService, wake_workflow_execution) - } rpc => Err(anyhow::anyhow!("Unknown RPC call {rpc}")), } } From 0c21f1c0f637acc056660f49db1c0214a8e0d85c Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Thu, 1 Oct 2026 22:27:26 -0700 Subject: [PATCH 23/27] Carried the linked channel contract in the Core's api tree. Applies api a9e6517 to the vendored tree. The new fields carry no payloads, so the payload-limits table in `crates/common/build.rs` needed no entry. --- .../api/notification/v1/message.proto | 17 ++++++++++ .../workflowservice/v1/request_response.proto | 26 ++++++++++++++ .../api/workflowservice/v1/service.proto | 34 +++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto index 63e1b7716..e1413efe1 100644 --- a/crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto @@ -32,6 +32,12 @@ message Notification { // Details for the listener, such as which topic moved. Bounded in size and // carried as payloads, so a codec applies as to any payload. map metadata = 4; + // Set for a channel linked to a workflow: the owner and the run that + // received the notification. Empty for an independent channel. A listener + // that holds both kinds routes the notification by it. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "to" names the owner the channel is linked to. --) + temporal.api.common.v1.WorkflowExecution linked_to = 5; } // A listener of a channel: a Workflow Execution woken with a Workflow Task, or @@ -53,3 +59,14 @@ message WorkflowListener { // chain's current run when it delivers. string run_id = 2; } + +// Where a channel lives, which decides how a call addresses it. +enum ChannelKind { + CHANNEL_KIND_UNSPECIFIED = 0; + // Its own execution, keyed by namespace and channel name. Any number of + // workflows and callbacks listen to it. + CHANNEL_KIND_INDEPENDENT = 1; + // Kept in one workflow's state, keyed by namespace, workflow id and + // channel name. The owning workflow is its listener by construction. + CHANNEL_KIND_LINKED = 2; +} diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto index 57d4327cd..660ebf809 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto @@ -890,6 +890,10 @@ message NotifyChannelRequest { string identity = 3; // Used to de-dupe a retried notification. string request_id = 4; + // When set, the call addresses the channel linked to this workflow; `run_id` + // is optional and resolves to the chain's current run, as a Signal does. + // When unset, the call addresses the independent channel of that name. + temporal.api.common.v1.WorkflowExecution workflow_execution = 5; } message NotifyChannelResponse { @@ -907,6 +911,10 @@ message RegisterChannelListenerRequest { string request_id = 4; // The identity of the caller, for metrics and logs. string identity = 5; + // When set, the call addresses the channel linked to this workflow; `run_id` + // is optional and resolves to the chain's current run, as a Signal does. + // When unset, the call addresses the independent channel of that name. + temporal.api.common.v1.WorkflowExecution workflow_execution = 6; } message RegisterChannelListenerResponse { @@ -919,6 +927,10 @@ message UnregisterChannelListenerRequest { string listener_id = 3; // The identity of the caller, for metrics and logs. string identity = 4; + // When set, the call addresses the channel linked to this workflow; `run_id` + // is optional and resolves to the chain's current run, as a Signal does. + // When unset, the call addresses the independent channel of that name. + temporal.api.common.v1.WorkflowExecution workflow_execution = 5; } message UnregisterChannelListenerResponse { @@ -937,6 +949,10 @@ message PollChannelRequest { // At most this many notifications are returned. Zero means the server's // default. int32 max_notifications = 5; + // When set, the call addresses the channel linked to this workflow; `run_id` + // is optional and resolves to the chain's current run, as a Signal does. + // When unset, the call addresses the independent channel of that name. + temporal.api.common.v1.WorkflowExecution workflow_execution = 6; } message PollChannelResponse { @@ -946,6 +962,10 @@ message PollChannelResponse { message DescribeChannelRequest { string namespace = 1; string channel = 2; + // When set, the call addresses the channel linked to this workflow; `run_id` + // is optional and resolves to the chain's current run, as a Signal does. + // When unset, the call addresses the independent channel of that name. + temporal.api.common.v1.WorkflowExecution workflow_execution = 3; } message DescribeChannelResponse { @@ -954,6 +974,12 @@ message DescribeChannelResponse { temporal.api.notification.v1.Notification latest = 2; // How many notifications the channel retains for pollers. int32 retained_count = 3; + temporal.api.notification.v1.ChannelKind kind = 4; + // The owner of a linked channel and the run that holds it. Empty for an + // independent channel. + // (-- api-linter: core::0140::prepositions=disabled + // aip.dev/not-precedent: "to" names the owner the channel is linked to. --) + temporal.api.common.v1.WorkflowExecution linked_to = 5; } message SignalWithStartWorkflowExecutionRequest { diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto index 1f55b34c0..9dbf0317f 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto @@ -496,6 +496,14 @@ service WorkflowService { post: "/api/v1/namespaces/{namespace}/channels/{notification.channel}/notify" body: "*" } + additional_bindings { + post: "/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{notification.channel}/notify" + body: "*" + } + additional_bindings { + post: "/api/v1/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{notification.channel}/notify" + body: "*" + } }; } @@ -511,6 +519,14 @@ service WorkflowService { post: "/api/v1/namespaces/{namespace}/channels/{channel}/listeners" body: "*" } + additional_bindings { + post: "/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}/listeners" + body: "*" + } + additional_bindings { + post: "/api/v1/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}/listeners" + body: "*" + } }; } @@ -525,6 +541,12 @@ service WorkflowService { additional_bindings { delete: "/api/v1/namespaces/{namespace}/channels/{channel}/listeners/{listener_id}" } + additional_bindings { + delete: "/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}/listeners/{listener_id}" + } + additional_bindings { + delete: "/api/v1/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}/listeners/{listener_id}" + } }; } @@ -537,6 +559,12 @@ service WorkflowService { additional_bindings { get: "/api/v1/namespaces/{namespace}/channels/{channel}/notifications" } + additional_bindings { + get: "/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}/notifications" + } + additional_bindings { + get: "/api/v1/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}/notifications" + } }; } @@ -548,6 +576,12 @@ service WorkflowService { additional_bindings { get: "/api/v1/namespaces/{namespace}/channels/{channel}" } + additional_bindings { + get: "/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}" + } + additional_bindings { + get: "/api/v1/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}" + } }; } From 8103755dd928c176d7d055ab5731635b1b70ddeb Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 2 Oct 2026 02:55:51 -0700 Subject: [PATCH 24/27] Carried the channel subscriptions on describe in the Core's api tree. Applies api 30918a0 to the vendored tree. The new message nests a Notification, whose metadata the payload-limits table already exempts, so the table needed no entry. --- .../temporal/api/workflow/v1/message.proto | 27 +++++++++++++++++++ .../workflowservice/v1/request_response.proto | 3 +++ 2 files changed, 30 insertions(+) diff --git a/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto index 1ed33fa4c..4fea9a63e 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflow/v1/message.proto @@ -21,6 +21,7 @@ import "temporal/api/enums/v1/workflow.proto"; import "temporal/api/common/v1/message.proto"; import "temporal/api/deployment/v1/message.proto"; import "temporal/api/failure/v1/message.proto"; +import "temporal/api/notification/v1/message.proto"; import "temporal/api/taskqueue/v1/message.proto"; import "temporal/api/sdk/v1/user_metadata.proto"; @@ -582,6 +583,32 @@ message NexusOperationCancellationInfo { string blocked_reason = 7; } +// A workflow's standing on a notification channel, as reported by DescribeWorkflowExecution. +message ChannelSubscriptionInfo { + // Channel name. + string channel = 1; + // CHANNEL_KIND_INDEPENDENT for a channel the workflow subscribed to with a + // SubscribeNotificationChannel command. CHANNEL_KIND_LINKED for a channel linked to this + // workflow, which lists it once the channel holds any state. + temporal.api.notification.v1.ChannelKind kind = 2; + // Independent kind: id of the WorkflowNotificationChannelSubscribed event that recorded the + // subscription. Zero for the linked kind. + int64 subscribed_event_id = 3; + // Highest counter the workflow has accepted from the channel. Zero when none has arrived. + int64 last_counter = 4; + // The notification held for the workflow's next Workflow Task, when one is pending. + temporal.api.notification.v1.Notification pending_notification = 5; + // Counter carried by the scheduled event of a Workflow Task that has not started yet. Zero + // otherwise. + int64 scheduled_counter = 6; + // Linked kind: callback listeners registered on the channel. + int32 listener_count = 7; + // Linked kind: notifications retained for pollers. + int32 retained_count = 8; + // Linked kind: notifications the channel has accepted over its life. + int64 accepted_count = 9; +} + message WorkflowExecutionOptions { // If set, takes precedence over the Versioning Behavior sent by the SDK on Workflow Task completion. VersioningOverride versioning_override = 1; diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto index 660ebf809..37c881f24 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto @@ -1314,6 +1314,9 @@ message DescribeWorkflowExecutionResponse { repeated temporal.api.workflow.v1.CallbackInfo callbacks = 6; repeated temporal.api.workflow.v1.PendingNexusOperationInfo pending_nexus_operations = 7; temporal.api.workflow.v1.WorkflowExecutionExtendedInfo workflow_extended_info = 8; + // The notification channels this run stands on: the independent channels it subscribed to and + // the channels linked to it that hold any state. Empty when there are none. + repeated temporal.api.workflow.v1.ChannelSubscriptionInfo channel_subscriptions = 9; } // (-- api-linter: core::0203::optional=disabled From acd30916e56c10d0dd47e6774a26cb0e9532aaee Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 2 Oct 2026 02:55:51 -0700 Subject: [PATCH 25/27] Carried the unsubscribe channel command in the Core's api tree. Applies api c239d35 to the vendored tree, with the lang command at 32 next to the subscribe. The protos pin refuses the command the way it refuses the subscribe, so a workflow cannot go on as if unsubscribed while the server keeps delivering. --- .../temporal/api/command/v1/message.proto | 11 +++++++++++ .../temporal/api/enums/v1/command_type.proto | 1 + .../temporal/api/enums/v1/event_type.proto | 4 ++++ .../temporal/api/enums/v1/failed_cause.proto | 2 ++ .../temporal/api/history/v1/message.proto | 14 ++++++++++++++ .../core/workflow_commands/workflow_commands.proto | 11 +++++++++++ crates/protos/src/protos/mod.rs | 14 ++++++++++++++ crates/sdk-core/src/worker/workflow/mod.rs | 5 +++++ 8 files changed, 62 insertions(+) diff --git a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto index 38bdc0b35..aadc8f89d 100644 --- a/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/command/v1/message.proto @@ -329,6 +329,8 @@ message Command { SubscribeStreamCommandAttributes subscribe_stream_command_attributes = 21; SubscribeNotificationChannelCommandAttributes subscribe_notification_channel_command_attributes = 22; + UnsubscribeNotificationChannelCommandAttributes + unsubscribe_notification_channel_command_attributes = 23; } } @@ -379,3 +381,12 @@ message SubscribeNotificationChannelCommandAttributes { // The channel to listen on, as the writers name it. string channel = 1; } + +// Ends the run's subscription to a notification channel. Notifications already +// recorded on a scheduled event still reach that Workflow Task; later ones do +// not. A command naming a channel the run is not subscribed to records its +// event and changes nothing, so replay matches every command to an event. +message UnsubscribeNotificationChannelCommandAttributes { + // The channel to stop listening on, as the writers name it. + string channel = 1; +} diff --git a/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto b/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto index b8eada084..629a26528 100644 --- a/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto +++ b/crates/protos/protos/api_upstream/temporal/api/enums/v1/command_type.proto @@ -32,4 +32,5 @@ enum CommandType { COMMAND_TYPE_APPEND_STREAM_RECORDS = 19; COMMAND_TYPE_SUBSCRIBE_STREAM = 20; COMMAND_TYPE_SUBSCRIBE_NOTIFICATION_CHANNEL = 21; + COMMAND_TYPE_UNSUBSCRIBE_NOTIFICATION_CHANNEL = 22; } diff --git a/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto b/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto index f70de73fe..815e1aebe 100644 --- a/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto +++ b/crates/protos/protos/api_upstream/temporal/api/enums/v1/event_type.proto @@ -186,4 +186,8 @@ enum EventType { // A Workflow became a listener of a notification channel for its run. // The notifications themselves ride the WorkflowTaskScheduled event. EVENT_TYPE_WORKFLOW_NOTIFICATION_CHANNEL_SUBSCRIBED = 63; + // A Workflow stopped listening on a notification channel for its run. + // Recorded for every UnsubscribeNotificationChannel command, including one + // naming a channel the run was not subscribed to. + EVENT_TYPE_WORKFLOW_NOTIFICATION_CHANNEL_UNSUBSCRIBED = 64; } diff --git a/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto b/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto index e11048302..cccabc13c 100644 --- a/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto +++ b/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto @@ -101,6 +101,8 @@ enum WorkflowTaskFailedCause { // A SubscribeNotificationChannel command named an empty or too-long channel, or hit a // subscription or listener limit. WORKFLOW_TASK_FAILED_CAUSE_BAD_SUBSCRIBE_NOTIFICATION_CHANNEL_ATTRIBUTES = 44; + // An UnsubscribeNotificationChannel command named an empty or too-long channel. + WORKFLOW_TASK_FAILED_CAUSE_BAD_UNSUBSCRIBE_NOTIFICATION_CHANNEL_ATTRIBUTES = 45; } enum StartChildWorkflowExecutionFailedCause { diff --git a/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto index 9eb2d07dc..094a5a5e7 100644 --- a/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/history/v1/message.proto @@ -992,6 +992,18 @@ message WorkflowNotificationChannelSubscribedEventAttributes { string channel = 2; } +message WorkflowNotificationChannelUnsubscribedEventAttributes { + // The WorkflowTaskCompleted event of the task whose command ended this + // subscription. + int64 workflow_task_completed_event_id = 1; + // The channel the Workflow stopped listening on. + string channel = 2; + // The WorkflowNotificationChannelSubscribed event that recorded the + // subscription this command ended. Zero when the run held no subscription + // for the channel. + int64 subscribed_event_id = 3; +} + message WorkflowStreamRecordsAppendedEventAttributes { // The WorkflowTaskCompleted event of the task whose command appended this // batch. @@ -1339,6 +1351,8 @@ message HistoryEvent { WorkflowStreamRecordsAppendedEventAttributes workflow_stream_records_appended_event_attributes = 67; WorkflowNotificationChannelSubscribedEventAttributes workflow_notification_channel_subscribed_event_attributes = 68; + WorkflowNotificationChannelUnsubscribedEventAttributes + workflow_notification_channel_unsubscribed_event_attributes = 69; } } diff --git a/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto b/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto index a183e0f05..b82d752e6 100644 --- a/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto +++ b/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto @@ -56,6 +56,7 @@ message WorkflowCommand { RequestCancelNexusOperation request_cancel_nexus_operation = 22; // 23 to 30 are taken by the stream commands, which share this message. SubscribeNotificationChannel subscribe_notification_channel = 31; + UnsubscribeNotificationChannel unsubscribe_notification_channel = 32; } } @@ -69,6 +70,16 @@ message SubscribeNotificationChannel { string channel = 1; } +// End this workflow's subscription to a notification channel. Notifications +// already recorded on a scheduled event still reach that Workflow Task. +// +// The server records an event for every unsubscribe, also one naming a channel +// the run is not subscribed to, so replay can hold each command to its event. +message UnsubscribeNotificationChannel { + // Name of the channel, scoped to the namespace. + string channel = 1; +} + message StartTimer { // Lang's incremental sequence number, used as the operation identifier uint32 seq = 1; diff --git a/crates/protos/src/protos/mod.rs b/crates/protos/src/protos/mod.rs index 9a240e0be..144c3c7d2 100644 --- a/crates/protos/src/protos/mod.rs +++ b/crates/protos/src/protos/mod.rs @@ -1486,6 +1486,12 @@ pub mod coresdk { } } + impl Display for UnsubscribeNotificationChannel { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "UnsubscribeNotificationChannel({})", self.channel) + } + } + impl Display for WorkflowCommand { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match &self.variant { @@ -1843,6 +1849,9 @@ pub mod temporal { Attributes::SubscribeNotificationChannelCommandAttributes(_) => { CommandType::SubscribeNotificationChannel } + Attributes::UnsubscribeNotificationChannelCommandAttributes(_) => { + CommandType::UnsubscribeNotificationChannel + } Attributes::CompleteWorkflowExecutionCommandAttributes(_) => { CommandType::CompleteWorkflowExecution } @@ -2358,6 +2367,7 @@ pub mod temporal { | EventType::WorkflowStreamSubscribed | EventType::WorkflowStreamRecordsAppended | EventType::WorkflowNotificationChannelSubscribed + | EventType::WorkflowNotificationChannelUnsubscribed | EventType::NexusOperationScheduled | EventType::NexusOperationCancelRequested | EventType::WorkflowExecutionCanceled @@ -2464,6 +2474,9 @@ pub mod temporal { Attributes::WorkflowNotificationChannelSubscribedEventAttributes(_) => { false } + Attributes::WorkflowNotificationChannelUnsubscribedEventAttributes(_) => { + false + } Attributes::WorkflowExecutionStartedEventAttributes(_) => false, Attributes::WorkflowExecutionCompletedEventAttributes(_) => false, Attributes::WorkflowExecutionFailedEventAttributes(_) => false, @@ -2554,6 +2567,7 @@ pub mod temporal { Attributes::WorkflowStreamSubscribedEventAttributes(_) => { EventType::WorkflowStreamSubscribed } Attributes::WorkflowStreamRecordsAppendedEventAttributes(_) => { EventType::WorkflowStreamRecordsAppended } Attributes::WorkflowNotificationChannelSubscribedEventAttributes(_) => { EventType::WorkflowNotificationChannelSubscribed } + Attributes::WorkflowNotificationChannelUnsubscribedEventAttributes(_) => { EventType::WorkflowNotificationChannelUnsubscribed } Attributes::WorkflowExecutionStartedEventAttributes(_) => { EventType::WorkflowExecutionStarted } Attributes::WorkflowExecutionCompletedEventAttributes(_) => { EventType::WorkflowExecutionCompleted } Attributes::WorkflowExecutionFailedEventAttributes(_) => { EventType::WorkflowExecutionFailed } diff --git a/crates/sdk-core/src/worker/workflow/mod.rs b/crates/sdk-core/src/worker/workflow/mod.rs index be73d5d94..bdc3b96fb 100644 --- a/crates/sdk-core/src/worker/workflow/mod.rs +++ b/crates/sdk-core/src/worker/workflow/mod.rs @@ -1593,6 +1593,11 @@ impl TryFrom for WFCommand { workflow_command::Variant::SubscribeNotificationChannel(_) => { return Err(EmptyWorkflowCommandErr); } + // Same reason, reversed: the workflow would go on as if unsubscribed + // while the server keeps delivering. + workflow_command::Variant::UnsubscribeNotificationChannel(_) => { + return Err(EmptyWorkflowCommandErr); + } }; Ok(Self { variant, From 7a0d774b5dab911a90497b840e6574dd14a48f13 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 2 Oct 2026 17:30:03 -0700 Subject: [PATCH 26/27] Addressed a linked channel by execution in the Core's api tree. Carries api c239d35..4304fd8: the five channel requests, the notification and the describe response name the linked owner as an Execution, so an activity can own a channel too. --- .../api/notification/v1/message.proto | 17 ++-- .../workflowservice/v1/request_response.proto | 80 ++++++++++++++----- .../api/workflowservice/v1/service.proto | 54 ++++++++++--- 3 files changed, 114 insertions(+), 37 deletions(-) diff --git a/crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto index e1413efe1..85519f697 100644 --- a/crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto @@ -30,14 +30,21 @@ message Notification { // notifications folded together, the one with the highest counter is kept. int64 counter = 3; // Details for the listener, such as which topic moved. Bounded in size and - // carried as payloads, so a codec applies as to any payload. + // carried as payloads, so a codec applies as to any payload. This is state, + // not a log: a fold keeps the latest notification only, so a writer puts + // here what is true at `position`, such as which topic moved or a close + // flag, never something a consumer must see once per write. map metadata = 4; - // Set for a channel linked to a workflow: the owner and the run that + + // Was temporal.api.common.v1.WorkflowExecution linked_to. + reserved 5; + + // Set for a channel linked to an execution: the owner and the run that // received the notification. Empty for an independent channel. A listener // that holds both kinds routes the notification by it. // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: "to" names the owner the channel is linked to. --) - temporal.api.common.v1.WorkflowExecution linked_to = 5; + temporal.api.common.v1.Execution linked_to = 6; } // A listener of a channel: a Workflow Execution woken with a Workflow Task, or @@ -66,7 +73,7 @@ enum ChannelKind { // Its own execution, keyed by namespace and channel name. Any number of // workflows and callbacks listen to it. CHANNEL_KIND_INDEPENDENT = 1; - // Kept in one workflow's state, keyed by namespace, workflow id and - // channel name. The owning workflow is its listener by construction. + // Kept in one execution's state, keyed by namespace, execution and + // channel name. The owning execution is its listener by construction. CHANNEL_KIND_LINKED = 2; } diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto index 37c881f24..a3a8b9253 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto @@ -886,14 +886,22 @@ message SignalWorkflowExecutionResponse { message NotifyChannelRequest { string namespace = 1; temporal.api.notification.v1.Notification notification = 2; - // The identity of the writer, for metrics and logs. + // The identity of the caller, for audit, metrics and logs. It is not copied + // into the notification. A writer that wants the consumer to see who wrote + // puts that in the notification's `metadata`. string identity = 3; // Used to de-dupe a retried notification. string request_id = 4; - // When set, the call addresses the channel linked to this workflow; `run_id` - // is optional and resolves to the chain's current run, as a Signal does. - // When unset, the call addresses the independent channel of that name. - temporal.api.common.v1.WorkflowExecution workflow_execution = 5; + + // Was temporal.api.common.v1.WorkflowExecution workflow_execution. + reserved 5; + reserved "workflow_execution"; + + // When set, the call addresses the channel linked to this execution. + // `run_id` is optional and resolves to the current run of a workflow chain, + // as a Signal does. When unset, the call addresses the independent channel + // of that name. + temporal.api.common.v1.Execution execution = 6; } message NotifyChannelResponse { @@ -911,10 +919,16 @@ message RegisterChannelListenerRequest { string request_id = 4; // The identity of the caller, for metrics and logs. string identity = 5; - // When set, the call addresses the channel linked to this workflow; `run_id` - // is optional and resolves to the chain's current run, as a Signal does. - // When unset, the call addresses the independent channel of that name. - temporal.api.common.v1.WorkflowExecution workflow_execution = 6; + + // Was temporal.api.common.v1.WorkflowExecution workflow_execution. + reserved 6; + reserved "workflow_execution"; + + // When set, the call addresses the channel linked to this execution. + // `run_id` is optional and resolves to the current run of a workflow chain, + // as a Signal does. When unset, the call addresses the independent channel + // of that name. + temporal.api.common.v1.Execution execution = 7; } message RegisterChannelListenerResponse { @@ -927,10 +941,16 @@ message UnregisterChannelListenerRequest { string listener_id = 3; // The identity of the caller, for metrics and logs. string identity = 4; - // When set, the call addresses the channel linked to this workflow; `run_id` - // is optional and resolves to the chain's current run, as a Signal does. - // When unset, the call addresses the independent channel of that name. - temporal.api.common.v1.WorkflowExecution workflow_execution = 5; + + // Was temporal.api.common.v1.WorkflowExecution workflow_execution. + reserved 5; + reserved "workflow_execution"; + + // When set, the call addresses the channel linked to this execution. + // `run_id` is optional and resolves to the current run of a workflow chain, + // as a Signal does. When unset, the call addresses the independent channel + // of that name. + temporal.api.common.v1.Execution execution = 6; } message UnregisterChannelListenerResponse { @@ -949,10 +969,16 @@ message PollChannelRequest { // At most this many notifications are returned. Zero means the server's // default. int32 max_notifications = 5; - // When set, the call addresses the channel linked to this workflow; `run_id` - // is optional and resolves to the chain's current run, as a Signal does. - // When unset, the call addresses the independent channel of that name. - temporal.api.common.v1.WorkflowExecution workflow_execution = 6; + + // Was temporal.api.common.v1.WorkflowExecution workflow_execution. + reserved 6; + reserved "workflow_execution"; + + // When set, the call addresses the channel linked to this execution. + // `run_id` is optional and resolves to the current run of a workflow chain, + // as a Signal does. When unset, the call addresses the independent channel + // of that name. + temporal.api.common.v1.Execution execution = 7; } message PollChannelResponse { @@ -962,10 +988,16 @@ message PollChannelResponse { message DescribeChannelRequest { string namespace = 1; string channel = 2; - // When set, the call addresses the channel linked to this workflow; `run_id` - // is optional and resolves to the chain's current run, as a Signal does. - // When unset, the call addresses the independent channel of that name. - temporal.api.common.v1.WorkflowExecution workflow_execution = 3; + + // Was temporal.api.common.v1.WorkflowExecution workflow_execution. + reserved 3; + reserved "workflow_execution"; + + // When set, the call addresses the channel linked to this execution. + // `run_id` is optional and resolves to the current run of a workflow chain, + // as a Signal does. When unset, the call addresses the independent channel + // of that name. + temporal.api.common.v1.Execution execution = 4; } message DescribeChannelResponse { @@ -975,11 +1007,15 @@ message DescribeChannelResponse { // How many notifications the channel retains for pollers. int32 retained_count = 3; temporal.api.notification.v1.ChannelKind kind = 4; + + // Was temporal.api.common.v1.WorkflowExecution linked_to. + reserved 5; + // The owner of a linked channel and the run that holds it. Empty for an // independent channel. // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: "to" names the owner the channel is linked to. --) - temporal.api.common.v1.WorkflowExecution linked_to = 5; + temporal.api.common.v1.Execution linked_to = 6; } message SignalWithStartWorkflowExecutionRequest { diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto index 9dbf0317f..b2ec8a5e9 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto @@ -497,11 +497,19 @@ service WorkflowService { body: "*" } additional_bindings { - post: "/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{notification.channel}/notify" + post: "/namespaces/{namespace}/workflows/{execution.business_id}/channels/{notification.channel}/notify" body: "*" } additional_bindings { - post: "/api/v1/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{notification.channel}/notify" + post: "/api/v1/namespaces/{namespace}/workflows/{execution.business_id}/channels/{notification.channel}/notify" + body: "*" + } + additional_bindings { + post: "/namespaces/{namespace}/activities/{execution.business_id}/channels/{notification.channel}/notify" + body: "*" + } + additional_bindings { + post: "/api/v1/namespaces/{namespace}/activities/{execution.business_id}/channels/{notification.channel}/notify" body: "*" } }; @@ -520,11 +528,19 @@ service WorkflowService { body: "*" } additional_bindings { - post: "/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}/listeners" + post: "/namespaces/{namespace}/workflows/{execution.business_id}/channels/{channel}/listeners" + body: "*" + } + additional_bindings { + post: "/api/v1/namespaces/{namespace}/workflows/{execution.business_id}/channels/{channel}/listeners" + body: "*" + } + additional_bindings { + post: "/namespaces/{namespace}/activities/{execution.business_id}/channels/{channel}/listeners" body: "*" } additional_bindings { - post: "/api/v1/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}/listeners" + post: "/api/v1/namespaces/{namespace}/activities/{execution.business_id}/channels/{channel}/listeners" body: "*" } }; @@ -542,10 +558,16 @@ service WorkflowService { delete: "/api/v1/namespaces/{namespace}/channels/{channel}/listeners/{listener_id}" } additional_bindings { - delete: "/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}/listeners/{listener_id}" + delete: "/namespaces/{namespace}/workflows/{execution.business_id}/channels/{channel}/listeners/{listener_id}" } additional_bindings { - delete: "/api/v1/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}/listeners/{listener_id}" + delete: "/api/v1/namespaces/{namespace}/workflows/{execution.business_id}/channels/{channel}/listeners/{listener_id}" + } + additional_bindings { + delete: "/namespaces/{namespace}/activities/{execution.business_id}/channels/{channel}/listeners/{listener_id}" + } + additional_bindings { + delete: "/api/v1/namespaces/{namespace}/activities/{execution.business_id}/channels/{channel}/listeners/{listener_id}" } }; } @@ -560,10 +582,16 @@ service WorkflowService { get: "/api/v1/namespaces/{namespace}/channels/{channel}/notifications" } additional_bindings { - get: "/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}/notifications" + get: "/namespaces/{namespace}/workflows/{execution.business_id}/channels/{channel}/notifications" + } + additional_bindings { + get: "/api/v1/namespaces/{namespace}/workflows/{execution.business_id}/channels/{channel}/notifications" } additional_bindings { - get: "/api/v1/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}/notifications" + get: "/namespaces/{namespace}/activities/{execution.business_id}/channels/{channel}/notifications" + } + additional_bindings { + get: "/api/v1/namespaces/{namespace}/activities/{execution.business_id}/channels/{channel}/notifications" } }; } @@ -577,10 +605,16 @@ service WorkflowService { get: "/api/v1/namespaces/{namespace}/channels/{channel}" } additional_bindings { - get: "/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}" + get: "/namespaces/{namespace}/workflows/{execution.business_id}/channels/{channel}" + } + additional_bindings { + get: "/api/v1/namespaces/{namespace}/workflows/{execution.business_id}/channels/{channel}" + } + additional_bindings { + get: "/namespaces/{namespace}/activities/{execution.business_id}/channels/{channel}" } additional_bindings { - get: "/api/v1/namespaces/{namespace}/workflows/{workflow_execution.workflow_id}/channels/{channel}" + get: "/api/v1/namespaces/{namespace}/activities/{execution.business_id}/channels/{channel}" } }; } From fbc064b8b7bf5b0a30b6b18ad94a3d026e14c93d Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 2 Oct 2026 19:18:18 -0700 Subject: [PATCH 27/27] Reused the old numbers for the execution fields in the Core's api tree. Carries api 4304fd8..071feb8. The earlier shape was never released, so the Execution fields take the numbers the WorkflowExecution fields had and nothing is reserved. --- .../api/notification/v1/message.proto | 6 +-- .../workflowservice/v1/request_response.proto | 41 +++---------------- 2 files changed, 7 insertions(+), 40 deletions(-) diff --git a/crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto index 85519f697..584619582 100644 --- a/crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/notification/v1/message.proto @@ -35,16 +35,12 @@ message Notification { // here what is true at `position`, such as which topic moved or a close // flag, never something a consumer must see once per write. map metadata = 4; - - // Was temporal.api.common.v1.WorkflowExecution linked_to. - reserved 5; - // Set for a channel linked to an execution: the owner and the run that // received the notification. Empty for an independent channel. A listener // that holds both kinds routes the notification by it. // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: "to" names the owner the channel is linked to. --) - temporal.api.common.v1.Execution linked_to = 6; + temporal.api.common.v1.Execution linked_to = 5; } // A listener of a channel: a Workflow Execution woken with a Workflow Task, or diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto index a3a8b9253..fb70ab180 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/request_response.proto @@ -892,16 +892,11 @@ message NotifyChannelRequest { string identity = 3; // Used to de-dupe a retried notification. string request_id = 4; - - // Was temporal.api.common.v1.WorkflowExecution workflow_execution. - reserved 5; - reserved "workflow_execution"; - // When set, the call addresses the channel linked to this execution. // `run_id` is optional and resolves to the current run of a workflow chain, // as a Signal does. When unset, the call addresses the independent channel // of that name. - temporal.api.common.v1.Execution execution = 6; + temporal.api.common.v1.Execution execution = 5; } message NotifyChannelResponse { @@ -919,16 +914,11 @@ message RegisterChannelListenerRequest { string request_id = 4; // The identity of the caller, for metrics and logs. string identity = 5; - - // Was temporal.api.common.v1.WorkflowExecution workflow_execution. - reserved 6; - reserved "workflow_execution"; - // When set, the call addresses the channel linked to this execution. // `run_id` is optional and resolves to the current run of a workflow chain, // as a Signal does. When unset, the call addresses the independent channel // of that name. - temporal.api.common.v1.Execution execution = 7; + temporal.api.common.v1.Execution execution = 6; } message RegisterChannelListenerResponse { @@ -941,16 +931,11 @@ message UnregisterChannelListenerRequest { string listener_id = 3; // The identity of the caller, for metrics and logs. string identity = 4; - - // Was temporal.api.common.v1.WorkflowExecution workflow_execution. - reserved 5; - reserved "workflow_execution"; - // When set, the call addresses the channel linked to this execution. // `run_id` is optional and resolves to the current run of a workflow chain, // as a Signal does. When unset, the call addresses the independent channel // of that name. - temporal.api.common.v1.Execution execution = 6; + temporal.api.common.v1.Execution execution = 5; } message UnregisterChannelListenerResponse { @@ -969,16 +954,11 @@ message PollChannelRequest { // At most this many notifications are returned. Zero means the server's // default. int32 max_notifications = 5; - - // Was temporal.api.common.v1.WorkflowExecution workflow_execution. - reserved 6; - reserved "workflow_execution"; - // When set, the call addresses the channel linked to this execution. // `run_id` is optional and resolves to the current run of a workflow chain, // as a Signal does. When unset, the call addresses the independent channel // of that name. - temporal.api.common.v1.Execution execution = 7; + temporal.api.common.v1.Execution execution = 6; } message PollChannelResponse { @@ -988,16 +968,11 @@ message PollChannelResponse { message DescribeChannelRequest { string namespace = 1; string channel = 2; - - // Was temporal.api.common.v1.WorkflowExecution workflow_execution. - reserved 3; - reserved "workflow_execution"; - // When set, the call addresses the channel linked to this execution. // `run_id` is optional and resolves to the current run of a workflow chain, // as a Signal does. When unset, the call addresses the independent channel // of that name. - temporal.api.common.v1.Execution execution = 4; + temporal.api.common.v1.Execution execution = 3; } message DescribeChannelResponse { @@ -1007,15 +982,11 @@ message DescribeChannelResponse { // How many notifications the channel retains for pollers. int32 retained_count = 3; temporal.api.notification.v1.ChannelKind kind = 4; - - // Was temporal.api.common.v1.WorkflowExecution linked_to. - reserved 5; - // The owner of a linked channel and the run that holds it. Empty for an // independent channel. // (-- api-linter: core::0140::prepositions=disabled // aip.dev/not-precedent: "to" names the owner the channel is linked to. --) - temporal.api.common.v1.Execution linked_to = 6; + temporal.api.common.v1.Execution linked_to = 5; } message SignalWithStartWorkflowExecutionRequest {