Implement room purge after last local user leaves. (resolves #83)

Consume a state_lock for room delete call.

Signed-off-by: Jason Volk <jason@zemos.net>
This commit is contained in:
Jason Volk
2025-08-30 04:40:26 +00:00
parent 7c19d1e1ff
commit aa413ae601
5 changed files with 121 additions and 35 deletions

View File

@@ -72,7 +72,12 @@ pub(super) async fn delete_room(&self, room_id: OwnedRoomId) -> Result {
return Err!("Cannot delete admin room");
}
self.services.delete.delete_room(room_id).await?;
let state_lock = self.services.state.mutex.lock(&room_id).await;
self.services
.delete
.delete_room(&room_id, state_lock)
.await?;
self.write_str("Successfully deleted the room from our database.")
.await?;

View File

@@ -14,17 +14,21 @@ pub(crate) async fn leave_room_route(
State(services): State<crate::State>,
body: Ruma<leave_room::v3::Request>,
) -> Result<leave_room::v3::Response> {
let room_id = &body.room_id;
let state_lock = services.state.mutex.lock(room_id).await;
let state_lock = services.state.mutex.lock(&body.room_id).await;
services
.membership
.leave(body.sender_user(), room_id, body.reason.clone(), &state_lock)
.leave(body.sender_user(), &body.room_id, body.reason.clone(), &state_lock)
.boxed()
.await?;
drop(state_lock);
if services.config.delete_rooms_after_leave {
services
.delete
.delete_if_empty_local(&body.room_id, state_lock)
.boxed()
.await;
}
Ok(leave_room::v3::Response {})
}

View File

@@ -1929,6 +1929,19 @@ pub struct Config {
#[serde(default)]
pub hydra_backports: bool,
/// Delete rooms when the last user from this server leaves. This feature is
/// experimental and for the purpose of least-surprise is not enabled by
/// default but can be enabled for deployments interested in conserving
/// space. It may eventually default to true in a future release.
///
/// Note that not all pathways which can remove the last local user
/// currently invoke this operation, so in some cases you may find the room
/// still exists.
///
/// default: false
#[serde(default)]
pub delete_rooms_after_leave: bool,
// external structure; separate section
#[serde(default)]
pub blurhashing: BlurhashConfig,

View File

@@ -1,8 +1,17 @@
use std::sync::Arc;
use std::{sync::Arc, time::Duration};
use futures::StreamExt;
use ruma::OwnedRoomId;
use tuwunel_core::{Result, debug, result::LogErr, utils::ReadyExt, warn};
use futures::{FutureExt, StreamExt, pin_mut};
use ruma::RoomId;
use tokio::time::sleep;
use tuwunel_core::{
Result, debug,
result::LogErr,
trace,
utils::{ReadyExt, future::BoolExt},
warn,
};
use crate::rooms::timeline::RoomMutexGuard;
pub struct Service {
services: Arc<crate::services::OnceServices>,
@@ -17,17 +26,63 @@ impl crate::Service for Service {
}
impl Service {
pub async fn delete_room(&self, room_id: OwnedRoomId) -> Result {
pub async fn delete_if_empty_local(&self, room_id: &RoomId, state_lock: RoomMutexGuard) {
debug_assert!(
self.services.config.delete_rooms_after_leave,
"Caller must checking if delete_rooms_after_leave configured."
);
let has_local_users = self
.services
.state_cache
.local_users_in_room(room_id)
.into_future()
.map(|(next, ..)| next.as_ref().is_some());
let has_local_invites = self
.services
.state_cache
.local_users_invited_to_room(room_id)
.into_future()
.map(|(next, ..)| next.as_ref().is_some());
pin_mut!(has_local_users, has_local_invites);
if has_local_users.or(has_local_invites).await {
trace!(?room_id, "Not deleting with local joined or invited");
return;
}
debug!(?room_id, "Preparing to delete room...");
// Some arbitrary delay has to account for the leave event being synced to the
// client or they'll never be updated on their leave. This can be removed once
// a tombstone solution is implemented instead.
sleep(Duration::from_millis(2500)).await;
self.services
.delete
.delete_room(room_id, state_lock)
.boxed()
.await
.expect("unhandled error during room deletion");
}
pub async fn delete_room(&self, room_id: &RoomId, state_lock: RoomMutexGuard) -> Result {
// ban the room locally so new users cannot join while we're in the process of
// deleting it
debug!("Banning room {}", &room_id);
self.services.metadata.ban_room(&room_id);
debug!("Banning room {room_id} prior to deletion.");
self.services.metadata.ban_room(room_id);
// This might have to be dropped here to prevent deadlock, but the goal should
// be to hold it all the way through. For now the room is banned under lock at
// least.
drop(state_lock);
debug!("Making all users leave the room {room_id} and forgetting it");
let mut users = self
.services
.state_cache
.room_members(&room_id)
.room_members(room_id)
.ready_filter(|user| self.services.globals.user_is_local(user))
.boxed();
@@ -40,24 +95,22 @@ impl Service {
if let Err(e) = self
.services
.membership
.remote_leave(user_id, &room_id)
.remote_leave(user_id, room_id)
.await
{
warn!("Failed to leave room: {e}");
}
self.services
.state_cache
.forget(&room_id, user_id);
self.services.state_cache.forget(room_id, user_id);
}
debug!("Disabling incoming federation on room {}", &room_id);
self.services.metadata.disable_room(&room_id);
debug!("Disabling incoming federation on room {room_id}");
self.services.metadata.disable_room(room_id);
debug!("Deleting all our room aliases for the room");
self.services
.alias
.local_aliases_for_room(&room_id)
.local_aliases_for_room(room_id)
.for_each(async |local_alias| {
self.services
.alias
@@ -69,12 +122,12 @@ impl Service {
.await;
debug!("Removing/unpublishing room from our room directory");
self.services.directory.set_not_public(&room_id);
self.services.directory.set_not_public(room_id);
debug!("Deleting room's threads from database");
self.services
.threads
.delete_all_rooms_threads(&room_id)
.delete_all_rooms_threads(room_id)
.await
.log_err()
.ok();
@@ -82,7 +135,7 @@ impl Service {
debug!("Deleting all the room's search token IDs from our database");
self.services
.search
.delete_all_search_tokenids_for_room(&room_id)
.delete_all_search_tokenids_for_room(room_id)
.await
.log_err()
.ok();
@@ -90,7 +143,7 @@ impl Service {
debug!("Deleting all room's forward extremities from our database");
self.services
.state
.delete_all_rooms_forward_extremities(&room_id)
.delete_all_rooms_forward_extremities(room_id)
.await
.log_err()
.ok();
@@ -98,7 +151,7 @@ impl Service {
debug!("Deleting all the room's event (PDU) references");
self.services
.pdu_metadata
.delete_all_referenced_for_room(&room_id)
.delete_all_referenced_for_room(room_id)
.await
.log_err()
.ok();
@@ -106,7 +159,7 @@ impl Service {
debug!("Deleting all the room's member counts");
self.services
.state_cache
.delete_room_join_counts(&room_id)
.delete_room_join_counts(room_id)
.await
.log_err()
.ok();
@@ -114,7 +167,7 @@ impl Service {
debug!("Deleting all the room's private read receipts");
self.services
.read_receipt
.delete_all_read_receipts(&room_id)
.delete_all_read_receipts(room_id)
.await
.log_err()
.ok();
@@ -122,12 +175,12 @@ impl Service {
debug!("Final stages of deleting the room");
debug!("Obtaining a mutex state lock for safety and future database operations");
let state_lock = self.services.state.mutex.lock(&room_id).await;
let state_lock = self.services.state.mutex.lock(room_id).await;
debug!("Deleting room state hash from our database");
self.services
.state
.delete_room_shortstatehash(&room_id, &state_lock)
.delete_room_shortstatehash(room_id, &state_lock)
.await
.log_err()
.ok();
@@ -135,7 +188,7 @@ impl Service {
debug!("Deleting PDUs");
self.services
.timeline
.delete_pdus(&room_id)
.delete_pdus(room_id)
.await
.log_err()
.ok();
@@ -143,18 +196,18 @@ impl Service {
debug!("Deleting internal room ID from our database");
self.services
.short
.delete_shortroomid(&room_id)
.delete_shortroomid(room_id)
.await
.log_err()
.ok();
// TODO: add option to keep a room banned (`--block` or `--ban`)
self.services.metadata.enable_room(&room_id);
self.services.metadata.unban_room(&room_id);
self.services.metadata.enable_room(room_id);
self.services.metadata.unban_room(room_id);
drop(state_lock);
debug!("Successfully deleted room {} from our database", &room_id);
debug!("Successfully deleted room {room_id} from our database");
Ok(())
}
}

View File

@@ -1657,6 +1657,17 @@
#
#hydra_backports = false
# Delete rooms when the last user from this server leaves. This feature is
# experimental and for the purpose of least-surprise is not enabled by
# default but can be enabled for deployments interested in conserving
# space. It may eventually default to true in a future release.
#
# Note that not all pathways which can remove the last local user
# currently invoke this operation, so in some cases you may find the room
# still exists.
#
#delete_rooms_after_leave = false
#[global.tls]
# Path to a valid TLS certificate file.