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

gavinchou pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 4c13cb1da9d [fix](file-cache) recycle busy blocks that were marked 
deleting (#68348)
4c13cb1da9d is described below

commit 4c13cb1da9d4599bfffb720357795f8ef44c1a50
Author: Xin Liao <[email protected]>
AuthorDate: Thu Oct 15 20:46:42 2026 +0800

    [fix](file-cache) recycle busy blocks that were marked deleting (#68348)
    
    ### What problem does this PR solve?
    
    Related Jira: CORE-6174
    
    When `clear_file_cache_sync/async`, `remove_if_cached[_async]`,
    `try_release` or `reset_capacity` run into a block that is still
    referenced, they do not remove it. They only call
    `FileBlock::set_deleting()` and leave the block in `_files`, expecting
    the last reference holder to remove it when it releases.
    
    The only code that honours that expectation is
    `FileBlock::release_cache_reference()`, which runs from
    `~FileBlocksHolder` and `~FileBlocksProbeResult`. Every other reference
    holder simply drops its `shared_ptr`:
    
    - the `batch` of `run_background_block_lru_update()`, which is declared
    outside the loop and therefore keeps its references alive across a whole
    sleep interval (5s by default);
    - `clear_need_update_lru_blocks()`, which `clear_file_cache_impl()`
    called *after* the scan, so a block queued there was first counted as
    busy and then had its reference dropped;
    - the `_cache_file_readers` map of `CachedRemoteFileReader`;
    - the block vector of `BlockFileCacheTtlMgr::reconcile_tablet_blocks()`.
    
    If one of those held the last extra reference, the block ended up
    stranded: marked deleting, no longer referenced, but still in `_files`,
    still in its LRU queue and still on disk. Nothing ever removed it,
    because no background thread looks for deleting blocks —
    `run_background_gc()` only drains `_recycle_keys`, which is filled by
    `remove()`. The block only disappeared if it happened to be read again
    (`get_impl()` does not filter deleting cells, so a later holder release
    cleans it up), or if eviction or `try_release()` picked it up.
    
    So a synchronous clear could return `status=OK` with
    `num_cells_wait_recycle=1` and leave that block in `file_cache_info` and
    on disk indefinitely. That is what a regression test caught: no query
    touched the table after the clear, and the same 2599-byte TTL block was
    still there more than 10 minutes later.
    
    ### Release note
    
    [fix](file-cache) Fix file cache blocks that were busy during a clear
    never being reclaimed
    
    ### What is changed and how it works?
    
    Make the reclaim independent of which code path drops the last
    reference:
    
    - New `_deleting_blocks` set holds exactly the keys of the cells in
    `_files` whose block is deleting. A key is added by the new
    `mark_cell_deleting()` helper, which is now the only place that sets a
    block deleting (every busy-block call site and the downloading branch of
    `remove()` go through it), and is erased by `remove()` together with its
    cell. Keeping the set in step with `_files`, instead of trimming it
    lazily, means a block cached again at the same offset after the holder
    removed the marked one is never taken for it, and the set never holds
    more than the deleting cells that are still cached.
    - New `recycle_deleting_blocks()` rechecks those candidates and removes
    the ones that became releasable, skipping only blocks that are still
    being downloaded (an `EMPTY` block that nobody downloads is removed
    too). `run_background_gc()` calls it every round, bounded by the
    existing `file_cache_remove_block_qps_limit`, so a stranded block is now
    reclaimed within one GC interval (100ms by default) instead of never.
    Removal is asynchronous, so the storage is not touched while the cache
    lock is held.
    - Two bvars per cache path: `file_cache_recycle_deleting_blocks` counts
    the blocks the sweep had to reclaim, i.e. blocks whose last reference
    was dropped outside the holder path, and the gauge
    `file_cache_deleting_block_count` shows how many deleting blocks are
    still waiting for their references to go away.
    
    The sweep also uncovered a latent use-after-free in `remove()` itself.
    When it is called on a block whose state is `DOWNLOADING`, it takes the
    block out of its LRU queue and then returns early without erasing the
    cell, leaving `cell->queue_iterator` pointing at a freed `std::list`
    node. Nothing ever reset it, so the `remove()` that finally erased that
    cell — from `release_cache_reference()` once the download finished —
    read freed memory in `LRUQueue::remove()` and decremented the queue size
    a second time. `move_to_end()`, `resize()` and `change_cache_type()`
    would have done the same. The iterator is now reset when `remove()`
    bails out, and an empty iterator simply means "already out of the
    queue": the `DCHECK` in `remove()` that forbade it is gone, and
    `change_cache_type()`, the one caller that still dereferenced it
    unconditionally, now leaves such a cell out of every queue. ASAN in the
    first CI run on this PR reported exactly this, because the sweep is the
    first thing that reliably re-enters `remove()` for such a cell.
    
    Two related cleanups, both of which reduce how often a block is treated
    as busy in the first place:
    
    - `clear_file_cache_impl()` now drops the pending LRU updates *before*
    the scan rather than after it.
    - `run_background_block_lru_update()` clears its batch right after
    applying it instead of at the top of the next round.
    
    And, since the existing log line made this impossible to diagnose from a
    BE log, `clear_file_cache_impl()` now logs the hash of the blocks it has
    to wait for, which was missing entirely.
    
    Seven unit tests:
    
    - three cover the sweep itself: blocks are dropped the way a non-holder
    reference holder drops them, and the tests assert that they are then
    reclaimed, that still-referenced blocks survive, that the batch limit is
    honoured, and that `EMPTY` blocks are reclaimed too;
    - one covers a block cached again at the same offset after the holder
    removed the marked one, which the sweep must leave alone;
    - three cover a block removed while it is downloading: removing it
    twice, which fails under ASAN without the iterator fix, converting it to
    another cache type before its holder releases it, and reclaiming it
    through the sweep once the download is done.
---
 be/src/io/cache/block_file_cache.cpp       | 107 +++++-
 be/src/io/cache/block_file_cache.h         |  29 +-
 be/test/io/cache/block_file_cache_test.cpp | 558 +++++++++++++++++++++++++++++
 3 files changed, 683 insertions(+), 11 deletions(-)

diff --git a/be/src/io/cache/block_file_cache.cpp 
b/be/src/io/cache/block_file_cache.cpp
index 33bb9cd1055..0e4b51ad161 100644
--- a/be/src/io/cache/block_file_cache.cpp
+++ b/be/src/io/cache/block_file_cache.cpp
@@ -403,6 +403,10 @@ BlockFileCache::BlockFileCache(const std::string& 
cache_base_path,
             _cache_base_path.c_str(), "file_cache_lru_dump_latency_us");
     _recycle_keys_length_recorder = std::make_shared<bvar::LatencyRecorder>(
             _cache_base_path.c_str(), "file_cache_recycle_keys_length");
+    _deleting_block_count_metrics = std::make_shared<bvar::Status<size_t>>(
+            _cache_base_path.c_str(), "file_cache_deleting_block_count", 0);
+    _recycle_deleting_blocks_metrics = std::make_shared<bvar::Adder<size_t>>(
+            _cache_base_path.c_str(), "file_cache_recycle_deleting_blocks");
     _need_update_lru_blocks_length_recorder = 
std::make_shared<bvar::LatencyRecorder>(
             _cache_base_path.c_str(), 
"file_cache_need_update_lru_blocks_length");
     _need_update_lru_blocks_produce_metrics = 
std::make_shared<bvar::Adder<size_t>>(
@@ -990,6 +994,63 @@ bool BlockFileCache::is_block_deleting(const 
FileBlockSPtr& block) const {
     return block->_is_deleting;
 }
 
+template <class T>
+    requires IsXLock<T>
+void BlockFileCache::mark_cell_deleting(FileBlockCell& cell, T& /* cache_lock 
*/) {
+    cell.file_block->set_deleting();
+    // The block stays in _files until its last reference is dropped. Remember 
it so that
+    // run_background_gc() can finish the job if that reference is not 
released through
+    // FileBlock::release_cache_reference(). remove() forgets it again when it 
erases the cell.
+    _deleting_blocks.emplace(cell.file_block->get_hash_value(), 
cell.file_block->offset());
+}
+
+size_t BlockFileCache::recycle_deleting_blocks(size_t batch_limit) {
+    if (batch_limit == 0) {
+        return 0;
+    }
+    size_t recycled = 0;
+    size_t pending = 0;
+    {
+        SCOPED_CACHE_LOCK(_mutex, this);
+        std::vector<FileBlockSPtr> to_remove;
+        for (const auto& [hash, offset] : _deleting_blocks) {
+            if (to_remove.size() >= batch_limit) {
+                break;
+            }
+            auto* cell = get_cell(hash, offset, cache_lock);
+            if (cell == nullptr || !cell->file_block->is_deleting()) 
[[unlikely]] {
+                // Cannot happen: remove() erases the key together with the 
cell.
+                DCHECK(false) << "stale deleting block, hash=" << 
hash.to_string()
+                              << " offset=" << offset;
+                continue;
+            }
+            if (!cell->releasable() ||
+                cell->file_block->state_unsafe() == 
FileBlock::State::DOWNLOADING) {
+                // Still in use, or still being downloaded. Look at it again 
in the next round.
+                continue;
+            }
+            to_remove.push_back(cell->file_block);
+        }
+        // remove() erases the key of every block it removes, so it must not 
run while
+        // _deleting_blocks is being iterated above.
+        for (auto& file_block : to_remove) {
+            std::lock_guard block_lock(file_block->_mutex);
+            // Deleting the file itself happens asynchronously: the storage 
must not be touched
+            // while the cache lock is held.
+            remove(file_block, cache_lock, block_lock, false);
+            ++recycled;
+        }
+        pending = _deleting_blocks.size();
+    }
+    if (recycled > 0) {
+        *_recycle_deleting_blocks_metrics << recycled;
+        LOG(INFO) << "recycled deleting blocks, path=" << _cache_base_path << 
" num=" << recycled
+                  << " pending=" << pending;
+    }
+    _deleting_block_count_metrics->set_value(pending);
+    return recycled;
+}
+
 std::string BlockFileCache::clear_file_cache_async() {
     return clear_file_cache_impl(false);
 }
@@ -1021,6 +1082,11 @@ std::string BlockFileCache::clear_file_cache_impl(bool 
sync_remove) {
             }
         }
 
+        // Drop the pending LRU updates before the scan below, not after it: 
every block still
+        // queued there carries an extra reference that would make the block 
look busy and send
+        // it down the wait-for-recycle path for no reason.
+        clear_need_update_lru_blocks();
+
         // Do not erase while walking _files above: remove() may erase the 
current map element.
         //
         // sync_remove only changes how already releasable DOWNLOADED blocks 
are deleted from
@@ -1029,8 +1095,9 @@ std::string BlockFileCache::clear_file_cache_impl(bool 
sync_remove) {
         for (auto& cell : deleting_cells) {
             if (!cell->releasable()) {
                 LOG(INFO) << "cell is not releasable, hash="
+                          << cell->file_block->get_hash_value().to_string()
                           << " offset=" << cell->file_block->offset();
-                cell->file_block->set_deleting();
+                mark_cell_deleting(*cell, cache_lock);
                 ++num_cells_wait_recycle;
                 continue;
             }
@@ -1041,7 +1108,6 @@ std::string BlockFileCache::clear_file_cache_impl(bool 
sync_remove) {
                 ++num_cells_to_delete;
             }
         }
-        clear_need_update_lru_blocks();
     }
 
     std::stringstream ss;
@@ -1318,7 +1384,7 @@ size_t BlockFileCache::try_release() {
             if (cell.releasable()) {
                 trash.emplace_back(&cell);
             } else {
-                cell.file_block->set_deleting();
+                mark_cell_deleting(cell, cache_lock);
             }
         }
     }
@@ -1551,7 +1617,7 @@ void BlockFileCache::remove_if_cached(const 
UInt128Wrapper& file_key) {
             if (cell.releasable()) {
                 to_remove.push_back(&cell);
             } else {
-                cell.file_block->set_deleting();
+                mark_cell_deleting(cell, cache_lock);
             }
         }
     }
@@ -1576,7 +1642,7 @@ void BlockFileCache::remove_if_cached_async(const 
UInt128Wrapper& file_key) {
             if (cell.releasable()) {
                 to_remove.push_back(&cell);
             } else {
-                cell.file_block->set_deleting();
+                mark_cell_deleting(cell, cache_lock);
             }
         }
     }
@@ -1802,7 +1868,8 @@ void BlockFileCache::remove(FileBlockSPtr file_block, T& 
cache_lock, U& block_lo
     auto* cell = get_cell(hash, offset, cache_lock);
     file_block->cell = nullptr;
     // Holder cleanup can race with prior cache metadata cleanup. In that case,
-    // skip the duplicate remove instead of touching a detached or replaced 
cell.
+    // skip the duplicate remove instead of touching a detached or replaced 
cell. That includes
+    // _deleting_blocks: a key found there now belongs to the block that 
replaced this one.
     if (cell == nullptr) {
         LOG(WARNING) << "remove skipped because cache cell is missing. hash=" 
<< hash.to_string()
                      << " offset=" << offset << " size=" << 
file_block->range().size()
@@ -1832,7 +1899,8 @@ void BlockFileCache::remove(FileBlockSPtr file_block, T& 
cache_lock, U& block_lo
                                     : "<null>");
         return;
     }
-    DCHECK(cell->queue_iterator);
+    // No iterator means the block was already taken out of its queue by an 
earlier remove()
+    // that could not erase the cell because the block was still downloading.
     if (cell->queue_iterator) {
         auto& queue = get_queue(file_block->cache_type());
         queue.remove(*cell->queue_iterator, cache_lock);
@@ -1888,7 +1956,11 @@ void BlockFileCache::remove(FileBlockSPtr file_block, T& 
cache_lock, U& block_lo
             }
         }
     } else if (state == FileBlock::State::DOWNLOADING) {
-        file_block->set_deleting();
+        // The block is already out of its LRU queue, but the cell stays in 
_files until the
+        // downloader is done with it. Forget the iterator: it points at a 
freed queue entry now,
+        // and whoever finally erases this cell must not touch it again.
+        cell->queue_iterator.reset();
+        mark_cell_deleting(*cell, cache_lock);
         return;
     } else {
         *_evict_not_downloaded_size_metrics << file_block->range().size();
@@ -1901,6 +1973,10 @@ void BlockFileCache::remove(FileBlockSPtr file_block, T& 
cache_lock, U& block_lo
     auto it = _files.find(hash);
     if (it != _files.end()) {
         it->second.erase(file_block->offset());
+        if (file_block->is_deleting()) {
+            // The key leaves _deleting_blocks together with its cell.
+            _deleting_blocks.erase(AccessKeyAndOffset(hash, offset));
+        }
         if (it->second.empty()) {
             _files.erase(hash);
         }
@@ -2113,8 +2189,12 @@ void BlockFileCache::change_cache_type(const 
UInt128Wrapper& hash, size_t offset
         auto& file_blocks = iter->second;
         if (auto cell_it = file_blocks.find(offset); cell_it != 
file_blocks.end()) {
             FileBlockCell& cell = cell_it->second;
+            // No iterator means remove() already took the block out of its 
queue while it was
+            // downloading. The block is deleting, so keep it out of every 
queue.
+            if (!cell.queue_iterator) {
+                return;
+            }
             auto& cur_queue = get_queue(cell.file_block->cache_type());
-            DCHECK(cell.queue_iterator.has_value());
             cur_queue.remove(*cell.queue_iterator, cache_lock);
             _lru_recorder->record_queue_event(
                     cell.file_block->cache_type(), CacheLRULogType::REMOVE,
@@ -2180,7 +2260,7 @@ std::string BlockFileCache::reset_capacity(size_t 
new_capacity) {
                     queue_released += entry_size;
                     auto* cell = get_cell(entry_key, entry_offset, cache_lock);
                     if (!cell->releasable()) {
-                        cell->file_block->set_deleting();
+                        mark_cell_deleting(*cell, cache_lock);
                         continue;
                     }
                     to_evict.push_back(cell);
@@ -2462,6 +2542,10 @@ void BlockFileCache::run_background_gc() {
             }
         }
 
+        // Blocks that were busy when they were marked deleting, but are not 
referenced any
+        // more, are only reclaimed here.
+        recycle_deleting_blocks(batch_limit);
+
         while (batch_count < batch_limit && _recycle_keys.try_dequeue(key)) {
             int64_t duration_ns = 0;
             Status st;
@@ -2547,6 +2631,9 @@ void BlockFileCache::run_background_block_lru_update() {
                 update_block_lru(block, cache_lock);
             }
         }
+        // Drop the references now instead of at the top of the next round: 
holding them across
+        // the sleep would keep every block in this batch looking busy for a 
whole interval.
+        batch.clear();
         *_update_lru_blocks_latency_us << (duration_ns / 1000);
         *_need_update_lru_blocks_length_recorder << 
_need_update_lru_blocks.size();
     }
diff --git a/be/src/io/cache/block_file_cache.h 
b/be/src/io/cache/block_file_cache.h
index 69c79c50e52..89c03129b18 100644
--- a/be/src/io/cache/block_file_cache.h
+++ b/be/src/io/cache/block_file_cache.h
@@ -33,6 +33,7 @@
 #include <optional>
 #include <thread>
 #include <unordered_map>
+#include <unordered_set>
 #include <vector>
 
 #include "io/cache/block_file_cache_ttl_mgr.h"
@@ -425,7 +426,8 @@ public:
 private:
     // Shared scan used by both clear modes. It keeps the FileBlock holder 
lifecycle intact:
     // releasable blocks are removed immediately, while blocks held by readers 
are only marked
-    // deleting and are later removed by FileBlocksHolder destruction.
+    // deleting and are later removed by FileBlocksHolder destruction or, when 
the last holder
+    // does not release through FileBlock::release_cache_reference(), by 
run_background_gc().
     std::string clear_file_cache_impl(bool sync_remove);
 
     LRUQueue& get_queue(FileCacheType type);
@@ -542,6 +544,17 @@ private:
 
     void clear_need_update_lru_blocks();
 
+    // Marks a cell that cannot be erased yet as deleting and remembers it in 
_deleting_blocks, so
+    // that the block is still reclaimed when its last reference is dropped 
without going through
+    // FileBlock::release_cache_reference(). This is the only place that sets 
a block deleting.
+    template <class T>
+        requires IsXLock<T>
+    void mark_cell_deleting(FileBlockCell& cell, T& cache_lock);
+
+    // Removes up to batch_limit blocks that were marked deleting and are not 
referenced any
+    // more. Returns how many of them were removed.
+    size_t recycle_deleting_blocks(size_t batch_limit);
+
     // info
     std::string _cache_base_path;
     size_t _capacity = 0;
@@ -586,6 +599,18 @@ private:
     // keys for async remove
     RecycleFileCacheKeys _recycle_keys;
 
+    // Blocks that were busy when something asked to delete them. They keep 
sitting in _files
+    // until their last reference goes away. Only FileBlocksHolder and 
FileBlocksProbeResult
+    // release through FileBlock::release_cache_reference(), which is the one 
path that notices
+    // is_deleting and removes the block; every other reference holder just 
drops its shared_ptr
+    // and would strand the block here forever. run_background_gc() rechecks 
these candidates and
+    // removes the ones that became releasable.
+    //
+    // Invariant: holds exactly the keys of the cells in _files whose block is 
deleting. A key is
+    // added by mark_cell_deleting() and erased by remove() together with its 
cell, so a block
+    // created later at the same offset is never taken for the one that was 
marked.
+    std::unordered_set<AccessKeyAndOffset, KeyAndOffsetHash> _deleting_blocks;
+
     std::unique_ptr<LRUQueueRecorder> _lru_recorder;
     std::unique_ptr<CacheLRUDumper> _lru_dumper;
     std::unique_ptr<BlockFileCacheTtlMgr> _ttl_mgr;
@@ -653,6 +678,8 @@ private:
     std::shared_ptr<bvar::LatencyRecorder> _storage_async_remove_latency_us;
     std::shared_ptr<bvar::LatencyRecorder> _evict_in_advance_latency_us;
     std::shared_ptr<bvar::LatencyRecorder> _recycle_keys_length_recorder;
+    std::shared_ptr<bvar::Status<size_t>> _deleting_block_count_metrics;
+    std::shared_ptr<bvar::Adder<size_t>> _recycle_deleting_blocks_metrics;
     std::shared_ptr<bvar::LatencyRecorder> _update_lru_blocks_latency_us;
     std::shared_ptr<bvar::LatencyRecorder> 
_need_update_lru_blocks_length_recorder;
     std::shared_ptr<bvar::Adder<size_t>> 
_need_update_lru_blocks_produce_metrics;
diff --git a/be/test/io/cache/block_file_cache_test.cpp 
b/be/test/io/cache/block_file_cache_test.cpp
index 85935068f9c..549efd6b67e 100644
--- a/be/test/io/cache/block_file_cache_test.cpp
+++ b/be/test/io/cache/block_file_cache_test.cpp
@@ -3700,6 +3700,564 @@ TEST_F(BlockFileCacheTest, 
clear_file_cache_sync_removes_releasable_blocks_synch
     }
 }
 
+TEST_F(BlockFileCacheTest, 
remove_downloading_block_forgets_its_queue_iterator) {
+    std::string my_cache_path = caches_dir / "remove_downloading_block" / "";
+    if (fs::exists(my_cache_path)) {
+        fs::remove_all(my_cache_path);
+    }
+    fs::create_directories(my_cache_path);
+
+    constexpr size_t block_size = 4;
+    io::FileCacheSettings settings;
+    settings.query_queue_size = 64;
+    settings.query_queue_elements = 16;
+    settings.index_queue_size = 64;
+    settings.index_queue_elements = 16;
+    settings.disposable_queue_size = 64;
+    settings.disposable_queue_elements = 16;
+    settings.capacity = 192;
+    settings.max_file_block_size = 64;
+    settings.max_query_cache_size = 64;
+
+    io::BlockFileCache cache(my_cache_path, settings);
+    ASSERT_TRUE(cache.initialize());
+    wait_until_cache_ready(cache);
+
+    io::CacheContext context;
+    context.cache_type = io::FileCacheType::NORMAL;
+    auto key = io::BlockFileCache::hash("remove_downloading_block");
+
+    io::FileBlockSPtr block;
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        auto* cell = cache.add_cell(key, context, 0, block_size, 
io::FileBlock::State::DOWNLOADED,
+                                    cache_lock);
+        ASSERT_NE(cell, nullptr);
+        FileBlockTestAccessor::set_state(*cell->file_block, 
io::FileBlock::State::DOWNLOADING);
+        FileBlockTestAccessor::set_downloader_id(*cell->file_block, 1234);
+        block = cell->file_block;
+    }
+    auto& queue = cache.get_queue(io::FileCacheType::NORMAL);
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        ASSERT_EQ(queue.get_elements_num(cache_lock), 1);
+    }
+
+    // A block that is still downloading is taken out of its LRU queue, but 
its cell stays in
+    // _files, so the iterator it holds points at a freed queue entry from 
here on.
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        std::lock_guard<std::mutex> block_lock(block->_mutex);
+        cache.remove(block, cache_lock, block_lock, false);
+    }
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        auto* cell = cache.get_cell(key, 0, cache_lock);
+        ASSERT_NE(cell, nullptr);
+        EXPECT_TRUE(cell->file_block->is_deleting());
+        EXPECT_EQ(cache._deleting_blocks.count(io::AccessKeyAndOffset(key, 
0)), 1);
+        EXPECT_FALSE(cell->queue_iterator.has_value());
+        EXPECT_EQ(queue.get_elements_num(cache_lock), 0);
+        EXPECT_EQ(queue.get_capacity(cache_lock), 0);
+    }
+
+    // The download finishes and the last holder releases the block: this 
second remove() is the
+    // one that erases the cell, and it must not touch the queue entry again.
+    FileBlockTestAccessor::set_state(*block, io::FileBlock::State::DOWNLOADED);
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        std::lock_guard<std::mutex> block_lock(block->_mutex);
+        cache.remove(block, cache_lock, block_lock, false);
+    }
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        EXPECT_EQ(cache.get_cell(key, 0, cache_lock), nullptr);
+        EXPECT_TRUE(cache._deleting_blocks.empty());
+        EXPECT_EQ(queue.get_elements_num(cache_lock), 0);
+        EXPECT_EQ(queue.get_capacity(cache_lock), 0);
+    }
+    EXPECT_EQ(cache._cur_cache_size, 0);
+
+    block.reset();
+    if (fs::exists(my_cache_path)) {
+        fs::remove_all(my_cache_path);
+    }
+}
+
+TEST_F(BlockFileCacheTest, 
change_cache_type_keeps_removed_downloading_block_out_of_queues) {
+    std::string my_cache_path = caches_dir / 
"change_cache_type_removed_downloading_block" / "";
+    if (fs::exists(my_cache_path)) {
+        fs::remove_all(my_cache_path);
+    }
+    fs::create_directories(my_cache_path);
+
+    constexpr size_t block_size = 4;
+    io::FileCacheSettings settings;
+    settings.query_queue_size = 64;
+    settings.query_queue_elements = 16;
+    settings.index_queue_size = 64;
+    settings.index_queue_elements = 16;
+    settings.disposable_queue_size = 64;
+    settings.disposable_queue_elements = 16;
+    settings.capacity = 192;
+    settings.max_file_block_size = 64;
+    settings.max_query_cache_size = 64;
+
+    io::BlockFileCache cache(my_cache_path, settings);
+    ASSERT_TRUE(cache.initialize());
+    wait_until_cache_ready(cache);
+
+    io::CacheContext context;
+    context.cache_type = io::FileCacheType::NORMAL;
+    auto key = 
io::BlockFileCache::hash("change_cache_type_removed_downloading_block");
+
+    io::FileBlockSPtr block;
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        auto* cell = cache.add_cell(key, context, 0, block_size, 
io::FileBlock::State::DOWNLOADED,
+                                    cache_lock);
+        ASSERT_NE(cell, nullptr);
+        FileBlockTestAccessor::set_state(*cell->file_block, 
io::FileBlock::State::DOWNLOADING);
+        FileBlockTestAccessor::set_downloader_id(*cell->file_block, 1234);
+        block = cell->file_block;
+    }
+    // remove() takes a downloading block out of its LRU queue but leaves the 
cell in _files.
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        std::lock_guard<std::mutex> block_lock(block->_mutex);
+        cache.remove(block, cache_lock, block_lock, false);
+    }
+
+    // The download finishes, and the block is converted to another cache type 
before its holder
+    // lets go of it, as BlockFileCacheTtlMgr does. It must stay out of every 
queue.
+    FileBlockTestAccessor::set_state(*block, io::FileBlock::State::DOWNLOADED);
+    auto& normal_queue = cache.get_queue(io::FileCacheType::NORMAL);
+    auto& ttl_queue = cache.get_queue(io::FileCacheType::TTL);
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        cache.change_cache_type(key, 0, io::FileCacheType::TTL, cache_lock);
+        auto* cell = cache.get_cell(key, 0, cache_lock);
+        ASSERT_NE(cell, nullptr);
+        EXPECT_FALSE(cell->queue_iterator.has_value());
+        EXPECT_EQ(normal_queue.get_elements_num(cache_lock), 0);
+        EXPECT_EQ(ttl_queue.get_elements_num(cache_lock), 0);
+    }
+
+    // The holder releases the block: erasing the cell must not touch any 
queue either.
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        std::lock_guard<std::mutex> block_lock(block->_mutex);
+        cache.remove(block, cache_lock, block_lock, false);
+    }
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        EXPECT_EQ(cache.get_cell(key, 0, cache_lock), nullptr);
+        EXPECT_EQ(normal_queue.get_elements_num(cache_lock), 0);
+        EXPECT_EQ(normal_queue.get_capacity(cache_lock), 0);
+        EXPECT_EQ(ttl_queue.get_elements_num(cache_lock), 0);
+        EXPECT_EQ(ttl_queue.get_capacity(cache_lock), 0);
+    }
+    EXPECT_EQ(cache._cur_cache_size, 0);
+
+    block.reset();
+    if (fs::exists(my_cache_path)) {
+        fs::remove_all(my_cache_path);
+    }
+}
+
+TEST_F(BlockFileCacheTest, 
recycle_deleting_blocks_reclaims_stranded_busy_blocks) {
+    std::string my_cache_path = caches_dir / "recycle_deleting_blocks" / "";
+    if (fs::exists(my_cache_path)) {
+        fs::remove_all(my_cache_path);
+    }
+    fs::create_directories(my_cache_path);
+
+    constexpr int num_blocks = 8;
+    constexpr size_t block_size = 1;
+    io::FileCacheSettings settings;
+    settings.query_queue_size = num_blocks * block_size * 2;
+    settings.query_queue_elements = num_blocks + 16;
+    settings.index_queue_size = num_blocks * block_size * 2;
+    settings.index_queue_elements = num_blocks + 16;
+    settings.disposable_queue_size = num_blocks * block_size * 2;
+    settings.disposable_queue_elements = num_blocks + 16;
+    settings.capacity = num_blocks * block_size * 2;
+    settings.max_file_block_size = 64;
+    settings.max_query_cache_size = num_blocks * block_size * 2;
+
+    int64_t origin_qps_limit = config::file_cache_remove_block_qps_limit;
+    // Give the background GC thread no removal budget: the explicit 
recycle_deleting_blocks()
+    // calls below must be the only thing reclaiming blocks, otherwise the 
assertions race with
+    // it. The GC interval stays short, so ~BlockFileCache() never waits long 
for that thread.
+    config::file_cache_remove_block_qps_limit = 0;
+    Defer restore_qps_limit {[&] { config::file_cache_remove_block_qps_limit = 
origin_qps_limit; }};
+
+    io::BlockFileCache cache(my_cache_path, settings);
+    ASSERT_TRUE(cache.initialize());
+    wait_until_cache_ready(cache);
+
+    io::CacheContext context;
+    context.cache_type = io::FileCacheType::NORMAL;
+    auto key = io::BlockFileCache::hash("recycle_deleting_blocks");
+
+    // A plain shared_ptr, standing in for the reference holders that do not 
release through
+    // FileBlock::release_cache_reference(): the background LRU update queue, 
the block map of
+    // CachedRemoteFileReader, the block vector of BlockFileCacheTtlMgr.
+    std::vector<io::FileBlockSPtr> busy_refs;
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        for (int i = 0; i < num_blocks; ++i) {
+            auto* cell = cache.add_cell(key, context, i * block_size, 
block_size,
+                                        io::FileBlock::State::DOWNLOADED, 
cache_lock);
+            ASSERT_NE(cell, nullptr);
+            busy_refs.push_back(cell->file_block);
+        }
+    }
+
+    std::atomic<int> storage_remove_calls {0};
+    auto sp = SyncPoint::get_instance();
+    SyncPoint::CallbackGuard guard;
+    sp->set_call_back(
+            "FSFileCacheStorage::remove",
+            [&storage_remove_calls](auto&& args) {
+                storage_remove_calls.fetch_add(1);
+                try_any_cast_ret<Status>(args)->second = true;
+            },
+            &guard);
+    sp->enable_processing();
+    Defer defer {[sp] {
+        sp->disable_processing();
+        sp->clear_all_call_backs();
+    }};
+
+    // Every block is busy, so clear() can only mark them and wait.
+    auto msg = cache.clear_file_cache_sync();
+    EXPECT_NE(msg.find("num_cells_wait_recycle=" + 
std::to_string(num_blocks)), std::string::npos);
+    EXPECT_EQ(storage_remove_calls.load(), 0);
+    EXPECT_EQ(cache._cur_cache_size, num_blocks * block_size);
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        EXPECT_EQ(cache._deleting_blocks.size(), num_blocks);
+        for (int i = 0; i < num_blocks; ++i) {
+            auto* cell = cache.get_cell(key, i * block_size, cache_lock);
+            ASSERT_NE(cell, nullptr);
+            EXPECT_TRUE(cell->file_block->is_deleting());
+        }
+    }
+
+    // Blocks that are still referenced must survive the sweep.
+    EXPECT_EQ(cache.recycle_deleting_blocks(num_blocks), 0);
+    EXPECT_EQ(cache._cur_cache_size, num_blocks * block_size);
+
+    // Dropping the references this way never reaches 
FileBlock::release_cache_reference(), so
+    // without the sweep below the blocks would stay in _files and on disk 
forever.
+    busy_refs.clear();
+
+    EXPECT_EQ(cache.recycle_deleting_blocks(num_blocks), num_blocks);
+    EXPECT_EQ(cache._cur_cache_size, 0);
+    // The sweep runs under the cache lock, so it hands the files to the async 
recycle queue
+    // that run_background_gc() drains right after.
+    EXPECT_EQ(cache._recycle_keys.size_approx(), num_blocks);
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        EXPECT_TRUE(cache._deleting_blocks.empty());
+        for (int i = 0; i < num_blocks; ++i) {
+            EXPECT_EQ(cache.get_cell(key, i * block_size, cache_lock), 
nullptr);
+        }
+    }
+
+    // Nothing left to do, and no block is looked at twice.
+    EXPECT_EQ(cache.recycle_deleting_blocks(num_blocks), 0);
+
+    if (fs::exists(my_cache_path)) {
+        fs::remove_all(my_cache_path);
+    }
+}
+
+TEST_F(BlockFileCacheTest, recycle_deleting_blocks_honours_batch_limit) {
+    std::string my_cache_path = caches_dir / "recycle_deleting_blocks_batch" / 
"";
+    if (fs::exists(my_cache_path)) {
+        fs::remove_all(my_cache_path);
+    }
+    fs::create_directories(my_cache_path);
+
+    constexpr int num_blocks = 10;
+    constexpr size_t block_size = 1;
+    io::FileCacheSettings settings;
+    settings.query_queue_size = num_blocks * block_size * 2;
+    settings.query_queue_elements = num_blocks + 16;
+    settings.index_queue_size = num_blocks * block_size * 2;
+    settings.index_queue_elements = num_blocks + 16;
+    settings.disposable_queue_size = num_blocks * block_size * 2;
+    settings.disposable_queue_elements = num_blocks + 16;
+    settings.capacity = num_blocks * block_size * 2;
+    settings.max_file_block_size = 64;
+    settings.max_query_cache_size = num_blocks * block_size * 2;
+
+    int64_t origin_qps_limit = config::file_cache_remove_block_qps_limit;
+    // Give the background GC thread no removal budget: the explicit 
recycle_deleting_blocks()
+    // calls below must be the only thing reclaiming blocks, otherwise the 
assertions race with
+    // it. The GC interval stays short, so ~BlockFileCache() never waits long 
for that thread.
+    config::file_cache_remove_block_qps_limit = 0;
+    Defer restore_qps_limit {[&] { config::file_cache_remove_block_qps_limit = 
origin_qps_limit; }};
+
+    io::BlockFileCache cache(my_cache_path, settings);
+    ASSERT_TRUE(cache.initialize());
+    wait_until_cache_ready(cache);
+
+    io::CacheContext context;
+    context.cache_type = io::FileCacheType::NORMAL;
+    auto key = io::BlockFileCache::hash("recycle_deleting_blocks_batch");
+
+    std::vector<io::FileBlockSPtr> busy_refs;
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        for (int i = 0; i < num_blocks; ++i) {
+            auto* cell = cache.add_cell(key, context, i * block_size, 
block_size,
+                                        io::FileBlock::State::DOWNLOADED, 
cache_lock);
+            ASSERT_NE(cell, nullptr);
+            busy_refs.push_back(cell->file_block);
+        }
+    }
+
+    cache.clear_file_cache_sync();
+    busy_refs.clear();
+
+    EXPECT_EQ(cache.recycle_deleting_blocks(0), 0);
+    EXPECT_EQ(cache.recycle_deleting_blocks(4), 4);
+    EXPECT_EQ(cache.recycle_deleting_blocks(4), 4);
+    EXPECT_EQ(cache.recycle_deleting_blocks(4), 2);
+    EXPECT_EQ(cache.recycle_deleting_blocks(4), 0);
+    EXPECT_EQ(cache._cur_cache_size, 0);
+
+    if (fs::exists(my_cache_path)) {
+        fs::remove_all(my_cache_path);
+    }
+}
+
+TEST_F(BlockFileCacheTest, 
recycle_deleting_blocks_ignores_block_recreated_at_same_offset) {
+    std::string my_cache_path = caches_dir / 
"recycle_deleting_blocks_recreated" / "";
+    if (fs::exists(my_cache_path)) {
+        fs::remove_all(my_cache_path);
+    }
+    fs::create_directories(my_cache_path);
+
+    constexpr size_t block_size = 4;
+    io::FileCacheSettings settings;
+    settings.query_queue_size = 64;
+    settings.query_queue_elements = 16;
+    settings.index_queue_size = 64;
+    settings.index_queue_elements = 16;
+    settings.disposable_queue_size = 64;
+    settings.disposable_queue_elements = 16;
+    settings.capacity = 192;
+    settings.max_file_block_size = 64;
+    settings.max_query_cache_size = 64;
+
+    int64_t origin_qps_limit = config::file_cache_remove_block_qps_limit;
+    // Give the background GC thread no removal budget: the explicit 
recycle_deleting_blocks()
+    // calls below must be the only thing reclaiming blocks, otherwise the 
assertions race with
+    // it. The GC interval stays short, so ~BlockFileCache() never waits long 
for that thread.
+    config::file_cache_remove_block_qps_limit = 0;
+    Defer restore_qps_limit {[&] { config::file_cache_remove_block_qps_limit = 
origin_qps_limit; }};
+
+    io::BlockFileCache cache(my_cache_path, settings);
+    ASSERT_TRUE(cache.initialize());
+    wait_until_cache_ready(cache);
+
+    io::CacheContext context;
+    context.cache_type = io::FileCacheType::NORMAL;
+    auto key = io::BlockFileCache::hash("recycle_deleting_blocks_recreated");
+
+    // A reader holds the block while the cache is cleared, so the block is 
only marked deleting.
+    io::FileBlocks old_blocks;
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        auto* cell = cache.add_cell(key, context, 0, block_size, 
io::FileBlock::State::DOWNLOADED,
+                                    cache_lock);
+        ASSERT_NE(cell, nullptr);
+        old_blocks.push_back(cell->file_block);
+    }
+    cache.clear_file_cache_sync();
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        EXPECT_EQ(cache._deleting_blocks.size(), 1);
+    }
+
+    // The reader releases it through its holder, which removes the block and 
its key.
+    { io::FileBlocksHolder holder(std::move(old_blocks)); }
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        EXPECT_EQ(cache.get_cell(key, 0, cache_lock), nullptr);
+        EXPECT_TRUE(cache._deleting_blocks.empty());
+    }
+
+    // The same offset is read again and cached by a new block before the next 
GC round.
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        auto* cell = cache.add_cell(key, context, 0, block_size, 
io::FileBlock::State::DOWNLOADED,
+                                    cache_lock);
+        ASSERT_NE(cell, nullptr);
+        EXPECT_FALSE(cell->file_block->is_deleting());
+    }
+
+    // The sweep must not take the new block for the one that was marked 
deleting.
+    EXPECT_EQ(cache.recycle_deleting_blocks(16), 0);
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        EXPECT_NE(cache.get_cell(key, 0, cache_lock), nullptr);
+    }
+    EXPECT_EQ(cache._cur_cache_size, block_size);
+
+    if (fs::exists(my_cache_path)) {
+        fs::remove_all(my_cache_path);
+    }
+}
+
+TEST_F(BlockFileCacheTest, recycle_deleting_blocks_reclaims_empty_blocks) {
+    std::string my_cache_path = caches_dir / "recycle_deleting_blocks_empty" / 
"";
+    if (fs::exists(my_cache_path)) {
+        fs::remove_all(my_cache_path);
+    }
+    fs::create_directories(my_cache_path);
+
+    constexpr size_t block_size = 4;
+    io::FileCacheSettings settings;
+    settings.query_queue_size = 64;
+    settings.query_queue_elements = 16;
+    settings.index_queue_size = 64;
+    settings.index_queue_elements = 16;
+    settings.disposable_queue_size = 64;
+    settings.disposable_queue_elements = 16;
+    settings.capacity = 192;
+    settings.max_file_block_size = 64;
+    settings.max_query_cache_size = 64;
+
+    int64_t origin_qps_limit = config::file_cache_remove_block_qps_limit;
+    // Give the background GC thread no removal budget: the explicit 
recycle_deleting_blocks()
+    // calls below must be the only thing reclaiming blocks, otherwise the 
assertions race with
+    // it. The GC interval stays short, so ~BlockFileCache() never waits long 
for that thread.
+    config::file_cache_remove_block_qps_limit = 0;
+    Defer restore_qps_limit {[&] { config::file_cache_remove_block_qps_limit = 
origin_qps_limit; }};
+
+    io::BlockFileCache cache(my_cache_path, settings);
+    ASSERT_TRUE(cache.initialize());
+    wait_until_cache_ready(cache);
+
+    io::CacheContext context;
+    context.cache_type = io::FileCacheType::NORMAL;
+    auto key = io::BlockFileCache::hash("recycle_deleting_blocks_empty");
+
+    // A block nobody started to download, referenced by something that is not 
a holder.
+    io::FileBlockSPtr busy_ref;
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        auto* cell = cache.add_cell(key, context, 0, block_size, 
io::FileBlock::State::EMPTY,
+                                    cache_lock);
+        ASSERT_NE(cell, nullptr);
+        busy_ref = cell->file_block;
+    }
+    cache.clear_file_cache_sync();
+    busy_ref.reset();
+
+    // Nobody will ever download it, so the sweep has to remove it.
+    EXPECT_EQ(cache.recycle_deleting_blocks(16), 1);
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        EXPECT_EQ(cache.get_cell(key, 0, cache_lock), nullptr);
+        EXPECT_TRUE(cache._deleting_blocks.empty());
+    }
+    EXPECT_EQ(cache._cur_cache_size, 0);
+
+    if (fs::exists(my_cache_path)) {
+        fs::remove_all(my_cache_path);
+    }
+}
+
+TEST_F(BlockFileCacheTest, 
recycle_deleting_blocks_reclaims_block_removed_while_downloading) {
+    std::string my_cache_path = caches_dir / 
"recycle_deleting_blocks_downloading" / "";
+    if (fs::exists(my_cache_path)) {
+        fs::remove_all(my_cache_path);
+    }
+    fs::create_directories(my_cache_path);
+
+    constexpr size_t block_size = 4;
+    io::FileCacheSettings settings;
+    settings.query_queue_size = 64;
+    settings.query_queue_elements = 16;
+    settings.index_queue_size = 64;
+    settings.index_queue_elements = 16;
+    settings.disposable_queue_size = 64;
+    settings.disposable_queue_elements = 16;
+    settings.capacity = 192;
+    settings.max_file_block_size = 64;
+    settings.max_query_cache_size = 64;
+
+    int64_t origin_qps_limit = config::file_cache_remove_block_qps_limit;
+    // Give the background GC thread no removal budget: the explicit 
recycle_deleting_blocks()
+    // calls below must be the only thing reclaiming blocks, otherwise the 
assertions race with
+    // it. The GC interval stays short, so ~BlockFileCache() never waits long 
for that thread.
+    config::file_cache_remove_block_qps_limit = 0;
+    Defer restore_qps_limit {[&] { config::file_cache_remove_block_qps_limit = 
origin_qps_limit; }};
+
+    io::BlockFileCache cache(my_cache_path, settings);
+    ASSERT_TRUE(cache.initialize());
+    wait_until_cache_ready(cache);
+
+    io::CacheContext context;
+    context.cache_type = io::FileCacheType::NORMAL;
+    auto key = io::BlockFileCache::hash("recycle_deleting_blocks_downloading");
+
+    io::FileBlockSPtr block;
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        auto* cell = cache.add_cell(key, context, 0, block_size, 
io::FileBlock::State::DOWNLOADED,
+                                    cache_lock);
+        ASSERT_NE(cell, nullptr);
+        FileBlockTestAccessor::set_state(*cell->file_block, 
io::FileBlock::State::DOWNLOADING);
+        FileBlockTestAccessor::set_downloader_id(*cell->file_block, 1234);
+        block = cell->file_block;
+    }
+
+    // remove() cannot erase a block that is still downloading, it only marks 
it deleting.
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        std::lock_guard<std::mutex> block_lock(block->_mutex);
+        cache.remove(block, cache_lock, block_lock, false);
+    }
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        EXPECT_EQ(cache._deleting_blocks.count(io::AccessKeyAndOffset(key, 
0)), 1);
+    }
+
+    // Drop the reference the way a non-holder does. The sweep still leaves 
the block to its
+    // downloader.
+    block.reset();
+    EXPECT_EQ(cache.recycle_deleting_blocks(16), 0);
+
+    // Once the download is done nobody would remove the block but the sweep.
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        auto* cell = cache.get_cell(key, 0, cache_lock);
+        ASSERT_NE(cell, nullptr);
+        FileBlockTestAccessor::set_state(*cell->file_block, 
io::FileBlock::State::DOWNLOADED);
+    }
+    EXPECT_EQ(cache.recycle_deleting_blocks(16), 1);
+    {
+        std::lock_guard<std::mutex> cache_lock(cache._mutex);
+        auto& queue = cache.get_queue(io::FileCacheType::NORMAL);
+        EXPECT_EQ(cache.get_cell(key, 0, cache_lock), nullptr);
+        EXPECT_TRUE(cache._deleting_blocks.empty());
+        EXPECT_EQ(queue.get_elements_num(cache_lock), 0);
+        EXPECT_EQ(queue.get_capacity(cache_lock), 0);
+    }
+    EXPECT_EQ(cache._cur_cache_size, 0);
+
+    if (fs::exists(my_cache_path)) {
+        fs::remove_all(my_cache_path);
+    }
+}
+
 TEST_F(BlockFileCacheTest, 
clear_file_cache_sync_factory_rejects_concurrent_sync_clear) {
     std::string my_cache_path = caches_dir / 
"clear_file_cache_sync_factory_busy" / "";
     if (fs::exists(my_cache_path)) {


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

Reply via email to