From ae1fa58a3a209467badc6340feea9d3f43d42864 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 21 Oct 2025 12:43:38 -0400 Subject: [PATCH 01/12] Rust module bindings and macros for defining procedures This commit adds a macro attribute `#[procedure]` which applies to functions, and various in-WASM machinery for defining, calling and running procedures. A simple example of a procedure, included in `modules/module-test`: ```rust fn sleep_one_second(ctx: &mut ProcedureContext) { let prev_time = ctx.timestamp; let target = prev_time + Duration::from_secs(1); ctx.sleep_until(target); let new_time = ctx.timestamp; let actual_delta = new_time.duration_since(prev_time).unwrap(); log::info!("Slept from {prev_time} to {new_time}, a total of {actual_delta:?}"); } ``` We intend eventually to make procedures be `async` functions (with the trivial `now_or_never` executor from `future-util`), but I found that making the types work for this was giving me a lot of trouble, and decided to put it off in the interest of unblocking more parallelizable work. Host-side infrastructure for executing procedures is not included in this commit. I have a prototype working, but cleaning it up for review and merge will come a bit later. --- crates/bindings-macro/src/lib.rs | 9 + crates/bindings-macro/src/procedure.rs | 111 ++++++++++ crates/bindings-macro/src/reducer.rs | 52 +++-- crates/bindings-macro/src/table.rs | 30 ++- crates/bindings-sys/src/lib.rs | 8 + crates/bindings/src/lib.rs | 83 +++++++- crates/bindings/src/rt.rs | 282 +++++++++++++++++++++---- crates/bindings/src/table.rs | 2 +- modules/module-test/src/lib.rs | 14 +- 9 files changed, 522 insertions(+), 69 deletions(-) create mode 100644 crates/bindings-macro/src/procedure.rs diff --git a/crates/bindings-macro/src/lib.rs b/crates/bindings-macro/src/lib.rs index cd85b80ef65..ee0e0cc020d 100644 --- a/crates/bindings-macro/src/lib.rs +++ b/crates/bindings-macro/src/lib.rs @@ -8,6 +8,7 @@ // // (private documentation for the macro authors is totally fine here and you SHOULD write that!) +mod procedure; mod reducer; mod sats; mod table; @@ -104,6 +105,14 @@ mod sym { } } +#[proc_macro_attribute] +pub fn procedure(args: StdTokenStream, item: StdTokenStream) -> StdTokenStream { + cvt_attr::(args, item, quote!(), |args, original_function| { + let args = procedure::ProcedureArgs::parse(args)?; + procedure::procedure_impl(args, original_function) + }) +} + #[proc_macro_attribute] pub fn reducer(args: StdTokenStream, item: StdTokenStream) -> StdTokenStream { cvt_attr::(args, item, quote!(), |args, original_function| { diff --git a/crates/bindings-macro/src/procedure.rs b/crates/bindings-macro/src/procedure.rs new file mode 100644 index 00000000000..9e7e61f52f4 --- /dev/null +++ b/crates/bindings-macro/src/procedure.rs @@ -0,0 +1,111 @@ +use crate::reducer::{assert_only_lifetime_generics, extract_typed_args}; +use crate::sym; +use crate::util::{check_duplicate, ident_to_litstr, match_meta}; +use proc_macro2::TokenStream; +use quote::quote; +use syn::parse::Parser as _; +use syn::{ItemFn, LitStr}; + +#[derive(Default)] +pub(crate) struct ProcedureArgs { + name: Option, +} + +impl ProcedureArgs { + pub(crate) fn parse(input: TokenStream) -> syn::Result { + let mut args = Self::default(); + syn::meta::parser(|meta| { + match_meta!(match meta { + sym::name => { + check_duplicate(&args.name, &meta)?; + args.name = Some(meta.value()?.parse()?); + } + }); + Ok(()) + }) + .parse2(input)?; + Ok(args) + } +} + +pub(crate) fn procedure_impl(args: ProcedureArgs, original_function: &ItemFn) -> syn::Result { + let func_name = &original_function.sig.ident; + let vis = &original_function.vis; + + let procedure_name = args.name.unwrap_or_else(|| ident_to_litstr(func_name)); + + assert_only_lifetime_generics(original_function, "procedures")?; + + let typed_args = extract_typed_args(original_function)?; + + // TODO: Require that procedures be `async` functions syntactically, + // and use `futures_util::FutureExt::now_or_never` to poll them. + // if !&original_function.sig.asyncness.is_some() { + // return Err(syn::Error::new_spanned( + // original_function.sig.clone(), + // "procedures must be `async`", + // )); + // }; + + // Extract all function parameter names. + let opt_arg_names = typed_args.iter().map(|arg| { + if let syn::Pat::Ident(i) = &*arg.pat { + let name = i.ident.to_string(); + quote!(Some(#name)) + } else { + quote!(None) + } + }); + + let arg_tys = typed_args.iter().map(|arg| arg.ty.as_ref()).collect::>(); + let first_arg_ty = arg_tys.first().into_iter(); + let rest_arg_tys = arg_tys.iter().skip(1); + + // Extract the return type. + let ret_ty = match &original_function.sig.output { + syn::ReturnType::Default => None, + syn::ReturnType::Type(_, t) => Some(&**t), + } + .into_iter(); + + let register_describer_symbol = format!("__preinit__20_register_describer_{}", procedure_name.value()); + + let lifetime_params = &original_function.sig.generics; + let lifetime_where_clause = &lifetime_params.where_clause; + + let generated_describe_function = quote! { + #[export_name = #register_describer_symbol] + pub extern "C" fn __register_describer() { + spacetimedb::rt::register_procedure::<_, _, #func_name>(#func_name) + } + }; + + Ok(quote! { + const _: () = { + #generated_describe_function + }; + #[allow(non_camel_case_types)] + #vis struct #func_name { _never: ::core::convert::Infallible } + const _: () = { + fn _assert_args #lifetime_params () #lifetime_where_clause { + #(let _ = <#first_arg_ty as spacetimedb::rt::ProcedureContextArg>::_ITEM;)* + #(let _ = <#rest_arg_tys as spacetimedb::rt::ProcedureArg>::_ITEM;)* + #(let _ = <#ret_ty as spacetimedb::rt::IntoProcedureResult>::into_result;)* + } + }; + impl #func_name { + fn invoke(__ctx: spacetimedb::ProcedureContext, __args: &[u8]) -> spacetimedb::ProcedureResult { + spacetimedb::rt::invoke_procedure(#func_name, __ctx, __args) + } + } + #[automatically_derived] + impl spacetimedb::rt::ExportFunctionInfo for #func_name { + const NAME: &'static str = #procedure_name; + const ARG_NAMES: &'static [Option<&'static str>] = &[#(#opt_arg_names),*]; + } + #[automatically_derived] + impl spacetimedb::rt::ProcedureInfo for #func_name { + const INVOKE: spacetimedb::rt::ProcedureFn = #func_name::invoke; + } + }) +} diff --git a/crates/bindings-macro/src/reducer.rs b/crates/bindings-macro/src/reducer.rs index ff98cc6250b..193bae2dcf8 100644 --- a/crates/bindings-macro/src/reducer.rs +++ b/crates/bindings-macro/src/reducer.rs @@ -4,7 +4,7 @@ use proc_macro2::{Span, TokenStream}; use quote::{quote, quote_spanned}; use syn::parse::Parser as _; use syn::spanned::Spanned; -use syn::{FnArg, Ident, ItemFn, LitStr}; +use syn::{FnArg, Ident, ItemFn, LitStr, PatType}; #[derive(Default)] pub(crate) struct ReducerArgs { @@ -59,25 +59,29 @@ impl ReducerArgs { } } -pub(crate) fn reducer_impl(args: ReducerArgs, original_function: &ItemFn) -> syn::Result { - let func_name = &original_function.sig.ident; - let vis = &original_function.vis; - - let reducer_name = args.name.unwrap_or_else(|| ident_to_litstr(func_name)); - +pub(crate) fn assert_only_lifetime_generics(original_function: &ItemFn, function_kind_plural: &str) -> syn::Result<()> { for param in &original_function.sig.generics.params { let err = |msg| syn::Error::new_spanned(param, msg); match param { syn::GenericParam::Lifetime(_) => {} - syn::GenericParam::Type(_) => return Err(err("type parameters are not allowed on reducers")), - syn::GenericParam::Const(_) => return Err(err("const parameters are not allowed on reducers")), + syn::GenericParam::Type(_) => { + return Err(err(format!( + "type parameters are not allowed on {function_kind_plural}" + ))) + } + syn::GenericParam::Const(_) => { + return Err(err(format!( + "const parameters are not allowed on {function_kind_plural}" + ))) + } } } + Ok(()) +} - let lifecycle = args.lifecycle.iter().filter_map(|lc| lc.to_lifecycle_value()); - - // Extract all function parameters, except for `self` ones that aren't allowed. - let typed_args = original_function +/// Extract all function parameters, except for `self` ones that aren't allowed. +pub(crate) fn extract_typed_args(original_function: &ItemFn) -> syn::Result> { + original_function .sig .inputs .iter() @@ -85,7 +89,20 @@ pub(crate) fn reducer_impl(args: ReducerArgs, original_function: &ItemFn) -> syn FnArg::Typed(arg) => Ok(arg), _ => Err(syn::Error::new_spanned(arg, "expected typed argument")), }) - .collect::>>()?; + .collect() +} + +pub(crate) fn reducer_impl(args: ReducerArgs, original_function: &ItemFn) -> syn::Result { + let func_name = &original_function.sig.ident; + let vis = &original_function.vis; + + let reducer_name = args.name.unwrap_or_else(|| ident_to_litstr(func_name)); + + assert_only_lifetime_generics(original_function, "reducers")?; + + let lifecycle = args.lifecycle.iter().filter_map(|lc| lc.to_lifecycle_value()); + + let typed_args = extract_typed_args(original_function)?; // Extract all function parameter names. let opt_arg_names = typed_args.iter().map(|arg| { @@ -139,10 +156,13 @@ pub(crate) fn reducer_impl(args: ReducerArgs, original_function: &ItemFn) -> syn } } #[automatically_derived] - impl spacetimedb::rt::ReducerInfo for #func_name { + impl spacetimedb::rt::ExportFunctionInfo for #func_name { const NAME: &'static str = #reducer_name; - #(const LIFECYCLE: Option = Some(#lifecycle);)* const ARG_NAMES: &'static [Option<&'static str>] = &[#(#opt_arg_names),*]; + } + #[automatically_derived] + impl spacetimedb::rt::ReducerInfo for #func_name { + #(const LIFECYCLE: Option = Some(#lifecycle);)* const INVOKE: spacetimedb::rt::ReducerFn = #func_name::invoke; } }) diff --git a/crates/bindings-macro/src/table.rs b/crates/bindings-macro/src/table.rs index 4e2b079992f..e5350236fbd 100644 --- a/crates/bindings-macro/src/table.rs +++ b/crates/bindings-macro/src/table.rs @@ -40,7 +40,7 @@ impl TableAccess { struct ScheduledArg { span: Span, - reducer: Path, + reducer_or_procedure: Path, at: Option, } @@ -113,7 +113,7 @@ impl TableArgs { impl ScheduledArg { fn parse_meta(meta: ParseNestedMeta) -> syn::Result { let span = meta.path.span(); - let mut reducer = None; + let mut reducer_or_procedure = None; let mut at = None; meta.parse_nested_meta(|meta| { @@ -126,16 +126,26 @@ impl ScheduledArg { } }) } else { - check_duplicate_msg(&reducer, &meta, "can only specify one scheduled reducer")?; - reducer = Some(meta.path); + check_duplicate_msg( + &reducer_or_procedure, + &meta, + "can only specify one scheduled reducer or procedure", + )?; + reducer_or_procedure = Some(meta.path); } Ok(()) })?; - let reducer = reducer.ok_or_else(|| { - meta.error("must specify scheduled reducer associated with the table: scheduled(reducer_name)") + let reducer_or_procedure = reducer_or_procedure.ok_or_else(|| { + meta.error( + "must specify scheduled reducer or procedure associated with the table: scheduled(function_name)", + ) })?; - Ok(Self { span, reducer, at }) + Ok(Self { + span, + reducer_or_procedure, + at, + }) } } @@ -818,17 +828,17 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R ) })?; - let reducer = &sched.reducer; + let reducer_or_procedure = &sched.reducer_or_procedure; let scheduled_at_id = scheduled_at_column.index; let desc = quote!(spacetimedb::table::ScheduleDesc { - reducer_name: <#reducer as spacetimedb::rt::ReducerInfo>::NAME, + reducer_or_procedure_name: <#reducer_or_procedure as spacetimedb::rt::ExportFunctionInfo>::NAME, scheduled_at_column: #scheduled_at_id, }); let primary_key_ty = primary_key_column.ty; let scheduled_at_ty = scheduled_at_column.ty; let typecheck = quote! { - spacetimedb::rt::scheduled_reducer_typecheck::<#original_struct_ident>(#reducer); + spacetimedb::rt::scheduled_typecheck::<#original_struct_ident>(#reducer_or_procedure); spacetimedb::rt::assert_scheduled_table_primary_key::<#primary_key_ty>(); let _ = |x: #scheduled_at_ty| { let _: spacetimedb::ScheduleAt = x; }; }; diff --git a/crates/bindings-sys/src/lib.rs b/crates/bindings-sys/src/lib.rs index 52c3b47f38e..764b73387c6 100644 --- a/crates/bindings-sys/src/lib.rs +++ b/crates/bindings-sys/src/lib.rs @@ -1218,3 +1218,11 @@ impl Drop for RowIter { } } } + +pub mod procedure { + //! Side-effecting or asynchronous operations which only procedures are allowed to perform. + #[inline] + pub fn sleep_until(_wake_at_timestamp: i64) -> i64 { + todo!("Add `procedure_sleep_until` host function") + } +} diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index decc98f4f42..9525002a371 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -49,6 +49,8 @@ pub use table::{ pub type ReducerResult = core::result::Result<(), Box>; +pub type ProcedureResult = Vec; + pub use spacetimedb_bindings_macro::duration; /// Generates code for registering a row-level security rule. @@ -667,6 +669,10 @@ pub use spacetimedb_bindings_macro::table; #[doc(inline)] pub use spacetimedb_bindings_macro::reducer; +// TODO: document +#[doc(inline)] +pub use spacetimedb_bindings_macro::procedure; + /// One of two possible types that can be passed as the first argument to a `#[view]`. /// The other is [`ViewContext`]. /// Use this type if the view does not depend on the caller's identity. @@ -707,11 +713,8 @@ pub struct ReducerContext { /// The `ConnectionId` of the client that invoked the reducer. /// - /// `None` if no `ConnectionId` was supplied to the `/database/call` HTTP endpoint, - /// or via the CLI's `spacetime call` subcommand. - /// - /// For automatic reducers, i.e. `init`, `client_connected`, `client_disconnected`, and scheduled reducers, - /// this will be the module's `ConnectionId`. + /// Will be `None` for certain reducers invoked automatically by the host, + /// including `init` and scheduled reducers. pub connection_id: Option, /// Allows accessing the local database attached to a module. @@ -821,6 +824,72 @@ impl ReducerContext { } } +/// The context that any procedure is provided with. +/// +/// Each procedure must accept `&mut ProcedureContext` as its first argument. +/// +/// Includes information about the client calling the procedure and the time of invocation, +/// and exposes methods for running transactions and performing side-effecting operations. +/// +/// If the crate was compiled with the `rand` feature, +/// also includes faculties for random number generation. +pub struct ProcedureContext { + /// The `Identity` of the client that invoked the procedure. + pub sender: Identity, + + /// The time at which the procedure was started. + pub timestamp: Timestamp, + + /// The `ConnectionId` of the client that invoked the procedure. + /// + /// Will be `None` for certain scheduled procedures. + pub connection_id: Option, + // TODO: Add rng? + // Complex and requires design because we may want procedure RNG to behave differently from reducer RNG, + // as it could actually be seeded by OS randomness rather than a deterministic source. +} + +impl ProcedureContext { + /// Read the current module's [`Identity`]. + pub fn identity(&self) -> Identity { + // Hypothetically, we *could* read the module identity out of the system tables. + // However, this would be: + // - Onerous, because we have no tooling to inspect the system tables from module code. + // - Slow (at least relatively), + // because it would involve multiple host calls which hit the datastore, + // as compared to a single host call which does not. + // As such, we've just defined a host call + // which reads the module identity out of the `InstanceEnv`. + Identity::from_byte_array(spacetimedb_bindings_sys::identity()) + } + + /// Suspend execution until approximately `Timestamp`. + /// + /// This will update `self.timestamp` to the new time after execution resumes. + /// + /// Actual time suspended may not be exactly equal to `duration`. + /// Callers should read `self.timestamp` after resuming to determine the new time. + /// + /// ```no-run + /// # use std::time::Duration; + /// # #[procedure] + /// # fn sleep_one_second(ctx: &mut ProcedureContext) { + /// let prev_time = ctx.timestamp; + /// let target = prev_time + Duration::from_secs(1); + /// ctx.sleep_until(target); + /// let new_time = ctx.timestamp; + /// let actual_delta = new_time.duration_since(prev_time).unwrap(); + /// log::info!("Slept from {prev_time} to {new_time}, a total of {actual_delta:?}"); + /// # } + /// ``` + // TODO(procedure-async): mark this method `async`. + pub fn sleep_until(&mut self, timestamp: Timestamp) { + let new_time = sys::procedure::sleep_until(timestamp.to_micros_since_unix_epoch()); + let new_time = Timestamp::from_micros_since_unix_epoch(new_time); + self.timestamp = new_time; + } +} + /// A handle on a database with a particular table schema. pub trait DbContext { /// A view into the tables of a database. @@ -847,6 +916,10 @@ impl DbContext for ReducerContext { } } +// `ProcedureContext` is *not* a `DbContext`. We will add a `TxContext` +// which can be obtained from `ProcedureContext::start_tx`, +// and that will be a `DbContext`. + /// Allows accessing the local database attached to the module. /// /// This slightly strange type appears to have no methods, but that is misleading. diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index be513ea52f4..2208d8a1158 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -1,7 +1,7 @@ #![deny(unsafe_op_in_unsafe_fn)] use crate::table::IndexAlgo; -use crate::{sys, IterBuf, ReducerContext, ReducerResult, SpacetimeType, Table}; +use crate::{sys, IterBuf, ProcedureContext, ProcedureResult, ReducerContext, ReducerResult, SpacetimeType, Table}; pub use spacetimedb_lib::db::raw_def::v9::Lifecycle as LifecycleReducer; use spacetimedb_lib::db::raw_def::v9::{RawIndexAlgorithm, RawModuleDefV9Builder, TableType}; use spacetimedb_lib::de::{self, Deserialize, Error as _, SeqProductAccess}; @@ -29,6 +29,26 @@ pub fn invoke_reducer<'a, A: Args<'a>>( reducer.invoke(&ctx, args) } + +pub fn invoke_procedure<'a, A: Args<'a>, Ret: IntoProcedureResult>( + procedure: impl Procedure<'a, A, Ret>, + mut ctx: ProcedureContext, + args: &'a [u8], +) -> ProcedureResult { + // Deserialize the arguments from a bsatn encoding. + let SerDeArgs(args) = bsatn::from_slice(args).expect("unable to decode args"); + + // TODO(procedure-async): get a future out of `procedure.invoke` and call `FutureExt::now_or_never` on it? + // Or maybe do that within the `Procedure::invoke` method? + let res = procedure.invoke(&mut ctx, args); + + res.into_result() +} + +/// Marker supertrait for [`Reducer`] and [`Procedure`], +/// used for typechecking by [`scheduled_typecheck`]. +pub trait ExportFunction<'de, A: Args<'de>> {} + /// A trait for types representing the *execution logic* of a reducer. #[diagnostic::on_unimplemented( message = "invalid reducer signature", @@ -39,26 +59,47 @@ pub fn invoke_reducer<'a, A: Args<'a>>( note = "where each `Ti` type implements `SpacetimeType`.", note = "" )] -pub trait Reducer<'de, A: Args<'de>> { +pub trait Reducer<'de, A: Args<'de>>: ExportFunction<'de, A> { fn invoke(&self, ctx: &ReducerContext, args: A) -> ReducerResult; } /// A trait for types that can *describe* a reducer. -pub trait ReducerInfo { - /// The name of the reducer. - const NAME: &'static str; - +/// +/// The `#[reducer]` macro generates an empty struct which implements this trait, +/// along with [`ExportFunctionInfo`]. +pub trait ReducerInfo: ExportFunctionInfo { /// The lifecycle of the reducer, if there is one. const LIFECYCLE: Option = None; - /// A description of the parameter names of the reducer. - const ARG_NAMES: &'static [Option<&'static str>]; - /// The function to call to invoke the reducer. const INVOKE: ReducerFn; } -/// A trait of types representing the arguments of a reducer. +pub trait Procedure<'de, A: Args<'de>, Ret: IntoProcedureResult>: ExportFunction<'de, A> { + fn invoke(&self, ctx: &mut ProcedureContext, args: A) -> Ret; +} + +/// A trait for types that can *describe* a procedure. +/// +/// The `#[procedure]` macro generates an empty struct which implements this trait, +/// along with [`ExportFunctionInfo`]. +pub trait ProcedureInfo: ExportFunctionInfo { + /// The function to invoke the procedure. + const INVOKE: ProcedureFn; +} + +/// Shared super-trait of [`ProcedureInfo`] and [`ReducerInfo`]. +pub trait ExportFunctionInfo { + const NAME: &'static str; + + const ARG_NAMES: &'static [Option<&'static str>]; +} + +/// A trait of types representing the arguments of a reducer or procedure. +/// +/// This does not include the `ReducerContext` or `ProcedureContext` first argument, +/// only the client-provided args. +/// As such, the same trait can be used for both procedures and reducers. pub trait Args<'de>: Sized { /// How many arguments does the reducer accept? const LEN: usize; @@ -70,7 +111,7 @@ pub trait Args<'de>: Sized { fn serialize_seq_product(&self, prod: &mut S) -> Result<(), S::Error>; /// Returns the schema for this reducer provided a `typespace`. - fn schema(typespace: &mut impl TypespaceBuilder) -> ProductType; + fn schema(typespace: &mut impl TypespaceBuilder) -> ProductType; } /// A trait of types representing the result of executing a reducer. @@ -96,6 +137,18 @@ impl IntoReducerResult for Result<(), E> { } } +#[diagnostic::on_unimplemented( + message = "The procdure return type `{Self}` does not implement `SpacetimeType`", + note = "if you own the type, try adding `#[derive(SpacetimeType)]` to its definition" +)] +pub trait IntoProcedureResult: SpacetimeType + Serialize { + #[inline] + fn into_result(&self) -> ProcedureResult { + bsatn::to_vec(&self).expect("Failed to serialize procedure result") + } +} +impl IntoProcedureResult for T {} + #[diagnostic::on_unimplemented( message = "the first argument of a reducer must be `&ReducerContext`", label = "first argument must be `&ReducerContext`" @@ -119,8 +172,31 @@ pub trait ReducerArg { } impl ReducerArg for T {} -/// Assert that a reducer type-checks with a given type. -pub const fn scheduled_reducer_typecheck<'de, Row>(_x: impl ReducerForScheduledTable<'de, Row>) +#[diagnostic::on_unimplemented( + message = "the first argument of a procedure must be `&mut ProcedureContext`", + label = "first argument must be `&mut ProcedureContext`" +)] +pub trait ProcedureContextArg { + // a little hack used in the macro to make error messages nicer. it generates ::_ITEM + #[doc(hidden)] + const _ITEM: () = (); +} +impl ProcedureContextArg for &mut ProcedureContext {} + +/// A trait of types that can be an argument of a procedure. +#[diagnostic::on_unimplemented( + message = "the procedure argument `{Self}` does not implement `SpacetimeType`", + note = "if you own the type, try adding `#[derive(SpacetimeType)]` to its definition" +)] +pub trait ProcedureArg { + // a little hack used in the macro to make error messages nicer. it generates ::_ITEM + #[doc(hidden)] + const _ITEM: () = (); +} +impl ProcedureArg for T {} + +/// Assert that a reducer or procedure type-checks with a given argument type. +pub const fn scheduled_typecheck<'de, Row>(_x: impl ExportFunctionForScheduledTable<'de, Row>) where Row: SpacetimeType + Serialize + Deserialize<'de>, { @@ -128,13 +204,15 @@ where } #[diagnostic::on_unimplemented( - message = "invalid signature for scheduled table reducer", - note = "the scheduled reducer must take `{TableRow}` as its sole argument", - note = "e.g: `fn scheduled_reducer(ctx: &ReducerContext, arg: {TableRow})`" + message = "invalid signature for scheduled table reducer or procedure", + note = "the scheduled function must take `{TableRow}` as its sole argument", + note = "e.g: `fn scheduled_reducer(ctx: &ReducerContext, arg: {TableRow})`", + // TODO(procedure-async): amend this to `async fn` once procedures are `async`-ified + note = "or `fn scheduled_procedure(ctx: &mut ProcedureContext, arg: {TableRow})`" )] -pub trait ReducerForScheduledTable<'de, TableRow> {} -impl<'de, TableRow: SpacetimeType + Serialize + Deserialize<'de>, R: Reducer<'de, (TableRow,)>> - ReducerForScheduledTable<'de, TableRow> for R +pub trait ExportFunctionForScheduledTable<'de, TableRow> {} +impl<'de, TableRow: SpacetimeType + Serialize + Deserialize<'de>, F: ExportFunction<'de, (TableRow,)>> + ExportFunctionForScheduledTable<'de, TableRow> for F { } @@ -206,15 +284,15 @@ impl<'de, A: Args<'de>> de::ProductVisitor<'de> for ArgsVisitor { } } -macro_rules! impl_reducer { +macro_rules! impl_reducer_and_procedure { ($($T1:ident $(, $T:ident)*)?) => { - impl_reducer!(@impl $($T1 $(, $T)*)?); - $(impl_reducer!($($T),*);)? + impl_reducer_and_procedure!(@impl $($T1 $(, $T)*)?); + $(impl_reducer_and_procedure!($($T),*);)? }; (@impl $($T:ident),*) => { // Implement `Args` for the tuple type `($($T,)*)`. impl<'de, $($T: SpacetimeType + Deserialize<'de> + Serialize),*> Args<'de> for ($($T,)*) { - const LEN: usize = impl_reducer!(@count $($T)*); + const LEN: usize = impl_reducer_and_procedure!(@count $($T)*); #[allow(non_snake_case)] #[allow(unused)] fn visit_seq_product>(mut prod: Acc) -> Result { @@ -239,7 +317,7 @@ macro_rules! impl_reducer { #[inline] #[allow(non_snake_case, irrefutable_let_patterns)] - fn schema(_typespace: &mut impl TypespaceBuilder) -> ProductType { + fn schema(_typespace: &mut impl TypespaceBuilder) -> ProductType { // Extract the names of the arguments. let [.., $($T),*] = Info::ARG_NAMES else { panic!() }; ProductType::new(vec![ @@ -251,6 +329,8 @@ macro_rules! impl_reducer { } } + impl<'de, Func, $($T: SpacetimeType + Deserialize<'de> + Serialize),*> ExportFunction<'de, ($($T,)*)> for Func {} + // Implement `Reducer<..., ContextArg>` for the tuple type `($($T,)*)`. impl<'de, Func, Ret, $($T: SpacetimeType + Deserialize<'de> + Serialize),*> Reducer<'de, ($($T,)*)> for Func where @@ -264,15 +344,29 @@ macro_rules! impl_reducer { } } + impl<'de, Func, Ret, $($T: SpacetimeType + Deserialize<'de> + Serialize),*> Procedure<'de, ($($T,)*), Ret> for Func + where + Func: Fn(&mut ProcedureContext, $($T),*) -> Ret, + Ret: IntoProcedureResult, + { + #[allow(non_snake_case)] + fn invoke(&self, ctx: &mut ProcedureContext, args: ($($T,)*)) -> Ret { + let ($($T,)*) = args; + self(ctx, $($T),*) + } + } + }; // Counts the number of elements in the tuple. (@count $($T:ident)*) => { - 0 $(+ impl_reducer!(@drop $T 1))* + 0 $(+ impl_reducer_and_procedure!(@drop $T 1))* }; (@drop $a:tt $b:tt) => { $b }; } -impl_reducer!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z, AA, AB, AC, AD, AE, AF); +impl_reducer_and_procedure!( + A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z, AA, AB, AC, AD, AE, AF +); /// Provides deserialization and serialization for any type `A: Args`. struct SerDeArgs(A); @@ -339,7 +433,7 @@ pub fn register_table() { table = table.with_column_sequence(col); } if let Some(schedule) = T::SCHEDULE { - table = table.with_schedule(schedule.reducer_name, schedule.scheduled_at_column); + table = table.with_schedule(schedule.reducer_or_procedure_name, schedule.scheduled_at_column); } for col in T::get_default_col_values().iter_mut() { @@ -370,6 +464,20 @@ pub fn register_reducer<'a, A: Args<'a>, I: ReducerInfo>(_: impl Reducer<'a, A>) }) } +pub fn register_procedure<'a, A, Ret, I>(_: impl Procedure<'a, A, Ret>) +where + A: Args<'a>, + Ret: SpacetimeType + Serialize, + I: ProcedureInfo, +{ + register_describer(|module| { + let params = A::schema::(&mut module.inner); + let ret_ty = ::make_type(&mut module.inner); + module.inner.add_procedure(I::NAME, params, ret_ty); + module.procedures.push(I::INVOKE); + }) +} + /// Registers a row-level security policy. pub fn register_row_level_security(sql: &'static str) { register_describer(|module| { @@ -384,6 +492,8 @@ struct ModuleBuilder { inner: RawModuleDefV9Builder, /// The reducers of the module. reducers: Vec, + /// The procedures of the module. + procedures: Vec, } // Not actually a mutex; because WASM is single-threaded this basically just turns into a refcell. @@ -394,6 +504,9 @@ static DESCRIBERS: Mutex>> = Mutex::new(Vec::new()); pub type ReducerFn = fn(ReducerContext, &[u8]) -> ReducerResult; static REDUCERS: OnceLock> = OnceLock::new(); +pub type ProcedureFn = fn(ProcedureContext, &[u8]) -> ProcedureResult; +static PROCEDURES: OnceLock> = OnceLock::new(); + /// Called by the host when the module is initialized /// to describe the module into a serialized form that is returned. /// @@ -422,8 +535,9 @@ extern "C" fn __describe_module__(description: BytesSink) { let module_def = RawModuleDef::V9(module_def); let bytes = bsatn::to_vec(&module_def).expect("unable to serialize typespace"); - // Write the set of reducers. + // Write the set of reducers and the set of procedures. REDUCERS.set(module.reducers).ok().unwrap(); + PROCEDURES.set(module.procedures).ok().unwrap(); // Write the bsatn data into the sink. write_to_sink(description, &bytes); @@ -475,16 +589,11 @@ extern "C" fn __call_reducer__( error: BytesSink, ) -> i16 { // Piece together `sender_i` into an `Identity`. - let sender = [sender_0, sender_1, sender_2, sender_3]; - let sender: [u8; 32] = bytemuck::must_cast(sender); - let sender = Identity::from_byte_array(sender); // The LITTLE-ENDIAN constructor. + let sender = reconstruct_sender_identity(sender_0, sender_1, sender_2, sender_3); // Piece together `conn_id_i` into a `ConnectionId`. // The all-zeros `ConnectionId` (`ConnectionId::ZERO`) is interpreted as `None`. - let conn_id = [conn_id_0, conn_id_1]; - let conn_id: [u8; 16] = bytemuck::must_cast(conn_id); - let conn_id = ConnectionId::from_le_byte_array(conn_id); // The LITTLE-ENDIAN constructor. - let conn_id = (conn_id != ConnectionId::ZERO).then_some(conn_id); + let conn_id = reconstruct_connection_id(conn_id_0, conn_id_1); // Assemble the `ReducerContext`. let timestamp = Timestamp::from_micros_since_unix_epoch(timestamp as i64); @@ -495,15 +604,116 @@ extern "C" fn __call_reducer__( // Dispatch to it with the arguments read. let res = with_read_args(args, |args| reducers[id](ctx, args)); // Convert any error message to an error code and writes to the `error` sink. + convert_err_to_errno(res, error) +} + +/// Reconstruct the `sender_i` args to [`__call_reducer__`] and [`__call_procedure__`] into an [`Identity`]. +fn reconstruct_sender_identity(sender_0: u64, sender_1: u64, sender_2: u64, sender_3: u64) -> Identity { + let sender = [sender_0, sender_1, sender_2, sender_3]; + let sender: [u8; 32] = bytemuck::must_cast(sender); + let sender = Identity::from_byte_array(sender); // The LITTLE-ENDIAN constructor. + sender +} + +/// Reconstruct the `conn_id_i` args to [`__call_reducer__`] and [`__call_procedure__`] into a [`ConnectionId`]. +/// +/// The all-zeros `ConnectionId` (`ConnectionId::ZERO`) is interpreted as `None`. +fn reconstruct_connection_id(conn_id_0: u64, conn_id_1: u64) -> Option { + // Piece together `conn_id_i` into a `ConnectionId`. + // The all-zeros `ConnectionId` (`ConnectionId::ZERO`) is interpreted as `None`. + let conn_id = [conn_id_0, conn_id_1]; + let conn_id: [u8; 16] = bytemuck::must_cast(conn_id); + let conn_id = ConnectionId::from_le_byte_array(conn_id); // The LITTLE-ENDIAN constructor. + let conn_id = (conn_id != ConnectionId::ZERO).then_some(conn_id); + conn_id +} + +/// If `res` is `Err`, write the message to `out` and return non-zero. +/// If `res` is `Ok`, return zero. +/// +/// Called by [`__call_reducer__`] and [`__call_procedure__`] +/// to convert the user-returned `Result` into a low-level errno return. +fn convert_err_to_errno(res: Result<(), Box>, out: BytesSink) -> i16 { match res { Ok(()) => 0, Err(msg) => { - write_to_sink(error, msg.as_bytes()); + write_to_sink(out, msg.as_bytes()); errno::HOST_CALL_FAILURE.get() as i16 } } } +/// Called by the host to execute a procedure +/// when the `sender` calls the reducer identified by `id` at `timestamp` with `args`. +/// +/// The `sender_{0-3}` are the pieces of a `[u8; 32]` (`u256`) representing the sender's `Identity`. +/// They are encoded as follows (assuming `identity.to_byte_array(): [u8; 32]`): +/// - `sender_0` contains bytes `[0 ..8 ]`. +/// - `sender_1` contains bytes `[8 ..16]`. +/// - `sender_2` contains bytes `[16..24]`. +/// - `sender_3` contains bytes `[24..32]`. +/// +/// Note that `to_byte_array` uses LITTLE-ENDIAN order! This matches most host systems. +/// +/// The `conn_id_{0-1}` are the pieces of a `[u8; 16]` (`u128`) representing the callers's [`ConnectionId`]. +/// They are encoded as follows (assuming `conn_id.as_le_byte_array(): [u8; 16]`): +/// - `conn_id_0` contains bytes `[0 ..8 ]`. +/// - `conn_id_1` contains bytes `[8 ..16]`. +/// +/// Again, note that `to_byte_array` uses LITTLE-ENDIAN order! This matches most host systems. +/// +/// The `args` is a `BytesSource`, registered on the host side, +/// which can be read with `bytes_source_read`. +/// The contents of the buffer are the BSATN-encoding of the arguments to the reducer. +/// In the case of empty arguments, `args` will be 0, that is, invalid. +/// +/// The `result_sink` is a `BytesSink`, registered on the host side, +/// which can be written to with `bytes_sink_write`. +/// Procedures are expected to always write to this sink +/// the BSATN-serialized bytes of a value of the procedure's return type. +/// +/// Procedures always return the error 0. All other return values are reserved. +#[no_mangle] +extern "C" fn __call_procedure__( + id: usize, + sender_0: u64, + sender_1: u64, + sender_2: u64, + sender_3: u64, + conn_id_0: u64, + conn_id_1: u64, + timestamp: u64, + args: BytesSource, + result_sink: BytesSink, +) -> i16 { + // Piece together `sender_i` into an `Identity`. + let sender = reconstruct_sender_identity(sender_0, sender_1, sender_2, sender_3); + + // Piece together `conn_id_i` into a `ConnectionId`. + let conn_id = reconstruct_connection_id(conn_id_0, conn_id_1); + + let timestamp = Timestamp::from_micros_since_unix_epoch(timestamp as i64); + + // Assemble the `ProcedureContext`. + let ctx = ProcedureContext { + connection_id: conn_id, + sender, + timestamp, + }; + + // Grab the list of procedures, which is populated by the preinit functions. + let procedures = PROCEDURES.get().unwrap(); + + // Deserialize the args and pass them to the actual procedure. + let res = with_read_args(args, |args| procedures[id](ctx, args)); + + // Write the result bytes to the `result_sink`. + write_to_sink(result_sink, &res); + + // Return 0 for no error. Procedures always either trap or return 0. + 0 +} + /// Run `logic` with `args` read from the host into a `&[u8]`. fn with_read_args(args: BytesSource, logic: impl FnOnce(&[u8]) -> R) -> R { if args == BytesSource::INVALID { diff --git a/crates/bindings/src/table.rs b/crates/bindings/src/table.rs index 0ca8e174989..0dd7a6edc17 100644 --- a/crates/bindings/src/table.rs +++ b/crates/bindings/src/table.rs @@ -149,7 +149,7 @@ pub enum IndexAlgo<'a> { } pub struct ScheduleDesc<'a> { - pub reducer_name: &'a str, + pub reducer_or_procedure_name: &'a str, pub scheduled_at_column: u16, } diff --git a/modules/module-test/src/lib.rs b/modules/module-test/src/lib.rs index 7986af92841..cb208c39fba 100644 --- a/modules/module-test/src/lib.rs +++ b/modules/module-test/src/lib.rs @@ -1,10 +1,12 @@ #![allow(clippy::disallowed_names)] -use spacetimedb::log; +use std::time::Duration; + use spacetimedb::spacetimedb_lib::db::raw_def::v9::TableAccess; use spacetimedb::spacetimedb_lib::{self, bsatn}; use spacetimedb::{ duration, table, ConnectionId, Deserialize, Identity, ReducerContext, SpacetimeType, Table, Timestamp, }; +use spacetimedb::{log, ProcedureContext}; pub type TestAlias = TestA; @@ -437,3 +439,13 @@ fn assert_caller_identity_is_module_identity(ctx: &ReducerContext) { log::info!("Called by the owner {owner}"); } } + +#[spacetimedb::procedure] +fn sleep_one_second(ctx: &mut ProcedureContext) { + let prev_time = ctx.timestamp; + let target = prev_time + Duration::from_secs(1); + ctx.sleep_until(target); + let new_time = ctx.timestamp; + let actual_delta = new_time.duration_since(prev_time).unwrap(); + log::info!("Slept from {prev_time} to {new_time}, a total of {actual_delta:?}"); +} From 563aeb70a453d7d8fc21a7882cda32abcf56d48e Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 21 Oct 2025 12:56:26 -0400 Subject: [PATCH 02/12] Clippy --- crates/bindings/src/rt.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index 2208d8a1158..7efb45102cc 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -42,7 +42,7 @@ pub fn invoke_procedure<'a, A: Args<'a>, Ret: IntoProcedureResult>( // Or maybe do that within the `Procedure::invoke` method? let res = procedure.invoke(&mut ctx, args); - res.into_result() + res.to_result() } /// Marker supertrait for [`Reducer`] and [`Procedure`], @@ -143,7 +143,7 @@ impl IntoReducerResult for Result<(), E> { )] pub trait IntoProcedureResult: SpacetimeType + Serialize { #[inline] - fn into_result(&self) -> ProcedureResult { + fn to_result(&self) -> ProcedureResult { bsatn::to_vec(&self).expect("Failed to serialize procedure result") } } @@ -611,8 +611,7 @@ extern "C" fn __call_reducer__( fn reconstruct_sender_identity(sender_0: u64, sender_1: u64, sender_2: u64, sender_3: u64) -> Identity { let sender = [sender_0, sender_1, sender_2, sender_3]; let sender: [u8; 32] = bytemuck::must_cast(sender); - let sender = Identity::from_byte_array(sender); // The LITTLE-ENDIAN constructor. - sender + Identity::from_byte_array(sender) // The LITTLE-ENDIAN constructor. } /// Reconstruct the `conn_id_i` args to [`__call_reducer__`] and [`__call_procedure__`] into a [`ConnectionId`]. @@ -624,8 +623,7 @@ fn reconstruct_connection_id(conn_id_0: u64, conn_id_1: u64) -> Option Date: Tue, 21 Oct 2025 13:01:15 -0400 Subject: [PATCH 03/12] fix misspelled `no_run` --- crates/bindings/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 9525002a371..793de480015 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -870,7 +870,7 @@ impl ProcedureContext { /// Actual time suspended may not be exactly equal to `duration`. /// Callers should read `self.timestamp` after resuming to determine the new time. /// - /// ```no-run + /// ```no_run /// # use std::time::Duration; /// # #[procedure] /// # fn sleep_one_second(ctx: &mut ProcedureContext) { From 8d1f528ba2e4d1f5bcac72a5ef88392c6e488901 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Thu, 23 Oct 2025 10:54:16 -0400 Subject: [PATCH 04/12] Make procedures able to be scheduled Using Will Crichton's blog post about Tacit Trait Parameters, https://willcrichton.net/notes/defeating-coherence-rust/ --- crates/bindings-macro/src/procedure.rs | 16 ++++++- crates/bindings-macro/src/reducer.rs | 2 + crates/bindings-macro/src/table.rs | 5 ++- crates/bindings-macro/src/view.rs | 3 ++ crates/bindings/src/rt.rs | 54 +++++++++++++++++++++--- crates/bindings/tests/ui/reducers.stderr | 8 ++-- crates/bindings/tests/ui/views.stderr | 28 +++++------- 7 files changed, 87 insertions(+), 29 deletions(-) diff --git a/crates/bindings-macro/src/procedure.rs b/crates/bindings-macro/src/procedure.rs index d206cbb8bf9..d3ab20fb17c 100644 --- a/crates/bindings-macro/src/procedure.rs +++ b/crates/bindings-macro/src/procedure.rs @@ -68,7 +68,7 @@ pub(crate) fn procedure_impl(args: ProcedureArgs, original_function: &ItemFn) -> } .into_iter(); - let ret_ty_for_builder = match &original_function.sig.output { + let ret_ty_for_info = match &original_function.sig.output { syn::ReturnType::Default => quote!(()), syn::ReturnType::Type(_, t) => quote!(#t), }; @@ -105,12 +105,24 @@ pub(crate) fn procedure_impl(args: ProcedureArgs, original_function: &ItemFn) -> } #[automatically_derived] impl spacetimedb::rt::FnInfo for #func_name { + /// The type of this function. type Invoke = spacetimedb::rt::ProcedureFn; + + /// The function kind, which will cause scheduled tables to accept procedures. + type FnKind = spacetimedb::rt::FnKindProcedure<#ret_ty_for_info>; + + /// The name of this function const NAME: &'static str = #procedure_name; + + /// The parameter names of this function const ARG_NAMES: &'static [Option<&'static str>] = &[#(#opt_arg_names),*]; + + /// The pointer for invoking this function const INVOKE: spacetimedb::rt::ProcedureFn = #func_name::invoke; + + /// The return type of this function fn return_type(ts: &mut impl spacetimedb::sats::typespace::TypespaceBuilder) -> Option { - Some(<#ret_ty_for_builder as spacetimedb::SpacetimeType>::make_type(ts)) + Some(<#ret_ty_for_info as spacetimedb::SpacetimeType>::make_type(ts)) } } }) diff --git a/crates/bindings-macro/src/reducer.rs b/crates/bindings-macro/src/reducer.rs index af2539a9991..5adf4075284 100644 --- a/crates/bindings-macro/src/reducer.rs +++ b/crates/bindings-macro/src/reducer.rs @@ -158,6 +158,8 @@ pub(crate) fn reducer_impl(args: ReducerArgs, original_function: &ItemFn) -> syn #[automatically_derived] impl spacetimedb::rt::FnInfo for #func_name { type Invoke = spacetimedb::rt::ReducerFn; + /// The function kind, which will cause scheduled tables to accept reducers. + type FnKind = spacetimedb::rt::FnKindReducer; const NAME: &'static str = #reducer_name; #(const LIFECYCLE: Option = Some(#lifecycle);)* const ARG_NAMES: &'static [Option<&'static str>] = &[#(#opt_arg_names),*]; diff --git a/crates/bindings-macro/src/table.rs b/crates/bindings-macro/src/table.rs index d5938689872..2748f195e64 100644 --- a/crates/bindings-macro/src/table.rs +++ b/crates/bindings-macro/src/table.rs @@ -838,7 +838,10 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R let primary_key_ty = primary_key_column.ty; let scheduled_at_ty = scheduled_at_column.ty; let typecheck = quote! { - spacetimedb::rt::scheduled_typecheck::<#original_struct_ident>(#reducer_or_procedure); + spacetimedb::rt::scheduled_typecheck::< + #original_struct_ident, + <#reducer_or_procedure as spacetimedb::rt::FnInfo>::FnKind, + >(#reducer_or_procedure); spacetimedb::rt::assert_scheduled_table_primary_key::<#primary_key_ty>(); let _ = |x: #scheduled_at_ty| { let _: spacetimedb::ScheduleAt = x; }; }; diff --git a/crates/bindings-macro/src/view.rs b/crates/bindings-macro/src/view.rs index ba6a529ebe3..e409cddd11f 100644 --- a/crates/bindings-macro/src/view.rs +++ b/crates/bindings-macro/src/view.rs @@ -158,6 +158,9 @@ pub(crate) fn view_impl(_args: ViewArgs, original_function: &ItemFn) -> syn::Res /// The type of this function type Invoke = as spacetimedb::rt::ViewKindTrait>::InvokeFn; + /// The function kind, which will cause scheduled tables to reject views. + type FnKind = spacetimedb::rt::FnKindView; + /// The name of this function const NAME: &'static str = #view_name; diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index 23b33880124..23d1a3b7276 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -13,6 +13,7 @@ use spacetimedb_lib::sats::{impl_deserialize, impl_serialize, ProductTypeElement use spacetimedb_lib::ser::{Serialize, SerializeSeqProduct}; use spacetimedb_lib::{bsatn, AlgebraicType, ConnectionId, Identity, ProductType, RawModuleDef, Timestamp}; use spacetimedb_primitives::*; +use std::convert::Infallible; use std::fmt; use std::marker::PhantomData; use std::sync::{Mutex, OnceLock}; @@ -139,6 +140,12 @@ pub trait FnInfo { /// The type of function to invoke. type Invoke; + /// One of [`FnKindReducer`], [`FnKindProcedure`] or [`FnKindView`]. + /// + /// Used as a type argument to [`ExportFunctionForScheduledTable`] and [`scheduled_typecheck`]. + /// See for details on this technique. + type FnKind; + /// The name of the function. const NAME: &'static str; @@ -370,23 +377,60 @@ impl ViewRegistrar { } /// Assert that a reducer type-checks with a given type. -pub const fn scheduled_reducer_typecheck<'de, Row>(_x: impl ReducerForScheduledTable<'de, Row>) +pub const fn scheduled_typecheck<'de, Row, FnKind>(_x: impl ExportFunctionForScheduledTable<'de, Row, FnKind>) where Row: SpacetimeType + Serialize + Deserialize<'de>, { core::mem::forget(_x); } +/// Tacit marker argument to [`ExportFunctionForScheduledTable`] for reducers. +pub struct FnKindReducer { + _never: Infallible, +} + +/// Tacit marker argument to [`ExportFunctionForScheduledTable`] for procedures. +/// +/// Holds the procedure's return type in order to avoid an error due to an unconstrained type argument. +pub struct FnKindProcedure { + _never: Infallible, + _ret_ty: PhantomData Ret>, +} + +/// Tacit marker argument to [`ExportFunctionForScheduledTable`] for views. +/// +/// Because views are never scheduled, we don't need to distringuish between anonymous or sender-identity views, +/// or to include their return type. +pub struct FnKindView { + _never: Infallible, +} + +/// Trait bound for [`scheduled_typecheck`], which the [`crate::table`] macro generates to typecheck scheduled functions. +/// +/// The `FnKind` parameter here is a coherence-defeating marker, which Will Crichton calls a "tacit parameter." +/// See for details on this technique. +/// It will be one of [`FnKindReducer`] or [`FnKindProcedure`] in modules that compile successfully. +/// It may be [`FnKindView`], but that will always fail to typecheck, as views cannot be used as scheduled functions. #[diagnostic::on_unimplemented( message = "invalid signature for scheduled table reducer or procedure", + note = "views cannot be scheduled", note = "the scheduled function must take `{TableRow}` as its sole argument", note = "e.g: `fn scheduled_reducer(ctx: &ReducerContext, arg: {TableRow})`", // TODO(procedure-async): amend this to `async fn` once procedures are `async`-ified - note = "or `fn scheduled_procedure(ctx: &mut ProcedureContext, arg: {TableRow})`" + note = "or `fn scheduled_procedure(ctx: &mut ProcedureContext, arg: {TableRow})`", )] -pub trait ExportFunctionForScheduledTable<'de, TableRow> {} -impl<'de, TableRow: SpacetimeType + Serialize + Deserialize<'de>, F: ExportFunction<'de, (TableRow,)>> - ExportFunctionForScheduledTable<'de, TableRow> for F +pub trait ExportFunctionForScheduledTable<'de, TableRow, FnKind> {} +impl<'de, TableRow: SpacetimeType + Serialize + Deserialize<'de>, F: Reducer<'de, (TableRow,)>> + ExportFunctionForScheduledTable<'de, TableRow, FnKindReducer> for F +{ +} + +impl< + 'de, + TableRow: SpacetimeType + Serialize + Deserialize<'de>, + Ret: SpacetimeType + Serialize + Deserialize<'de>, + F: Procedure<'de, (TableRow,), Ret>, + > ExportFunctionForScheduledTable<'de, TableRow, FnKindProcedure> for F { } diff --git a/crates/bindings/tests/ui/reducers.stderr b/crates/bindings/tests/ui/reducers.stderr index 91775aa9ee2..3c1ea8ae015 100644 --- a/crates/bindings/tests/ui/reducers.stderr +++ b/crates/bindings/tests/ui/reducers.stderr @@ -249,9 +249,9 @@ error[E0593]: function is expected to take 2 arguments, but it takes 3 arguments | ----------------------------------------------------------------- takes 3 arguments | = note: required for `for<'a> fn(&'a ReducerContext, u8, u8) {scheduled_table_reducer}` to implement `Reducer<'_, (ScheduledTable,)>` - = note: required for `for<'a> fn(&'a ReducerContext, u8, u8) {scheduled_table_reducer}` to implement `ReducerForScheduledTable<'_, ScheduledTable>` -note: required by a bound in `scheduled_reducer_typecheck` + = note: required for `for<'a> fn(&'a ReducerContext, u8, u8) {scheduled_table_reducer}` to implement `ExportFunctionForScheduledTable<'_, ScheduledTable, FnKindReducer>` +note: required by a bound in `scheduled_typecheck` --> src/rt.rs | - | pub const fn scheduled_reducer_typecheck<'de, Row>(_x: impl ReducerForScheduledTable<'de, Row>) - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `scheduled_reducer_typecheck` + | pub const fn scheduled_typecheck<'de, Row, FnKind>(_x: impl ExportFunctionForScheduledTable<'de, Row, FnKind>) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `scheduled_typecheck` diff --git a/crates/bindings/tests/ui/views.stderr b/crates/bindings/tests/ui/views.stderr index 99616d3c899..1f7b0ad4f78 100644 --- a/crates/bindings/tests/ui/views.stderr +++ b/crates/bindings/tests/ui/views.stderr @@ -453,28 +453,22 @@ error[E0277]: the trait bound `NotSpacetimeType: SpacetimeType` is not satisfied and $N others = note: required for `Option` to implement `SpacetimeType` -error[E0631]: type mismatch in function arguments +error[E0277]: invalid signature for scheduled table reducer or procedure --> tests/ui/views.rs:136:56 | 136 | #[spacetimedb::table(name = scheduled_table, scheduled(scheduled_table_view))] | -------------------------------------------------------^^^^^^^^^^^^^^^^^^^^--- | | | - | | expected due to this + | | unsatisfied trait bound | required by a bound introduced by this call -... -148 | fn scheduled_table_view(_: &ViewContext, _args: ScheduledTable) -> Vec { - | ------------------------------------------------------------------------------ found signature defined here - | - = note: expected function signature `for<'a> fn(&'a ReducerContext, ScheduledTable) -> _` - found function signature `fn(&ViewContext, ScheduledTable) -> _` - = note: required for `for<'a> fn(&'a ViewContext, ScheduledTable) -> Vec {scheduled_table_view}` to implement `Reducer<'_, (ScheduledTable,)>` - = note: required for `for<'a> fn(&'a ViewContext, ScheduledTable) -> Vec {scheduled_table_view}` to implement `ReducerForScheduledTable<'_, ScheduledTable>` -note: required by a bound in `scheduled_reducer_typecheck` - --> src/rt.rs | - | pub const fn scheduled_reducer_typecheck<'de, Row>(_x: impl ReducerForScheduledTable<'de, Row>) - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `scheduled_reducer_typecheck` -help: consider wrapping the function in a closure + = help: the trait `ExportFunctionForScheduledTable<'_, ScheduledTable, FnKindView>` is not implemented for fn item `for<'a> fn(&'a ViewContext, ScheduledTable) -> Vec {scheduled_table_view}` + = note: views cannot be scheduled + = note: the scheduled function must take `ScheduledTable` as its sole argument + = note: e.g: `fn scheduled_reducer(ctx: &ReducerContext, arg: ScheduledTable)` + = note: or `fn scheduled_procedure(ctx: &mut ProcedureContext, arg: ScheduledTable)` +note: required by a bound in `scheduled_typecheck` + --> src/rt.rs | -136 | #[spacetimedb::table(name = scheduled_table, scheduled(|arg0: &ReducerContext, arg1: ScheduledTable| scheduled_table_view(/* &ViewContext */, arg1)))] - | +++++++++++++++++++++++++++++++++++++++++++++ ++++++++++++++++++++++++++ + | pub const fn scheduled_typecheck<'de, Row, FnKind>(_x: impl ExportFunctionForScheduledTable<'de, Row, FnKind>) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `scheduled_typecheck` From ed782700101ada326399bc544ab46cfc1438e1e5 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Thu, 23 Oct 2025 10:57:22 -0400 Subject: [PATCH 05/12] Fix doc test's imports --- crates/bindings/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 967429e4f6a..86ee852a2ae 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -1000,6 +1000,7 @@ impl ProcedureContext { /// /// ```no_run /// # use std::time::Duration; + /// # use spacetimedb::{procedure, ProcedureContext}; /// # #[procedure] /// # fn sleep_one_second(ctx: &mut ProcedureContext) { /// let prev_time = ctx.timestamp; From 99f27ce26d3cea8572737e9862c707741c542597 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Thu, 23 Oct 2025 13:03:05 -0400 Subject: [PATCH 06/12] Changes from Joshua's review Turns out I can't type/spell --- crates/bindings-macro/src/procedure.rs | 1 + crates/bindings/src/lib.rs | 3 --- crates/bindings/src/rt.rs | 18 +++++++++--------- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/crates/bindings-macro/src/procedure.rs b/crates/bindings-macro/src/procedure.rs index d3ab20fb17c..d5632d96617 100644 --- a/crates/bindings-macro/src/procedure.rs +++ b/crates/bindings-macro/src/procedure.rs @@ -8,6 +8,7 @@ use syn::{ItemFn, LitStr}; #[derive(Default)] pub(crate) struct ProcedureArgs { + /// For consistency with reducers: allow specifying a different export name than the Rust function name. name: Option, } diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 86ee852a2ae..090fcd46784 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -958,9 +958,6 @@ impl ReducerContext { /// /// Includes information about the client calling the procedure and the time of invocation, /// and exposes methods for running transactions and performing side-effecting operations. -/// -/// If the crate was compiled with the `rand` feature, -/// also includes faculties for random number generation. pub struct ProcedureContext { /// The `Identity` of the client that invoked the procedure. pub sender: Identity, diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index 23d1a3b7276..22ccd4a20c3 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -212,7 +212,7 @@ impl IntoReducerResult for Result<(), E> { } #[diagnostic::on_unimplemented( - message = "The procdure return type `{Self}` does not implement `SpacetimeType`", + message = "The procedure return type `{Self}` does not implement `SpacetimeType`", note = "if you own the type, try adding `#[derive(SpacetimeType)]` to its definition" )] pub trait IntoProcedureResult: SpacetimeType + Serialize { @@ -399,7 +399,7 @@ pub struct FnKindProcedure { /// Tacit marker argument to [`ExportFunctionForScheduledTable`] for views. /// -/// Because views are never scheduled, we don't need to distringuish between anonymous or sender-identity views, +/// Because views are never scheduled, we don't need to distinguish between anonymous or sender-identity views, /// or to include their return type. pub struct FnKindView { _never: Infallible, @@ -502,15 +502,15 @@ impl<'de, A: Args<'de>> de::ProductVisitor<'de> for ArgsVisitor { } } -macro_rules! impl_reducer_and_procedure { +macro_rules! impl_reducer_procedure_view { ($($T1:ident $(, $T:ident)*)?) => { - impl_reducer_and_procedure!(@impl $($T1 $(, $T)*)?); - $(impl_reducer_and_procedure!($($T),*);)? + impl_reducer_procedure_view!(@impl $($T1 $(, $T)*)?); + $(impl_reducer_procedure_view!($($T),*);)? }; (@impl $($T:ident),*) => { // Implement `Args` for the tuple type `($($T,)*)`. impl<'de, $($T: SpacetimeType + Deserialize<'de> + Serialize),*> Args<'de> for ($($T,)*) { - const LEN: usize = impl_reducer_and_procedure!(@count $($T)*); + const LEN: usize = impl_reducer_procedure_view!(@count $($T)*); #[allow(non_snake_case)] #[allow(unused)] fn visit_seq_product>(mut prod: Acc) -> Result { @@ -606,12 +606,12 @@ macro_rules! impl_reducer_and_procedure { }; // Counts the number of elements in the tuple. (@count $($T:ident)*) => { - 0 $(+ impl_reducer_and_procedure!(@drop $T 1))* + 0 $(+ impl_reducer_procedure_view!(@drop $T 1))* }; (@drop $a:tt $b:tt) => { $b }; } -impl_reducer_and_procedure!( +impl_reducer_procedure_view!( A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z, AA, AB, AC, AD, AE, AF ); @@ -933,7 +933,7 @@ fn convert_err_to_errno(res: Result<(), Box>, out: BytesSink) -> i16 { } /// Called by the host to execute a procedure -/// when the `sender` calls the reducer identified by `id` at `timestamp` with `args`. +/// when the `sender` calls the procedure identified by `id` at `timestamp` with `args`. /// /// The `sender_{0-3}` are the pieces of a `[u8; 32]` (`u256`) representing the sender's `Identity`. /// They are encoded as follows (assuming `identity.to_byte_array(): [u8; 32]`): From 04fd1233f1d5979df8a17cdc7d291600cff37a51 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Thu, 23 Oct 2025 14:11:20 -0400 Subject: [PATCH 07/12] Preliminary procedure docs These are quite bare-bones, as much of the stuff we'd want to document isn't implemented yet. --- crates/bindings/src/lib.rs | 85 +++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 090fcd46784..957ae8a93cc 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -544,6 +544,7 @@ pub use spacetimedb_bindings_macro::table; /// If an error occurs in the disconnect reducer, /// the client is still recorded as disconnected. /// +// TODO(docs): Move these docs to be on `table`, rather than `reducer`. This will reduce duplication with procedure docs. /// # Scheduled reducers /// /// In addition to life cycle annotations, reducers can be made **scheduled**. @@ -669,7 +670,89 @@ pub use spacetimedb_bindings_macro::table; #[doc(inline)] pub use spacetimedb_bindings_macro::reducer; -// TODO: document +/// Marks a function as a SpacetimeDB procedure. +/// +/// A procedure is a function that runs within the database and can be invoked remotely by [clients], +/// but unlike a [`reducer`], a procedure is not automatically transactional. +/// This allows procedures to perform certain side-effecting operations, +/// but also means that module developers must be more careful not to corrupt the database state +/// when execution aborts or operations fail. +/// +/// When in doubt, prefer writing [`reducer`]s unless you need to perform an operation only available to procedures. +/// +/// The first argument of a reducer is always `&mut ProcedureContext`. +/// The [`ProcedureContext`] exposes information about the caller and allows side-effecting operations. +/// +/// After this, a reducer can take any number of arguments. +/// These arguments must implement the [`SpacetimeType`], [`Serialize`], and [`Deserialize`] traits. +/// All of these traits can be derived at once by marking a type with `#[derive(SpacetimeType)]`. +/// +/// A procedure may return any type that implements [`SpacetimeType`], [`Serialize`] and [`Deserialize`]. +/// Unlike [reducer]s, SpacetimeDB does not assign any special semantics to [`Result`] return values. +/// +/// If a procedure returns successfully (as opposed to panicking), its return value will be sent to the calling client. +/// If a procedure panics, its panic message will be sent to the calling client instead. +/// Procedure arguments and return values are not otherwise broadcast to clients. +/// +/// ```no_run +/// # use spacetimedb::{procedure, SpacetimeType, ProcedureContext}; +/// #[procedure] +/// fn return_value(ctx: &mut ProcedureContext, arg: MyArgument) -> MyReturnValue { +/// MyReturnValue { +/// a: format!("Hello, {}", ctx.sender), +/// b: ctx.timestamp, +/// } +/// } +/// +/// #[derive(SpacetimeType)] +/// struct MyArgument { +/// val: u32, +/// } +/// +/// #[derive(SpacetimeType)] +/// struct MyReturnValue { +/// a: String, +/// b: Timestamp, +/// } +/// ``` +/// +/// # Blocking operations +/// +/// Procedures are allowed to perform certain operations which take time. +/// During the execution of these operations, the procedure's execution will be suspended, +/// allowing other database operations to run in parallel. +/// The simplest (and least useful) of these operators is [`ProcedureContext::sleep_until`]. +/// +/// Procedures must not hold open a transaction while performing a blocking operation. +/// +/// ```no_run +/// # use std::time::Duration; +/// # use spacetimedb::{procedure, ProcedureContext}; +/// #[procedure] +/// fn sleep_one_second(ctx: &mut ProcedureContext) { +/// let prev_time = ctx.timestamp; +/// let target = prev_time + Duration::from_secs(1); +/// ctx.sleep_until(target); +/// let new_time = ctx.timestamp; +/// let actual_delta = new_time.duration_since(prev_time).unwrap(); +/// log::info!("Slept from {prev_time} to {new_time}, a total of {actual_delta:?}"); +/// } +/// ``` +// TODO(procedure-http): replace this example with an HTTP request. +// TODO(procedure-transaction): document obtaining and using a transaction within a procedure. +/// +/// # Scheduled procedures +// TODO(docs): after moving scheduled reducer docs into table secion, link there. +/// +/// Like [reducer]s, procedures can be made **scheduled**. +/// This allows calling procedures at a particular time, or in a loop. +/// It also allows reducers to enqueue procedure runs. +/// +/// Scheduled procedures are called on a best-effort basis and may be slightly delayed in their execution +/// when a database is under heavy load. +/// +/// [clients]: https://spacetimedb.com/docs/#client +// TODO(procedure-async): update docs and examples with `async`-ness. #[doc(inline)] pub use spacetimedb_bindings_macro::procedure; From 6819183e330f65a8117e1104ed55b7d43d1638f3 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Thu, 23 Oct 2025 14:18:23 -0400 Subject: [PATCH 08/12] Fix copy-paste errors --- crates/bindings/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 957ae8a93cc..7bcc738819d 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -680,10 +680,10 @@ pub use spacetimedb_bindings_macro::reducer; /// /// When in doubt, prefer writing [`reducer`]s unless you need to perform an operation only available to procedures. /// -/// The first argument of a reducer is always `&mut ProcedureContext`. +/// The first argument of a procedure is always `&mut ProcedureContext`. /// The [`ProcedureContext`] exposes information about the caller and allows side-effecting operations. /// -/// After this, a reducer can take any number of arguments. +/// After this, a procedure can take any number of arguments. /// These arguments must implement the [`SpacetimeType`], [`Serialize`], and [`Deserialize`] traits. /// All of these traits can be derived at once by marking a type with `#[derive(SpacetimeType)]`. /// From ac9737ffe2d305821e411c1d2a2ddd870b585eff Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Thu, 23 Oct 2025 14:50:53 -0400 Subject: [PATCH 09/12] Empty commit to re-try CI jobs From 1f163d3f434633be88fd7a2e3fb81adea298f499 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 24 Oct 2025 09:02:23 -0400 Subject: [PATCH 10/12] Fix doc tests, add another example/test to module-test --- crates/bindings-macro/src/procedure.rs | 2 +- crates/bindings/src/lib.rs | 2 +- modules/module-test/src/lib.rs | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/bindings-macro/src/procedure.rs b/crates/bindings-macro/src/procedure.rs index d5632d96617..0f4013d96ae 100644 --- a/crates/bindings-macro/src/procedure.rs +++ b/crates/bindings-macro/src/procedure.rs @@ -96,7 +96,7 @@ pub(crate) fn procedure_impl(args: ProcedureArgs, original_function: &ItemFn) -> fn _assert_args #lifetime_params () #lifetime_where_clause { #(let _ = <#first_arg_ty as spacetimedb::rt::ProcedureContextArg>::_ITEM;)* #(let _ = <#rest_arg_tys as spacetimedb::rt::ProcedureArg>::_ITEM;)* - #(let _ = <#ret_ty_for_assert as spacetimedb::rt::IntoProcedureResult>::into_result;)* + #(let _ = <#ret_ty_for_assert as spacetimedb::rt::IntoProcedureResult>::to_result;)* } }; impl #func_name { diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 7bcc738819d..89ac4ac72d6 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -695,7 +695,7 @@ pub use spacetimedb_bindings_macro::reducer; /// Procedure arguments and return values are not otherwise broadcast to clients. /// /// ```no_run -/// # use spacetimedb::{procedure, SpacetimeType, ProcedureContext}; +/// # use spacetimedb::{procedure, SpacetimeType, ProcedureContext, Timestamp}; /// #[procedure] /// fn return_value(ctx: &mut ProcedureContext, arg: MyArgument) -> MyReturnValue { /// MyReturnValue { diff --git a/modules/module-test/src/lib.rs b/modules/module-test/src/lib.rs index cb208c39fba..e57859d4518 100644 --- a/modules/module-test/src/lib.rs +++ b/modules/module-test/src/lib.rs @@ -449,3 +449,13 @@ fn sleep_one_second(ctx: &mut ProcedureContext) { let actual_delta = new_time.duration_since(prev_time).unwrap(); log::info!("Slept from {prev_time} to {new_time}, a total of {actual_delta:?}"); } + +#[derive(SpacetimeType)] +struct MyReturnValue { + foo: u64, +} + +#[spacetimedb::procedure] +fn return_value(ctx: &mut ProcedureContext, foo: u64) -> MyReturnValue { + MyReturnValue { foo } +} From 62673e4e442a2010127a5b0c0c8cda1e9e797034 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 24 Oct 2025 09:14:00 -0400 Subject: [PATCH 11/12] clippy --- modules/module-test/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/module-test/src/lib.rs b/modules/module-test/src/lib.rs index e57859d4518..a0b99232999 100644 --- a/modules/module-test/src/lib.rs +++ b/modules/module-test/src/lib.rs @@ -456,6 +456,6 @@ struct MyReturnValue { } #[spacetimedb::procedure] -fn return_value(ctx: &mut ProcedureContext, foo: u64) -> MyReturnValue { +fn return_value(_ctx: &mut ProcedureContext, foo: u64) -> MyReturnValue { MyReturnValue { foo } } From 583c097328220561deb9d42d8b80bfa3d6fd4eff Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 24 Oct 2025 09:29:54 -0400 Subject: [PATCH 12/12] Don't define a new type, use an existing type --- modules/module-test/src/lib.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/modules/module-test/src/lib.rs b/modules/module-test/src/lib.rs index a0b99232999..831b50b117a 100644 --- a/modules/module-test/src/lib.rs +++ b/modules/module-test/src/lib.rs @@ -450,12 +450,9 @@ fn sleep_one_second(ctx: &mut ProcedureContext) { log::info!("Slept from {prev_time} to {new_time}, a total of {actual_delta:?}"); } -#[derive(SpacetimeType)] -struct MyReturnValue { - foo: u64, -} - #[spacetimedb::procedure] -fn return_value(_ctx: &mut ProcedureContext, foo: u64) -> MyReturnValue { - MyReturnValue { foo } +fn return_value(_ctx: &mut ProcedureContext, foo: u64) -> Baz { + Baz { + field: format!("{foo}"), + } }