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

Gabriel39 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 648697d1414 [fix](be) Avoid remote IO under cache shard locks (#69324)
648697d1414 is described below

commit 648697d14141972e617f086b689464674c34cd7c
Author: daidai <[email protected]>
AuthorDate: Thu Oct 22 10:01:34 2026 +0800

    [fix](be) Avoid remote IO under cache shard locks (#69324)
    
    ### What problem does this PR solve?
    
    Issue Number: N/A
    
    Related PR: N/A
    
    Problem Summary:
    `KVCache` executed value factories while holding the shard mutex.
    Iceberg and Paimon deletion-vector loading could therefore block
    unrelated cache keys during remote I/O and decoding, and different value
    types could share the same string key.
    
    This PR moves factory execution to a per-key lock, keeps one in-flight
    load per key, isolates entries by value type, and simplifies Fluss tail
    loading to use the same single-flight path.
    
    ### Release note
    
    None
    
    ### Check List (For Author)
    
    - Test
        - [ ] Regression test
        - [x] Unit Test
    - 55 targeted ASAN tests passed: KVCache concurrency, Iceberg V1/V2
    deletion vectors, Paimon deletion vectors, and Fluss cache paths.
            - clang-format and build hygiene checks 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:
        - [x] No.
        - [ ] Yes.
    
    - 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/format/format_common.h                      |  77 ++++++---
 be/src/format_v2/table/fluss_union_lake_reader.cpp |  34 ++--
 be/test/format/format_common_test.cpp              | 174 +++++++++++++++++++++
 3 files changed, 247 insertions(+), 38 deletions(-)

diff --git a/be/src/format/format_common.h b/be/src/format/format_common.h
index 2d65ed721ef..eb3eed7d148 100644
--- a/be/src/format/format_common.h
+++ b/be/src/format/format_common.h
@@ -17,6 +17,10 @@
 
 #pragma once
 
+#include <memory>
+#include <typeindex>
+
+#include "core/data_type/primitive_type.h"
 #include "core/types.h"
 #include "exec/common/int_exp.h"
 
@@ -71,40 +75,47 @@ template <typename KType>
 class KVCache {
 public:
     KVCache() = default;
+    ~KVCache() = default;
 
-    ~KVCache() {
-        for (auto& kv : _storage) {
-            _delete_fn[kv.first](kv.second);
-        }
-    }
-
+    template <class T>
     void erase(const KType& key) {
-        std::lock_guard<std::mutex> lock(_lock);
-        auto it = _storage.find(key);
-        if (it != _storage.end()) {
-            _delete_fn[key](_storage[key]);
-            _storage.erase(key);
-            _delete_fn.erase(key);
+        std::shared_ptr<Entry> erased_entry;
+        {
+            std::lock_guard<std::mutex> lock(_lock);
+            auto key_it = _entries.find(key);
+            if (key_it == _entries.end()) {
+                return;
+            }
+            auto type_it = key_it->second.find(typeid(T));
+            if (type_it == key_it->second.end()) {
+                return;
+            }
+            erased_entry = std::move(type_it->second);
+            key_it->second.erase(type_it);
+            if (key_it->second.empty()) {
+                _entries.erase(key_it);
+            }
         }
+        // Destroy the cached value outside the shard lock.
     }
 
     template <class T>
     T* get(const KType& key, const std::function<T*()> create_func, bool* 
cache_hit = nullptr) {
-        std::lock_guard<std::mutex> lock(_lock);
-        auto it = _storage.find(key);
-        if (it != _storage.end()) {
+        std::shared_ptr<Entry> entry = _get_or_create_entry<T>(key);
+        std::lock_guard<std::mutex> entry_lock(entry->_lock);
+        if (entry->_value != nullptr) {
             if (cache_hit != nullptr) {
                 *cache_hit = true;
             }
-            return reinterpret_cast<T*>(it->second);
+            return static_cast<T*>(entry->_value);
         } else {
             if (cache_hit != nullptr) {
                 *cache_hit = false;
             }
             T* rawPtr = create_func();
             if (rawPtr != nullptr) {
-                _delete_fn[key] = [](void* obj) { delete 
reinterpret_cast<T*>(obj); };
-                _storage[key] = rawPtr;
+                entry->_delete_fn = [](void* obj) { delete 
static_cast<T*>(obj); };
+                entry->_value = rawPtr;
             }
             return rawPtr;
         }
@@ -113,9 +124,35 @@ public:
 private:
     using DeleteFn = void (*)(void*);
 
+    struct Entry {
+        ~Entry() {
+            if (_value != nullptr) {
+                _delete_fn(_value);
+            }
+        }
+
+        // The shard lock only protects the map. Factory calls hold this entry 
lock, so an
+        // unrelated key can keep loading while another key is doing remote 
I/O.
+        std::mutex _lock;
+        void* _value = nullptr;
+        DeleteFn _delete_fn = nullptr;
+    };
+
+    template <class T>
+    std::shared_ptr<Entry> _get_or_create_entry(const KType& key) {
+        std::lock_guard<std::mutex> lock(_lock);
+        auto key_it = _entries.try_emplace(key).first;
+        auto type_it = key_it->second.find(typeid(T));
+        if (type_it != key_it->second.end()) {
+            return type_it->second;
+        }
+        auto entry = std::make_shared<Entry>();
+        key_it->second.emplace(typeid(T), entry);
+        return entry;
+    }
+
     std::mutex _lock;
-    std::unordered_map<KType, DeleteFn> _delete_fn;
-    std::unordered_map<KType, void*> _storage;
+    std::unordered_map<KType, std::unordered_map<std::type_index, 
std::shared_ptr<Entry>>> _entries;
 };
 
 class ShardedKVCache {
diff --git a/be/src/format_v2/table/fluss_union_lake_reader.cpp 
b/be/src/format_v2/table/fluss_union_lake_reader.cpp
index d49803943f3..b381ac67684 100644
--- a/be/src/format_v2/table/fluss_union_lake_reader.cpp
+++ b/be/src/format_v2/table/fluss_union_lake_reader.cpp
@@ -354,27 +354,25 @@ Status FlussUnionLakeReader::_load_suppression_keys(const 
format::SplitReadOptio
     // Length-prefixed so that no boundary between the fixed prefix and the 
tail can be reinterpreted
     // as part of the tail itself. One scan node reads one table, so the tail 
alone identifies it.
     const auto cache_key = fmt::format("fluss_union_tail:{}:{}", 
tail.spec.size(), tail.spec);
+    Status read_status = Status::OK();
     bool cache_hit = false;
     auto* cached = options.cache->get<SuppressionKeys>(
-            cache_key, []() -> SuppressionKeys* { return nullptr; }, 
&cache_hit);
-    if (cached == nullptr) {
-        // A tail read is network I/O. KVCache invokes its create callback 
while holding a shard
-        // mutex, so do that I/O before the insertion attempt; otherwise one 
slow bucket blocks every
-        // unrelated tail that hashes to the same shard.
-        auto loaded = std::make_unique<SuppressionKeys>();
-        RETURN_IF_ERROR(_read_tail_keys(tail, loaded.get()));
-        update_counter(_tail_keys_read_counter, loaded->records);
-
-        bool raced_cache_hit = false;
-        cached = options.cache->get<SuppressionKeys>(
-                cache_key, [&]() -> SuppressionKeys* { return 
loaded.release(); },
-                &raced_cache_hit);
-        cache_hit = raced_cache_hit;
-        if (!raced_cache_hit) {
-            update_counter(_tail_keys_retained_counter, 
cast_set<int64_t>(cached->keys.rows()));
-        }
-    }
+            cache_key,
+            [&]() -> SuppressionKeys* {
+                auto loaded = std::make_unique<SuppressionKeys>();
+                read_status = _read_tail_keys(tail, loaded.get());
+                if (!read_status.ok()) {
+                    return nullptr;
+                }
+                update_counter(_tail_keys_read_counter, loaded->records);
+                return loaded.release();
+            },
+            &cache_hit);
+    RETURN_IF_ERROR(read_status);
     DORIS_CHECK(cached != nullptr);
+    if (!cache_hit) {
+        update_counter(_tail_keys_retained_counter, 
cast_set<int64_t>(cached->keys.rows()));
+    }
     if (cache_hit) {
         update_counter(_tail_cache_hit_counter, 1);
     }
diff --git a/be/test/format/format_common_test.cpp 
b/be/test/format/format_common_test.cpp
new file mode 100644
index 00000000000..051b5b36b55
--- /dev/null
+++ b/be/test/format/format_common_test.cpp
@@ -0,0 +1,174 @@
+// 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.
+
+#include "format/format_common.h"
+
+#include <gtest/gtest.h>
+
+#include <atomic>
+#include <chrono>
+#include <future>
+#include <memory>
+#include <thread>
+#include <vector>
+
+namespace doris {
+
+TEST(KVCacheTest, DifferentKeysOnSameShardLoadConcurrently) {
+    ShardedKVCache cache(1);
+    std::promise<void> first_entered;
+    std::promise<void> release_first;
+    std::promise<void> second_entered;
+    auto first_entered_future = first_entered.get_future();
+    auto release_first_future = release_first.get_future().share();
+    auto second_entered_future = second_entered.get_future();
+
+    std::thread first([&]() {
+        cache.get<int>("first", [&]() -> int* {
+            first_entered.set_value();
+            release_first_future.wait();
+            return new int(1);
+        });
+    });
+    first_entered_future.wait();
+
+    std::thread second([&]() {
+        cache.get<int>("second", [&]() -> int* {
+            second_entered.set_value();
+            return new int(2);
+        });
+    });
+
+    bool second_loaded_before_first =
+            second_entered_future.wait_for(std::chrono::seconds(1)) == 
std::future_status::ready;
+    release_first.set_value();
+    first.join();
+    second.join();
+
+    EXPECT_TRUE(second_loaded_before_first);
+}
+
+TEST(KVCacheTest, SameKeyLoadsOnceForConcurrentCallers) {
+    constexpr int num_callers = 8;
+    ShardedKVCache cache(1);
+    std::atomic<int> factory_calls {0};
+    std::promise<void> factory_entered;
+    std::promise<void> release_factory;
+    auto factory_entered_future = factory_entered.get_future();
+    auto release_factory_future = release_factory.get_future().share();
+    std::vector<int*> values(num_callers);
+    std::vector<std::thread> callers;
+
+    for (int i = 0; i < num_callers; ++i) {
+        callers.emplace_back([&, i]() {
+            values[i] = cache.get<int>("shared", [&]() -> int* {
+                if (++factory_calls == 1) {
+                    factory_entered.set_value();
+                }
+                release_factory_future.wait();
+                return new int(7);
+            });
+        });
+    }
+
+    factory_entered_future.wait();
+    release_factory.set_value();
+    for (auto& caller : callers) {
+        caller.join();
+    }
+
+    EXPECT_EQ(factory_calls.load(), 1);
+    ASSERT_NE(values.front(), nullptr);
+    for (int* value : values) {
+        EXPECT_EQ(value, values.front());
+    }
+}
+
+TEST(KVCacheTest, FailedFactoryDoesNotPoisonEntry) {
+    ShardedKVCache cache(1);
+    std::atomic<int> factory_calls {0};
+    std::promise<void> failed_factory_entered;
+    std::promise<void> release_failed_factory;
+    auto failed_factory_entered_future = failed_factory_entered.get_future();
+    auto release_failed_factory_future = 
release_failed_factory.get_future().share();
+    bool first_cache_hit = true;
+    bool second_cache_hit = true;
+    int* second_value = nullptr;
+
+    std::thread first([&]() {
+        EXPECT_EQ(cache.get<int>(
+                          "retry",
+                          [&]() -> int* {
+                              if (++factory_calls == 1) {
+                                  failed_factory_entered.set_value();
+                              }
+                              release_failed_factory_future.wait();
+                              return nullptr;
+                          },
+                          &first_cache_hit),
+                  nullptr);
+    });
+    failed_factory_entered_future.wait();
+
+    std::thread second([&]() {
+        second_value = cache.get<int>(
+                "retry",
+                [&]() -> int* {
+                    ++factory_calls;
+                    return new int(9);
+                },
+                &second_cache_hit);
+    });
+
+    release_failed_factory.set_value();
+    first.join();
+    second.join();
+
+    EXPECT_EQ(factory_calls.load(), 2);
+    EXPECT_FALSE(first_cache_hit);
+    EXPECT_FALSE(second_cache_hit);
+    ASSERT_NE(second_value, nullptr);
+    EXPECT_EQ(*second_value, 9);
+}
+
+TEST(KVCacheTest, SameStringKeyIsIsolatedByValueType) {
+    struct FirstValue {
+        int value = 1;
+    };
+    struct SecondValue {
+        int value = 2;
+    };
+
+    ShardedKVCache cache(1);
+    auto* first =
+            cache.get<FirstValue>("same-key", []() -> FirstValue* { return new 
FirstValue(); });
+    auto* second =
+            cache.get<SecondValue>("same-key", []() -> SecondValue* { return 
new SecondValue(); });
+
+    ASSERT_NE(first, nullptr);
+    ASSERT_NE(second, nullptr);
+    EXPECT_EQ(first->value, 1);
+    EXPECT_EQ(second->value, 2);
+    EXPECT_EQ(cache.get<FirstValue>("same-key",
+                                    []() -> FirstValue* {
+                                        ADD_FAILURE() << "cached FirstValue 
was rebuilt";
+                                        return nullptr;
+                                    }),
+              first);
+}
+
+} // namespace doris


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

Reply via email to