Skip to main content

Procedures - Overview

A procedure is a function exported by a database, similar to a reducer. Connected [clients](/#client-side-sdks** can call procedures. Procedures can perform additional operations not possible in reducers, including making HTTP requests to external services. However, procedures don't automatically run in database transactions, and must manually open and commit a transaction in order to read from or modify the database state. For this reason, prefer defining reducers rather than procedures unless you need to use one of the special procedure operators.

warning

Procedures are currently in beta, and their API may change in upcoming SpacetimeDB releases.

Defining Procedures

Because procedures are unstable, Rust modules that define them must opt in to the unstable feature in their Cargo.toml:

[dependencies]
spacetimedb = { version = "1.*", features = ["unstable"] }

Define a procedure by annotating a function with #[spacetimedb::procedure].

This function's first argument must be of type &mut spacetimedb::ProcedureContext. By convention, this argument is named ctx.

A procedure may accept any number of additional arguments. Each argument must be of a type that implements spacetimedb::SpacetimeType. When defining a struct or enum, annotate it with #[derive(spacetimedb::SpacetimeType)] to make it usable as a procedure argument. These argument values will not be broadcast to clients other than the caller.

A procedure may return a value of any type that implements spacetimedb::SpacetimeType. This return value will be sent to the caller, but will not be broadcast to any other clients.

#[spacetimedb::procedure]
fn add_two_numbers(ctx: &mut spacetimedb::ProcedureContext, lhs: u32, rhs: u32) -> u64 {
    lhs as u64 + rhs as u64
}

Accessing the database

Unlike reducers, procedures don't automatically run in database transactions. This means there's no ctx.db field to access the database. Instead, procedure code must manage transactions explicitly with ProcedureContext::with_tx.

#[spacetimedb::table(name = my_table)]
struct MyTable {
    a: u32,
    b: String,
}

#[spacetimedb::procedure]
fn insert_a_value(ctx: &mut ProcedureContext, a: u32, b: String) {
    ctx.with_tx(|ctx| {
        ctx.my_table().insert(MyTable { a, b });
    });
}

ProcedureContext::with_tx takes a function of type Fn(&TxContext) -> T. Within that function, the &TxContext can be used to access the database in all the same ways as a ReducerContext. When the function returns, the transaction will be committed, and its changes to the database state will become permanent and be broadcast to clients. If the function panics, the transaction will be rolled back, and its changes will be discarded. However, for transactions that may fail, prefer calling try_with_tx and returning a Result rather than panicking.

warning

The function passed to ProcedureContext::with_tx may be invoked multiple times, possibly seeing a different version of the database state each time.

If invoked more than once with reference to the same database state, it must perform the same operations and return the same result each time.

If invoked more than once with reference to different database states, values observed during prior runs must not influence the behavior of the function or the calling procedure.

Avoid capturing mutable state within functions passed to with_tx.

Fallible database operations

For fallible database operations, instead use ProcedureContext::try_with_tx:

#[spacetimedb::procedure]
fn maybe_insert_a_value(ctx: &mut ProcedureContext, a: u32, b: String) {
    ctx.try_with_tx(|ctx| {
        if a < 10 {
            return Err("a is less than 10!");
        }
        ctx.my_table().insert(MyTable { a, b });
        Ok(())
    });
}

ProcedureContext::try_with_tx takes a function of type Fn(&TxContext) -> Result<T, E>. If the function returns Ok, the transaction will be committed, and its changes to the database state will become permanent and be broadcast to clients. If that function returns Err, the transaction will be rolled back, and its changes will be discarded.

Reading values out of the database

Functions passed to ProcedureContext::with_tx and ProcedureContext::try_with_tx may return a value, and that value will be returned to the calling procedure.

Transaction return values are never saved or broadcast to clients, and are used only by the calling procedure.

#[spacetimedb::table(name = player)]
struct Player {
    id: spacetimedb::Identity,
    level: u32,
}

#[spacetimedb::procedure]
fn find_highest_level_player(ctx: &mut ProcedureContext) {
    let highest_level_player = ctx.with_tx(|ctx| {
        ctx.db.player().iter().max_by_key(|player| player.level)
    });
    match highest_level_player {
        Some(player) => log::info!("Congratulations to {}", player.id),
        None => log::warn!("No players..."),
    }
}

HTTP Requests

Procedures can make HTTP requests to external services using methods contained in ctx.http.

ctx.http.get performs simple GET requests with no headers:

#[spacetimedb::procedure]
fn get_request(ctx: &mut ProcedureContext) {
    match ctx.http.get("https://example.invalid") {
        Ok(response) => {
            let (response, body) = response.into_parts();
            log::info!(
                "Got response with status {} and body {}",
                response.status,
                body.into_string_lossy(),
            )
        },
        Err(error) => log::error!("Request failed: {error:?}"),
    }
}

ctx.http.send sends any http::Request whose body can be converted to spacetimedb::http::Body. http::Request is re-exported as spacetimedb::http::Request.

#[spacetimedb::procedure]
fn post_request(ctx: &mut spacetimedb::ProcedureContext) {
    let request = spacetimedb::http::Request::builder()
        .uri("https://example.invalid/upload")
        .method("POST")
        .header("Content-Type", "text/plain")
        .body("This is the body of the HTTP request")
        .expect("Building `Request` object failed");
    match ctx.http.send(request) {
        Ok(response) => {
            let (response, body) = response.into_parts();
            log::info!(
                "Got response with status {} and body {}",
                response.status,
                body.into_string_lossy(),
            )
        }
        Err(error) => log::error!("Request failed: {error:?}"),
    }
}

Each of these methods returns a http::Response containing a spacetimedb::http::Body. http::Response is re-exported as spacetimedb::http::Response.

Set a timeout for a ctx.http.send request by including a spacetimedb::http::Timeout as an extension:

#[spacetimedb::procedure]
fn get_request_with_short_timeout(ctx: &mut spacetimedb::ProcedureContext) {
    let request = spacetimedb::http::Request::builder()
        .uri("https://example.invalid")
        .method("GET")
        // Set a timeout of 10 ms.
        .extension(spacetimedb::http::Timeout(std::time::Duration::from_millis(10).into()))
        // Empty body for a `GET` request.
        .body(())
        .expect("Building `Request` object failed");
    ctx.http.send(request).expect("HTTP request failed");
}

Procedures can't send requests at the same time as holding open a transaction.

Calling procedures

Clients can invoke procedures using methods on ctx.procedures:

ctx.procedures.insert_a_value(12, "Foo".to_string());

Observing return values

A client can also invoke a procedure while registering a callback to run when it completes. That callback will have access to the return value of the procedure, or an error if the procedure fails.

ctx.procedures.add_two_numbers_then(1, 2, |ctx, result| {
    let sum = result.expect("Procedure failed");
    println!("1 + 2 = {sum}");
});