Skip to content

feat: add the Stream protos and WIT to the vendored API - #1733

Open
moetemp wants to merge 4 commits into
mainfrom
moe/streams-crate-protos
Open

moetemp wants to merge 4 commits into
mainfrom
moe/streams-crate-protos

Conversation

@moetemp

@moetemp moetemp commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What was changed

This PR adds the Stream protos and the Stream WIT to Core's vendored API, and compiles the protos in temporalio-protos. Core itself gets no Stream code.

  • The three Stream protos and nexus/stream-service.wit of feat(brook): add the Stream service messages (as experimental) api#895 sit in crates/protos/protos/api_upstream, as the subtree will have them once that PR merges. A subtree update then replaces them.
  • crates/protos/build.rs compiles the protos. temporalio-protos exposes them as temporal::api::stream::v1 and temporal::api::streamservice::v1, and the Stream enums are in temporal::api::enums::v1.

Why?

The Stream protos and the WIT are the contract with every SDK, so they come first. A lang SDK generates its Streams API from the WIT, and sdk-python reads these files through its Core submodule. The Stream crate that the bridges link is #1799, which sits on this branch.

Checklist

  1. How was this tested:
    • cargo check -p temporalio-protos on this head, which builds the new modules.

@moetemp
moetemp requested a review from a team as a code owner October 30, 2026 21:25
@moetemp moetemp added the skip-changelog PR changes do not require changelog updates label Oct 30, 2026
@moetemp moetemp changed the title feat(brook): add the temporalio-streams crate with the Stream protos feat: add the temporalio-streams crate with the Stream protos Nov 4, 2026

@Sushisource Sushisource left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall I think it would be really helpful to at least include the top-level Core APIs in this PR to see how these messages get used (even if they're stubbed out for now). Then we could merge that into a branch. I don't think we should merge to main until there's some kind of usable implementation.

Comment on lines +57 to +71
message CloseWriterRequest {
uint64 writer_id = 1;
}

message CloseWriterResponse {
// The offset of the newest Record the writer wrote. Zero if it wrote none.
int64 last_offset = 1;
}

// Method `FreeWriter`. Releases one handle of the writer with no wait, for a handle that lang
// drops, such as from a finalizer. The bridge may also call it with no async runtime. A write in
// flight still completes. The writer's error, if any, is lost.
message FreeWriterRequest {
uint64 writer_id = 1;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure these really need to be defined in proto at all since the types are so simple. We often don't define everything as protos, esp. simple stuff.

And free probably doesn't even need to be a dedicated top level method. It can be implemented in terms of Droping refs that the lang bridge holds.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept them as protos so every bridge can use one generic call. A bridge passes a method name and the request bytes to the crate's StreamRuntime::call, and gets the response bytes back. So the Python, TypeScript and .NET bridges have no Stream code, and a new method needs no bridge change. That only works if every request and response is a proto, even one with one field, like CloseWriterRequest.

FreeWriter comes from the same choice. Lang holds only the writer_id number, not a Rust object. When Python's garbage collector drops a writer, there's no Rust value to Drop, so a weakref.finalize callback sends FreeWriter with the id.

The other way is a writer object in each bridge, which Drop releases. Then we can remove FreeWriter, but each of the three bridges needs its own writer binding. Which one do you prefer?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, interesting. I did consider doing similar stuff for other methods. You incur some extra overhead doing it this way but it's probably immaterial.

The more important thing would be the Drop semantics I think. Using Drop is pretty foolproof whereas needing to explicitly call finalize is much easier to forget.

I don't have a strong preference for now. This is all internal so we can change it later. I'm fine doing the runtime dispatch to keep bridge changes minimal for now

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I'll keep the runtime dispatch and FreeWriter for now, so the bridges stay thin. We can move to Drop-based writer handles later if the finalize call proves easy to forget.

moetemp added a commit to temporalio/api that referenced this pull request Nov 5, 2026
Each message and field says what it is for. A create names its Stream only by its handle, the protos keep no reserved fields, and SEQUENCE and OFFSET are SEQUENCE_TOO_LOW and OFFSET_TOO_HIGH.
moetemp added a commit to temporalio/api that referenced this pull request Nov 5, 2026
Each kind has its own epoch fields, and the case picks the withdrawal rule, so StreamProducerKind is gone. The protos match temporalio/sdk-rust#1733.
moetemp added a commit to temporalio/api that referenced this pull request Nov 5, 2026
moetemp added a commit to temporalio/api that referenced this pull request Nov 5, 2026
A corrupt Store isn't retryable, unlike an unavailable one. A close carries no value, so a read gives no close item and the close takes no result or failure. The protos match temporalio/sdk-rust#1733.
moetemp added a commit to temporalio/api that referenced this pull request Nov 5, 2026
A ref is the data that code passes, and the stream resource, the object with calls, is the handle, as the API review decided. The protos match temporalio/sdk-rust#1733.

@moetemp moetemp left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it should not land on main before there is a usable implementation. I'll keep this PR open, as use it as the base branch. I'll stack the runtime object and its calls on this one, so you can see how lang uses each message. This push also takes the changes of the Nov. 4 API review. A create fails if the Stream exists unless it asks for USE_EXISTING, a request id keeps a retried create safe, and the SDKs create only standalone Streams.


// Experimental. Might change in the future.
// Why a Stream call failed. Each reason has one gRPC status code.
enum StreamErrorReason {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code acts on fewer of them. The SDKs give an error class only to the reasons that code can act on, like StreamClosedError to stop writing, StreamFencedError for an attempt that a retry replaced, or StreamStorageUnavailableError as the one to retry. The others, such as SEQUENCE_TOO_LOW, FORMAT_TOO_NEW, INVALID, UNKNOWN_WRITER and the new STORAGE_CORRUPT, raise the base class and are mostly for logs. The enum doc says that now (9adfe1e). We could still fold SEQUENCE_TOO_LOW into SEQ_DIVERGENT and UNKNOWN_WRITER into INVALID, since nobody handles them apart. Do you think it's worth it?

reserved "auto_close";
string namespace = 1;
// Required, and never empty.
string stream_id = 2;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. It was left over from before the ref carried the backend. The create now gives its Stream only as stream_ref, in 9adfe1e. Also, 259bf7d renames StreamHandle to StreamRef, since after the API review, the data is called a ref and the object with calls is called a handle. owner isn't quite the same as the ref's owner, though. The ref's owner is the run chain, and owner is the Producer of the creating run, with its epoch. Neither is set for now, since we create only standalone Streams for now.

Comment on lines +57 to +71
message CloseWriterRequest {
uint64 writer_id = 1;
}

message CloseWriterResponse {
// The offset of the newest Record the writer wrote. Zero if it wrote none.
int64 last_offset = 1;
}

// Method `FreeWriter`. Releases one handle of the writer with no wait, for a handle that lang
// drops, such as from a finalizer. The bridge may also call it with no async runtime. A write in
// flight still completes. The writer's error, if any, is lost.
message FreeWriterRequest {
uint64 writer_id = 1;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept them as protos so every bridge can use one generic call. A bridge passes a method name and the request bytes to the crate's StreamRuntime::call, and gets the response bytes back. So the Python, TypeScript and .NET bridges have no Stream code, and a new method needs no bridge change. That only works if every request and response is a proto, even one with one field, like CloseWriterRequest.

FreeWriter comes from the same choice. Lang holds only the writer_id number, not a Rust object. When Python's garbage collector drops a writer, there's no Rust value to Drop, so a weakref.finalize callback sends FreeWriter with the id.

The other way is a writer object in each bridge, which Drop releases. Then we can remove FreeWriter, but each of the three bridges needs its own writer binding. Which one do you prefer?

@Sushisource Sushisource left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking much cleaner. A few more things.

// The ordered attempt of a Producer. A higher epoch supersedes the Records of a lower epoch of the
// same Producer. The case is the kind of the Producer. It decides how the Store orders the epochs,
// and which earlier Records a higher epoch withdraws.
message ProducerEpoch {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that this is only used inside of Producer, this could just get inlined into there and it would resolve the fact that run_id doesn't exist for clients because it can move inside the oneof.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 4584348. ProducerEpoch is gone, and Producer has the kind oneof directly.

Comment on lines +105 to +114
// Workflow and Local Activity only. The run's last completed Workflow Task, from a Local
// Activity's start. Its started_event_id is 0 before the first. The write withdraws the run's
// Local Activity Records whose Workflow Task never completed.
WorkflowTaskEpoch previous_completed_task = 2;
// Workflow and Local Activity only. The started event ids of the run's failed or timed out
// Workflow Tasks. The write withdraws the run's Local Activity Records of these tasks.
repeated int64 failed_tasks = 3;
// Retry run only. The previous run, on the retry run's first write. Every Record whose
// Producer carries it is withdrawn.
string withdraw_run = 4;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these "only" statements are another indication we could be putting things inside of variants. IMO there's very, very rarely a good reason to allow the representation of invalid states like that in proto.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same change in 4584348. The producer-kind fields moved into the Producer oneof above. For the writer, the task facts and withdraw_run move into a RunWithdrawal message that only a Workflow or Local Activity write sets. I kept them in one message and not a oneof, since a retry run's first Local Activity write has both the task facts and withdraw_run. If you'd rather forbid the client case in the type, I can split StreamWriter by kind too, but that repeats the Producer oneof, so I left it for now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: I removed the withdrawal machinery. RunWithdrawal, the writer's withdrawal fields, StreamSuperseded, and the superseded read item are gone, and a write only has its Producer. Supersession moved to an opt-in client helper, with_superseded(), computed from the Producer epoch, so a Store keeps no supersession facts.

@moetemp moetemp left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sushisource Thanks for the review.

// The ordered attempt of a Producer. A higher epoch supersedes the Records of a lower epoch of the
// same Producer. The case is the kind of the Producer. It decides how the Store orders the epochs,
// and which earlier Records a higher epoch withdraws.
message ProducerEpoch {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 4584348. ProducerEpoch is gone, and Producer has the kind oneof directly.

Comment on lines +105 to +114
// Workflow and Local Activity only. The run's last completed Workflow Task, from a Local
// Activity's start. Its started_event_id is 0 before the first. The write withdraws the run's
// Local Activity Records whose Workflow Task never completed.
WorkflowTaskEpoch previous_completed_task = 2;
// Workflow and Local Activity only. The started event ids of the run's failed or timed out
// Workflow Tasks. The write withdraws the run's Local Activity Records of these tasks.
repeated int64 failed_tasks = 3;
// Retry run only. The previous run, on the retry run's first write. Every Record whose
// Producer carries it is withdrawn.
string withdraw_run = 4;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same change in 4584348. The producer-kind fields moved into the Producer oneof above. For the writer, the task facts and withdraw_run move into a RunWithdrawal message that only a Workflow or Local Activity write sets. I kept them in one message and not a oneof, since a retry run's first Local Activity write has both the task facts and withdraw_run. If you'd rather forbid the client case in the type, I can split StreamWriter by kind too, but that repeats the Producer oneof, so I left it for now.


// Experimental. Might change in the future.
// Why a Stream call failed. Each reason has one gRPC status code.
enum StreamErrorReason {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept the reasons distinct on the wire, since each one has its own gRPC status code that native Streams exposes. Bundling them would drop that code. As we discussed in the API review session, let's collapse the SDK error classes to the few that code acts on, and kept the proto reason so the rest can still be told apart in a log. Wdyt?

reserved "auto_close";
string namespace = 1;
// Required, and never empty.
string stream_id = 2;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dropped StreamRef.owner and the create/close owner fields. The protos are updated to match (api#895 and the vendored copy).

Comment on lines +57 to +71
message CloseWriterRequest {
uint64 writer_id = 1;
}

message CloseWriterResponse {
// The offset of the newest Record the writer wrote. Zero if it wrote none.
int64 last_offset = 1;
}

// Method `FreeWriter`. Releases one handle of the writer with no wait, for a handle that lang
// drops, such as from a finalizer. The bridge may also call it with no async runtime. A write in
// flight still completes. The writer's error, if any, is lost.
message FreeWriterRequest {
uint64 writer_id = 1;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I'll keep the runtime dispatch and FreeWriter for now, so the bridges stay thin. We can move to Drop-based writer handles later if the finalize call proves easy to forget.

Comment on lines +136 to +137
reserved 2, 3;
reserved "result", "failure";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reserved stuff still showing up

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They're gone now.


// Experimental. Might change in the future.
// Why a Stream call failed. Each reason has one gRPC status code.
enum StreamErrorReason {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fine but a lot of these do map to the same gRPC code, no? Those could potentially be combined too but it's not a big deal either way

@moetemp moetemp changed the title feat: add the temporalio-streams crate with the Stream protos feat: add the Stream protos and WIT to the vendored API Nov 13, 2026
These are the files of the Stream API that temporalio/api#895 adds, byte for byte. An update
of the API subtree replaces them once that PR merges.
The Stream crate and the SDK bridges take the Stream messages from the protos crate, like
every other API message.
The Stream WIT of temporalio/api#895 has append and close on the Workflow target again, and
this is its copy.
@moetemp
moetemp requested a review from Sushisource November 14, 2026 06:07
@moetemp
moetemp added this pull request to stack #1800 November 14, 2026 06:07
The file is a copy of the Stream WIT of the API repo, which got three methods that the SDK implements.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changelog PR changes do not require changelog updates

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants