Skip to content

Update Rust client SDK for V2 WebSocket format#4257

Merged
cloutiertyler merged 100 commits intomasterfrom
phoebe/rust-sdk-ws-v2
Feb 13, 2026
Merged

Update Rust client SDK for V2 WebSocket format#4257
cloutiertyler merged 100 commits intomasterfrom
phoebe/rust-sdk-ws-v2

Conversation

@gefjon
Copy link
Contributor

@gefjon gefjon commented Feb 10, 2026

Description of Changes

Update the Rust client SDK to use the new V2 WebSocket format, and present the V2 user-facing API.

Reducer events

Remove on-reducer callbacks

It's no longer possible to observe reducers called by other clients by registering callbacks with ctx.reducers.on_{my_reducer}. We no longer code-generate those methods, or the associated ctx.reducers.remove_on_{my_reducer}. Internal plumbing for storing and invoking those callbacks is also removed.

Add specific reducer invocation callbacks

In addition to the previous way to invoke reducers, ctx.reducers.{my_reducer}(args...), we add a method that registers a callback to run after the reducer is finished. This method has the suffix _then, as in ctx.reducers.{my_reducer}_then(args..., callback).

The callback will accept two arguments:

  • ctx: &ReducerEventContext, the same context as was previously passed to on-reducer callbacks.
  • status: Result<Result<(), String>, InternalError>, denoting the outcome of the reducer.
    • Ok(Ok(()) means the reducer committed. This corresponds to ReducerOutcome::Ok or ReducerOutcome::Okmpty in the new WS format.
    • Ok(Err(message)) means the reducer returned an "expected" or "user" error. This corresponds to ReducerOutcome::Err in the new WS format.
    • Err(internal_error) means something went wrong with host execution. This corresponds to ReducerOutcome::InternalError in the new WS format.

Internally, the SDK stores the callbacks in its ReducerCallbacks map. This is keyed on request_id: u32, a number that is generated for each reducer call (from an AtomicU32 that we increment each time), and included in the ClientMessage::CallReducer request. The ServerMessage::ReducerResult includes the same request_id, so the SDK pops out of the ReducerCallbacks and invokes the appropriate callback when processing that message.

These new callbacks are very similar to the existing procedure callbacks.

The Event exposed to row callbacks

Row callbacks caused by a reducer invoked by this client will see Event::Reducer, the same as they would prior to this PR. These callbacks will be the result of a ServerMessage::ReducerResult with ReducerOutcome::Ok. In order to expose the reducer name and arguments to this event, the client stores them in its ReducerCallbacks map, alongside the callback for when the reducer is complete.

Row callbacks caused by any other reducer, or any non-reducer transaction, are now indistinguishable to the client. These will see Event::Transaction, which is renamed from the old Event::UnknownTransaction.

Less metadata in ReducerEvent

Some metadata is removed from ReducerEvent, as the V2 WebSocket format no longer publishes it, even to the caller.

CallReducerFlags are removed

All machinery for setting, storing and applying call reducer flags is removed from the SDK, as the new WS format does not have any non-default flags.

Requesting rows in unsubscribe

When sending a ClientMessage::Unsubscribe, we always request that the server include the matching rows in its response ServerMessage::UnsubscribeApplied. This saves us having to update the SDK to store query sets separately, at least for now. (We'll do that later.)

Handling rows

The new SDK does some additional parsing to wrangle rows in the new WebSocket format into the same internal data structures as before, rather than re-writing the client cache. (We'll do that later.) Specifically, parsing of DbUpdate is changed so that:

  • We parse raw TransactionUpdate into the generated DbUpdate type, which requires an additional loop compared to the previous version, to cope with the new WS format's dividing updates by query set. We define a function transaction_update_iter_table_updates which encapsulates this nested loop in an iterator.
  • We have two new functions for parsing raw QueryRows into the generated DbUpdate type, one for when they come from a SubscribeApplied, and the other when they come from an UnsubscribeApplied. QueryRows from SubscribeApplied translate to a DbUpdate of all inserts, while one from UnsubscribeApplied will be all deletes.

Legacy subscriptions

"Legacy subscriptions" are removed. These were only used for subscribe_to_all_tables, which as of now is stubbed. I will follow up with a change to re-implement subscribe_to_all_tables by code-generating a list of all known tables, and having it subscribe to select * from {table} for every table in that list.

subscribe_to_all_tables via a list

Previously, subscribe_to_all_tables worked by sending a legacy subscription with the query SELECT * FROM *, which the host had special handling to expand to subscribing to all tables. As legacy subscriptions are no longer usable in V2 clients, this can't work. Instead, we code-generate SpacetimeModule::ALL_TABLE_NAMES, a list of all the known table names. subscribe_to_all_tables then maps across this list to construct a list of queries in the form SELECT * FROM {table_name}, and subscribes to all of those queries. This has the upside that defining a new table in the module without regenerating client bindings will no longer result in the client seeing rows of tables it does not know about and cannot parse.

Light mode removed

Light mode is no longer meaningful in the V2 WS format, so all code related to it is removed.

Internal changes

Renamed WS messages

The SDK's internal code is updated to account for various renames:

  • QueryId -> QuerySetId, query_id -> query_set_id.
  • SubscribeMulti -> Subscribe, UnsubscribeMulti -> Unsubscribe.

Incidental changes in this PR, not necessary for other client SDKs

Don't filter out empty ranges in RowSizeHint

The Rust implementation of RowSizeHint in BsatnRowList got regressed in the base branch to not work with zero-sized rows. This change fixes that.

API and ABI breaking changes

Boy howdy is it!

Expected complexity level and risk

3? Changes ended up being less complicated than I feared, but we do have some fiddly code here, and we have internal dependencies on the SDK.

Testing

gefjon and others added 30 commits January 13, 2026 12:58
Incl. mention of future possibility of PK-ful partial updates.
This compiles, but absolutely won't run, and is filled with a ton of TODOs.
Resolve merge conflicts and fix compilation errors:
- Recreate message_handlers_v1.rs and message_handlers_v2.rs (v1/v2 dispatch)
- Adapt for Option<ReducerName> change from master
- Convert TableName to Box<str> for TableUpdate::new calls
- Fix async closure annotations in call_view_add_v2_subscription
- Fix BTreeMap key type (TableName doesn't impl Ord)
Both types wrap Identifier which already implements Ord.
Revert the Box<str> workaround in module_subscription_manager.
RowListLen is defined in websocket::common but was not re-exported
from websocket::v1. Import it directly from common instead.
Copy link
Contributor

@jsdt jsdt left a comment

Choose a reason for hiding this comment

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

I didn't give it the deepest review, but it looks good to me.

@gefjon gefjon requested review from bfops and jdetter as code owners February 12, 2026 22:22
@gefjon gefjon changed the base branch from jsdt/ws-v2 to master February 12, 2026 22:22
The codegen emits __ws::v2::* references, but __ws was aliased to
websocket::v1, so __ws::v2 didn't resolve. Point it to the parent
websocket module instead.
@cloutiertyler cloutiertyler added this pull request to the merge queue Feb 13, 2026
Merged via the queue into master with commit 1592dec Feb 13, 2026
31 of 32 checks passed
gefjon added a commit that referenced this pull request Feb 13, 2026
At some point, a rebase on this branch appears to have accidentally overwritten
the Rust codegen changes in #4257 .
This commit fixes that, to integrate the event tables codegen changes
with the prior WS V2 codegen changes.

I've also taken the minor liberty of re-ordering the emitted top-level forms
in a table definition file, in order to allow merging the definitions of
`register_table` and `parse_table_update`,
which this branch previously had duplicated into the `if` and `else` cases
of the conditional on whether the table was an event table or not.
Plus I added a few comments.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants