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

liaoxin01 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 65d5751d0f9 [fix](filecache) convert blocks back to TTL when an 
expired TTL is extended (#67971)
65d5751d0f9 is described below

commit 65d5751d0f955e87a59e6093c5b235d93cac95b5
Author: Xin Liao <[email protected]>
AuthorDate: Wed Sep 16 20:47:19 2026 +0800

    [fix](filecache) convert blocks back to TTL when an expired TTL is extended 
(#67971)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: None
    
    Problem Summary:
    
    `BlockFileCacheTtlMgr` decided whether to promote a tablet's cached
    blocks into the TTL queue from `was_zero_ttl`, which is true only when
    `_ttl_info_map` holds no entry for the tablet. Since the entry is erased
    only when the tablet's TTL is zero, that flag really answers "has this
    BE ever seen a non-zero TTL here", not "are the blocks outside the TTL
    queue".
    
    The expiration check demotes blocks back to NORMAL but leaves the
    non-zero TTL in the map. So once a TTL expired, the promotion path was
    closed for that tablet for good: extending the expired TTL to a value
    that has not expired updated FE, Meta Service and `_ttl_info_map`, yet
    the blocks stayed in the normal queue and no later round ever brought
    them back. They were then evicted under the normal policy while `SHOW
    CREATE TABLE` and `information_schema.file_cache_info` disagreed about
    whether TTL applied. Nothing was logged, because the conversion was
    never attempted.
    
    ### Release note
    
    Fixed cached blocks staying out of the TTL queue when a table's expired
    `file_cache_ttl_seconds` is extended to a value that has not expired.
    
    ### What is changed and how it works?
    
    **1. Record what was applied instead of inferring it.** `TtlInfo`
    carries `blocks_promoted` — true once this manager has put the tablet's
    blocks into the TTL queue and nothing has taken them out since. It
    records what the manager did, not what the blocks are, so it starts
    false for a tablet seen for the first time after a restart, whose blocks
    on disk may well be TTL already.
    
    **2. Skip only when the TTL is still running and the blocks are already
    promoted.** The two directions are not symmetric:
    
    - *Promotion is edge triggered.* Once the blocks are in the TTL queue
    nothing takes them out behind our back, so rescanning a tablet whose TTL
    is still running is pure waste. This is also what makes a TTL rewritten
    to another still-valid value free, which matters where the property is
    rewritten on a schedule — the decision is a state comparison, not a
    comparison of TTL values.
    - *Demotion is level triggered.* Blocks can still land in the TTL queue
    after a tablet was demoted, and `blocks_promoted` is per tablet, so it
    cannot tell whether any have. Rescanning is the only way to collect
    them.
    
    | TTL state | `blocks_promoted` | action |
    |---|---|---|
    | running | true | skip |
    | running | false | scan, promote |
    | expired / none | true | scan, demote |
    | expired | false | scan, demote |
    | none | false | stop tracking the tablet |
    
    **3. Serialize transitions per tablet.** Both background threads funnel
    through `reconcile_tablet_blocks()`, serialized by a striped lock,
    re-reading the tablet's state after taking it. The expiration check
    previously decided from a snapshot of `_ttl_info_map` that could be a
    full gc interval old, so it could demote blocks the update thread had
    just promoted under a newly extended TTL, and neither thread would touch
    them again. Its snapshot is now only a candidate list.
    
    **4. Do not hold `_ttl_info_mutex` across the block scan.** It walks the
    meta store and takes the cache lock once per block, which on a tablet
    with a few thousand cached blocks blocked every other user of the map
    for the duration.
    
    **5. A failed conversion does not record success.**
    `change_cache_type()` rewrites storage metadata and can fail; recording
    the new state anyway left the tablet permanently mismatched, because
    every later round then saw state and wants agreeing. `blocks_promoted`
    is left alone unless every block converted, so the next round retries.
    
    **6. Stop tracking a tablet that has settled.** A tablet with no TTL
    whose blocks are not in the TTL queue is dropped from the map before the
    transition check, so one that settles without needing a conversion still
    stops being walked on every round.
    
    Adds a `file_cache_ttl_mgr_ttl_info_map_size` bvar and an INFO line
    naming the tablet, direction and block count on each conversion.
---
 be/src/io/cache/block_file_cache.h                 |   4 +
 be/src/io/cache/block_file_cache_ttl_mgr.cpp       | 184 ++++++++----
 be/src/io/cache/block_file_cache_ttl_mgr.h         |  45 ++-
 be/test/io/cache/block_file_cache_ttl_mgr_test.cpp | 308 ++++++++++++++++++++-
 4 files changed, 478 insertions(+), 63 deletions(-)

diff --git a/be/src/io/cache/block_file_cache.h 
b/be/src/io/cache/block_file_cache.h
index 91c453e12aa..99830cad839 100644
--- a/be/src/io/cache/block_file_cache.h
+++ b/be/src/io/cache/block_file_cache.h
@@ -312,6 +312,10 @@ public:
 
     [[nodiscard]] bool get_async_open_success() const { return 
_async_open_done; }
 
+    // The manager that keeps cached blocks in the cache type their tablet's 
TTL asks for.
+    // Exposed so that tests can drive it deterministically rather than race 
its threads.
+    BlockFileCacheTtlMgr* get_ttl_mgr() { return _ttl_mgr.get(); }
+
     BlockFileCache& operator=(const BlockFileCache&) = delete;
     BlockFileCache(const BlockFileCache&) = delete;
 
diff --git a/be/src/io/cache/block_file_cache_ttl_mgr.cpp 
b/be/src/io/cache/block_file_cache_ttl_mgr.cpp
index b394591d42d..924526709b8 100644
--- a/be/src/io/cache/block_file_cache_ttl_mgr.cpp
+++ b/be/src/io/cache/block_file_cache_ttl_mgr.cpp
@@ -41,6 +41,8 @@ BlockFileCacheTtlMgr::BlockFileCacheTtlMgr(BlockFileCache* 
mgr, CacheBlockMetaSt
         : _mgr(mgr), _meta_store(meta_store), _stop_background(false) {
     _tablet_id_set_size_metrics = std::make_shared<bvar::Status<size_t>>(
             _mgr->get_base_path().c_str(), 
"file_cache_ttl_mgr_tablet_id_set_size", 0);
+    _ttl_info_map_size_metrics = std::make_shared<bvar::Status<size_t>>(
+            _mgr->get_base_path().c_str(), 
"file_cache_ttl_mgr_ttl_info_map_size", 0);
     resume();
 }
 
@@ -85,6 +87,17 @@ void BlockFileCacheTtlMgr::register_tablet_id(int64_t 
tablet_id) {
     _tablet_id_queue.enqueue(tablet_id);
 }
 
+void BlockFileCacheTtlMgr::update_ttl_info_map_size_metrics() {
+    if (_ttl_info_map_size_metrics) {
+        _ttl_info_map_size_metrics->set_value(_ttl_info_map.size());
+    }
+}
+
+size_t BlockFileCacheTtlMgr::tracked_tablet_num() {
+    std::lock_guard<std::mutex> lock(_ttl_info_mutex);
+    return _ttl_info_map.size();
+}
+
 void BlockFileCacheTtlMgr::run_background_tablet_id_flush() {
     Thread::set_self_name("ttl_mgr_flush");
 
@@ -168,6 +181,83 @@ FileBlocks 
BlockFileCacheTtlMgr::get_file_blocks_from_tablet_id(int64_t tablet_i
     return result;
 }
 
+void BlockFileCacheTtlMgr::reconcile_tablet_blocks(int64_t tablet_id) {
+    // Serialize all conversions of this tablet. Whichever caller takes this 
lock last re-reads
+    // the state below and has the final say, so the update and expiration 
threads cannot fight
+    // over the same blocks and strand them in the loser's cache type.
+    std::lock_guard<std::mutex> 
transition_lock(transition_lock_for(tablet_id));
+
+    bool want_ttl = false;
+    bool blocks_promoted = false;
+    {
+        // Deliberately re-read the map rather than trust what the caller saw: 
the expiration
+        // thread picks its candidates up to a full gc interval before getting 
here.
+        std::lock_guard<std::mutex> lock(_ttl_info_mutex);
+        auto it = _ttl_info_map.find(tablet_id);
+        if (it != _ttl_info_map.end()) {
+            if (it->second.ttl == 0 && !it->second.blocks_promoted) {
+                // No TTL, and none of its blocks were put in the TTL queue by 
us: nothing left
+                // to track. Dropped here rather than after a conversion, so 
that a tablet which
+                // settles without needing one still stops being walked on 
every round.
+                _ttl_info_map.erase(it);
+                update_ttl_info_map_size_metrics();
+                return;
+            }
+            want_ttl = it->second.is_ttl_active(UnixSeconds());
+            blocks_promoted = it->second.blocks_promoted;
+        }
+    }
+
+    // The two directions are not symmetric.
+    //
+    // Promotion is edge triggered: once the blocks are in the TTL queue 
nothing takes them out
+    // behind our back, so rescanning a tablet whose TTL is still running is 
pure waste. This is
+    // also what makes a TTL rewritten to another still-valid value free, 
which matters where
+    // the property is rewritten on a schedule.
+    //
+    // Demotion is level triggered: blocks can still land in the TTL queue 
after a tablet was
+    // demoted, and what is recorded here is per tablet, so it cannot tell 
whether any have.
+    // Rescanning is the only way to collect them.
+    if (want_ttl && blocks_promoted) {
+        return;
+    }
+
+    // Scan and convert outside _ttl_info_mutex: this walks the meta store and 
takes the cache
+    // lock once per block, which is far too long to hold a mutex the other 
thread needs.
+    const auto target_type = want_ttl ? FileCacheType::TTL : 
FileCacheType::NORMAL;
+    FileBlocks blocks = get_file_blocks_from_tablet_id(tablet_id);
+    size_t converted = 0;
+    bool all_converted = true;
+    for (auto& block : blocks) {
+        if (block->cache_type() == target_type) {
+            continue;
+        }
+        auto st = block->change_cache_type(target_type);
+        if (st.ok()) {
+            ++converted;
+        } else {
+            all_converted = false;
+            LOG(WARNING) << "Failed to convert block to " << 
cache_type_to_string(target_type)
+                         << " cache_type, tablet_id=" << tablet_id << ", err=" 
<< st;
+        }
+    }
+    if (converted > 0) {
+        LOG(INFO) << "converted cached blocks to " << 
cache_type_to_string(target_type)
+                  << ", tablet_id=" << tablet_id << ", block_num=" << converted
+                  << ", scanned=" << blocks.size();
+    }
+
+    {
+        std::lock_guard<std::mutex> lock(_ttl_info_mutex);
+        auto it = _ttl_info_map.find(tablet_id);
+        if (it != _ttl_info_map.end() && all_converted) {
+            // Left alone when a block failed to convert, so the mismatch 
stays visible to the
+            // next round and gets retried instead of being recorded as done.
+            it->second.blocks_promoted = want_ttl;
+        }
+    }
+}
+
 void BlockFileCacheTtlMgr::run_backgroud_update_ttl_info_map() {
     Thread::set_self_name("ttl_mgr_update");
 
@@ -203,7 +293,9 @@ void 
BlockFileCacheTtlMgr::run_backgroud_update_ttl_info_map() {
                         }
                         {
                             std::lock_guard<std::mutex> lock(_ttl_info_mutex);
-                            _ttl_info_map.erase(tablet_id);
+                            if (_ttl_info_map.erase(tablet_id) > 0) {
+                                update_ttl_info_map_size_metrics();
+                            }
                         }
                     } else {
                         LOG(WARNING) << "Failed to get tablet meta for 
tablet_id: " << tablet_id
@@ -220,47 +312,36 @@ void 
BlockFileCacheTtlMgr::run_backgroud_update_ttl_info_map() {
                     }
                 }
 
-                // Update TTL info map
-                bool need_convert_from_ttl = false;
+                // Record the TTL this tablet currently has, then let 
reconcile_tablet_blocks()
+                // decide whether that moves its blocks between the TTL and 
normal queues.
+                bool tracked = false;
                 {
                     std::lock_guard<std::mutex> lock(_ttl_info_mutex);
+                    auto it = _ttl_info_map.find(tablet_id);
                     if (ttl > 0) {
-                        auto old_info_it = _ttl_info_map.find(tablet_id);
-                        bool was_zero_ttl = (old_info_it == 
_ttl_info_map.end() ||
-                                             old_info_it->second.ttl == 0);
-                        _ttl_info_map[tablet_id] = TtlInfo {ttl, tablet_ctime};
-
-                        // If TTL changed from 0 to non-zero, convert blocks 
to TTL type
-                        if (was_zero_ttl) {
-                            FileBlocks blocks = 
get_file_blocks_from_tablet_id(tablet_id);
-                            for (auto& block : blocks) {
-                                if (block->cache_type() != FileCacheType::TTL) 
{
-                                    auto change_status =
-                                            
block->change_cache_type(FileCacheType::TTL);
-                                    if (!change_status.ok()) {
-                                        LOG(WARNING) << "Failed to convert 
block to TTL cache_type";
-                                    }
-                                }
-                            }
+                        if (it == _ttl_info_map.end()) {
+                            _ttl_info_map.emplace(tablet_id, TtlInfo {ttl, 
tablet_ctime});
+                            update_ttl_info_map_size_metrics();
+                        } else {
+                            // Keep blocks_promoted: it records what we did to 
the blocks, not
+                            // what the tablet meta says.
+                            it->second.ttl = ttl;
+                            it->second.tablet_ctime = tablet_ctime;
                         }
-                    } else {
-                        // Periodically reconcile blocks restored from 
persisted TTL metadata,
-                        // because _ttl_info_map is rebuilt only in memory 
after restart.
-                        need_convert_from_ttl =
-                                _ttl_info_map.erase(tablet_id) > 0 || 
need_full_reconcile;
+                        tracked = true;
+                    } else if (it != _ttl_info_map.end()) {
+                        // Hold on to the entry until the blocks are actually 
demoted; it
+                        // drops itself once the tablet has settled back to 
NORMAL.
+                        it->second.ttl = 0;
+                        tracked = true;
                     }
                 }
 
-                if (need_convert_from_ttl) {
-                    FileBlocks blocks = 
get_file_blocks_from_tablet_id(tablet_id);
-                    for (auto& block : blocks) {
-                        if (block->cache_type() == FileCacheType::TTL) {
-                            auto st = 
block->change_cache_type(FileCacheType::NORMAL);
-                            if (!st.ok()) {
-                                LOG(WARNING) << "Failed to convert block back 
to NORMAL cache_type";
-                            }
-                        }
-                    }
+                // An untracked tablet reconciles to NORMAL, which is how TTL 
blocks restored
+                // from persisted metadata are cleaned up after a restart. 
Gated on the periodic
+                // round so that ordinary non-TTL tablets are not walked every 
time.
+                if (tracked || need_full_reconcile) {
+                    reconcile_tablet_blocks(tablet_id);
                 }
             }
 
@@ -279,29 +360,26 @@ void 
BlockFileCacheTtlMgr::run_backgroud_expiration_check() {
 
     while (!_stop_background.load(std::memory_order_acquire)) {
         try {
-            std::map<int64_t, TtlInfo> ttl_info_copy;
-
-            // Copy TTL info for processing
+            // Collect tablets whose TTL has run out.
+            std::vector<int64_t> expired_tablet_ids;
             {
                 std::lock_guard<std::mutex> lock(_ttl_info_mutex);
-                ttl_info_copy = _ttl_info_map;
+                uint64_t current_time = UnixSeconds();
+                for (const auto& [tablet_id, ttl_info] : _ttl_info_map) {
+                    if (ttl_info.ttl > 0 && 
!ttl_info.is_ttl_active(current_time)) {
+                        expired_tablet_ids.push_back(tablet_id);
+                    }
+                }
             }
 
-            uint64_t current_time = UnixSeconds();
-
-            for (const auto& [tablet_id, ttl_info] : ttl_info_copy) {
-                if (ttl_info.tablet_ctime + ttl_info.ttl < current_time) {
-                    // Tablet has expired, convert TTL blocks back to NORMAL 
type
-                    FileBlocks blocks = 
get_file_blocks_from_tablet_id(tablet_id);
-                    for (auto& block : blocks) {
-                        if (block->cache_type() == FileCacheType::TTL) {
-                            auto st = 
block->change_cache_type(FileCacheType::NORMAL);
-                            if (!st.ok()) {
-                                LOG(WARNING) << "Failed to convert block back 
to NORMAL cache_type";
-                            }
-                        }
-                    }
+            // Only a candidate list: reconcile_tablet_blocks() re-reads the 
tablet's state
+            // under the per-tablet lock, so a TTL extended in between is 
never demoted on the
+            // strength of what was observed here.
+            for (int64_t tablet_id : expired_tablet_ids) {
+                if (_stop_background.load(std::memory_order_acquire)) {
+                    break;
                 }
+                reconcile_tablet_blocks(tablet_id);
             }
 
             std::this_thread::sleep_for(
diff --git a/be/src/io/cache/block_file_cache_ttl_mgr.h 
b/be/src/io/cache/block_file_cache_ttl_mgr.h
index 8cd677446f1..59e2233f284 100644
--- a/be/src/io/cache/block_file_cache_ttl_mgr.h
+++ b/be/src/io/cache/block_file_cache_ttl_mgr.h
@@ -22,7 +22,9 @@
 #include <bvar/bvar.h>
 #include <concurrentqueue.h>
 
+#include <array>
 #include <atomic>
+#include <limits>
 #include <map>
 #include <memory>
 #include <mutex>
@@ -38,8 +40,25 @@ class BlockFileCache;
 class CacheBlockMetaStore;
 
 struct TtlInfo {
-    uint64_t ttl;
-    uint64_t tablet_ctime;
+    uint64_t ttl = 0;
+    uint64_t tablet_ctime = 0;
+    // True once this manager has put the tablet's blocks into the TTL queue 
and nothing has
+    // taken them out since. Note it records what we did, not what the blocks 
are: it starts
+    // false for a tablet seen for the first time after a restart, whose 
blocks on disk may
+    // well be TTL already, because this process has no record of putting them 
there and must
+    // scan to find out.
+    bool blocks_promoted = false;
+
+    // Whether this tablet's blocks belong in the TTL queue right now.
+    bool is_ttl_active(uint64_t now) const {
+        if (ttl == 0 || tablet_ctime == 0) {
+            return false;
+        }
+        if (tablet_ctime > std::numeric_limits<uint64_t>::max() - ttl) {
+            return false;
+        }
+        return tablet_ctime + ttl >= now;
+    }
 };
 
 class BlockFileCacheTtlMgr {
@@ -48,6 +67,9 @@ public:
     ~BlockFileCacheTtlMgr();
 
     void register_tablet_id(int64_t tablet_id);
+    // Number of tablets whose TTL state is currently tracked. Mirrors the
+    // file_cache_ttl_mgr_ttl_info_map_size bvar; entry leaks are otherwise 
invisible.
+    size_t tracked_tablet_num();
     void stop();
     void resume();
 
@@ -61,6 +83,19 @@ public:
 private:
     FileBlocks get_file_blocks_from_tablet_id(int64_t tablet_id);
 
+    // Drive this tablet's cached blocks to the cache type its current TTL 
state asks for.
+    // Both background threads funnel through here and it is serialized per 
tablet, so they can
+    // never scan the same tablet concurrently and leave the blocks in 
whatever type the scan
+    // that happened to finish last wrote.
+    void reconcile_tablet_blocks(int64_t tablet_id);
+
+    // Caller must hold _ttl_info_mutex.
+    void update_ttl_info_map_size_metrics();
+
+    std::mutex& transition_lock_for(int64_t tablet_id) {
+        return _transition_locks[static_cast<uint64_t>(tablet_id) % 
kTransitionLockStripes];
+    }
+
 private:
     // Tablet ids waiting to be deduplicated + set of unique ids known to have 
cached data
     moodycamel::ConcurrentQueue<int64_t> _tablet_id_queue;
@@ -79,7 +114,13 @@ private:
 
     std::mutex _ttl_info_mutex;
 
+    // Striped locks serializing block conversions per tablet. Lock order is 
always
+    // _transition_locks[i] -> _ttl_info_mutex; _ttl_info_mutex is never held 
across a block scan.
+    static constexpr size_t kTransitionLockStripes = 64;
+    std::array<std::mutex, kTransitionLockStripes> _transition_locks;
+
     std::shared_ptr<bvar::Status<size_t>> _tablet_id_set_size_metrics;
+    std::shared_ptr<bvar::Status<size_t>> _ttl_info_map_size_metrics;
 };
 
 } // namespace doris::io
diff --git a/be/test/io/cache/block_file_cache_ttl_mgr_test.cpp 
b/be/test/io/cache/block_file_cache_ttl_mgr_test.cpp
index 3aa00e67ab4..9edd5754e3f 100644
--- a/be/test/io/cache/block_file_cache_ttl_mgr_test.cpp
+++ b/be/test/io/cache/block_file_cache_ttl_mgr_test.cpp
@@ -224,6 +224,14 @@ protected:
         ASSERT_TRUE(_cache->initialize());
         ASSERT_TRUE(wait_for_condition([this]() { return 
_cache->get_async_open_success(); },
                                        std::chrono::seconds(5)));
+
+        // initialize() starts a TTL manager of its own against this cache. 
Every case below
+        // drives one it owns, and two of them converting the same blocks 
makes both the
+        // conversions and the scan counts nondeterministic -- a block can be 
demoted before
+        // the case has finished setting up the state it means to exercise.
+        if (auto* cache_owned_ttl_mgr = _cache->get_ttl_mgr()) {
+            cache_owned_ttl_mgr->stop();
+        }
     }
 
     void TearDown() override {
@@ -369,16 +377,18 @@ TEST_F(BlockFileCacheTtlMgrTest, 
NonTtlTabletWithoutPriorTtlInfoSkipsBlockScan)
     auto block = create_block(kTabletId, "non-ttl-tablet", 0, 1024, &hash);
     persist_block_meta(kTabletId, hash, block->range().left, 
block->range().size());
 
-    std::atomic<int64_t> block_scan_count {0};
+    // Held by value in the callback: the background threads outlive this 
stack frame, and
+    // neither the guard nor disable_processing() synchronizes with a callback 
in flight.
+    auto block_scan_count = std::make_shared<std::atomic<int64_t>>(0);
     auto* sync_point = SyncPoint::get_instance();
     sync_point->clear_all_call_backs();
     sync_point->clear_trace();
     SyncPoint::CallbackGuard guard;
     sync_point->set_call_back(
             "BlockFileCacheTtlMgr::get_file_blocks_from_tablet_id",
-            [&block_scan_count](std::vector<std::any>&& args) {
+            [block_scan_count](std::vector<std::any>&& args) {
                 if (doris::try_any_cast<int64_t>(args[0]) == kTabletId) {
-                    block_scan_count.fetch_add(1, std::memory_order_relaxed);
+                    block_scan_count->fetch_add(1, std::memory_order_relaxed);
                 }
             },
             &guard);
@@ -391,11 +401,13 @@ TEST_F(BlockFileCacheTtlMgrTest, 
NonTtlTabletWithoutPriorTtlInfoSkipsBlockScan)
             [this]() { return fake_engine()->get_tablet_meta_call_count() >= 
2; },
             std::chrono::seconds(5));
     std::this_thread::sleep_for(std::chrono::milliseconds(100));
+    // Join the background threads before the callback and its captures go 
away.
+    _ttl_mgr.reset();
     sync_point->disable_processing();
     sync_point->clear_trace();
 
     EXPECT_TRUE(update_thread_observed);
-    EXPECT_EQ(0, block_scan_count.load(std::memory_order_relaxed));
+    EXPECT_EQ(0, block_scan_count->load(std::memory_order_relaxed));
     EXPECT_EQ(FileCacheType::NORMAL, block->cache_type());
 }
 
@@ -412,16 +424,18 @@ TEST_F(BlockFileCacheTtlMgrTest, 
PeriodicReconcileDemotesTtlBlockWithoutPriorTtl
                        FileCacheType::TTL, expiration_time);
     ASSERT_EQ(FileCacheType::TTL, block->cache_type());
 
-    std::atomic<int64_t> block_scan_count {0};
+    // Held by value in the callback: the background threads outlive this 
stack frame, and
+    // neither the guard nor disable_processing() synchronizes with a callback 
in flight.
+    auto block_scan_count = std::make_shared<std::atomic<int64_t>>(0);
     auto* sync_point = SyncPoint::get_instance();
     sync_point->clear_all_call_backs();
     sync_point->clear_trace();
     SyncPoint::CallbackGuard guard;
     sync_point->set_call_back(
             "BlockFileCacheTtlMgr::get_file_blocks_from_tablet_id",
-            [&block_scan_count](std::vector<std::any>&& args) {
+            [block_scan_count](std::vector<std::any>&& args) {
                 if (doris::try_any_cast<int64_t>(args[0]) == kTabletId) {
-                    block_scan_count.fetch_add(1, std::memory_order_relaxed);
+                    block_scan_count->fetch_add(1, std::memory_order_relaxed);
                 }
             },
             &guard);
@@ -433,11 +447,13 @@ TEST_F(BlockFileCacheTtlMgrTest, 
PeriodicReconcileDemotesTtlBlockWithoutPriorTtl
     bool demoted =
             wait_for_condition([&]() { return block->cache_type() == 
FileCacheType::NORMAL; },
                                std::chrono::seconds(5));
+    // Join the background threads before the callback and its captures go 
away.
+    _ttl_mgr.reset();
     sync_point->disable_processing();
     sync_point->clear_trace();
 
     EXPECT_TRUE(demoted);
-    EXPECT_GE(block_scan_count.load(std::memory_order_relaxed), 1);
+    EXPECT_GE(block_scan_count->load(std::memory_order_relaxed), 1);
 }
 
 TEST_F(BlockFileCacheTtlMgrTest, TabletTtlRemovedMovesBlocksBackToNormal) {
@@ -462,4 +478,280 @@ TEST_F(BlockFileCacheTtlMgrTest, 
TabletTtlRemovedMovesBlocksBackToNormal) {
                                    std::chrono::seconds(5)));
 }
 
+TEST_F(BlockFileCacheTtlMgrTest, ExpiredTtlExtendedMovesBlocksBackToTtl) {
+    constexpr int64_t kTabletId = 6006;
+    auto tablet = std::make_shared<FakeTablet>(UnixSeconds(), 120);
+    fake_engine()->add_tablet(kTabletId, tablet);
+
+    UInt128Wrapper hash;
+    auto block = create_block(kTabletId, "ttl-extend-after-expire", 0, 1024, 
&hash);
+    persist_block_meta(kTabletId, hash, block->range().left, 
block->range().size());
+
+    _ttl_mgr = std::make_unique<BlockFileCacheTtlMgr>(_cache.get(), 
_meta_store.get());
+    _ttl_mgr->register_tablet_id(kTabletId);
+
+    ASSERT_TRUE(wait_for_condition([&]() { return block->cache_type() == 
FileCacheType::TTL; },
+                                   std::chrono::seconds(5)));
+
+    // Let the TTL expire. The block goes back to NORMAL while the manager 
keeps a non-zero TTL
+    // recorded for the tablet, which is the state that used to wedge the 
promotion path.
+    tablet->set_creation_time(UnixSeconds() - 120);
+    tablet->set_ttl_seconds(1);
+    ASSERT_TRUE(wait_for_condition([&]() { return block->cache_type() == 
FileCacheType::NORMAL; },
+                                   std::chrono::seconds(5)));
+
+    // Extending an already expired TTL to one that has not expired has to 
bring the block back.
+    tablet->set_ttl_seconds(30758400);
+    ASSERT_TRUE(wait_for_condition([&]() { return block->cache_type() == 
FileCacheType::TTL; },
+                                   std::chrono::seconds(5)));
+}
+
+TEST_F(BlockFileCacheTtlMgrTest, 
ExtendedTtlThatIsStillExpiredKeepsBlocksNormal) {
+    constexpr int64_t kTabletId = 7007;
+    const int64_t creation_time = UnixSeconds() - 7200;
+    auto tablet = std::make_shared<FakeTablet>(creation_time, 60);
+    fake_engine()->add_tablet(kTabletId, tablet);
+
+    UInt128Wrapper hash;
+    auto block = create_block(kTabletId, "ttl-extend-still-expired", 0, 1024, 
&hash);
+    persist_block_meta(kTabletId, hash, block->range().left, 
block->range().size());
+
+    _ttl_mgr = std::make_unique<BlockFileCacheTtlMgr>(_cache.get(), 
_meta_store.get());
+    _ttl_mgr->register_tablet_id(kTabletId);
+
+    int64_t call_count = fake_engine()->get_tablet_meta_call_count();
+    ASSERT_TRUE(wait_for_condition(
+            [&]() { return fake_engine()->get_tablet_meta_call_count() >= 
call_count + 2; },
+            std::chrono::seconds(5)));
+    ASSERT_EQ(FileCacheType::NORMAL, block->cache_type());
+
+    // A longer TTL that is still in the past must not promote anything.
+    tablet->set_ttl_seconds(120);
+    call_count = fake_engine()->get_tablet_meta_call_count();
+    ASSERT_TRUE(wait_for_condition(
+            [&]() { return fake_engine()->get_tablet_meta_call_count() >= 
call_count + 3; },
+            std::chrono::seconds(5)));
+    EXPECT_EQ(FileCacheType::NORMAL, block->cache_type());
+}
+
+TEST_F(BlockFileCacheTtlMgrTest, 
RewritingTtlToAnotherValidValueDoesNotRescanBlocks) {
+    constexpr int64_t kTabletId = 8008;
+    auto tablet = std::make_shared<FakeTablet>(UnixSeconds(), 3600);
+    fake_engine()->add_tablet(kTabletId, tablet);
+
+    UInt128Wrapper hash;
+    auto block = create_block(kTabletId, "ttl-rewrite-valid", 0, 1024, &hash);
+    persist_block_meta(kTabletId, hash, block->range().left, 
block->range().size());
+
+    _ttl_mgr = std::make_unique<BlockFileCacheTtlMgr>(_cache.get(), 
_meta_store.get());
+    _ttl_mgr->register_tablet_id(kTabletId);
+
+    ASSERT_TRUE(wait_for_condition([&]() { return block->cache_type() == 
FileCacheType::TTL; },
+                                   std::chrono::seconds(5)));
+
+    // The promotion is recorded only after every block has been converted, so 
the manager has
+    // not necessarily finished with the tablet at the moment the type flips. 
Let a couple of
+    // rounds pass before counting, or the tail of the promotion is charged to 
the rewrites.
+    int64_t settled_after = fake_engine()->get_tablet_meta_call_count();
+    ASSERT_TRUE(wait_for_condition(
+            [&]() { return fake_engine()->get_tablet_meta_call_count() >= 
settled_after + 2; },
+            std::chrono::seconds(5)));
+
+    // Held by value in the callback: the background threads outlive this 
stack frame, and
+    // neither the guard nor disable_processing() synchronizes with a callback 
in flight.
+    auto block_scan_count = std::make_shared<std::atomic<int64_t>>(0);
+    auto* sync_point = SyncPoint::get_instance();
+    sync_point->clear_all_call_backs();
+    sync_point->clear_trace();
+    SyncPoint::CallbackGuard guard;
+    sync_point->set_call_back(
+            "BlockFileCacheTtlMgr::get_file_blocks_from_tablet_id",
+            [block_scan_count](std::vector<std::any>&& args) {
+                if (doris::try_any_cast<int64_t>(args[0]) == kTabletId) {
+                    block_scan_count->fetch_add(1, std::memory_order_relaxed);
+                }
+            },
+            &guard);
+    sync_point->enable_processing();
+
+    // Automated jobs rewrite this property regularly. As long as the tablet 
stays in the same
+    // state, none of those rewrites may trigger another walk of the meta 
store.
+    for (int64_t ttl : {7200, 1800, 5400}) {
+        tablet->set_ttl_seconds(ttl);
+        int64_t call_count = fake_engine()->get_tablet_meta_call_count();
+        ASSERT_TRUE(wait_for_condition(
+                [&]() { return fake_engine()->get_tablet_meta_call_count() >= 
call_count + 2; },
+                std::chrono::seconds(5)));
+    }
+
+    // Join the background threads before the callback and its captures go 
away.
+    _ttl_mgr.reset();
+    sync_point->disable_processing();
+    sync_point->clear_trace();
+
+    EXPECT_EQ(0, block_scan_count->load(std::memory_order_relaxed));
+    EXPECT_EQ(FileCacheType::TTL, block->cache_type());
+}
+
+TEST_F(BlockFileCacheTtlMgrTest, TtlExtensionWinsOverConcurrentExpirationScan) 
{
+    constexpr int64_t kTabletId = 9009;
+    auto tablet = std::make_shared<FakeTablet>(UnixSeconds(), 3600);
+    fake_engine()->add_tablet(kTabletId, tablet);
+
+    UInt128Wrapper hash;
+    auto block = create_block(kTabletId, "ttl-extend-during-demote", 0, 1024, 
&hash);
+    persist_block_meta(kTabletId, hash, block->range().left, 
block->range().size());
+
+    _ttl_mgr = std::make_unique<BlockFileCacheTtlMgr>(_cache.get(), 
_meta_store.get());
+    _ttl_mgr->register_tablet_id(kTabletId);
+
+    ASSERT_TRUE(wait_for_condition([&]() { return block->cache_type() == 
FileCacheType::TTL; },
+                                   std::chrono::seconds(5)));
+
+    // Stall the demotion scan midway so the TTL can be extended underneath 
it, reproducing the
+    // window where the expiration check acts on a view of the tablet that is 
already stale.
+    // Held by value in the callback rather than captured by reference: the 
background threads
+    // outlive this stack frame, and neither the guard nor 
disable_processing() synchronizes
+    // with a callback already in flight. A callback that stalls makes that 
window wide.
+    auto demote_scan_entered = std::make_shared<std::atomic<bool>>(false);
+    auto release_demote_scan = std::make_shared<std::atomic<bool>>(false);
+    auto* sync_point = SyncPoint::get_instance();
+    sync_point->clear_all_call_backs();
+    sync_point->clear_trace();
+    SyncPoint::CallbackGuard guard;
+    sync_point->set_call_back(
+            "BlockFileCacheTtlMgr::get_file_blocks_from_tablet_id",
+            [demote_scan_entered, release_demote_scan](std::vector<std::any>&& 
args) {
+                if (doris::try_any_cast<int64_t>(args[0]) != kTabletId) {
+                    return;
+                }
+                if (demote_scan_entered->exchange(true, 
std::memory_order_acq_rel)) {
+                    return;
+                }
+                while (!release_demote_scan->load(std::memory_order_acquire)) {
+                    std::this_thread::sleep_for(std::chrono::milliseconds(5));
+                }
+            },
+            &guard);
+    sync_point->enable_processing();
+
+    tablet->set_creation_time(UnixSeconds() - 3600);
+    tablet->set_ttl_seconds(1);
+
+    bool scan_stalled = wait_for_condition(
+            [&]() { return 
demote_scan_entered->load(std::memory_order_acquire); },
+            std::chrono::seconds(10));
+
+    // Extend the TTL while the demotion is still in flight.
+    tablet->set_creation_time(UnixSeconds());
+    tablet->set_ttl_seconds(30758400);
+    std::this_thread::sleep_for(std::chrono::milliseconds(200));
+    // Must come before the join below, or stop() waits on a thread parked in 
the callback.
+    release_demote_scan->store(true, std::memory_order_release);
+
+    bool ends_as_ttl = wait_for_condition(
+            [&]() { return block->cache_type() == FileCacheType::TTL; }, 
std::chrono::seconds(10));
+    _ttl_mgr.reset();
+    sync_point->disable_processing();
+    sync_point->clear_trace();
+
+    ASSERT_TRUE(scan_stalled);
+    EXPECT_TRUE(ends_as_ttl);
+}
+
+// _ttl_info_map is rebuilt in memory only, while the cache type of each block 
survives on disk.
+// A tablet whose TTL expired while this BE was down therefore comes back with 
TTL blocks and no
+// recorded state, and nothing later in the tablet's life re-examines them.
+TEST_F(BlockFileCacheTtlMgrTest, 
ExpiredTabletDemotesTtlBlocksRestoredFromDisk) {
+    constexpr int64_t kTabletId = 10010;
+    const int64_t creation_time = UnixSeconds() - 7200;
+    auto tablet = std::make_shared<FakeTablet>(creation_time, 60);
+    fake_engine()->add_tablet(kTabletId, tablet);
+
+    const uint64_t expiration_time = static_cast<uint64_t>(creation_time) + 60;
+    UInt128Wrapper hash;
+    auto block = create_block(kTabletId, "ttl-restored-expired", 0, 1024, 
&hash, FileCacheType::TTL,
+                              expiration_time);
+    // Asserted before the block is published to the meta store: a scan can 
only reach a block
+    // that is listed there, so until then no manager can convert it out from 
under us.
+    ASSERT_EQ(FileCacheType::TTL, block->cache_type());
+    persist_block_meta(kTabletId, hash, block->range().left, 
block->range().size(),
+                       FileCacheType::TTL, expiration_time);
+
+    _ttl_mgr = std::make_unique<BlockFileCacheTtlMgr>(_cache.get(), 
_meta_store.get());
+    _ttl_mgr->register_tablet_id(kTabletId);
+
+    EXPECT_TRUE(wait_for_condition([&]() { return block->cache_type() == 
FileCacheType::NORMAL; },
+                                   std::chrono::seconds(5)));
+}
+
+// Blocks can still land in the TTL queue after a tablet's existing ones were 
demoted, and the
+// recorded state is per tablet, so it cannot tell that they have. They have 
to be collected too.
+TEST_F(BlockFileCacheTtlMgrTest, 
ExpiredTabletDemotesTtlBlocksCachedAfterDemotion) {
+    constexpr int64_t kTabletId = 11011;
+    auto tablet = std::make_shared<FakeTablet>(UnixSeconds(), 120);
+    fake_engine()->add_tablet(kTabletId, tablet);
+
+    UInt128Wrapper hash;
+    auto block = create_block(kTabletId, "ttl-expire-then-cache", 0, 1024, 
&hash);
+    persist_block_meta(kTabletId, hash, block->range().left, 
block->range().size());
+
+    _ttl_mgr = std::make_unique<BlockFileCacheTtlMgr>(_cache.get(), 
_meta_store.get());
+    _ttl_mgr->register_tablet_id(kTabletId);
+
+    ASSERT_TRUE(wait_for_condition([&]() { return block->cache_type() == 
FileCacheType::TTL; },
+                                   std::chrono::seconds(5)));
+
+    tablet->set_creation_time(UnixSeconds() - 120);
+    tablet->set_ttl_seconds(1);
+    ASSERT_TRUE(wait_for_condition([&]() { return block->cache_type() == 
FileCacheType::NORMAL; },
+                                   std::chrono::seconds(5)));
+
+    // A fresh block lands in the TTL queue after the demotion.
+    const uint64_t expiration_time = UnixSeconds() + 3600;
+    UInt128Wrapper late_hash;
+    auto late_block = create_block(kTabletId, "ttl-expire-then-cache-late", 0, 
1024, &late_hash,
+                                   FileCacheType::TTL, expiration_time);
+    // Same ordering as above, and here it matters: the manager is running by 
this point and
+    // sweeps this tablet every round, so publishing first would race the 
assertion.
+    ASSERT_EQ(FileCacheType::TTL, late_block->cache_type());
+    persist_block_meta(kTabletId, late_hash, late_block->range().left, 
late_block->range().size(),
+                       FileCacheType::TTL, expiration_time);
+
+    EXPECT_TRUE(
+            wait_for_condition([&]() { return late_block->cache_type() == 
FileCacheType::NORMAL; },
+                               std::chrono::seconds(5)));
+}
+
+// Dropping the TTL of a tablet that had already expired leaves nothing to 
convert, but the
+// tablet still has to stop being tracked -- otherwise it holds a map entry 
for the life of the
+// process and never again qualifies for the periodic reconcile.
+TEST_F(BlockFileCacheTtlMgrTest, TtlClearedAfterExpiryStopsTrackingTablet) {
+    constexpr int64_t kTabletId = 12012;
+    auto tablet = std::make_shared<FakeTablet>(UnixSeconds(), 120);
+    fake_engine()->add_tablet(kTabletId, tablet);
+
+    UInt128Wrapper hash;
+    auto block = create_block(kTabletId, "ttl-cleared-after-expiry", 0, 1024, 
&hash);
+    persist_block_meta(kTabletId, hash, block->range().left, 
block->range().size());
+
+    _ttl_mgr = std::make_unique<BlockFileCacheTtlMgr>(_cache.get(), 
_meta_store.get());
+    _ttl_mgr->register_tablet_id(kTabletId);
+
+    ASSERT_TRUE(wait_for_condition([&]() { return block->cache_type() == 
FileCacheType::TTL; },
+                                   std::chrono::seconds(5)));
+    ASSERT_TRUE(wait_for_condition([&]() { return 
_ttl_mgr->tracked_tablet_num() == 1; },
+                                   std::chrono::seconds(5)));
+
+    tablet->set_creation_time(UnixSeconds() - 120);
+    tablet->set_ttl_seconds(1);
+    ASSERT_TRUE(wait_for_condition([&]() { return block->cache_type() == 
FileCacheType::NORMAL; },
+                                   std::chrono::seconds(5)));
+
+    tablet->set_ttl_seconds(0);
+    EXPECT_TRUE(wait_for_condition([&]() { return 
_ttl_mgr->tracked_tablet_num() == 0; },
+                                   std::chrono::seconds(5)));
+    EXPECT_EQ(FileCacheType::NORMAL, block->cache_type());
+}
+
 } // namespace doris::io


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

Reply via email to