diff --git a/crates/bindings-macro/src/lib.rs b/crates/bindings-macro/src/lib.rs index e5ca3ad52c7..3bf1c54dc2b 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; @@ -105,6 +106,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..0f4013d96ae --- /dev/null +++ b/crates/bindings-macro/src/procedure.rs @@ -0,0 +1,130 @@ +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 { + /// For consistency with reducers: allow specifying a different export name than the Rust function name. + 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_for_assert = match &original_function.sig.output { + syn::ReturnType::Default => None, + syn::ReturnType::Type(_, t) => Some(&**t), + } + .into_iter(); + + let ret_ty_for_info = match &original_function.sig.output { + syn::ReturnType::Default => quote!(()), + syn::ReturnType::Type(_, t) => quote!(#t), + }; + + 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_for_assert as spacetimedb::rt::IntoProcedureResult>::to_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::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_info as spacetimedb::SpacetimeType>::make_type(ts)) + } + } + }) +} diff --git a/crates/bindings-macro/src/reducer.rs b/crates/bindings-macro/src/reducer.rs index 2ca10c48b4f..5adf4075284 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| { @@ -141,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 dd67ec22e9d..2748f195e64 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,20 @@ 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::FnInfo>::NAME, + reducer_or_procedure_name: <#reducer_or_procedure as spacetimedb::rt::FnInfo>::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 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-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 dfd0af179b8..89ac4ac72d6 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. @@ -542,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**. @@ -667,6 +670,92 @@ pub use spacetimedb_bindings_macro::table; #[doc(inline)] pub use spacetimedb_bindings_macro::reducer; +/// 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 procedure is always `&mut ProcedureContext`. +/// The [`ProcedureContext`] exposes information about the caller and allows side-effecting operations. +/// +/// 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)]`. +/// +/// 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, Timestamp}; +/// #[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; + /// Marks a function as a spacetimedb view. /// /// A view is a function with read-only access to the database. @@ -834,11 +923,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. @@ -949,6 +1035,70 @@ 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. +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; + /// # 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-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. @@ -975,6 +1125,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 739dda3635d..22ccd4a20c3 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -2,7 +2,8 @@ use crate::table::IndexAlgo; use crate::{ - sys, AnonymousViewContext, IterBuf, LocalReadOnly, ReducerContext, ReducerResult, SpacetimeType, Table, ViewContext, + sys, AnonymousViewContext, IterBuf, LocalReadOnly, ProcedureContext, ProcedureResult, ReducerContext, + ReducerResult, SpacetimeType, Table, ViewContext, }; pub use spacetimedb_lib::db::raw_def::v9::Lifecycle as LifecycleReducer; use spacetimedb_lib::db::raw_def::v9::{RawIndexAlgorithm, RawModuleDefV9Builder, TableType}; @@ -12,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}; @@ -47,6 +49,22 @@ 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.to_result() +} + /// A trait for types representing the *execution logic* of a reducer. #[diagnostic::on_unimplemented( message = "invalid reducer signature", @@ -122,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; @@ -141,7 +165,15 @@ pub trait FnInfo { } } -/// A trait of types representing the arguments of a reducer. +pub trait Procedure<'de, A: Args<'de>, Ret: IntoProcedureResult> { + fn invoke(&self, ctx: &mut ProcedureContext, args: A) -> Ret; +} + +/// A trait of types representing the arguments of a reducer, procedure or view. +/// +/// This does not include the context first argument, +/// only the client-provided args. +/// As such, the same trait can be used for all sorts of exported functions. pub trait Args<'de>: Sized { /// How many arguments does the reducer accept? const LEN: usize; @@ -179,6 +211,18 @@ impl IntoReducerResult for Result<(), E> { } } +#[diagnostic::on_unimplemented( + 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 { + #[inline] + fn to_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`" @@ -202,6 +246,29 @@ pub trait ReducerArg { } impl ReducerArg for T {} +#[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 {} + #[diagnostic::on_unimplemented( message = "The first parameter of a `#[view]` must be `&ViewContext` or `&AnonymousViewContext`" )] @@ -310,21 +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 distinguish 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", - 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 = "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})`", )] -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, 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 { } @@ -396,15 +502,15 @@ impl<'de, A: Args<'de>> de::ProductVisitor<'de> for ArgsVisitor { } } -macro_rules! impl_reducer { +macro_rules! impl_reducer_procedure_view { ($($T1:ident $(, $T:ident)*)?) => { - impl_reducer!(@impl $($T1 $(, $T)*)?); - $(impl_reducer!($($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!(@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 { @@ -441,7 +547,7 @@ macro_rules! impl_reducer { } } - // Implement `Reducer<..., ContextArg>` for the tuple type `($($T,)*)`. + // Implement `Reducer<..., ContextArg>` for the tuple type `($($T,)*)`. impl<'de, Func, Ret, $($T: SpacetimeType + Deserialize<'de> + Serialize),*> Reducer<'de, ($($T,)*)> for Func where Func: Fn(&ReducerContext, $($T),*) -> Ret, @@ -454,6 +560,18 @@ 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),*) + } + } + // Implement `View<..., ViewContext>` for the tuple type `($($T,)*)`. impl<'de, Func, Elem, Retn, $($T),*> View<'de, ($($T,)*), Elem> for Func @@ -488,12 +606,14 @@ macro_rules! impl_reducer { }; // Counts the number of elements in the tuple. (@count $($T:ident)*) => { - 0 $(+ impl_reducer!(@drop $T 1))* + 0 $(+ impl_reducer_procedure_view!(@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_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 +); /// Provides deserialization and serialization for any type `A: Args`. struct SerDeArgs(A); @@ -560,7 +680,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() { @@ -591,6 +711,20 @@ pub fn register_reducer<'a, A: Args<'a>, I: FnInfo>(_: impl }) } +pub fn register_procedure<'a, A, Ret, I>(_: impl Procedure<'a, A, Ret>) +where + A: Args<'a>, + Ret: SpacetimeType + Serialize, + I: FnInfo, +{ + 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 describer for the view `I` with arguments `A` and return type `Vec`. pub fn register_view<'a, A, I, T>(_: impl View<'a, A, T>) where @@ -635,6 +769,8 @@ pub struct ModuleBuilder { inner: RawModuleDefV9Builder, /// The reducers of the module. reducers: Vec, + /// The procedures of the module. + procedures: Vec, /// The client specific views of the module. views: Vec, /// The anonymous views of the module. @@ -649,6 +785,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(); + /// A view function takes in `(ViewContext, Args)` and returns a Vec of bytes. pub type ViewFn = fn(ViewContext, &[u8]) -> Vec; static VIEWS: OnceLock> = OnceLock::new(); @@ -685,8 +824,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 and views. + // Write the sets of reducers, procedures and views. REDUCERS.set(module.reducers).ok().unwrap(); + PROCEDURES.set(module.procedures).ok().unwrap(); VIEWS.set(module.views).ok().unwrap(); ANONYMOUS_VIEWS.set(module.views_anon).ok().unwrap(); @@ -740,16 +880,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); @@ -760,15 +895,114 @@ 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); + Identity::from_byte_array(sender) // The LITTLE-ENDIAN constructor. +} + +/// 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. + (conn_id != ConnectionId::ZERO).then_some(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 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]`): +/// - `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 +} + /// Called by the host to execute an anonymous view. /// /// The `args` is a `BytesSource`, registered on the host side, 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/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` diff --git a/modules/module-test/src/lib.rs b/modules/module-test/src/lib.rs index 7986af92841..831b50b117a 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,20 @@ 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:?}"); +} + +#[spacetimedb::procedure] +fn return_value(_ctx: &mut ProcedureContext, foo: u64) -> Baz { + Baz { + field: format!("{foo}"), + } +}