diff --git a/Cargo.lock b/Cargo.lock
index 4e224012f03..8d40d1d2779 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4247,6 +4247,14 @@ dependencies = [
"spacetimedb",
]
+[[package]]
+name = "module-test-stop"
+version = "0.0.0"
+dependencies = [
+ "log",
+ "spacetimedb",
+]
+
[[package]]
name = "multer"
version = "3.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index ba871cdd453..a3114714cac 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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",
diff --git a/crates/bindings-csharp/Codegen/Module.cs b/crates/bindings-csharp/Codegen/Module.cs
index f130a3efb05..2b55dc6b7eb 100644
--- a/crates/bindings-csharp/Codegen/Module.cs
+++ b/crates/bindings-csharp/Codegen/Module.cs
@@ -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"
}}};
diff --git a/crates/bindings-csharp/Runtime/Attrs.cs b/crates/bindings-csharp/Runtime/Attrs.cs
index 38b3c45edd9..6a543bb9c31 100644
--- a/crates/bindings-csharp/Runtime/Attrs.cs
+++ b/crates/bindings-csharp/Runtime/Attrs.cs
@@ -193,6 +193,14 @@ public enum ReducerKind
Init,
ClientConnected,
ClientDisconnected,
+
+ ///
+ /// 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.
+ ///
+ Stop,
}
[AttributeUsage(AttributeTargets.Method, Inherited = false)]
diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/Lifecycle.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/Lifecycle.g.cs
index de0e7ea2206..e8bdaab526b 100644
--- a/crates/bindings-csharp/Runtime/Internal/Autogen/Lifecycle.g.cs
+++ b/crates/bindings-csharp/Runtime/Internal/Autogen/Lifecycle.g.cs
@@ -13,5 +13,6 @@ public enum Lifecycle
Init,
OnConnect,
OnDisconnect,
+ Stop,
}
}
diff --git a/crates/bindings-macro/src/lib.rs b/crates/bindings-macro/src/lib.rs
index a3efdd3d2f4..1910d2d3307 100644
--- a/crates/bindings-macro/src/lib.rs
+++ b/crates/bindings-macro/src/lib.rs
@@ -147,6 +147,7 @@ mod sym {
symbol!(repr);
symbol!(sats);
symbol!(scheduled);
+ symbol!(stop);
symbol!(unique);
symbol!(update);
symbol!(default);
diff --git a/crates/bindings-macro/src/reducer.rs b/crates/bindings-macro/src/reducer.rs
index ac261ced35f..e03a31e98e5 100644
--- a/crates/bindings-macro/src/reducer.rs
+++ b/crates/bindings-macro/src/reducer.rs
@@ -17,16 +17,21 @@ enum LifecycleReducer {
ClientConnected(Span),
ClientDisconnected(Span),
Update(Span),
+ Stop(Span),
}
impl LifecycleReducer {
fn to_lifecycle_value(&self) -> Option {
- 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))
@@ -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()?);
diff --git a/crates/bindings-typescript/src/lib/autogen/types.ts b/crates/bindings-typescript/src/lib/autogen/types.ts
index 2c2b00143ff..f51bc0904fd 100644
--- a/crates/bindings-typescript/src/lib/autogen/types.ts
+++ b/crates/bindings-typescript/src/lib/autogen/types.ts
@@ -155,6 +155,7 @@ export const Lifecycle = __t.enum('Lifecycle', {
Init: __t.unit(),
OnConnect: __t.unit(),
OnDisconnect: __t.unit(),
+ Stop: __t.unit(),
});
export type Lifecycle = __Infer;
diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts
index 384e7ebcd0c..7cbecd778d9 100644
--- a/crates/bindings-typescript/src/server/schema.ts
+++ b/crates/bindings-typescript/src/server/schema.ts
@@ -393,6 +393,38 @@ export class Schema 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} fn - The stop reducer function.
+ * @example
+ * ```typescript
+ * export const stop = spacetime.stop((ctx) => {
+ * console.log('Database is being deleted');
+ * });
+ * ```
+ */
+ stop(fn: Reducer): ReducerExport;
+ stop(opts: ReducerOpts, fn: Reducer): ReducerExport;
+ stop(
+ ...args: [Reducer] | [ReducerOpts, Reducer]
+ ): ReducerExport {
+ let opts: ReducerOpts | undefined, fn: Reducer;
+ switch (args.length) {
+ case 1:
+ [fn] = args;
+ break;
+ case 2:
+ [opts, fn] = args;
+ break;
+ }
+ return makeReducerExport(this.#ctx, opts, {}, fn, Lifecycle.Stop);
+ }
+
view>(
opts: ViewOpts,
ret: Ret,
diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs
index 0d375719829..823619a9a15 100644
--- a/crates/bindings/src/lib.rs
+++ b/crates/bindings/src/lib.rs
@@ -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
///
diff --git a/crates/core/src/host/host_controller.rs b/crates/core/src/host/host_controller.rs
index dc34677b4a1..0722d9ae692 100644
--- a/crates/core/src/host/host_controller.rs
+++ b/crates/core/src/host/host_controller.rs
@@ -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(());
};
@@ -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");
diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs
index ec65b7e9dd4..9f6c0061f60 100644
--- a/crates/core/src/host/module_host.rs
+++ b/crates/core/src/host/module_host.rs
@@ -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, 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!(
diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs
index 52abf373e48..900968a2818 100644
--- a/crates/core/src/host/v8/mod.rs
+++ b/crates/core/src/host/v8/mod.rs
@@ -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 {
self.request(InitDatabaseRequest { program }).await
}
@@ -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,
@@ -860,6 +868,8 @@ enum JsMainWorkerRequest {
reply_tx: JsReplyTx>,
client_id: ClientActorId,
},
+ /// See [`JsMainInstance::call_module_stop`].
+ CallModuleStop(JsReplyTx<()>),
/// See [`JsMainInstance::init_database`].
InitDatabase {
reply_tx: JsReplyTx>,
@@ -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);
diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs
index 39aa30c3c78..5841c38b69e 100644
--- a/crates/core/src/host/wasm_common/module_host_actor.rs
+++ b/crates/core/src/host/wasm_common/module_host_actor.rs
@@ -534,6 +534,14 @@ impl WasmModuleInstance {
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);
diff --git a/crates/lib/src/db/raw_def/v9.rs b/crates/lib/src/db/raw_def/v9.rs
index ba5361c6cdb..c2e66fbcbf2 100644
--- a/crates/lib/src/db/raw_def/v9.rs
+++ b/crates/lib/src/db/raw_def/v9.rs
@@ -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.
diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs
index aa398743f97..e4bae579a35 100644
--- a/crates/schema/src/def/validate/v10.rs
+++ b/crates/schema/src/def/validate/v10.rs
@@ -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",
@@ -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);
@@ -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 = builder.finish().try_into();
+
+ expect_error_matching!(result, ValidationError::DuplicateLifecycle { lifecycle } => {
+ lifecycle == &Lifecycle::Stop
+ });
+ }
+
#[test]
fn missing_scheduled_reducer() {
let mut builder = RawModuleDefV10Builder::new();
diff --git a/crates/schema/src/def/validate/v9.rs b/crates/schema/src/def/validate/v9.rs
index 618f8e3c9c4..3d149b1e6c4 100644
--- a/crates/schema/src/def/validate/v9.rs
+++ b/crates/schema/src/def/validate/v9.rs
@@ -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",
@@ -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);
@@ -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 = builder.finish().try_into();
+
+ expect_error_matching!(result, ValidationError::DuplicateLifecycle { lifecycle } => {
+ lifecycle == &Lifecycle::Stop
+ });
+ }
+
#[test]
fn missing_scheduled_reducer() {
let mut builder = RawModuleDefV9Builder::new();
diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs
index 541dd5b6a39..3cab24117ab 100644
--- a/crates/standalone/src/lib.rs
+++ b/crates/standalone/src/lib.rs
@@ -327,8 +327,10 @@ impl spacetimedb_client_api::ControlStateWriteAccess for StandaloneEnv {
let desired_replicas = num_replicas as usize;
if desired_replicas == 0 {
log::info!("Decommissioning all replicas of database {database_identity}");
+ // The database itself isn't being deleted, it's paused with zero
+ // replicas and can be scaled back up later, so `stop` must not fire.
for instance in replicas {
- self.delete_replica(instance.id).await?;
+ self.delete_replica(instance.id, false).await?;
}
} else if desired_replicas > replicas.len() {
let n = desired_replicas - replicas.len();
@@ -356,7 +358,7 @@ impl spacetimedb_client_api::ControlStateWriteAccess for StandaloneEnv {
n
);
for instance in replicas.into_iter().filter(|instance| !instance.leader).take(n) {
- self.delete_replica(instance.id).await?;
+ self.delete_replica(instance.id, false).await?;
}
} else {
log::debug!(
@@ -403,8 +405,10 @@ impl spacetimedb_client_api::ControlStateWriteAccess for StandaloneEnv {
};
self.control_db.delete_database(database.id)?;
+ // The database is being permanently destroyed: run each replica's `stop`
+ // reducer, if any, before it goes away.
for instance in self.control_db.get_replicas_by_database(database.id)? {
- self.delete_replica(instance.id).await?;
+ self.delete_replica(instance.id, true).await?;
}
Ok(())
@@ -436,8 +440,10 @@ impl spacetimedb_client_api::ControlStateWriteAccess for StandaloneEnv {
self.control_db.update_database(database)?;
}
+ // The database's data is being wiped and reinitialized from scratch: run
+ // each replica's `stop` reducer, if any, before it goes away.
for instance in self.control_db.get_replicas_by_database(database_id)? {
- self.delete_replica(instance.id).await?;
+ self.delete_replica(instance.id, true).await?;
}
// Standalone only support a single replica.
let num_replicas = 1;
@@ -559,9 +565,10 @@ impl StandaloneEnv {
Ok(())
}
- async fn delete_replica(&self, replica_id: u64) -> Result<(), anyhow::Error> {
+ /// Delete a single replica.
+ async fn delete_replica(&self, replica_id: u64, is_final_teardown: bool) -> Result<(), anyhow::Error> {
self.control_db.delete_replica(replica_id)?;
- self.on_delete_replica(replica_id).await?;
+ self.on_delete_replica(replica_id, is_final_teardown).await?;
Ok(())
}
@@ -598,13 +605,13 @@ impl StandaloneEnv {
Ok(())
}
- async fn on_delete_replica(&self, replica_id: u64) -> anyhow::Result<()> {
+ async fn on_delete_replica(&self, replica_id: u64, is_final_teardown: bool) -> anyhow::Result<()> {
// TODO(cloutiertyler): We should think about how to clean up
// replicas which have been deleted. This will just drop
// them from memory, but will not remove them from disk. We need
// some kind of database lifecycle manager long term.
self.host_controller
- .exit_module_host(replica_id, Duration::from_secs(30))
+ .exit_module_host(replica_id, Duration::from_secs(30), is_final_teardown)
.await?;
Ok(())
diff --git a/crates/testing/src/modules.rs b/crates/testing/src/modules.rs
index b03b87df9b5..4fc804ada83 100644
--- a/crates/testing/src/modules.rs
+++ b/crates/testing/src/modules.rs
@@ -166,6 +166,33 @@ impl ModuleHandle {
.expect("failed to collect log stream");
String::from_utf8(bytes.into()).unwrap()
}
+
+ /// Permanently delete this module's database - running its `stop` reducer,
+ /// if it has one - then return the module's log tail.
+ pub async fn delete_and_read_log(&self, size: Option) -> anyhow::Result {
+ self.env.delete_database(&Identity::ZERO, &self.db_identity).await?;
+ Ok(self.read_log(size).await)
+ }
+
+ /// Publish new program bytes to this module's existing database identity,
+ /// exercising an ordinary hot-reload/update rather than a fresh publish.
+ pub async fn update(&self, program_bytes: Bytes, host_type: HostType) -> anyhow::Result<()> {
+ self.env
+ .publish_database(
+ &Identity::ZERO,
+ DatabaseDef {
+ database_identity: self.db_identity,
+ program_bytes,
+ num_replicas: None,
+ host_type,
+ parent: None,
+ organization: None,
+ },
+ MigrationPolicy::Compatible,
+ )
+ .await
+ .map(drop)
+ }
}
pub struct CompiledModule {
@@ -202,6 +229,10 @@ impl CompiledModule {
&self.path
}
+ pub fn host_type(&self) -> HostType {
+ self.host_type
+ }
+
pub fn program_bytes(&self) -> Bytes {
self.program_bytes
.get_or_init(|| std::fs::read(&self.path).unwrap().into())
diff --git a/crates/testing/tests/standalone_integration_test.rs b/crates/testing/tests/standalone_integration_test.rs
index 8de5cfe5bc4..3ebb1611a81 100644
--- a/crates/testing/tests/standalone_integration_test.rs
+++ b/crates/testing/tests/standalone_integration_test.rs
@@ -136,6 +136,48 @@ fn test_calling_a_reducer_with_private_table() {
);
}
+#[test]
+#[serial]
+fn test_stop_reducer_fires_only_on_deletion() {
+ init();
+
+ let compiled = CompiledModule::compile("module-test-stop", CompilationMode::Debug);
+ let program_bytes = compiled.program_bytes();
+ let host_type = compiled.host_type();
+
+ compiled.with_module_async(DEFAULT_CONFIG, move |module| async move {
+ module.call_reducer_binary("ping", &product![]).await.unwrap();
+
+ let log_before_update = module.read_log(None).await;
+ assert!(
+ log_before_update.contains("PING"),
+ "expected `ping` to have logged: {log_before_update}"
+ );
+ assert!(
+ !log_before_update.contains("STOP"),
+ "`stop` must not fire before deletion: {log_before_update}"
+ );
+
+ module.update(program_bytes, host_type).await.unwrap();
+ let log_after_update = module.read_log(None).await;
+ assert!(
+ !log_after_update.contains("STOP"),
+ "`stop` must not fire on an ordinary module update: {log_after_update}"
+ );
+
+ let log_after_delete = module.delete_and_read_log(None).await.unwrap();
+ assert_eq!(
+ log_after_delete.matches("STOP").count(),
+ 1,
+ "`stop` must fire exactly once, on deletion: {log_after_delete}"
+ );
+ assert!(
+ log_after_delete.find("PING").unwrap() < log_after_delete.find("STOP").unwrap(),
+ "`stop` must fire after prior reducer activity, immediately before teardown: {log_after_delete}"
+ );
+ });
+}
+
/// Returns `true` if `line` was produced by the `repeating_test` or `nonrepeating_test` scheduled reducers.
fn is_scheduled_test_log(line: &str) -> bool {
line.starts_with("Timestamp: ") || line.starts_with("This reducers runs only once")
diff --git a/modules/module-test-stop/Cargo.toml b/modules/module-test-stop/Cargo.toml
new file mode 100644
index 00000000000..19e69bc2c7c
--- /dev/null
+++ b/modules/module-test-stop/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "module-test-stop"
+version = "0.0.0"
+edition.workspace = true
+license-file = "LICENSE"
+
+[lib]
+crate-type = ["cdylib"]
+bench = false
+
+[dependencies]
+spacetimedb = { path = "../../crates/bindings" }
+
+log.workspace = true
diff --git a/modules/module-test-stop/LICENSE b/modules/module-test-stop/LICENSE
new file mode 120000
index 00000000000..8540cf8a991
--- /dev/null
+++ b/modules/module-test-stop/LICENSE
@@ -0,0 +1 @@
+../../licenses/BSL.txt
\ No newline at end of file
diff --git a/modules/module-test-stop/src/lib.rs b/modules/module-test-stop/src/lib.rs
new file mode 100644
index 00000000000..944707a8652
--- /dev/null
+++ b/modules/module-test-stop/src/lib.rs
@@ -0,0 +1,18 @@
+use spacetimedb::ReducerContext;
+
+// Minimal fixture module exercising the `stop` lifecycle reducer.
+
+#[spacetimedb::reducer(init)]
+pub fn init(_ctx: &ReducerContext) {
+ log::info!("INIT");
+}
+
+#[spacetimedb::reducer(stop)]
+pub fn stop(_ctx: &ReducerContext) {
+ log::info!("STOP");
+}
+
+#[spacetimedb::reducer]
+pub fn ping(_ctx: &ReducerContext) {
+ log::info!("PING");
+}