From d5996f92094c6f289c661df6e692e3dbdae36aae Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 29 Oct 2026 09:15:19 -0700 Subject: [PATCH] Files: wake the task manager when a deletion queues its undelete task Deleting a file queues the task that archives it for undelete. Files aren't search-indexed, so the file paths had never queued a task before, and none of them woke the task manager after writing it. The task waited for the manager's next periodic look at the queue, up to five minutes, unless other work happened to wake it first. FileNode/set and FileNode/copy, and WebDAV DELETE, COPY and MOVE now wake it after committing a deletion, as the calendar, contact and mail paths already do. The system and jmap suites each sat out one of those waits on every run, and the system suite's occasional LMTP timeout came right after it: they now take about 2.5 and 3 minutes instead of 7.5 and 8. Test: a new webdav check deletes files over WebDAV (DELETE, COPY and MOVE over a file) and JMAP, and needs the queue empty within 10 s. On main the WebDAV DELETE task was still queued after 10 s; taking out the JMAP or MOVE change alone fails its own step; with the change all pass. --- crates/dav/src/file/copy_move.rs | 5 ++ crates/groupware/src/file/storage.rs | 4 ++ crates/jmap/src/file/copy.rs | 2 + crates/jmap/src/file/set.rs | 2 + tests/src/utils/storage.rs | 33 +++++++++ tests/src/webdav/file_tasks.rs | 103 +++++++++++++++++++++++++++ tests/src/webdav/mod.rs | 4 ++ 7 files changed, 153 insertions(+) create mode 100644 tests/src/webdav/file_tasks.rs diff --git a/crates/dav/src/file/copy_move.rs b/crates/dav/src/file/copy_move.rs index 520da2b0..2c296223 100644 --- a/crates/dav/src/file/copy_move.rs +++ b/crates/dav/src/file/copy_move.rs @@ -653,6 +653,8 @@ async fn overwrite_and_delete_item( .commit_batch(batch) .await .caused_by(trc::location!())?; + // inbuxa: the deleted source queued its undelete task + server.notify_task_queue(); Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_etag_opt(etag)) } @@ -807,6 +809,9 @@ async fn move_item( .commit_batch(batch) .await .caused_by(trc::location!())?; + // inbuxa: a move to another account deletes the source, which queued + // its undelete task + server.notify_task_queue(); Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) } diff --git a/crates/groupware/src/file/storage.rs b/crates/groupware/src/file/storage.rs index d139d058..b9181587 100644 --- a/crates/groupware/src/file/storage.rs +++ b/crates/groupware/src/file/storage.rs @@ -129,6 +129,10 @@ impl DestroyArchive> { .commit_batch(batch) .await .caused_by(trc::location!())?; + // inbuxa: deleted files queue the task that notes them for + // undelete; wake the task manager rather than leave the task to + // its next periodic look at the queue + server.notify_task_queue(); } Ok(()) diff --git a/crates/jmap/src/file/copy.rs b/crates/jmap/src/file/copy.rs index e73e5aa2..619a3b77 100644 --- a/crates/jmap/src/file/copy.rs +++ b/crates/jmap/src/file/copy.rs @@ -495,6 +495,8 @@ impl FileNodeCopy for Server { .await .and_then(|ids| ids.last_change_id(account_id)) .caused_by(trc::location!())?; + // inbuxa: replaced files queued their undelete tasks + self.notify_task_queue(); response.new_state = State::Exact(change_id); } diff --git a/crates/jmap/src/file/set.rs b/crates/jmap/src/file/set.rs index d280f660..394a3668 100644 --- a/crates/jmap/src/file/set.rs +++ b/crates/jmap/src/file/set.rs @@ -764,6 +764,8 @@ impl FileNodeSet for Server { .await .and_then(|ids| ids.last_change_id(account_id)) .caused_by(trc::location!())?; + // inbuxa: destroyed files queued their undelete tasks + self.notify_task_queue(); response.new_state = State::Exact(change_id).into(); } diff --git a/tests/src/utils/storage.rs b/tests/src/utils/storage.rs index 22038e00..796ffbde 100644 --- a/tests/src/utils/storage.rs +++ b/tests/src/utils/storage.rs @@ -341,6 +341,39 @@ pub async fn wait_for_tasks(server: &Server, skip_not_due: bool, skip_permanent_ } } +/// inbuxa: waits for the task queue to empty, giving up after `within`, and +/// returns the task still queued then. The task manager only looks at the +/// queue again when woken or every few minutes, so a write that queues a +/// task without waking it shows up here as a task that sits. +pub async fn task_left_after(server: &Server, within: std::time::Duration) -> Option { + let give_up_at = std::time::Instant::now() + within; + loop { + let mut pending = None; + server + .core + .storage + .data + .iterate( + IterateParams::new( + ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Task { id: 0 })), + ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Task { id: u64::MAX })), + ) + .ascending(), + |_, value| { + pending = Some(Task::deserialize(value)?); + Ok(false) + }, + ) + .await + .unwrap(); + + if pending.is_none() || std::time::Instant::now() > give_up_at { + return pending; + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } +} + pub async fn assert_is_empty(server: &Server, include_registry: bool) { // Wait for pending index tasks wait_for_tasks(server, false, false).await; diff --git a/tests/src/webdav/file_tasks.rs b/tests/src/webdav/file_tasks.rs new file mode 100644 index 00000000..7799540c --- /dev/null +++ b/tests/src/webdav/file_tasks.rs @@ -0,0 +1,103 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// inbuxa: deleting a file queues the task that archives it for undelete +// (UD-1). Each way of deleting one must wake the task manager, or that task +// sits until its next periodic look at the queue, minutes later. + +use crate::utils::{server::TestServer, storage::task_left_after}; +use hyper::StatusCode; +use serde_json::json; +use std::time::Duration; + +const BASE: &str = "/dav/file/john%40example.com"; + +pub async fn test(test: &TestServer) { + println!("Running file deletion task tests..."); + let account = test.account("john@example.com"); + let client = account.webdav_client(); + test.wait_for_tasks().await; + + // WebDAV DELETE + let path = format!("{BASE}/tasks-1.txt"); + put(test, &path).await; + client + .request("DELETE", &path, "") + .await + .with_status(StatusCode::NO_CONTENT); + assert_tasks_run(test, "WebDAV DELETE").await; + + // WebDAV COPY over an existing file deletes the file it replaces + let (from, to) = (format!("{BASE}/tasks-2.txt"), format!("{BASE}/tasks-3.txt")); + put(test, &from).await; + put(test, &to).await; + client + .request_with_headers("COPY", &from, [("destination", to.as_str())], "") + .await + .with_status(StatusCode::NO_CONTENT); + assert_tasks_run(test, "WebDAV COPY over a file").await; + + // WebDAV MOVE over an existing file deletes the source + client + .request_with_headers("MOVE", &from, [("destination", to.as_str())], "") + .await + .with_status(StatusCode::NO_CONTENT); + assert_tasks_run(test, "WebDAV MOVE over a file").await; + client + .request("DELETE", &to, "") + .await + .with_status(StatusCode::NO_CONTENT); + assert_tasks_run(test, "WebDAV DELETE").await; + + // JMAP FileNode/set destroy + let response = account + .jmap_method_calls(json!([ + ["Blob/upload", { + "accountId": account.id_string(), + "create": { "file": { "data": [{ "data:asText": "tasks" }] } } + }, "0"], + ["FileNode/set", { + "accountId": account.id_string(), + "create": { "i0": { "name": "tasks-4.txt", "parentId": null, "blobId": "#file" } } + }, "1"] + ])) + .await + .into_inner(); + let file_id = response + .pointer("/methodResponses/1/1/created/i0/id") + .and_then(|id| id.as_str()) + .unwrap_or_else(|| panic!("{response}")) + .to_string(); + let response = account + .jmap_method_calls(json!([ + ["FileNode/set", { + "accountId": account.id_string(), + "destroy": [&file_id] + }, "0"] + ])) + .await + .into_inner(); + assert_eq!( + response.pointer("/methodResponses/0/1/destroyed/0"), + Some(&json!(file_id)), + "{response}" + ); + assert_tasks_run(test, "JMAP FileNode/set destroy").await; +} + +async fn put(test: &TestServer, path: &str) { + test.account("john@example.com") + .webdav_client() + .request_with_headers("PUT", path, [("content-type", "text/plain")], "tasks") + .await + .with_status(StatusCode::CREATED); +} + +async fn assert_tasks_run(test: &TestServer, what: &str) { + if let Some(task) = task_left_after(&test.server, Duration::from_secs(10)).await { + panic!("{what}: a task was still queued after 10 s: {task:?}"); + } +} diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index c9f495e0..dfd0903a 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::utils::server::TestServerBuilder; @@ -32,6 +34,7 @@ pub mod cal_query; pub mod cal_scheduling; pub mod card_query; pub mod copy_move; +pub mod file_tasks; // inbuxa: file deletes wake the task manager pub mod lock; pub mod mkcol; pub mod multiget; @@ -183,6 +186,7 @@ pub async fn webdav_tests() { } basic::test(&test).await; put_get::test(&test).await; + file_tasks::test(&test).await; mkcol::test(&test).await; copy_move::test(&test, assisted_discovery).await; prop::test(&test, assisted_discovery).await; -- 2.54.0