From 198dfdcb0260d4506c514b8ef077b47fed4927a0 Mon Sep 17 00:00:00 2001 From: RITANKAR SAHA Date: Mon, 6 Jul 2026 21:17:12 +0530 Subject: [PATCH] feat: Add module lifecycle reducer --- Cargo.lock | 8 +++ Cargo.toml | 1 + crates/bindings-csharp/Codegen/Module.cs | 1 + crates/bindings-csharp/Runtime/Attrs.cs | 8 +++ .../Runtime/Internal/Autogen/Lifecycle.g.cs | 1 + crates/bindings-macro/src/lib.rs | 1 + crates/bindings-macro/src/reducer.rs | 10 +++- .../src/lib/autogen/types.ts | 1 + .../bindings-typescript/src/server/schema.ts | 32 +++++++++++ crates/bindings/src/lib.rs | 10 ++++ crates/core/src/host/host_controller.rs | 12 +++- crates/core/src/host/module_host.rs | 56 +++++++++++++++++++ crates/core/src/host/v8/mod.rs | 16 ++++++ .../src/host/wasm_common/module_host_actor.rs | 8 +++ crates/lib/src/db/raw_def/v9.rs | 3 + crates/schema/src/def/validate/v10.rs | 17 ++++++ crates/schema/src/def/validate/v9.rs | 17 ++++++ crates/standalone/src/lib.rs | 23 +++++--- crates/testing/src/modules.rs | 31 ++++++++++ .../tests/standalone_integration_test.rs | 42 ++++++++++++++ modules/module-test-stop/Cargo.toml | 14 +++++ modules/module-test-stop/LICENSE | 1 + modules/module-test-stop/src/lib.rs | 18 ++++++ 23 files changed, 320 insertions(+), 11 deletions(-) create mode 100644 modules/module-test-stop/Cargo.toml create mode 120000 modules/module-test-stop/LICENSE create mode 100644 modules/module-test-stop/src/lib.rs 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"); +}