This is an automated email from the ASF dual-hosted git repository.

yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new 3ba435c26e0 branch-4.1: [fix](be) Clean up spill directories during 
query teardown (#66458)
3ba435c26e0 is described below

commit 3ba435c26e061fd22fd80fb5f3d84db12b2b28f0
Author: Jerry Hu <[email protected]>
AuthorDate: Thu Aug 6 17:23:07 2026 +0800

    branch-4.1: [fix](be) Clean up spill directories during query teardown 
(#66458)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #66328
    
    Problem Summary:
    
    Backport the spill query-directory cleanup from #66328 to `branch-4.1`.
    
    This change:
    
    - records each spill data root before opening the first spill part;
    - deletes touched per-query spill directories during `QueryContext`
    teardown;
    - retains failed deletions and retries them from spill GC and shutdown
    paths;
    - preserves the no-spill fast path without scanning every configured
    spill root.
    
    The recursive CTE and `FragmentMgr` lifecycle changes from the original
    PR are intentionally excluded because the `branch-4.1` implementation
    diverges. Spill cleanup for query contexts retained by that lifecycle
    remains out of scope for this backport.
    
    ### Release note
    
    None
    
    ### Check List (For Author)
    
    - Test
        - [x] Regression test
            - ASAN FE/BE build with `./build.sh --be --fe` succeeded.
    - `doris-local-regression run -d spill_p0 -s
    test_spill_directory_cleanup` (1/1 passed, then rerun 1/1 passed).
    - The case confirms real spill writes and observes
    `spill_disk_has_spill_data` transition from `0` to `1` and back to `0`
    after query cleanup.
        - [x] Unit Test
            - ASAN `doris_be_test` target compiled and linked successfully.
    - `./run-be-ut.sh --run
    
--filter='SpillFileTest.GCCleansUpFiles:SpillFileTest.QueryContextDeletesEmptySpillDirectory:SpillFileTest.QueryContextCleansUpNestedSpillDirectory:SpillFileTest.QueryContextDeletesResidualSpillDirectory:SpillFileTest.QueryContextCleansUpAllTouchedSpillDirectories:SpillFileTest.QueryContextContinuesCleanupAfterRootFailure:SpillFileTest.QueryContextRetriesSpillDirectoryDeletionUntilSuccess:SpillFileTest.RetryPreservesDirectoryQueuedAfterPendingDrain:SpillFileTest.QueryContextSkipsClean
 [...]
    -j 64` (10/10 passed, then rerun 10/10 passed)
            - `build-support/check-format.sh` (passed)
            - `git diff --check` (passed)
        - [ ] Manual test (add detailed scripts or steps below)
        - [ ] No need to test or manual test. Explain why:
    - [ ] This is a refactor/code format and no logic has been changed.
            - [ ] Previous test can cover this change.
            - [ ] No code files have been changed.
            - [ ] Other reason
    
    - Behavior changed:
        - [ ] No.
    - [x] Yes. Touched spill query directories are removed during query
    teardown, and transient deletion failures are retained for retry.
    
    - Does this need documentation?
        - [x] No.
        - [ ] Yes.
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label
---
 be/src/exec/spill/spill_file.cpp                   |   4 +-
 be/src/exec/spill/spill_file_manager.cpp           |  85 ++++-
 be/src/exec/spill/spill_file_manager.h             |  25 +-
 be/src/exec/spill/spill_file_writer.cpp            |   3 +
 be/src/runtime/query_context.cpp                   |  11 +
 be/src/runtime/query_context.h                     |   9 +
 be/test/vec/spill/spill_file_test.cpp              | 406 ++++++++++++++++++++-
 .../spill_p0/test_spill_directory_cleanup.groovy   | 113 ++++++
 8 files changed, 638 insertions(+), 18 deletions(-)

diff --git a/be/src/exec/spill/spill_file.cpp b/be/src/exec/spill/spill_file.cpp
index 78347ea78eb..7f403de57fc 100644
--- a/be/src/exec/spill/spill_file.cpp
+++ b/be/src/exec/spill/spill_file.cpp
@@ -58,8 +58,8 @@ void SpillFile::gc() {
                                                    _spill_dir, 
status.to_string());
         }
     }
-    // decrease spill data usage anyway, since in ~QueryContext() spill data 
of the query will be
-    // clean up as a last resort
+    // Decrease spill data usage even if per-file cleanup failed. QueryContext 
teardown deletes the
+    // whole query spill directory and retains failures for later retries.
     _data_dir->update_spill_data_usage(-_total_written_bytes);
     _total_written_bytes = 0;
 }
diff --git a/be/src/exec/spill/spill_file_manager.cpp 
b/be/src/exec/spill/spill_file_manager.cpp
index 0946e3209b7..048e1d2e551 100644
--- a/be/src/exec/spill/spill_file_manager.cpp
+++ b/be/src/exec/spill/spill_file_manager.cpp
@@ -24,6 +24,7 @@
 #include <filesystem>
 #include <memory>
 #include <string>
+#include <utility>
 
 #include "common/logging.h"
 #include "common/metrics/doris_metrics.h"
@@ -31,6 +32,7 @@
 #include "io/fs/file_system.h"
 #include "io/fs/local_file_system.h"
 #include "storage/olap_define.h"
+#include "util/debug_points.h"
 #include "util/parse_util.h"
 #include "util/pretty_printer.h"
 #include "util/time.h"
@@ -39,6 +41,11 @@ namespace doris {
 #include "common/compile_check_begin.h"
 
 SpillFileManager::~SpillFileManager() {
+    // QueryContext destruction can still queue failed deletions after stop(), 
for example while
+    // VDataStreamMgr is being destroyed. Retry them once more before dropping 
the in-memory state.
+    // Any directory that still cannot be deleted remains under the active 
spill root and will be
+    // moved to the GC root by init() after restart.
+    _retry_pending_query_spill_directories();
     DorisMetrics::instance()->metric_registry()->deregister_entity(_entity);
 }
 
@@ -46,6 +53,17 @@ SpillFileManager::SpillFileManager(
         std::unordered_map<std::string, std::unique_ptr<SpillDataDir>>&& 
spill_store_map)
         : _spill_store_map(std::move(spill_store_map)), 
_stop_background_threads_latch(1) {}
 
+void SpillFileManager::stop() {
+    _stop_background_threads_latch.count_down();
+    if (_spill_gc_thread) {
+        _spill_gc_thread->join();
+    }
+    // The GC thread may observe the stop latch before processing a recently 
queued failed deletion.
+    // Retry the pending directories after the thread exits; later failures 
get one final retry in
+    // the destructor.
+    _retry_pending_query_spill_directories();
+}
+
 Status SpillFileManager::init() {
     LOG(INFO) << "init spill stream manager";
     RETURN_IF_ERROR(_init_spill_store_map());
@@ -98,7 +116,7 @@ void SpillFileManager::_init_metrics() {
             _spill_read_bytes_metric.get()));
 }
 
-// clean up stale spilled files
+// Retry failed query-directory deletions and clean up stale spill files.
 void SpillFileManager::_spill_gc_thread_callback() {
     while (!_stop_background_threads_latch.wait_for(
             std::chrono::milliseconds(config::spill_gc_interval_ms))) {
@@ -163,6 +181,66 @@ void SpillFileManager::delete_spill_file(SpillFileSPtr 
spill_file) {
     spill_file->gc();
 }
 
+void SpillFileManager::delete_query_spill_directory(const std::string& 
query_id,
+                                                    SpillDataDir* data_dir) {
+    PendingQuerySpillDirectory pending_directory {
+            .query_dir = data_dir->get_spill_data_path(query_id),
+    };
+
+    auto status = _try_delete_query_spill_directory(pending_directory);
+    if (!status.ok()) {
+        std::lock_guard lock(_pending_query_spill_directories_mutex);
+        ++pending_directory.failed_count;
+        
_pending_query_spill_directories.emplace_back(std::move(pending_directory));
+    }
+}
+
+Status SpillFileManager::_try_delete_query_spill_directory(
+        const PendingQuerySpillDirectory& pending_directory) {
+    
DBUG_EXECUTE_IF("fault_inject::spill_file_manager::delete_query_spill_directory",
 {
+        return Status::Error<INTERNAL_ERROR>("injected query spill directory 
deletion failure");
+    });
+    const auto& fs = io::global_local_filesystem();
+    return fs->delete_directory(pending_directory.query_dir);
+}
+
+void SpillFileManager::_retry_pending_query_spill_directories() {
+    std::vector<PendingQuerySpillDirectory> pending_directories;
+    {
+        std::lock_guard lock(_pending_query_spill_directories_mutex);
+        pending_directories.swap(_pending_query_spill_directories);
+    }
+    DBUG_EXECUTE_IF(
+            
"fault_inject::spill_file_manager::retry_pending_query_spill_directories_after_drain",
+            { DBUG_RUN_CALLBACK(); });
+
+    // Limit repeated warnings for a persistently unavailable directory while 
retaining it for
+    // every subsequent retry.
+    constexpr int log_interval = 5;
+    std::vector<PendingQuerySpillDirectory> failed_directories;
+    for (auto& pending_directory : pending_directories) {
+        auto status = _try_delete_query_spill_directory(pending_directory);
+        if (status.ok()) {
+            continue;
+        }
+
+        ++pending_directory.failed_count;
+        if (pending_directory.failed_count % log_interval == 0) {
+            LOG(WARNING) << fmt::format(
+                    "failed to retry deleting spill query directory, dir {}, 
error: {}",
+                    pending_directory.query_dir, status.to_string());
+        }
+        failed_directories.emplace_back(std::move(pending_directory));
+    }
+
+    if (!failed_directories.empty()) {
+        std::lock_guard lock(_pending_query_spill_directories_mutex);
+        for (auto& pending_directory : failed_directories) {
+            
_pending_query_spill_directories.emplace_back(std::move(pending_directory));
+        }
+    }
+}
+
 void SpillFileManager::gc(int32_t max_work_time_ms) {
     bool exists = true;
     bool has_work = false;
@@ -182,6 +260,7 @@ void SpillFileManager::gc(int32_t max_work_time_ms) {
             LOG(INFO) << msg;
         }
     }};
+    _retry_pending_query_spill_directories();
     for (const auto& [path, store_dir] : _spill_store_map) {
         std::string gc_root_dir = store_dir->get_spill_data_gc_path();
 
@@ -253,12 +332,12 @@ SpillDataDir::SpillDataDir(std::string path, int64_t 
capacity_bytes,
 }
 
 bool is_directory_empty(const std::filesystem::path& dir) {
+    // Spill cleanup may delete the directory while the iterator is 
constructed or advanced. Treat
+    // that race as empty for these presence metrics.
     try {
         return std::filesystem::is_directory(dir) &&
                std::filesystem::directory_iterator(dir) ==
                        
std::filesystem::end(std::filesystem::directory_iterator {});
-        // this method is not thread safe, the file referenced by 
directory_iterator
-        // maybe moved to spill_gc dir during this function call, so need to 
catch expection
     } catch (const std::filesystem::filesystem_error&) {
         return true;
     }
diff --git a/be/src/exec/spill/spill_file_manager.h 
b/be/src/exec/spill/spill_file_manager.h
index 582df3f1f63..7455789c216 100644
--- a/be/src/exec/spill/spill_file_manager.h
+++ b/be/src/exec/spill/spill_file_manager.h
@@ -20,10 +20,12 @@
 #include <atomic>
 #include <memory>
 #include <mutex>
+#include <string>
 #include <unordered_map>
 #include <vector>
 
 #include "common/metrics/metrics.h"
+#include "common/status.h"
 #include "exec/spill/spill_file.h"
 #include "storage/options.h"
 #include "util/threadpool.h"
@@ -119,12 +121,7 @@ public:
 
     Status init();
 
-    void stop() {
-        _stop_background_threads_latch.count_down();
-        if (_spill_gc_thread) {
-            _spill_gc_thread->join();
-        }
-    }
+    void stop();
 
     // Create SpillFile and register it
     // @param relative_path  Operator-formatted path under the spill root,
@@ -134,9 +131,13 @@ public:
     /// Get a unique ID for constructing spill file paths.
     uint64_t next_id() { return id_++; }
 
-    // Mark SpillFile for deletion; asynchronously delete spill files in the 
GC thread
+    // Delete SpillFile data synchronously.
     void delete_spill_file(SpillFileSPtr spill_file);
 
+    // Recursively delete a per-query spill directory during query teardown. 
Failed deletions are
+    // retained by the manager and retried by its GC and shutdown paths.
+    void delete_query_spill_directory(const std::string& query_id, 
SpillDataDir* data_dir);
+
     void gc(int32_t max_work_time_ms);
 
     void update_spill_write_bytes(int64_t bytes) { 
_spill_write_bytes_counter->increment(bytes); }
@@ -144,9 +145,16 @@ public:
     void update_spill_read_bytes(int64_t bytes) { 
_spill_read_bytes_counter->increment(bytes); }
 
 private:
+    struct PendingQuerySpillDirectory {
+        int failed_count {0};
+        std::string query_dir;
+    };
+
     void _init_metrics();
     Status _init_spill_store_map();
     void _spill_gc_thread_callback();
+    Status _try_delete_query_spill_directory(const PendingQuerySpillDirectory& 
pending_directory);
+    void _retry_pending_query_spill_directories();
     std::vector<SpillDataDir*> _get_stores_for_spill(TStorageMedium::type 
storage_medium);
 
     std::unordered_map<std::string, std::unique_ptr<SpillDataDir>> 
_spill_store_map;
@@ -154,6 +162,9 @@ private:
     CountDownLatch _stop_background_threads_latch;
     std::shared_ptr<Thread> _spill_gc_thread;
 
+    std::mutex _pending_query_spill_directories_mutex;
+    std::vector<PendingQuerySpillDirectory> _pending_query_spill_directories;
+
     std::atomic_uint64_t id_ = 0;
 
     std::shared_ptr<MetricEntity> _entity {nullptr};
diff --git a/be/src/exec/spill/spill_file_writer.cpp 
b/be/src/exec/spill/spill_file_writer.cpp
index ced813e51e3..62320ef9c05 100644
--- a/be/src/exec/spill/spill_file_writer.cpp
+++ b/be/src/exec/spill/spill_file_writer.cpp
@@ -152,6 +152,9 @@ Status SpillFileWriter::write_block(RuntimeState* state, 
const Block& block) {
 
     // Lazily open the first part
     if (!_file_writer) {
+        if (_current_part_index == 0) {
+            state->get_query_ctx()->record_spill_data_dir(_data_dir);
+        }
         RETURN_IF_ERROR(_open_next_part());
     }
 
diff --git a/be/src/runtime/query_context.cpp b/be/src/runtime/query_context.cpp
index 249d0ed861f..aaab0ad698c 100644
--- a/be/src/runtime/query_context.cpp
+++ b/be/src/runtime/query_context.cpp
@@ -223,6 +223,11 @@ void QueryContext::init_query_task_controller() {
 #endif
 }
 
+void QueryContext::record_spill_data_dir(SpillDataDir* data_dir) {
+    std::lock_guard lock(_spill_data_dirs_mutex);
+    _spill_data_dirs.emplace(data_dir);
+}
+
 QueryContext::~QueryContext() {
     SCOPED_SWITCH_THREAD_MEM_TRACKER_LIMITER(query_mem_tracker());
     // query mem tracker consumption is equal to 0, it means that after 
QueryContext is created,
@@ -265,6 +270,12 @@ QueryContext::~QueryContext() {
     obj_pool.clear();
     _merge_controller_handler.reset();
 
+    if (auto* spill_file_mgr = _exec_env->spill_file_mgr()) {
+        for (auto* data_dir : _spill_data_dirs) {
+            spill_file_mgr->delete_query_spill_directory(print_id(_query_id), 
data_dir);
+        }
+    }
+
     DorisMetrics::instance()->query_ctx_cnt->increment(-1);
     // fragment_mgr is nullptr in unittest
     if (ExecEnv::GetInstance()->fragment_mgr()) {
diff --git a/be/src/runtime/query_context.h b/be/src/runtime/query_context.h
index 13eb6ffe649..e0368def54e 100644
--- a/be/src/runtime/query_context.h
+++ b/be/src/runtime/query_context.h
@@ -28,6 +28,7 @@
 #include <mutex>
 #include <string>
 #include <unordered_map>
+#include <unordered_set>
 
 #include "common/config.h"
 #include "common/factory_creator.h"
@@ -54,6 +55,7 @@ class PipelineTask;
 class QueryTaskController;
 class Dependency;
 class RecCTEScanLocalState;
+class SpillDataDir;
 
 struct ReportStatusRequest {
     const Status status;
@@ -202,6 +204,10 @@ public:
 
     TUniqueId query_id() const { return _query_id; }
 
+    // Record a spill data directory before opening the first spill part so 
teardown only visits
+    // touched roots.
+    void record_spill_data_dir(SpillDataDir* data_dir);
+
     // Expose task-level query progress counters for runtime statistics 
reporting.
     void add_total_task_num(int delta);
     void inc_finished_task_num();
@@ -327,6 +333,9 @@ private:
     MonotonicStopWatch _query_watcher;
     bool _is_nereids = false;
 
+    std::mutex _spill_data_dirs_mutex;
+    std::unordered_set<SpillDataDir*> _spill_data_dirs;
+
     std::shared_ptr<ResourceContext> _resource_ctx;
 
     void _init_resource_context();
diff --git a/be/test/vec/spill/spill_file_test.cpp 
b/be/test/vec/spill/spill_file_test.cpp
index 67740acdcd2..11b16df1712 100644
--- a/be/test/vec/spill/spill_file_test.cpp
+++ b/be/test/vec/spill/spill_file_test.cpp
@@ -21,8 +21,11 @@
 
 #include <algorithm>
 #include <filesystem>
+#include <functional>
 #include <memory>
 #include <numeric>
+#include <set>
+#include <string>
 #include <vector>
 
 #include "common/config.h"
@@ -32,11 +35,16 @@
 #include "exec/spill/spill_file_manager.h"
 #include "exec/spill/spill_file_reader.h"
 #include "exec/spill/spill_file_writer.h"
+#include "io/fs/file_writer.h"
 #include "io/fs/local_file_system.h"
 #include "runtime/exec_env.h"
 #include "runtime/runtime_profile.h"
 #include "testutil/column_helper.h"
+#include "testutil/mock/mock_query_context.h"
 #include "testutil/mock/mock_runtime_state.h"
+#include "util/debug_points.h"
+#include "util/defer_op.h"
+#include "util/uid_util.h"
 
 namespace doris::vectorized {
 
@@ -79,13 +87,21 @@ protected:
         _profile->add_child(_common_profile.get(), true);
 
         _spill_dir = "./ut_dir/spill_file_test";
-        auto spill_data_dir = std::make_unique<SpillDataDir>(_spill_dir, 1024L 
* 1024 * 128);
+        _second_spill_dir = "./ut_dir/spill_file_test_second";
+        auto spill_data_dir =
+                std::make_unique<SpillDataDir>(_spill_dir, 1024L * 1024 * 128, 
TStorageMedium::SSD);
         auto st = 
io::global_local_filesystem()->create_directory(spill_data_dir->path(), false);
         ASSERT_TRUE(st.ok()) << "create directory failed: " << st.to_string();
+        auto second_spill_data_dir = std::make_unique<SpillDataDir>(
+                _second_spill_dir, 1024L * 1024 * 128, TStorageMedium::HDD);
+        st = 
io::global_local_filesystem()->create_directory(second_spill_data_dir->path(), 
false);
+        ASSERT_TRUE(st.ok()) << "create directory failed: " << st.to_string();
 
         std::unordered_map<std::string, std::unique_ptr<SpillDataDir>> 
data_map;
         _data_dir_ptr = spill_data_dir.get();
+        _second_data_dir_ptr = second_spill_data_dir.get();
         data_map.emplace("test", std::move(spill_data_dir));
+        data_map.emplace("test_second", std::move(second_spill_data_dir));
         auto* spill_file_manager = new SpillFileManager(std::move(data_map));
         ExecEnv::GetInstance()->_spill_file_mgr = spill_file_manager;
         st = spill_file_manager->init();
@@ -94,11 +110,13 @@ protected:
 
     void TearDown() override {
         ExecEnv::GetInstance()->spill_file_mgr()->stop();
+        _runtime_state.reset();
         SAFE_DELETE(ExecEnv::GetInstance()->_spill_file_mgr);
         // Clean up test directory
         auto st = io::global_local_filesystem()->delete_directory(_spill_dir);
         (void)st;
-        _runtime_state.reset();
+        st = 
io::global_local_filesystem()->delete_directory(_second_spill_dir);
+        (void)st;
     }
 
     Block _create_int_block(const std::vector<int32_t>& data) {
@@ -112,12 +130,58 @@ protected:
         return block;
     }
 
+    void _write_and_release_spill_file(const TUniqueId& query_id, 
QueryContext* query_ctx,
+                                       SpillDataDir* data_dir, const 
std::string& relative_path) {
+        TQueryGlobals query_globals;
+        auto runtime_state = std::make_unique<MockRuntimeState>(
+                query_id, 0, query_ctx->query_options(), query_globals, 
ExecEnv::GetInstance(),
+                query_ctx);
+
+        auto spill_file = std::make_shared<SpillFile>(
+                data_dir, fmt::format("{}/{}", print_id(query_id), 
relative_path));
+
+        SpillFileWriterSPtr writer;
+        auto st = spill_file->create_writer(runtime_state.get(), 
_profile.get(), writer);
+        ASSERT_TRUE(st.ok());
+        auto block = _create_int_block({1, 2, 3});
+        st = writer->write_block(runtime_state.get(), block);
+        ASSERT_TRUE(st.ok());
+        st = writer->close();
+        ASSERT_TRUE(st.ok());
+        writer.reset();
+        spill_file.reset();
+    }
+
+    void _create_residual_file(const std::string& file_path) {
+        auto st = io::global_local_filesystem()->create_directory(
+                std::filesystem::path(file_path).parent_path(), false);
+        ASSERT_TRUE(st.ok()) << st.to_string();
+        io::FileWriterPtr writer;
+        st = io::global_local_filesystem()->create_file(file_path, &writer);
+        ASSERT_TRUE(st.ok()) << st.to_string();
+        st = writer->close();
+        ASSERT_TRUE(st.ok()) << st.to_string();
+    }
+
+    std::set<std::string> _gc_subdirectories(SpillDataDir* data_dir) {
+        std::set<std::string> subdirectories;
+        for (const auto& entry :
+             
std::filesystem::directory_iterator(data_dir->get_spill_data_gc_path())) {
+            if (entry.is_directory()) {
+                subdirectories.emplace(entry.path().filename().string());
+            }
+        }
+        return subdirectories;
+    }
+
     std::unique_ptr<MockRuntimeState> _runtime_state;
     std::unique_ptr<RuntimeProfile> _profile;
     std::unique_ptr<RuntimeProfile> _custom_profile;
     std::unique_ptr<RuntimeProfile> _common_profile;
     std::string _spill_dir;
+    std::string _second_spill_dir;
     SpillDataDir* _data_dir_ptr = nullptr;
+    SpillDataDir* _second_data_dir_ptr = nullptr;
 };
 
 // ═══════════════════════════════════════════════════════════════════════
@@ -890,7 +954,331 @@ TEST_F(SpillFileTest, GCCleansUpFiles) {
     ASSERT_FALSE(exists);
 }
 
-TEST_F(SpillFileTest, DeleteSpillFileThroughManager) {
+TEST_F(SpillFileTest, QueryContextDeletesEmptySpillDirectory) {
+    ExecEnv::GetInstance()->spill_file_mgr()->stop();
+
+    TUniqueId query_id;
+    query_id.hi = 1;
+    query_id.lo = 2;
+    auto query_id_str = print_id(query_id);
+    auto query_ctx = MockQueryContext::create(query_id);
+
+    auto query_dir = _data_dir_ptr->get_spill_data_path(query_id_str);
+    const auto gc_subdirectories_before = _gc_subdirectories(_data_dir_ptr);
+    _write_and_release_spill_file(query_id, query_ctx.get(), _data_dir_ptr, 
"query_context_gc");
+
+    bool exists = false;
+    auto st = io::global_local_filesystem()->exists(query_dir, &exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_TRUE(exists);
+
+    query_ctx.reset();
+
+    st = io::global_local_filesystem()->exists(query_dir, &exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_FALSE(exists);
+    st = 
io::global_local_filesystem()->exists(_data_dir_ptr->get_spill_data_path(), 
&exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_TRUE(exists);
+    EXPECT_EQ(_gc_subdirectories(_data_dir_ptr), gc_subdirectories_before);
+}
+
+TEST_F(SpillFileTest, QueryContextCleansUpNestedSpillDirectory) {
+    TUniqueId query_id;
+    query_id.hi = 3;
+    query_id.lo = 4;
+    auto query_id_str = print_id(query_id);
+    auto query_ctx = MockQueryContext::create(query_id);
+
+    auto query_dir = _data_dir_ptr->get_spill_data_path(query_id_str);
+    auto nested_dir = query_dir + "/nested";
+    _write_and_release_spill_file(query_id, query_ctx.get(), _data_dir_ptr,
+                                  "nested/query_context_gc");
+
+    bool exists = false;
+    auto st = io::global_local_filesystem()->exists(query_dir, &exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_TRUE(exists);
+    st = io::global_local_filesystem()->exists(nested_dir, &exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_TRUE(exists);
+
+    query_ctx.reset();
+
+    st = io::global_local_filesystem()->exists(query_dir, &exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_FALSE(exists);
+}
+
+TEST_F(SpillFileTest, QueryContextDeletesResidualSpillDirectory) {
+    ExecEnv::GetInstance()->spill_file_mgr()->stop();
+
+    TUniqueId query_id;
+    query_id.hi = 5;
+    query_id.lo = 6;
+    auto query_id_str = print_id(query_id);
+    auto query_ctx = MockQueryContext::create(query_id);
+
+    auto query_dir = _data_dir_ptr->get_spill_data_path(query_id_str);
+    const auto gc_subdirectories_before = _gc_subdirectories(_data_dir_ptr);
+    _write_and_release_spill_file(query_id, query_ctx.get(), _data_dir_ptr, 
"query_context_gc");
+
+    auto residual_file = query_dir + "/residual/temporary-data";
+    _create_residual_file(residual_file);
+
+    bool exists = false;
+    auto st = io::global_local_filesystem()->exists(residual_file, &exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_TRUE(exists);
+
+    query_ctx.reset();
+
+    st = io::global_local_filesystem()->exists(query_dir, &exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_FALSE(exists);
+
+    EXPECT_EQ(_gc_subdirectories(_data_dir_ptr), gc_subdirectories_before);
+}
+
+TEST_F(SpillFileTest, QueryContextCleansUpAllTouchedSpillDirectories) {
+    TUniqueId query_id;
+    query_id.hi = 9;
+    query_id.lo = 10;
+    auto query_id_str = print_id(query_id);
+    auto query_ctx = MockQueryContext::create(query_id);
+
+    auto first_query_dir = _data_dir_ptr->get_spill_data_path(query_id_str);
+    auto second_query_dir = 
_second_data_dir_ptr->get_spill_data_path(query_id_str);
+    const auto first_gc_subdirectories_before = 
_gc_subdirectories(_data_dir_ptr);
+    const auto second_gc_subdirectories_before = 
_gc_subdirectories(_second_data_dir_ptr);
+    _write_and_release_spill_file(query_id, query_ctx.get(), _data_dir_ptr, 
"first");
+    _write_and_release_spill_file(query_id, query_ctx.get(), 
_second_data_dir_ptr, "second");
+
+    bool first_exists = false;
+    bool second_exists = false;
+    auto st = io::global_local_filesystem()->exists(first_query_dir, 
&first_exists);
+    ASSERT_TRUE(st.ok());
+    st = io::global_local_filesystem()->exists(second_query_dir, 
&second_exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_TRUE(first_exists);
+    ASSERT_TRUE(second_exists);
+
+    query_ctx.reset();
+
+    st = io::global_local_filesystem()->exists(first_query_dir, &first_exists);
+    ASSERT_TRUE(st.ok());
+    st = io::global_local_filesystem()->exists(second_query_dir, 
&second_exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_FALSE(first_exists);
+    ASSERT_FALSE(second_exists);
+    EXPECT_EQ(_gc_subdirectories(_data_dir_ptr), 
first_gc_subdirectories_before);
+    EXPECT_EQ(_gc_subdirectories(_second_data_dir_ptr), 
second_gc_subdirectories_before);
+}
+
+TEST_F(SpillFileTest, QueryContextContinuesCleanupAfterRootFailure) {
+    ExecEnv::GetInstance()->spill_file_mgr()->stop();
+
+    TUniqueId query_id;
+    query_id.hi = 11;
+    query_id.lo = 12;
+    auto query_id_str = print_id(query_id);
+    auto query_ctx = MockQueryContext::create(query_id);
+
+    auto first_query_dir = _data_dir_ptr->get_spill_data_path(query_id_str);
+    auto second_query_dir = 
_second_data_dir_ptr->get_spill_data_path(query_id_str);
+    const auto first_gc_subdirectories_before = 
_gc_subdirectories(_data_dir_ptr);
+    const auto second_gc_subdirectories_before = 
_gc_subdirectories(_second_data_dir_ptr);
+    _write_and_release_spill_file(query_id, query_ctx.get(), _data_dir_ptr, 
"first");
+    _write_and_release_spill_file(query_id, query_ctx.get(), 
_second_data_dir_ptr, "second");
+    _create_residual_file(first_query_dir + "/residual/temporary-data");
+    _create_residual_file(second_query_dir + "/residual/temporary-data");
+
+    const auto live_query_dir = 
_data_dir_ptr->get_spill_data_path("live-query");
+    const auto live_query_file = live_query_dir + "/sentinel";
+    _create_residual_file(live_query_file);
+
+    bool first_exists = false;
+    bool second_exists = false;
+    auto st = io::global_local_filesystem()->exists(first_query_dir, 
&first_exists);
+    ASSERT_TRUE(st.ok());
+    st = io::global_local_filesystem()->exists(second_query_dir, 
&second_exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_TRUE(first_exists);
+    ASSERT_TRUE(second_exists);
+
+    const bool previous_enable_debug_points = config::enable_debug_points;
+    constexpr auto debug_point_name =
+            "fault_inject::spill_file_manager::delete_query_spill_directory";
+    Defer restore_debug_point([&] {
+        DebugPoints::instance()->remove(debug_point_name);
+        config::enable_debug_points = previous_enable_debug_points;
+    });
+    auto debug_point = std::make_shared<DebugPoint>();
+    debug_point->execute_limit = 1;
+    config::enable_debug_points = true;
+    DebugPoints::instance()->add(debug_point_name, debug_point);
+
+    query_ctx.reset();
+
+    st = io::global_local_filesystem()->exists(first_query_dir, &first_exists);
+    ASSERT_TRUE(st.ok());
+    st = io::global_local_filesystem()->exists(second_query_dir, 
&second_exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_NE(first_exists, second_exists);
+    ASSERT_EQ(debug_point->execute_num.load(), 2);
+
+    ExecEnv::GetInstance()->spill_file_mgr()->gc(10000);
+
+    st = io::global_local_filesystem()->exists(first_query_dir, &first_exists);
+    ASSERT_TRUE(st.ok());
+    st = io::global_local_filesystem()->exists(second_query_dir, 
&second_exists);
+    ASSERT_TRUE(st.ok());
+    EXPECT_FALSE(first_exists);
+    EXPECT_FALSE(second_exists);
+
+    bool live_query_exists = false;
+    st = io::global_local_filesystem()->exists(live_query_file, 
&live_query_exists);
+    ASSERT_TRUE(st.ok());
+    EXPECT_TRUE(live_query_exists);
+
+    EXPECT_EQ(_gc_subdirectories(_data_dir_ptr), 
first_gc_subdirectories_before);
+    EXPECT_EQ(_gc_subdirectories(_second_data_dir_ptr), 
second_gc_subdirectories_before);
+}
+
+TEST_F(SpillFileTest, QueryContextRetriesSpillDirectoryDeletionUntilSuccess) {
+    ExecEnv::GetInstance()->spill_file_mgr()->stop();
+
+    TUniqueId query_id;
+    query_id.hi = 15;
+    query_id.lo = 16;
+    auto query_id_str = print_id(query_id);
+    auto query_ctx = MockQueryContext::create(query_id);
+
+    auto query_dir = _data_dir_ptr->get_spill_data_path(query_id_str);
+    const auto gc_subdirectories_before = _gc_subdirectories(_data_dir_ptr);
+    _write_and_release_spill_file(query_id, query_ctx.get(), _data_dir_ptr, 
"retry_cleanup");
+    _create_residual_file(query_dir + "/residual/temporary-data");
+
+    const bool previous_enable_debug_points = config::enable_debug_points;
+    constexpr auto debug_point_name =
+            "fault_inject::spill_file_manager::delete_query_spill_directory";
+    Defer restore_debug_point([&] {
+        DebugPoints::instance()->remove(debug_point_name);
+        config::enable_debug_points = previous_enable_debug_points;
+    });
+    auto debug_point = std::make_shared<DebugPoint>();
+    debug_point->execute_limit = 5;
+    config::enable_debug_points = true;
+    DebugPoints::instance()->add(debug_point_name, debug_point);
+
+    query_ctx.reset();
+
+    bool exists = false;
+    auto st = io::global_local_filesystem()->exists(query_dir, &exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_TRUE(exists);
+
+    for (int i = 0; i < 4; ++i) {
+        ExecEnv::GetInstance()->spill_file_mgr()->gc(10000);
+        st = io::global_local_filesystem()->exists(query_dir, &exists);
+        ASSERT_TRUE(st.ok());
+        ASSERT_TRUE(exists);
+    }
+
+    ExecEnv::GetInstance()->spill_file_mgr()->gc(10000);
+    st = io::global_local_filesystem()->exists(query_dir, &exists);
+    ASSERT_TRUE(st.ok());
+    EXPECT_FALSE(exists);
+    EXPECT_EQ(debug_point->execute_num.load(), 6);
+    EXPECT_EQ(_gc_subdirectories(_data_dir_ptr), gc_subdirectories_before);
+}
+
+TEST_F(SpillFileTest, RetryPreservesDirectoryQueuedAfterPendingDrain) {
+    ExecEnv::GetInstance()->spill_file_mgr()->stop();
+
+    TUniqueId first_query_id;
+    first_query_id.hi = 17;
+    first_query_id.lo = 18;
+    auto first_query_ctx = MockQueryContext::create(first_query_id);
+    auto first_query_dir = 
_data_dir_ptr->get_spill_data_path(print_id(first_query_id));
+    _write_and_release_spill_file(first_query_id, first_query_ctx.get(), 
_data_dir_ptr,
+                                  "first_retry_cleanup");
+    _create_residual_file(first_query_dir + "/residual/temporary-data");
+
+    TUniqueId second_query_id;
+    second_query_id.hi = 19;
+    second_query_id.lo = 20;
+    auto second_query_ctx = MockQueryContext::create(second_query_id);
+    auto second_query_dir = 
_data_dir_ptr->get_spill_data_path(print_id(second_query_id));
+    _write_and_release_spill_file(second_query_id, second_query_ctx.get(), 
_data_dir_ptr,
+                                  "second_retry_cleanup");
+    _create_residual_file(second_query_dir + "/residual/temporary-data");
+
+    const bool previous_enable_debug_points = config::enable_debug_points;
+    constexpr auto delete_debug_point_name =
+            "fault_inject::spill_file_manager::delete_query_spill_directory";
+    constexpr auto after_drain_debug_point_name =
+            
"fault_inject::spill_file_manager::retry_pending_query_spill_directories_after_drain";
+    Defer restore_debug_points([&] {
+        DebugPoints::instance()->remove(after_drain_debug_point_name);
+        DebugPoints::instance()->remove(delete_debug_point_name);
+        config::enable_debug_points = previous_enable_debug_points;
+    });
+    config::enable_debug_points = true;
+    DebugPoints::instance()->add(delete_debug_point_name);
+
+    first_query_ctx.reset();
+
+    auto after_drain_debug_point = std::make_shared<DebugPoint>();
+    after_drain_debug_point->execute_limit = 1;
+    after_drain_debug_point->callback = std::function<void()>([&]() { 
second_query_ctx.reset(); });
+    DebugPoints::instance()->add(after_drain_debug_point_name, 
after_drain_debug_point);
+
+    ExecEnv::GetInstance()->spill_file_mgr()->gc(10000);
+
+    bool first_exists = false;
+    auto st = io::global_local_filesystem()->exists(first_query_dir, 
&first_exists);
+    ASSERT_TRUE(st.ok());
+    bool second_exists = false;
+    st = io::global_local_filesystem()->exists(second_query_dir, 
&second_exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_TRUE(first_exists);
+    ASSERT_TRUE(second_exists);
+    ASSERT_EQ(after_drain_debug_point->execute_num.load(), 1);
+
+    DebugPoints::instance()->remove(after_drain_debug_point_name);
+    DebugPoints::instance()->remove(delete_debug_point_name);
+    ExecEnv::GetInstance()->spill_file_mgr()->gc(10000);
+
+    st = io::global_local_filesystem()->exists(first_query_dir, &first_exists);
+    ASSERT_TRUE(st.ok());
+    st = io::global_local_filesystem()->exists(second_query_dir, 
&second_exists);
+    ASSERT_TRUE(st.ok());
+    EXPECT_FALSE(first_exists);
+    EXPECT_FALSE(second_exists);
+}
+
+TEST_F(SpillFileTest, QueryContextSkipsCleanupWithoutSpill) {
+    TUniqueId query_id;
+    query_id.hi = 7;
+    query_id.lo = 8;
+    auto query_id_str = print_id(query_id);
+    auto query_ctx = MockQueryContext::create(query_id);
+    auto query_dir = _data_dir_ptr->get_spill_data_path(query_id_str);
+
+    // No spill root was recorded for this query, so teardown must leave this 
untracked directory.
+    auto st = io::global_local_filesystem()->create_directory(query_dir, 
false);
+    ASSERT_TRUE(st.ok());
+
+    query_ctx.reset();
+
+    bool exists = false;
+    st = io::global_local_filesystem()->exists(query_dir, &exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_TRUE(exists);
+}
+
+TEST_F(SpillFileTest, DeleteSpillFileThroughManagerSynchronously) {
     SpillFileSPtr spill_file;
     auto st = 
ExecEnv::GetInstance()->spill_file_mgr()->create_spill_file("test_query/mgr_delete",
                                                                           
spill_file);
@@ -907,11 +1295,17 @@ TEST_F(SpillFileTest, DeleteSpillFileThroughManager) {
     st = writer->close();
     ASSERT_TRUE(st.ok());
 
-    // Delete through manager (async GC)
+    auto spill_file_dir = 
_data_dir_ptr->get_spill_data_path("test_query/mgr_delete");
+    bool exists = false;
+    st = io::global_local_filesystem()->exists(spill_file_dir, &exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_TRUE(exists);
+
     ExecEnv::GetInstance()->spill_file_mgr()->delete_spill_file(spill_file);
 
-    // Run GC to process the deletion
-    ExecEnv::GetInstance()->spill_file_mgr()->gc(1000);
+    st = io::global_local_filesystem()->exists(spill_file_dir, &exists);
+    ASSERT_TRUE(st.ok());
+    ASSERT_FALSE(exists);
 }
 
 // ═══════════════════════════════════════════════════════════════════════
diff --git 
a/regression-test/suites/spill_p0/test_spill_directory_cleanup.groovy 
b/regression-test/suites/spill_p0/test_spill_directory_cleanup.groovy
new file mode 100644
index 00000000000..ba9b99774fb
--- /dev/null
+++ b/regression-test/suites/spill_p0/test_spill_directory_cleanup.groovy
@@ -0,0 +1,113 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.apache.doris.regression.util.Http
+
+suite("test_spill_directory_cleanup", "nonConcurrent") {
+    def backends = sql_return_maparray("SHOW BACKENDS").findAll {
+        it.Alive.toString().equalsIgnoreCase("true")
+    }
+    assertTrue(!backends.isEmpty(), "No alive backend found")
+
+    def readSpillMetric = { String metricName ->
+        def valuesByBackend = [:]
+        backends.each { backend ->
+            def endpoint = "http://${backend.Host}:${backend.HttpPort}/metrics";
+            def metrics = Http.GET(endpoint, false, false).toString()
+            def metricPattern = '(?m)^' + 
java.util.regex.Pattern.quote(metricName) +
+                    '(?:\\{[^}]*\\})?\\s+(\\d+)$'
+            def matcher = 
java.util.regex.Pattern.compile(metricPattern).matcher(metrics)
+            long value = 0
+            int matchedMetrics = 0
+            while (matcher.find()) {
+                value += matcher.group(1).toLong()
+                matchedMetrics++
+            }
+            assertTrue(matchedMetrics > 0, "Metric ${metricName} not found on 
${endpoint}")
+            valuesByBackend[backend.BackendId.toString()] = value
+        }
+        return valuesByBackend
+    }
+
+    def waitForSpillDirectoryState = { boolean expectedPresent, String stage ->
+        def lastPresence = [:]
+        Throwable lastError = null
+        long deadline = System.currentTimeMillis() + 60_000
+        while (System.currentTimeMillis() < deadline) {
+            try {
+                lastPresence = 
readSpillMetric("doris_be_spill_disk_has_spill_data")
+                boolean anyPresent = lastPresence.values().any { it > 0 }
+                if (anyPresent == expectedPresent) {
+                    logger.info("Spill directory state at ${stage}: 
${lastPresence}")
+                    return lastPresence
+                }
+                lastError = null
+            } catch (Throwable t) {
+                lastError = t
+            }
+            sleep(500)
+        }
+        def errorDetail = lastError == null ? "" : ", last error: 
${lastError.message}"
+        assertTrue(false,
+                "Timed out waiting for spill directory state at ${stage}; " +
+                        "expectedPresent=${expectedPresent}, 
lastPresence=${lastPresence}" +
+                        errorDetail)
+    }
+
+    GetDebugPoint().clearDebugPointsForAllBEs()
+    waitForSpillDirectoryState(false, "before query")
+    def spillWriteBytesBefore =
+            readSpillMetric("doris_be_spill_write_bytes").values().sum(0L)
+
+    def deleteFailureDebugPoint =
+            "fault_inject::spill_file_manager::delete_query_spill_directory"
+    try {
+        // Keep the otherwise short-lived empty query directory visible until 
the metric refreshes.
+        // Disabling this point lets the spill GC retry the same deletion.
+        GetDebugPoint().enableDebugPointForAllBEs(deleteFailureDebugPoint, 
[timeout: "120"])
+
+        sql "SET enable_spill = true"
+        sql "SET enable_force_spill = true"
+        sql "SET spill_min_revocable_mem = 1048576"
+        sql "SET parallel_pipeline_task_num = 1"
+        sql "SET batch_size = 1024"
+        sql "SET enable_reserve_memory = true"
+
+        def result = sql """
+            SELECT COUNT(*)
+            FROM (
+                SELECT number
+                FROM numbers("number" = "200000")
+                GROUP BY number
+                HAVING SUM(number) >= 0
+            ) t
+        """
+        assertEquals("200000", result[0][0].toString())
+
+        def spillWriteBytesAfter =
+                readSpillMetric("doris_be_spill_write_bytes").values().sum(0L)
+        assertTrue(spillWriteBytesAfter > spillWriteBytesBefore,
+                "The query did not write spill data: 
before=${spillWriteBytesBefore}, " +
+                        "after=${spillWriteBytesAfter}")
+        waitForSpillDirectoryState(true, "after forced spill")
+    } finally {
+        GetDebugPoint().disableDebugPointForAllBEs(deleteFailureDebugPoint)
+    }
+
+    // The next spill GC cycle retries the failed query-directory deletion and 
refreshes the metric.
+    waitForSpillDirectoryState(false, "after query cleanup")
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to