diff --git a/crates/rustmail-api/src/handlers.rs b/crates/rustmail-api/src/handlers.rs index 51b17a6..5906382 100644 --- a/crates/rustmail-api/src/handlers.rs +++ b/crates/rustmail-api/src/handlers.rs @@ -229,6 +229,7 @@ pub async fn delete_all_messages( ) -> Result { let count = state.repo.delete_all().await?; state.broadcast(WsEvent::MessagesClear); + state.repo.reclaim_after_delete_all().await; Ok(Json(serde_json::json!({ "deleted": count }))) } diff --git a/crates/rustmail-storage/src/reclaim.rs b/crates/rustmail-storage/src/reclaim.rs index 219d856..ef3007a 100644 --- a/crates/rustmail-storage/src/reclaim.rs +++ b/crates/rustmail-storage/src/reclaim.rs @@ -328,6 +328,17 @@ mod tests { assert!(stats(file_pages, 257 * MIB / PAGE).warrants_retention_reclaim()); } + #[tokio::test] + async fn delete_all_returns_before_it_reclaims_so_the_caller_can_announce_it_first() { + let file = file_repo("delete-all-then-reclaim").await; + fill(&file.repo, (0..SMALL_MESSAGES).map(small_message).collect()).await; + + file.repo.delete_all().await.unwrap(); + + assert_eq!(file.repo.count().await.unwrap(), 0); + assert!(page_stats(&file.writer).await.unwrap().freelist_count > 0); + } + #[tokio::test] async fn delete_all_leaves_no_free_pages_and_a_small_file() { let file = file_repo("delete-all").await; @@ -339,6 +350,7 @@ mod tests { ); let deleted = file.repo.delete_all().await.unwrap(); + file.repo.reclaim_after_delete_all().await; assert_eq!(deleted, SMALL_MESSAGES as u64); assert_eq!(page_stats(&file.writer).await.unwrap().freelist_count, 0); @@ -435,6 +447,7 @@ mod tests { let populated = page_stats(&pool).await.unwrap(); repo.delete_all().await.unwrap(); + repo.reclaim_after_delete_all().await; let emptied = page_stats(&pool).await.unwrap(); assert_eq!(emptied.freelist_count, 0); diff --git a/crates/rustmail-storage/src/repo.rs b/crates/rustmail-storage/src/repo.rs index 89c21bb..c38d6de 100644 --- a/crates/rustmail-storage/src/repo.rs +++ b/crates/rustmail-storage/src/repo.rs @@ -434,9 +434,8 @@ impl MessageRepository { Ok(()) } - /// Deletes all messages and clears the FTS5 index atomically, then gives - /// the freed pages back to the filesystem. Returns the count of deleted - /// messages. + /// Deletes all messages and clears the FTS5 index atomically. Returns the + /// count of deleted messages. /// /// Uses FTS5's `delete-all` command rather than `DELETE FROM messages_fts`. /// An external-content index reads the content row to work out which tokens @@ -446,11 +445,20 @@ impl MessageRepository { /// dropping its pages whole, so removing `messages` last has no cascade left /// to walk. /// - /// The reclaim runs after the commit and before this returns. The messages - /// are gone once the commit lands, so a failed reclaim is logged rather than - /// reported: the pages stay on the freelist for new mail to reuse. + /// The freed pages stay on the freelist until + /// [`reclaim_after_delete_all`](Self::reclaim_after_delete_all) gives them + /// back to the filesystem. pub async fn delete_all(&self) -> Result { - let deleted = retry_on_lock(|| self.delete_all_once()).await?; + retry_on_lock(|| self.delete_all_once()).await + } + + /// Gives the pages a [`delete_all`](Self::delete_all) freed back to the + /// filesystem, within a time budget. + /// + /// Inserts keep committing between its steps. The messages are already gone, + /// so a failed reclaim is logged rather than reported: the pages stay on the + /// freelist for new mail to reuse. + pub async fn reclaim_after_delete_all(&self) { if let Err(failure) = reclaim( &self.writer, ReclaimTrigger::DeleteAll, @@ -466,7 +474,6 @@ impl MessageRepository { "failed to reclaim disk after deleting every message" ); } - Ok(deleted) } async fn delete_all_once(&self) -> Result {