Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions crates/codegen/src/rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1795,6 +1795,10 @@ impl __sdk::DbContext for DbConnection {{
self.imp.is_active()
}}

fn is_reconnecting(&self) -> bool {{
self.imp.is_reconnecting()
}}

fn disconnect(&self) -> __sdk::Result<()> {{
self.imp.disconnect()
}}
Expand Down Expand Up @@ -2122,6 +2126,10 @@ impl __sdk::DbContext for {struct_and_trait_name} {{
self.imp.is_active()
}}

fn is_reconnecting(&self) -> bool {{
self.imp.is_reconnecting()
}}

fn disconnect(&self) -> __sdk::Result<()> {{
self.imp.disconnect()
}}
Expand Down
24 changes: 24 additions & 0 deletions crates/codegen/tests/snapshots/codegen__codegen_rust.snap
Original file line number Diff line number Diff line change
Expand Up @@ -1602,6 +1602,10 @@ impl __sdk::DbContext for DbConnection {
self.imp.is_active()
}

fn is_reconnecting(&self) -> bool {
self.imp.is_reconnecting()
}

fn disconnect(&self) -> __sdk::Result<()> {
self.imp.disconnect()
}
Expand Down Expand Up @@ -1821,6 +1825,10 @@ impl __sdk::DbContext for EventContext {
self.imp.is_active()
}

fn is_reconnecting(&self) -> bool {
self.imp.is_reconnecting()
}

fn disconnect(&self) -> __sdk::Result<()> {
self.imp.disconnect()
}
Expand Down Expand Up @@ -1897,6 +1905,10 @@ impl __sdk::DbContext for ReducerEventContext {
self.imp.is_active()
}

fn is_reconnecting(&self) -> bool {
self.imp.is_reconnecting()
}

fn disconnect(&self) -> __sdk::Result<()> {
self.imp.disconnect()
}
Expand Down Expand Up @@ -1969,6 +1981,10 @@ impl __sdk::DbContext for ProcedureEventContext {
self.imp.is_active()
}

fn is_reconnecting(&self) -> bool {
self.imp.is_reconnecting()
}

fn disconnect(&self) -> __sdk::Result<()> {
self.imp.disconnect()
}
Expand Down Expand Up @@ -2041,6 +2057,10 @@ impl __sdk::DbContext for SubscriptionEventContext {
self.imp.is_active()
}

fn is_reconnecting(&self) -> bool {
self.imp.is_reconnecting()
}

fn disconnect(&self) -> __sdk::Result<()> {
self.imp.disconnect()
}
Expand Down Expand Up @@ -2117,6 +2137,10 @@ impl __sdk::DbContext for ErrorContext {
self.imp.is_active()
}

fn is_reconnecting(&self) -> bool {
self.imp.is_reconnecting()
}

fn disconnect(&self) -> __sdk::Result<()> {
self.imp.disconnect()
}
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/00100-intro/00300-tutorials/00100-chat-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -2452,13 +2452,13 @@ To `src/main.rs`, add:

```rust
/// Our `on_connect_error` callback: print the error, then exit the process.
fn on_connect_error(_ctx: &ErrorContext, err: Error) {
fn on_connect_error(_ctx: &ErrorContext, err: Error, _next: Option<spacetimedb_sdk::NextReconnect>) {
eprintln!("Connection error: {:?}", err);
std::process::exit(1);
}

/// Our `on_disconnect` callback: print a note, then exit the process.
fn on_disconnected(_ctx: &ErrorContext, err: Option<Error>) {
fn on_disconnected(_ctx: &ErrorContext, err: Option<Error>, _next: Option<spacetimedb_sdk::NextReconnect>) {
if let Some(err) = err {
eprintln!("Disconnected: {}", err);
std::process::exit(1);
Expand Down
20 changes: 16 additions & 4 deletions docs/docs/00200-core-concepts/00600-clients/00300-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ Failure to advance the connection means your client will not receive any updates

:::

In Rust and TypeScript, the connection processes messages automatically via the browser's event loop or Node.js's event loop. No manual polling is required.
TypeScript processes messages through the browser or Node.js event loop. Rust applications must advance their connection using `frame_tick()`, `run_threaded()`, `run_async()`, or the browser target's `run_background_task()`.

## Connection Lifecycle

Expand Down Expand Up @@ -327,10 +327,10 @@ let conn = DbConnection::builder()
println!("Connected! Saving token...");
// Save token for reconnection
})
.on_connect_error(|_ctx, error| {
.on_connect_error(|_ctx, error, _next| {
eprintln!("Connection failed: {}", error);
})
.on_disconnect(|_ctx, error| {
.on_disconnect(|_ctx, error, _next| {
if let Some(err) = error {
eprintln!("Disconnected with error: {}", err);
} else {
Expand Down Expand Up @@ -450,7 +450,19 @@ Without automatic reconnect, create a new connection after a connection loss. Se
</TabItem>
<TabItem value="rust" label="Rust">

Automatic reconnect support is coming soon. For now, implement reconnection in your application: create a new connection and restore subscriptions after a connection loss.
Enable `.with_automatic_reconnect()` to recover after an established connection drops. The same connection, table handles, callbacks, and subscription handles survive. While `is_reconnecting()` is true, cache reads return the last known rows. **Keep advancing the connection during outages**, using `frame_tick()`, `run_threaded()`, or `run_async()`; do not stop ticking when `is_active()` becomes false.

`on_connect` fires only for the initial connection. Register `on_automatic_reconnect` for successful automatic reconnects. Both receive the connection, identity, and token. Subscriptions replay together in one atomic batch, and their `on_applied` callbacks fire again; unchanged rows do not fire row callbacks.

`on_disconnect(|ctx, error, next| ...)` and `on_connect_error(|ctx, error, next| ...)` receive `Option<NextReconnect>`, with the next attempt number and `Duration`, or `None` when no retry is scheduled. Initial connection failures are not retried. `disconnect()` cancels recovery.

Use `.with_automatic_reconnect_options(AutomaticReconnectOptions { min_delay, max_delay })` to configure backoff. Defaults are one second and 30 seconds, with exponential growth and 50% jitter clamped to the bounds. The SDK raises `min_delay` to 500 ms if it is lower, then raises `max_delay` to the greater of one second and `min_delay` if necessary.

For expiring credentials, combine `.with_token(Some(initial_token))` and `.with_token_provider(|| async { /* return Result<String> */ })`. The provider refreshes near expiry or after rejection and must return a token for the same identity. Provider failures retry; rejection of a fresh token and identity changes are terminal.

Function calls (i.e. reducers/procedures) made while reconnecting fail with a disconnected error. Calls interrupted by a drop may have executed: callback errors expose `is_unknown_result()`. Calls are never replayed. Subscription creation is queued during recovery, and cancellation removes a set from replay.

Regenerate Rust bindings to expose `is_reconnecting()`. Lifecycle error callbacks gain the third argument; subscription `on_applied` closures must implement `FnMut`. `on_connect` remains `FnOnce`. Automatic reconnect requires a server supporting session replacement and batch subscriptions.

</TabItem>
<TabItem value="unreal" label="Unreal">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ This interface may change in an upcoming release as we rework SpacetimeDB's auth
impl DbConnectionBuilder {
fn on_connect_error(
self,
callback: impl FnOnce(&ErrorContext, spacetimedb_sdk::Error),
callback: impl FnMut(&ErrorContext, spacetimedb_sdk::Error, Option<spacetimedb_sdk::NextReconnect>),
) -> DbConnectionBuilder;
}
```
Expand All @@ -155,13 +155,27 @@ Chain a call to `.on_connect_error(callback)` to your builder to register a call
impl DbConnectionBuilder {
fn on_disconnect(
self,
callback: impl FnOnce(&ErrorContext, Option<spacetimedb_sdk::Error>),
callback: impl FnMut(&ErrorContext, Option<spacetimedb_sdk::Error>, Option<spacetimedb_sdk::NextReconnect>),
) -> DbConnectionBuilder;
}
```

Chain a call to `.on_disconnect(callback)` to your builder to register a callback to run when your established `DbConnection` disconnects from the remote database, either as a result of a call to [`disconnect`](#method-disconnect) or due to an error.

#### Automatic reconnection

Enable `with_automatic_reconnect()` on the builder to retain the connection, cache, subscriptions, and callbacks across transient outages. `with_automatic_reconnect_options(AutomaticReconnectOptions { min_delay, max_delay })` changes the default one-second minimum and 30-second maximum delays. Retries use exponential backoff with 50% jitter clamped to these bounds. The SDK enforces a 500 ms minimum and a maximum of at least one second and `min_delay`.

Register `on_automatic_reconnect(|conn, identity, token| { ... })` for each successful reconnect. It accepts `FnMut`; `on_connect` remains a one-shot initial-connection callback. The third parameter of `on_disconnect` and `on_connect_error` is `Some(NextReconnect { attempt, delay })` when a retry is scheduled, and `None` when the connection has ended. Initial connection failures do not retry.

A reconnect reuses the retained token. Use `with_token_provider(|| async { /* return spacetimedb_sdk::Result<String> */ })` to refresh expiring tokens. Refresh happens when remaining validity is at most 5% of token lifetime or 30 seconds, whichever is larger, when expiry cannot be read, or after the retained token is rejected. A provider failure retries. A freshly returned token that is rejected, a changed identity, and fatal protocol errors stop recovery.

`is_reconnecting()` is true while waiting for or establishing a reconnect; `is_active()` is false. Keep advancing the connection during this interval. Cache reads remain available. Existing subscriptions replay in one batch and call `on_applied` again. Row callbacks report only net changes. Subscriptions created during an outage are queued; unsubscribed handles are excluded from replay.

Reducer calls while reconnecting return `Error::Disconnected`; procedure callbacks receive a disconnected error. In-flight calls fail with an `InternalError` for which `is_unknown_result()` is true, because they may have executed before the connection dropped. The SDK never retries these calls. Synthetic reducer error events use `Timestamp::UNIX_EPOCH` because no server completion timestamp is known. Explicit `disconnect()` cancels all reconnect work.

Regenerate client bindings when upgrading to expose `is_reconnecting()`. Update lifecycle error callbacks to accept their third parameter and make subscription `on_applied` callbacks repeatable. The server must support session replacement and batch subscriptions.

#### Method `with_token`

```rust
Expand Down Expand Up @@ -361,7 +375,7 @@ Subscribe to queries by calling `ctx.subscription_builder()` and chaining config

```rust
impl SubscriptionBuilder {
fn on_applied(self, callback: impl FnOnce(&SubscriptionEventContext)) -> Self;
fn on_applied(self, callback: impl FnMut(&SubscriptionEventContext)) -> Self;
}
```

Expand Down
4 changes: 2 additions & 2 deletions modules/sdk-test-procedure-cpp/client/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@ fn on_connected(_ctx: &DbConnection, identity: Identity, token: &str) {
}

/// Our `on_connect_error` callback: print the error, then exit the process.
fn on_connect_error(_ctx: &ErrorContext, err: Error) {
fn on_connect_error(_ctx: &ErrorContext, err: Error, _next: Option<spacetimedb_sdk::NextReconnect>) {
eprintln!("Connection error: {err}");
std::process::exit(1);
}

/// Our `on_disconnect` callback: print a note, then exit the process.
fn on_disconnected(_ctx: &ErrorContext, err: Option<Error>) {
fn on_disconnected(_ctx: &ErrorContext, err: Option<Error>, _next: Option<spacetimedb_sdk::NextReconnect>) {
if let Some(err) = err {
eprintln!("Disconnected: {err}");
std::process::exit(1);
Expand Down
24 changes: 24 additions & 0 deletions modules/sdk-test-procedure-cpp/client/src/module_bindings/mod.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions sdks/rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ browser = [
"dep:gloo-net",
"dep:gloo-storage",
"dep:gloo-utils",
"dep:gloo-timers",
"dep:js-sys",
"dep:tokio-tungstenite-wasm",
"dep:wasm-bindgen",
Expand All @@ -33,6 +34,10 @@ spacetimedb-metrics.workspace = true
spacetimedb-query-builder.workspace = true

thiserror.workspace = true
base64.workspace = true
serde_json.workspace = true
rand.workspace = true
gloo-timers = { version = "0.3", features = ["futures"], optional = true }
anymap3 = "1.0.1"
brotli.workspace = true
bytes.workspace = true
Expand Down
4 changes: 2 additions & 2 deletions sdks/rust/src/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ pub(crate) type ReducerCallback<M> = Box<
/// function. Users should not reference this struct directly.
pub(crate) struct ReducerCallbacks<M: SpacetimeModule> {
/// Maps WebSocket `request_id` to the arguments of that call and user-supplied callback.
callbacks: HashMap<u32, (M::Reducer, ReducerCallback<M>)>,
pub(crate) callbacks: HashMap<u32, (M::Reducer, ReducerCallback<M>)>,
}

impl<M: SpacetimeModule> Default for ReducerCallbacks<M> {
Expand Down Expand Up @@ -247,7 +247,7 @@ pub(crate) type ProcedureCallback<M> =
Box<dyn FnOnce(&<M as SpacetimeModule>::ProcedureEventContext, Result<Bytes, InternalError>) + Send + 'static>;

pub struct ProcedureCallbacks<M: SpacetimeModule> {
request_id_to_callback: HashMap<u32, ProcedureCallback<M>>,
pub(crate) request_id_to_callback: HashMap<u32, ProcedureCallback<M>>,
}

impl<M: SpacetimeModule> Default for ProcedureCallbacks<M> {
Expand Down
35 changes: 35 additions & 0 deletions sdks/rust/src/client_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,10 @@ pub struct ClientCache<M: SpacetimeModule + ?Sized> {
///
/// The strings are table names, since we may have multiple tables with the same row type.
tables: Map<dyn Any + Send + Sync>,
// One synthetic delete builder per table. Auto-reconnect combines these deletes
// with new subscription inserts and applies them through the existing DbUpdate code.
synthetic_delete_builders:
HashMap<&'static str, fn(&Self, &'static str) -> spacetimedb_client_api_messages::websocket::v2::TableUpdate>,

/// Clone of the [`crate::db_connection::DbContextImpl::extra_logging`].
extra_logging: Option<SharedCell<File>>,
Expand All @@ -352,11 +356,19 @@ impl<M: SpacetimeModule> ClientCache<M> {
pub(crate) fn new(extra_logging: Option<SharedCell<File>>) -> Self {
Self {
tables: Map::new(),
synthetic_delete_builders: HashMap::default(),
extra_logging,
_module: PhantomData,
}
}

pub(crate) fn build_synthetic_deletes(&self) -> Vec<spacetimedb_client_api_messages::websocket::v2::TableUpdate> {
self.synthetic_delete_builders
.iter()
.map(|(&name, build_deletes)| build_deletes(self, name))
.collect()
}

/// Get a handle on the [`TableCache`] which stores rows of type `Row` for the table `table_name`.
pub(crate) fn get_table<Row: InModule<Module = M> + Send + Sync + 'static>(
&self,
Expand All @@ -373,6 +385,29 @@ impl<M: SpacetimeModule> ClientCache<M> {
&mut self,
table_name: &'static str,
) -> &mut TableCache<Row> {
self.synthetic_delete_builders
.entry(table_name)
.or_insert(|cache, name| {
use spacetimedb_client_api_messages::websocket::{common::RowSizeHint, v2};
let mut bytes = Vec::new();
let mut offsets = Vec::new();
if let Some(table) = cache.get_table::<Row>(name) {
for (bsatn, entry) in &table.entries {
for _ in 0..entry.ref_count {
offsets.push(bytes.len() as u64);
bytes.extend_from_slice(bsatn);
}
}
}
v2::TableUpdate {
table_name: name.into(),
rows: vec![v2::TableUpdateRows::PersistentTable(v2::PersistentTableRows {
inserts: v2::BsatnRowList::default(),
deletes: v2::BsatnRowList::new(RowSizeHint::RowOffsets(offsets.into()), bytes.into()),
})]
.into(),
}
});
self.tables
.entry::<HashMap<&'static str, TableCache<Row>>>()
.or_default()
Expand Down
Loading
Loading