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

Filter by extension

Filter by extension


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

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ members = [
"modules/keynote-benchmarks",
"modules/perf-test",
"modules/module-test",
"modules/module-test-stop",
"templates/basic-rs/spacetimedb",
"templates/chat-console-rs/spacetimedb",
"modules/sdk-test",
Expand Down
1 change: 1 addition & 0 deletions crates/bindings-csharp/Codegen/Module.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1583,6 +1583,7 @@ class {{Identifier}}: SpacetimeDB.Internal.IReducer {
ReducerKind.Init => "SpacetimeDB.Internal.Lifecycle.Init",
ReducerKind.ClientConnected => "SpacetimeDB.Internal.Lifecycle.OnConnect",
ReducerKind.ClientDisconnected => "SpacetimeDB.Internal.Lifecycle.OnDisconnect",
ReducerKind.Stop => "SpacetimeDB.Internal.Lifecycle.Stop",
_ => "null"
}}};

Expand Down
8 changes: 8 additions & 0 deletions crates/bindings-csharp/Runtime/Attrs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,14 @@ public enum ReducerKind
Init,
ClientConnected,
ClientDisconnected,

/// <summary>
/// Invoked exactly once, immediately before the database is permanently
/// destroyed (e.g. via `spacetime delete`, or a database reset). Never
/// invoked on an ordinary module update/hot-reload, since the database's
/// data survives those.
/// </summary>
Stop,
}

[AttributeUsage(AttributeTargets.Method, Inherited = false)]
Expand Down

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

1 change: 1 addition & 0 deletions crates/bindings-macro/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ mod sym {
symbol!(repr);
symbol!(sats);
symbol!(scheduled);
symbol!(stop);
symbol!(unique);
symbol!(update);
symbol!(default);
Expand Down
10 changes: 8 additions & 2 deletions crates/bindings-macro/src/reducer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,21 @@ enum LifecycleReducer {
ClientConnected(Span),
ClientDisconnected(Span),
Update(Span),
Stop(Span),
}
impl LifecycleReducer {
fn to_lifecycle_value(&self) -> Option<TokenStream> {
let (Self::Init(span) | Self::ClientConnected(span) | Self::ClientDisconnected(span) | Self::Update(span)) =
*self;
let (Self::Init(span)
| Self::ClientConnected(span)
| Self::ClientDisconnected(span)
| Self::Update(span)
| Self::Stop(span)) = *self;
let name = match self {
Self::Init(_) => "Init",
Self::ClientConnected(_) => "OnConnect",
Self::ClientDisconnected(_) => "OnDisconnect",
Self::Update(_) => return None,
Self::Stop(_) => "Stop",
};
let ident = Ident::new(name, span);
Some(quote_spanned!(span => spacetimedb::rt::LifecycleReducer::#ident))
Expand All @@ -47,6 +52,7 @@ impl ReducerArgs {
sym::client_connected => set_lifecycle(LifecycleReducer::ClientConnected)?,
sym::client_disconnected => set_lifecycle(LifecycleReducer::ClientDisconnected)?,
sym::update => set_lifecycle(LifecycleReducer::Update)?,
sym::stop => set_lifecycle(LifecycleReducer::Stop)?,
sym::name => {
check_duplicate(&args.name, &meta)?;
args.name = Some(meta.value()?.parse()?);
Expand Down
1 change: 1 addition & 0 deletions crates/bindings-typescript/src/lib/autogen/types.ts

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

32 changes: 32 additions & 0 deletions crates/bindings-typescript/src/server/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,38 @@ export class Schema<S extends UntypedSchemaDef> implements ModuleDefaultExport {
return makeReducerExport(this.#ctx, opts, {}, fn, Lifecycle.OnDisconnect);
}

/**
* Registers a reducer to be invoked exactly once, immediately before the
* database is permanently destroyed (e.g. via `spacetime delete`, or a
* database reset). It is never invoked on an ordinary module
* update/hot-reload, since the database's data survives those.
*
* @template S - The inferred schema type of the SpacetimeDB module.
* @param {Reducer<S, {}>} fn - The stop reducer function.
* @example
* ```typescript
* export const stop = spacetime.stop((ctx) => {
* console.log('Database is being deleted');
* });
* ```
*/
stop(fn: Reducer<S, {}>): ReducerExport<S, {}>;
stop(opts: ReducerOpts, fn: Reducer<S, {}>): ReducerExport<S, {}>;
stop(
...args: [Reducer<S, {}>] | [ReducerOpts, Reducer<S, {}>]
): ReducerExport<S, {}> {
let opts: ReducerOpts | undefined, fn: Reducer<S, {}>;
switch (args.length) {
case 1:
[fn] = args;
break;
case 2:
[opts, fn] = args;
break;
}
return makeReducerExport(this.#ctx, opts, {}, fn, Lifecycle.Stop);
}

view<Ret extends ViewReturnTypeBuilder, F extends ViewFn<S, {}, Ret>>(
opts: ViewOpts,
ret: Ret,
Expand Down
10 changes: 10 additions & 0 deletions crates/bindings/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,16 @@ pub use spacetimedb_bindings_macro::table;
/// If an error occurs in the disconnect reducer,
/// the client is still recorded as disconnected.
///
/// ### The `stop` reducer
///
/// This reducer is marked with `#[spacetimedb::reducer(stop)]`. It is run exactly once,
/// immediately before the database is permanently destroyed - e.g. via `spacetime delete`,
/// or a database reset.
///
/// It does **not** run when a module is updated or hot-reloaded (the database's data
/// survives those), nor when a replica is scaled down or evicted while the database
/// lives on elsewhere.
///
// TODO(docs): Move these docs to be on `table`, rather than `reducer`. This will reduce duplication with procedure docs.
/// # Scheduled reducers
///
Expand Down
12 changes: 11 additions & 1 deletion crates/core/src/host/host_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,12 @@ impl HostController {
/// Release all resources of the [`ModuleHost`] identified by `replica_id`,
/// and deregister it from the controller.
#[tracing::instrument(level = "trace", skip_all)]
pub async fn exit_module_host(&self, replica_id: u64, timeout: Duration) -> Result<(), anyhow::Error> {
pub async fn exit_module_host(
&self,
replica_id: u64,
timeout: Duration,
is_final_teardown: bool,
) -> Result<(), anyhow::Error> {
let Some(lock) = self.hosts.lock().remove(&replica_id) else {
return Ok(());
};
Expand Down Expand Up @@ -654,6 +659,11 @@ impl HostController {
// Ensure we clear the metrics even if the future is cancelled.
defer!(remove_database_gauges(&database_identity, table_names));

if is_final_teardown {
info!("replica={replica_id} database={database_identity} invoking `stop` reducer");
let _ = module.call_module_stop().await;
}

info!("replica={replica_id} database={database_identity} exiting module");
module.exit().await;
info!("replica={replica_id} database={database_identity} exiting database");
Expand Down
56 changes: 56 additions & 0 deletions crates/core/src/host/module_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2204,6 +2204,62 @@ impl ModuleHost {
)?
}

/// Invoke the module's `stop` reducer, if it has one.
///
/// This is called exactly once, immediately before a database is permanently
/// destroyed (e.g. by `spacetime delete`, or a database reset), and never on
/// an ordinary module update/hot-reload, since the database's data survives those.
pub(crate) fn call_module_stop_inner(
info: &ModuleInfo,
call_reducer: impl FnOnce(Option<MutTxId>, CallReducerParams) -> (ReducerCallResult, bool),
trapped_slot: &mut bool,
) {
let Some((reducer_id, reducer_def)) = info.module_def.lifecycle_reducer(Lifecycle::Stop) else {
return;
};

let stdb = info.relational_db();
let owner_identity = info.owner_identity;
let workload = Workload::reducer_no_args(reducer_def.name.clone(), owner_identity, ConnectionId::ZERO);
let mut_tx = stdb.begin_mut_tx(IsolationLevel::Serializable, workload);

let params = match Self::call_reducer_params(
info,
owner_identity,
None,
None,
None,
None,
reducer_id,
reducer_def,
FunctionArgs::Nullary,
) {
Ok(params) => params,
Err(e) => {
log::error!("failed to build call params for `stop` reducer: {e:#?}");
return;
}
};

let (result, trapped) = call_reducer(Some(mut_tx), params);
*trapped_slot = trapped;

if let Err(e) = result.outcome.into_result() {
log::error!("`stop` reducer did not commit cleanly, proceeding with deletion anyway: {e:#}");
}
}

/// Invoke the module's `stop` reducer, if it has one. See [`Self::call_module_stop_inner`].
pub async fn call_module_stop(&self) -> Result<(), NoSuchModule> {
call_instance!(
self,
"call_module_stop",
(),
|_, inst| inst.call_module_stop(),
|_, inst| inst.call_module_stop().await,
)
}

/// Empty the system tables tracking clients without running any lifecycle reducers.
pub async fn clear_all_clients(&self) -> anyhow::Result<()> {
call_instance!(
Expand Down
16 changes: 16 additions & 0 deletions crates/core/src/host/v8/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,10 @@ impl JsMainInstance {
self.request(DisconnectClientRequest { client_id }).await
}

pub async fn call_module_stop(&self) {
self.request(CallModuleStopRequest).await
}

pub async fn init_database(&self, program: Program) -> anyhow::Result<InitDatabaseResult> {
self.request(InitDatabaseRequest { program }).await
}
Expand Down Expand Up @@ -659,6 +663,10 @@ js_main_request! {
} => "disconnect_client", Result<(), ReducerCallError>, DisconnectClient
}

js_main_request! {
CallModuleStopRequest => "call_module_stop", (), CallModuleStop
}

js_main_request! {
InitDatabaseRequest {
program: Program,
Expand Down Expand Up @@ -860,6 +868,8 @@ enum JsMainWorkerRequest {
reply_tx: JsReplyTx<Result<(), ReducerCallError>>,
client_id: ClientActorId,
},
/// See [`JsMainInstance::call_module_stop`].
CallModuleStop(JsReplyTx<()>),
/// See [`JsMainInstance::init_database`].
InitDatabase {
reply_tx: JsReplyTx<anyhow::Result<InitDatabaseResult>>,
Expand Down Expand Up @@ -1487,6 +1497,12 @@ fn handle_main_worker_request(
(res, trapped)
})
}
JsMainWorkerRequest::CallModuleStop(reply_tx) => handle_worker_request("call_module_stop", reply_tx, || {
let call_reducer = |tx, params| instance_common.call_reducer_with_tx(tx, params, inst);
let mut trapped = false;
ModuleHost::call_module_stop_inner(&info, call_reducer, &mut trapped);
((), trapped)
}),
JsMainWorkerRequest::InitDatabase { reply_tx, program } => {
handle_worker_request("init_database", reply_tx, || {
let call_reducer = |tx, params| instance_common.call_reducer_with_tx_offset(tx, params, inst);
Expand Down
8 changes: 8 additions & 0 deletions crates/core/src/host/wasm_common/module_host_actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,14 @@ impl<T: WasmInstance> WasmModuleInstance<T> {
res
}

pub fn call_module_stop(&mut self) {
let module = &self.common.info.clone();
let call_reducer = |tx, params| self.call_reducer_with_tx(tx, params);
let mut trapped = false;
ModuleHost::call_module_stop_inner(module, call_reducer, &mut trapped);
self.trapped = trapped;
}

pub fn disconnect_client(&mut self, client_id: ClientActorId) -> Result<(), ReducerCallError> {
let module = &self.common.info.clone();
let call_reducer = |tx, params| self.call_reducer_with_tx(tx, params);
Expand Down
3 changes: 3 additions & 0 deletions crates/lib/src/db/raw_def/v9.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,9 @@ pub enum Lifecycle {
OnConnect,
/// The reducer will be invoked when a client disconnects.
OnDisconnect,
/// The reducer will be invoked once, immediately before the database is
/// permanently deleted (e.g. via `spacetime delete` or a database reset).
Stop,
}

/// A procedure definition.
Expand Down
17 changes: 17 additions & 0 deletions crates/schema/src/def/validate/v10.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,7 @@ mod tests {
builder.add_lifecycle_reducer(Lifecycle::Init, "init", ProductType::unit());
builder.add_lifecycle_reducer(Lifecycle::OnConnect, "on_connect", ProductType::unit());
builder.add_lifecycle_reducer(Lifecycle::OnDisconnect, "on_disconnect", ProductType::unit());
builder.add_lifecycle_reducer(Lifecycle::Stop, "stop", ProductType::unit());
builder.add_reducer("extra_reducer", ProductType::from([("a", AlgebraicType::U64)]));
builder.add_reducer(
"check_deliveries",
Expand Down Expand Up @@ -1363,6 +1364,10 @@ mod tests {
Some(Lifecycle::OnDisconnect)
);

let stop_name = expect_identifier("stop");
assert_eq!(&*def.reducers[&stop_name].name, &*stop_name);
assert_eq!(def.reducers[&stop_name].lifecycle, Some(Lifecycle::Stop));

let extra_reducer_name = expect_identifier("extra_reducer");
assert_eq!(&*def.reducers[&extra_reducer_name].name, &*extra_reducer_name);
assert_eq!(def.reducers[&extra_reducer_name].lifecycle, None);
Expand Down Expand Up @@ -1762,6 +1767,18 @@ mod tests {
});
}

#[test]
fn duplicate_stop_lifecycle() {
let mut builder = RawModuleDefV10Builder::new();
builder.add_lifecycle_reducer(Lifecycle::Stop, "stop1", ProductType::unit());
builder.add_lifecycle_reducer(Lifecycle::Stop, "stop2", ProductType::unit());
let result: Result<ModuleDef> = builder.finish().try_into();

expect_error_matching!(result, ValidationError::DuplicateLifecycle { lifecycle } => {
lifecycle == &Lifecycle::Stop
});
}

#[test]
fn missing_scheduled_reducer() {
let mut builder = RawModuleDefV10Builder::new();
Expand Down
17 changes: 17 additions & 0 deletions crates/schema/src/def/validate/v9.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1741,6 +1741,7 @@ mod tests {
builder.add_reducer("init", ProductType::unit(), Some(Lifecycle::Init));
builder.add_reducer("on_connect", ProductType::unit(), Some(Lifecycle::OnConnect));
builder.add_reducer("on_disconnect", ProductType::unit(), Some(Lifecycle::OnDisconnect));
builder.add_reducer("stop", ProductType::unit(), Some(Lifecycle::Stop));
builder.add_reducer("extra_reducer", ProductType::from([("a", AlgebraicType::U64)]), None);
builder.add_reducer(
"check_deliveries",
Expand Down Expand Up @@ -1909,6 +1910,10 @@ mod tests {
Some(Lifecycle::OnDisconnect)
);

let stop_name = expect_identifier("stop");
assert_eq!(&*def.reducers[&stop_name].name, &*stop_name);
assert_eq!(def.reducers[&stop_name].lifecycle, Some(Lifecycle::Stop));

let extra_reducer_name = expect_identifier("extra_reducer");
assert_eq!(&*def.reducers[&extra_reducer_name].name, &*extra_reducer_name);
assert_eq!(def.reducers[&extra_reducer_name].lifecycle, None);
Expand Down Expand Up @@ -2298,6 +2303,18 @@ mod tests {
});
}

#[test]
fn duplicate_stop_lifecycle() {
let mut builder = RawModuleDefV9Builder::new();
builder.add_reducer("stop1", ProductType::unit(), Some(Lifecycle::Stop));
builder.add_reducer("stop2", ProductType::unit(), Some(Lifecycle::Stop));
let result: Result<ModuleDef> = builder.finish().try_into();

expect_error_matching!(result, ValidationError::DuplicateLifecycle { lifecycle } => {
lifecycle == &Lifecycle::Stop
});
}

#[test]
fn missing_scheduled_reducer() {
let mut builder = RawModuleDefV9Builder::new();
Expand Down
Loading