github-actions[bot] commented on code in PR #66812:
URL: https://github.com/apache/doris/pull/66812#discussion_r3794796709


##########
be/src/exprs/function/dictionary_factory.h:
##########
@@ -88,65 +110,121 @@ class DictionaryFactory : private boost::noncopyable {
                     "Version ID is not equal to the refreshing version ID. {} 
: {}", version_id,
                     refresh_version_id);
         }
-        {
-            // commit the dictionary
-            if (_dict_id_to_version_id_map.contains(dict_id)) {
-                // check version_id
-                if (version_id <= _dict_id_to_version_id_map[dict_id]) {
-                    LOG_WARNING(
-                            "DictionaryFactory Failed to commit dictionary 
because version ID "
-                            "is not greater than the existing version ID")
-                            .tag("dict_id", dict_id)
-                            .tag("version_id", version_id)
-                            .tag("dict name", dict->dict_name())
-                            .tag("existing version ID", 
_dict_id_to_version_id_map[dict_id]);
-                    return Status::InvalidArgument(
-                            "Version ID is not greater than the existing 
version ID for the "
-                            "dictionary. {} : {}",
-                            version_id, _dict_id_to_version_id_map[dict_id]);
-                }
+        auto& versioned_map = _dict_id_to_versioned_map[dict_id];
+        if (!versioned_map.empty()) {
+            int64_t latest = versioned_map.rbegin()->first;
+            if (version_id <= latest) {
+                LOG_WARNING(
+                        "DictionaryFactory Failed to commit dictionary because 
version ID "
+                        "is not greater than the existing version ID")
+                        .tag("dict_id", dict_id)
+                        .tag("version_id", version_id)
+                        .tag("dict name", dict->dict_name())
+                        .tag("existing version ID", latest);
+                return Status::InvalidArgument(
+                        "Version ID is not greater than the existing version 
ID for the "
+                        "dictionary. {} : {}",
+                        version_id, latest);
             }
-            LOG_INFO("DictionaryFactory Successfully commit dictionary")
-                    .tag("dict_id", dict_id)
-                    .tag("version_id", version_id)
-                    .tag("dict name", dict->dict_name());
-            _dict_id_to_dict_map[dict_id] = dict;
-            _dict_id_to_version_id_map[dict_id] = version_id;
-            _refreshing_dict_map.erase(dict_id);
         }
+        LOG_INFO("DictionaryFactory Successfully commit dictionary")
+                .tag("dict_id", dict_id)
+                .tag("version_id", version_id)
+                .tag("dict name", dict->dict_name());
+        dict->set_commit_time_ms(UnixMillis());
+        versioned_map[version_id] = dict;
+        _refreshing_dict_map.erase(dict_id);
+        lc.unlock();
+        gc_if_needed();
         return Status::OK();
     }
 
     Status delete_dict(int64_t dict_id) {
         VLOG_DEBUG << "DictionaryFactory delete dictionary, dict_id: " << 
dict_id;
         std::unique_lock lc(_mutex);
-        if (!_dict_id_to_dict_map.contains(dict_id)) {
-            LOG_WARNING("DictionaryFactory Failed to delete 
dictionary").tag("dict_id", dict_id);
+        auto it = _dict_id_to_versioned_map.find(dict_id);
+        if (it == _dict_id_to_versioned_map.end()) {
             return Status::OK();
         }
-        auto dict = _dict_id_to_dict_map[dict_id];
-        LOG_INFO("DictionaryFactory Successfully delete dictionary")
-                .tag("dict_id", dict_id)
-                .tag("dict name", dict->dict_name());
-        _dict_id_to_dict_map.erase(dict_id);
-        _dict_id_to_version_id_map.erase(dict_id);
+        if (it->second.empty()) {
+            LOG_WARNING("DictionaryFactory delete dictionary with empty 
version map")
+                    .tag("dict_id", dict_id);
+        } else {
+            auto latest_it = it->second.rbegin();
+            LOG_INFO("DictionaryFactory Successfully delete dictionary")
+                    .tag("dict_id", dict_id)
+                    .tag("dict name", latest_it->second->dict_name())
+                    .tag("latest version_id", latest_it->first);
+        }
+        _dict_id_to_versioned_map.erase(it);
         return Status::OK();
     }
 
     std::shared_ptr<MemTrackerLimiter> mem_tracker() const { return 
_mem_tracker; }
 
+    // unified GC entry: count-based + ttl-based, with interval protection
+    void gc_if_needed() {
+        int64_t gc_interval_ms = std::max(1, 
config::dictionary_gc_interval_seconds) * 1000LL;
+        int64_t now = UnixMillis();
+        if (now - _last_gc_time_ms.load(std::memory_order_relaxed) < 
gc_interval_ms) {

Review Comment:
   [P1] Enforce the version cap independently of the GC interval
   
   This return skips count enforcement as well as TTL sweeping. After one pass 
sets `_last_gc_time_ms`, every refresh in the next 60 seconds can retain 
another full dictionary despite `dictionary_max_versions=2`; if those are the 
last commits, neither the count excess nor future TTL expiry is ever revisited. 
Since the load limit is per version and the factory tracker has no aggregate 
byte limit, retained memory is not bounded by the configured maximum. After 
adding query-safe pinning so count GC cannot worsen the pre-open race, please 
enforce the changed dictionary's count on every commit and give TTL a periodic 
terminal trigger, retiring pointers outside `_mutex`.



##########
be/src/exprs/function/dictionary_factory.cpp:
##########
@@ -32,30 +32,34 @@ DictionaryFactory::DictionaryFactory()
 
 DictionaryFactory::~DictionaryFactory() {
     SCOPED_SWITCH_THREAD_MEM_TRACKER_LIMITER(_mem_tracker);
-    _dict_id_to_dict_map.clear();
-    _dict_id_to_version_id_map.clear();
+    _dict_id_to_versioned_map.clear();
 }
 
 void DictionaryFactory::get_dictionary_status(std::vector<TDictionaryStatus>& 
result,
                                               std::vector<int64_t> dict_ids) {
+    // only report the latest version per dict; historical versions are 
invisible to FE
     std::shared_lock lc(_mutex);
-    if (dict_ids.empty()) { // empty means ALL
-        for (const auto& [dict_id, dict] : _dict_id_to_dict_map) {
-            TDictionaryStatus status;
-            status.__set_dictionary_id(dict_id);
-            status.__set_version_id(_dict_id_to_version_id_map[dict_id]);
-            status.__set_dictionary_memory_size(dict->allocated_bytes());
-            result.emplace_back(std::move(status));
+    auto build_latest_status = [&result](int64_t dict_id,
+                                         const std::map<int64_t, 
DictionaryPtr>& versioned_map) {
+        if (versioned_map.empty()) {
+            return;
+        }
+        const auto& [version_id, dict] = *versioned_map.rbegin();
+        TDictionaryStatus status;
+        status.__set_dictionary_id(dict_id);
+        status.__set_version_id(version_id);
+        status.__set_dictionary_memory_size(dict->allocated_bytes());

Review Comment:
   [P2] Report memory retained by every committed version
   
   Before multi-version retention, this field was the complete committed 
payload footprint for the dictionary. The new map retains two versions by 
default (and can retain more because of the interval issue), but this reports 
only `rbegin()`'s bytes. FE exposes the value as `memoryBytes` and the BE 
endpoint labels it `Memory Size`, so operators can see roughly half—or much 
less—of the per-dictionary memory actually retained; the aggregate factory 
tracker cannot restore that attribution. Please sum `allocated_bytes()` across 
retained committed versions, or expose explicit latest/total/per-version 
fields, and test with differently sized versions.



##########
be/src/exprs/function/dictionary_factory.h:
##########
@@ -37,11 +41,29 @@ class DictionaryFactory : private boost::noncopyable {
 
     // Returns nullptr if failed
     std::shared_ptr<const IDictionary> get(int64_t dict_id, int64_t 
version_id) {
-        std::unique_lock lc(_mutex);
-        // dict_id and version_id must match
-        if (_dict_id_to_dict_map.contains(dict_id) &&
-            _dict_id_to_version_id_map[dict_id] == version_id) {
-            return _dict_id_to_dict_map[dict_id];
+        // simulate slow query holding old version_id
+        DBUG_EXECUTE_IF("dict_get_delay", {
+            int sleep_sec = dp->param<int>("sleep_sec", 10);
+            LOG(INFO) << "debug point dict_get_delay: sleeping " << sleep_sec
+                      << "s before get dict_id=" << dict_id << " version_id=" 
<< version_id;
+            sleep(sleep_sec);
+        });
+        std::shared_lock lc(_mutex);
+        auto it = _dict_id_to_versioned_map.find(dict_id);
+        if (it != _dict_id_to_versioned_map.end()) {
+            auto vit = it->second.find(version_id);
+            if (vit != it->second.end()) {
+                return vit->second;
+            }
+        }
+        // fallback to staging: version may have been increased by FE but not 
yet committed
+        auto rit = _refreshing_dict_map.find(dict_id);

Review Comment:
   [P1] Keep published staging versions available to stamped plans
   
   FE journals N+1 before the commit RPC and immediately stamps it into plans, 
but those plans may not reach BE `open()` yet. If commit fails, abort erases 
N+1 on the failed BE; if the master fails instead, replay restores FE version 
N+1 with `OUT_OF_DATE` status and the recovery load stages N+2, replacing the 
sole N+1 entry here. Either path leaves an N+1-stamped plan with neither a 
committed nor staged match and reproduces `dictionary not found` (partial 
commit also makes the result BE-dependent). Please retain published staging 
entries by version until stamped plans can no longer reach them, or use a 
recoverable publication/commit decision, and test both abort-before-open and 
journal-to-commit failover.



##########
be/src/exprs/function/dictionary_factory.h:
##########
@@ -88,65 +110,121 @@ class DictionaryFactory : private boost::noncopyable {
                     "Version ID is not equal to the refreshing version ID. {} 
: {}", version_id,
                     refresh_version_id);
         }
-        {
-            // commit the dictionary
-            if (_dict_id_to_version_id_map.contains(dict_id)) {
-                // check version_id
-                if (version_id <= _dict_id_to_version_id_map[dict_id]) {
-                    LOG_WARNING(
-                            "DictionaryFactory Failed to commit dictionary 
because version ID "
-                            "is not greater than the existing version ID")
-                            .tag("dict_id", dict_id)
-                            .tag("version_id", version_id)
-                            .tag("dict name", dict->dict_name())
-                            .tag("existing version ID", 
_dict_id_to_version_id_map[dict_id]);
-                    return Status::InvalidArgument(
-                            "Version ID is not greater than the existing 
version ID for the "
-                            "dictionary. {} : {}",
-                            version_id, _dict_id_to_version_id_map[dict_id]);
-                }
+        auto& versioned_map = _dict_id_to_versioned_map[dict_id];
+        if (!versioned_map.empty()) {
+            int64_t latest = versioned_map.rbegin()->first;
+            if (version_id <= latest) {
+                LOG_WARNING(
+                        "DictionaryFactory Failed to commit dictionary because 
version ID "
+                        "is not greater than the existing version ID")
+                        .tag("dict_id", dict_id)
+                        .tag("version_id", version_id)
+                        .tag("dict name", dict->dict_name())
+                        .tag("existing version ID", latest);
+                return Status::InvalidArgument(
+                        "Version ID is not greater than the existing version 
ID for the "
+                        "dictionary. {} : {}",
+                        version_id, latest);
             }
-            LOG_INFO("DictionaryFactory Successfully commit dictionary")
-                    .tag("dict_id", dict_id)
-                    .tag("version_id", version_id)
-                    .tag("dict name", dict->dict_name());
-            _dict_id_to_dict_map[dict_id] = dict;
-            _dict_id_to_version_id_map[dict_id] = version_id;
-            _refreshing_dict_map.erase(dict_id);
         }
+        LOG_INFO("DictionaryFactory Successfully commit dictionary")
+                .tag("dict_id", dict_id)
+                .tag("version_id", version_id)
+                .tag("dict name", dict->dict_name());
+        dict->set_commit_time_ms(UnixMillis());
+        versioned_map[version_id] = dict;
+        _refreshing_dict_map.erase(dict_id);
+        lc.unlock();
+        gc_if_needed();
         return Status::OK();
     }
 
     Status delete_dict(int64_t dict_id) {
         VLOG_DEBUG << "DictionaryFactory delete dictionary, dict_id: " << 
dict_id;
         std::unique_lock lc(_mutex);
-        if (!_dict_id_to_dict_map.contains(dict_id)) {
-            LOG_WARNING("DictionaryFactory Failed to delete 
dictionary").tag("dict_id", dict_id);
+        auto it = _dict_id_to_versioned_map.find(dict_id);
+        if (it == _dict_id_to_versioned_map.end()) {
             return Status::OK();
         }
-        auto dict = _dict_id_to_dict_map[dict_id];
-        LOG_INFO("DictionaryFactory Successfully delete dictionary")
-                .tag("dict_id", dict_id)
-                .tag("dict name", dict->dict_name());
-        _dict_id_to_dict_map.erase(dict_id);
-        _dict_id_to_version_id_map.erase(dict_id);
+        if (it->second.empty()) {
+            LOG_WARNING("DictionaryFactory delete dictionary with empty 
version map")
+                    .tag("dict_id", dict_id);
+        } else {
+            auto latest_it = it->second.rbegin();
+            LOG_INFO("DictionaryFactory Successfully delete dictionary")
+                    .tag("dict_id", dict_id)
+                    .tag("dict name", latest_it->second->dict_name())
+                    .tag("latest version_id", latest_it->first);
+        }
+        _dict_id_to_versioned_map.erase(it);
         return Status::OK();
     }
 
     std::shared_ptr<MemTrackerLimiter> mem_tracker() const { return 
_mem_tracker; }
 
+    // unified GC entry: count-based + ttl-based, with interval protection
+    void gc_if_needed() {
+        int64_t gc_interval_ms = std::max(1, 
config::dictionary_gc_interval_seconds) * 1000LL;
+        int64_t now = UnixMillis();
+        if (now - _last_gc_time_ms.load(std::memory_order_relaxed) < 
gc_interval_ms) {
+            return;
+        }
+        std::unique_lock<std::shared_mutex> lc(_mutex);
+        // re-check under lock to avoid duplicate GC
+        if (now - _last_gc_time_ms.load(std::memory_order_relaxed) < 
gc_interval_ms) {
+            return;
+        }
+        _last_gc_time_ms.store(now, std::memory_order_relaxed);
+        _gc_all_no_lock(now);
+    }
+
     void get_dictionary_status(std::vector<TDictionaryStatus>& result,
                                std::vector<int64_t> dict_ids);
 
 private:
-    std::map<int64_t, DictionaryPtr> _dict_id_to_dict_map;
-    std::map<int64_t, int64_t> _dict_id_to_version_id_map;
+    // GC all dicts: first count-based, then ttl-based. Always keeps the 
latest version.
+    void _gc_all_no_lock(int64_t now) {
+        int32_t max_versions = std::max(1, config::dictionary_max_versions);
+        int64_t ttl_ms = 
static_cast<int64_t>(config::dictionary_version_ttl_seconds) * 1000;
+        int64_t threshold_ms = ttl_ms > 0 ? now - ttl_ms : 0;
+        for (auto& [dict_id, versioned_map] : _dict_id_to_versioned_map) {
+            if (versioned_map.size() <= 1) {
+                continue;
+            }
+            // count-based: drop oldest while exceeding max_versions
+            while (versioned_map.size() > static_cast<size_t>(max_versions)) {
+                auto it = versioned_map.begin();
+                LOG_INFO("DictionaryFactory GC old version by count")
+                        .tag("dict_id", dict_id)
+                        .tag("version_id", it->first)
+                        .tag("dict name", it->second->dict_name());
+                versioned_map.erase(it);
+            }
+            // ttl-based: drop non-latest versions older than ttl
+            while (ttl_ms > 0 && versioned_map.size() > 1) {
+                auto it = versioned_map.begin();
+                if (it->second->commit_time_ms() >= threshold_ms) {
+                    break;
+                }
+                LOG_INFO("DictionaryFactory GC old version by ttl")
+                        .tag("dict_id", dict_id)
+                        .tag("version_id", it->first)
+                        .tag("age_sec", (now - it->second->commit_time_ms()) / 
1000);
+                versioned_map.erase(it);

Review Comment:
   [P1] Do not let an orphaned future version replace FE's rolled-back version
   
   If one BE commits N+1 and another commit RPC fails, FE durably decrements 
back to N. Abort cannot undo the successful BE because its staging entry is 
already gone, leaving `{N, N+1}` locally. This GC then assumes numeric N+1 is 
authoritative and can erase N by TTL/count, while status reports N+1 and FE's 
`dataCompleted()` accepts any version `>= N`; every future plan still stamps N 
and will fail on that BE. Please carry a recoverable commit/abort decision to 
BE (including lost-response idempotency), preserve/report the FE-decided 
version until reconciliation, and test partial success followed by DEC, abort, 
and GC.



##########
be/test/exec/dictionary/dictionary_multi_version_test.cpp:
##########
@@ -0,0 +1,370 @@
+// 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 <gtest/gtest.h>
+
+#include <memory>
+
+#include "core/block/columns_with_type_and_name.h"
+#include "core/data_type/data_type_number.h"
+#include "exprs/function/complex_hash_map_dictionary.h"
+#include "exprs/function/dictionary.h"
+#include "exprs/function/dictionary_factory.h"
+
+namespace doris {
+
+static DictionaryPtr make_dict(const std::string& name) {
+    return create_complex_hash_map_dict_from_column(
+            name,
+            ColumnsWithTypeAndName {
+                    {DataTypeInt32::ColumnType::create(), 
std::make_shared<DataTypeInt32>(), ""}},
+            ColumnsWithTypeAndName {
+                    ColumnWithTypeAndName {DataTypeInt32::ColumnType::create(),
+                                           std::make_shared<DataTypeInt32>(), 
""},
+            });
+}
+
+static void commit_version(DictionaryFactory& f, int64_t dict_id, int64_t 
version_id) {
+    auto dict = make_dict("dict_" + std::to_string(dict_id));
+    EXPECT_TRUE(f.refresh_dict(dict_id, version_id, dict));
+    // reset GC timer to force GC on every commit
+    f._last_gc_time_ms.store(0, std::memory_order_relaxed);
+    EXPECT_TRUE(f.commit_refresh_dict(dict_id, version_id));
+}
+
+// ============ basic multi-version ============
+
+TEST(DictionaryMultiVersionTest, GetAfterMultipleCommits) {
+    auto old = config::dictionary_max_versions;
+    config::dictionary_max_versions = 10;
+    DictionaryFactory f;
+    commit_version(f, 1, 1);
+    commit_version(f, 1, 2);
+    commit_version(f, 1, 3);
+
+    EXPECT_NE(nullptr, f.get(1, 3));
+    EXPECT_NE(nullptr, f.get(1, 2));
+    EXPECT_NE(nullptr, f.get(1, 1));
+    config::dictionary_max_versions = old;
+}
+
+TEST(DictionaryMultiVersionTest, GetMissingVersion) {
+    DictionaryFactory f;
+    commit_version(f, 1, 1);
+    EXPECT_NE(nullptr, f.get(1, 1));
+    EXPECT_EQ(nullptr, f.get(1, 99));
+    EXPECT_EQ(nullptr, f.get(999, 1));
+}
+
+// ============ count-based GC ============
+
+TEST(DictionaryMultiVersionTest, GCByCount) {
+    auto old_max = config::dictionary_max_versions;
+    config::dictionary_max_versions = 2;
+    DictionaryFactory f;
+    commit_version(f, 1, 1);
+    commit_version(f, 1, 2);
+    commit_version(f, 1, 3);
+    // max_versions=2, v=1 should be GC'd
+    EXPECT_EQ(nullptr, f.get(1, 1));
+    EXPECT_NE(nullptr, f.get(1, 2));
+    EXPECT_NE(nullptr, f.get(1, 3));
+    config::dictionary_max_versions = old_max;
+}
+
+TEST(DictionaryMultiVersionTest, GCByCountOne) {
+    auto old_max = config::dictionary_max_versions;
+    config::dictionary_max_versions = 1;
+    DictionaryFactory f;
+    commit_version(f, 1, 1);
+    commit_version(f, 1, 2);
+    EXPECT_EQ(nullptr, f.get(1, 1));
+    EXPECT_NE(nullptr, f.get(1, 2));
+    config::dictionary_max_versions = old_max;
+}
+
+TEST(DictionaryMultiVersionTest, GCKeepsLatest) {
+    auto old_max = config::dictionary_max_versions;
+    config::dictionary_max_versions = 1;
+    DictionaryFactory f;
+    for (int i = 1; i <= 5; i++) {
+        commit_version(f, 1, i);
+    }
+    EXPECT_EQ(nullptr, f.get(1, 4));
+    EXPECT_NE(nullptr, f.get(1, 5));
+    config::dictionary_max_versions = old_max;
+}
+
+// ============ TTL-based GC ============
+
+TEST(DictionaryMultiVersionTest, TTLExpired) {
+    auto old_ttl = config::dictionary_version_ttl_seconds;
+    auto old_max = config::dictionary_max_versions;
+    config::dictionary_version_ttl_seconds = 1;
+    config::dictionary_max_versions = 10;
+    DictionaryFactory f;
+    commit_version(f, 1, 1);
+    // simulate v=1 committed 2 seconds ago
+    f._dict_id_to_versioned_map[1][1]->set_commit_time_ms(UnixMillis() - 2000);
+    commit_version(f, 1, 2);
+    // GC triggered by commit should drop v=1 (expired)
+    EXPECT_EQ(nullptr, f.get(1, 1));
+    EXPECT_NE(nullptr, f.get(1, 2));
+    config::dictionary_version_ttl_seconds = old_ttl;
+    config::dictionary_max_versions = old_max;
+}
+
+TEST(DictionaryMultiVersionTest, TTLKeepsLatest) {
+    auto old_ttl = config::dictionary_version_ttl_seconds;
+    auto old_max = config::dictionary_max_versions;
+    config::dictionary_version_ttl_seconds = 1;
+    config::dictionary_max_versions = 10;
+    DictionaryFactory f;
+    commit_version(f, 1, 1);
+    f._dict_id_to_versioned_map[1][1]->set_commit_time_ms(UnixMillis() - 2000);
+    commit_version(f, 1, 2);
+    f._dict_id_to_versioned_map[1][2]->set_commit_time_ms(UnixMillis() - 2000);
+    // both expired, but latest must survive
+    EXPECT_NE(nullptr, f.get(1, 2));
+    config::dictionary_version_ttl_seconds = old_ttl;
+    config::dictionary_max_versions = old_max;
+}
+
+TEST(DictionaryMultiVersionTest, TTLZero) {
+    auto old_ttl = config::dictionary_version_ttl_seconds;
+    auto old_max = config::dictionary_max_versions;
+    config::dictionary_version_ttl_seconds = 0;
+    config::dictionary_max_versions = 10;
+    DictionaryFactory f;
+    commit_version(f, 1, 1);
+    f._dict_id_to_versioned_map[1][1]->set_commit_time_ms(UnixMillis() - 
100000);
+    commit_version(f, 1, 2);
+    // ttl=0, no TTL GC; both kept
+    EXPECT_NE(nullptr, f.get(1, 1));
+    EXPECT_NE(nullptr, f.get(1, 2));
+    config::dictionary_version_ttl_seconds = old_ttl;
+    config::dictionary_max_versions = old_max;
+}
+
+// ============ extreme configs ============
+
+TEST(DictionaryMultiVersionTest, GCMaxVersionsZero) {
+    auto old = config::dictionary_max_versions;
+    config::dictionary_max_versions = 0;
+    DictionaryFactory f;
+    commit_version(f, 1, 1);
+    commit_version(f, 1, 2);
+    // 0 falls back to 1; only latest kept
+    EXPECT_EQ(nullptr, f.get(1, 1));
+    EXPECT_NE(nullptr, f.get(1, 2));
+    config::dictionary_max_versions = old;
+}
+
+TEST(DictionaryMultiVersionTest, GCMaxVersionsNegative) {
+    auto old = config::dictionary_max_versions;
+    config::dictionary_max_versions = -5;
+    DictionaryFactory f;
+    commit_version(f, 1, 1);
+    commit_version(f, 1, 2);
+    // negative falls back to 1; only latest kept
+    EXPECT_EQ(nullptr, f.get(1, 1));
+    EXPECT_NE(nullptr, f.get(1, 2));
+    config::dictionary_max_versions = old;
+}
+
+TEST(DictionaryMultiVersionTest, GCMaxVersionsLarge) {
+    auto old = config::dictionary_max_versions;
+    config::dictionary_max_versions = 1000000;
+    DictionaryFactory f;
+    for (int i = 1; i <= 5; i++) {
+        commit_version(f, 1, i);
+    }
+    // large max; all versions kept
+    for (int i = 1; i <= 5; i++) {
+        EXPECT_NE(nullptr, f.get(1, i));
+    }
+    config::dictionary_max_versions = old;
+}
+
+TEST(DictionaryMultiVersionTest, TTLNegative) {
+    auto old_ttl = config::dictionary_version_ttl_seconds;
+    auto old_max = config::dictionary_max_versions;
+    config::dictionary_version_ttl_seconds = -1;
+    config::dictionary_max_versions = 10;
+    DictionaryFactory f;
+    commit_version(f, 1, 1);
+    f._dict_id_to_versioned_map[1][1]->set_commit_time_ms(UnixMillis() - 
100000);
+    commit_version(f, 1, 2);
+    // negative ttl = no TTL GC
+    EXPECT_NE(nullptr, f.get(1, 1));
+    config::dictionary_version_ttl_seconds = old_ttl;
+    config::dictionary_max_versions = old_max;
+}
+
+TEST(DictionaryMultiVersionTest, GCIntervalLarge) {
+    auto old_interval = config::dictionary_gc_interval_seconds;
+    auto old_max = config::dictionary_max_versions;
+    config::dictionary_gc_interval_seconds = 1000000;
+    config::dictionary_max_versions = 1;
+    DictionaryFactory f;
+    f._last_gc_time_ms.store(UnixMillis(), std::memory_order_relaxed);
+    // manual commit without resetting GC timer
+    auto dict1 = make_dict("dict_1");
+    EXPECT_TRUE(f.refresh_dict(1, 1, dict1));
+    EXPECT_TRUE(f.commit_refresh_dict(1, 1));
+    auto dict2 = make_dict("dict_1");
+    EXPECT_TRUE(f.refresh_dict(1, 2, dict2));
+    EXPECT_TRUE(f.commit_refresh_dict(1, 2));
+    // interval not elapsed; GC skipped; both kept
+    EXPECT_NE(nullptr, f.get(1, 1));
+    EXPECT_NE(nullptr, f.get(1, 2));
+    config::dictionary_gc_interval_seconds = old_interval;
+    config::dictionary_max_versions = old_max;
+}
+
+// ============ boundary scenarios ============
+
+TEST(DictionaryMultiVersionTest, CommitDuplicateVersion) {
+    DictionaryFactory f;
+    commit_version(f, 1, 1);
+    // commit same version again should fail
+    auto dict = make_dict("dict_1");
+    EXPECT_TRUE(f.refresh_dict(1, 1, dict));
+    auto st = f.commit_refresh_dict(1, 1);
+    EXPECT_FALSE(st.ok());
+}
+
+TEST(DictionaryMultiVersionTest, GetNonExistentDict) {
+    DictionaryFactory f;
+    EXPECT_EQ(nullptr, f.get(999, 1));
+}
+
+TEST(DictionaryMultiVersionTest, DeleteAllVersions) {
+    DictionaryFactory f;
+    config::dictionary_max_versions = 10;

Review Comment:
   [P2] Restore the process-global config in every unit test
   
   This assignment is not factory-local. `DeleteAllVersions`, 
`CommitAfterDelete`, and `GetStatusReportsLatest` each set 
`dictionary_max_versions=10` without restoring it, and `exec/*.cpp` tests are 
linked into the same `doris_be_test` process, so later or shuffled tests run 
with polluted state instead of the declared default. Please use a scoped 
save/restore in all three tests so exceptional exits cannot leak the value 
either.



##########
be/src/exprs/function/dictionary_factory.h:
##########
@@ -88,65 +110,121 @@ class DictionaryFactory : private boost::noncopyable {
                     "Version ID is not equal to the refreshing version ID. {} 
: {}", version_id,
                     refresh_version_id);
         }
-        {
-            // commit the dictionary
-            if (_dict_id_to_version_id_map.contains(dict_id)) {
-                // check version_id
-                if (version_id <= _dict_id_to_version_id_map[dict_id]) {
-                    LOG_WARNING(
-                            "DictionaryFactory Failed to commit dictionary 
because version ID "
-                            "is not greater than the existing version ID")
-                            .tag("dict_id", dict_id)
-                            .tag("version_id", version_id)
-                            .tag("dict name", dict->dict_name())
-                            .tag("existing version ID", 
_dict_id_to_version_id_map[dict_id]);
-                    return Status::InvalidArgument(
-                            "Version ID is not greater than the existing 
version ID for the "
-                            "dictionary. {} : {}",
-                            version_id, _dict_id_to_version_id_map[dict_id]);
-                }
+        auto& versioned_map = _dict_id_to_versioned_map[dict_id];
+        if (!versioned_map.empty()) {
+            int64_t latest = versioned_map.rbegin()->first;
+            if (version_id <= latest) {
+                LOG_WARNING(
+                        "DictionaryFactory Failed to commit dictionary because 
version ID "
+                        "is not greater than the existing version ID")
+                        .tag("dict_id", dict_id)
+                        .tag("version_id", version_id)
+                        .tag("dict name", dict->dict_name())
+                        .tag("existing version ID", latest);
+                return Status::InvalidArgument(
+                        "Version ID is not greater than the existing version 
ID for the "
+                        "dictionary. {} : {}",
+                        version_id, latest);
             }
-            LOG_INFO("DictionaryFactory Successfully commit dictionary")
-                    .tag("dict_id", dict_id)
-                    .tag("version_id", version_id)
-                    .tag("dict name", dict->dict_name());
-            _dict_id_to_dict_map[dict_id] = dict;
-            _dict_id_to_version_id_map[dict_id] = version_id;
-            _refreshing_dict_map.erase(dict_id);
         }
+        LOG_INFO("DictionaryFactory Successfully commit dictionary")
+                .tag("dict_id", dict_id)
+                .tag("version_id", version_id)
+                .tag("dict name", dict->dict_name());
+        dict->set_commit_time_ms(UnixMillis());
+        versioned_map[version_id] = dict;
+        _refreshing_dict_map.erase(dict_id);
+        lc.unlock();
+        gc_if_needed();
         return Status::OK();
     }
 
     Status delete_dict(int64_t dict_id) {
         VLOG_DEBUG << "DictionaryFactory delete dictionary, dict_id: " << 
dict_id;
         std::unique_lock lc(_mutex);
-        if (!_dict_id_to_dict_map.contains(dict_id)) {
-            LOG_WARNING("DictionaryFactory Failed to delete 
dictionary").tag("dict_id", dict_id);
+        auto it = _dict_id_to_versioned_map.find(dict_id);
+        if (it == _dict_id_to_versioned_map.end()) {
             return Status::OK();
         }
-        auto dict = _dict_id_to_dict_map[dict_id];
-        LOG_INFO("DictionaryFactory Successfully delete dictionary")
-                .tag("dict_id", dict_id)
-                .tag("dict name", dict->dict_name());
-        _dict_id_to_dict_map.erase(dict_id);
-        _dict_id_to_version_id_map.erase(dict_id);
+        if (it->second.empty()) {
+            LOG_WARNING("DictionaryFactory delete dictionary with empty 
version map")
+                    .tag("dict_id", dict_id);
+        } else {
+            auto latest_it = it->second.rbegin();
+            LOG_INFO("DictionaryFactory Successfully delete dictionary")
+                    .tag("dict_id", dict_id)
+                    .tag("dict name", latest_it->second->dict_name())
+                    .tag("latest version_id", latest_it->first);
+        }
+        _dict_id_to_versioned_map.erase(it);
         return Status::OK();
     }
 
     std::shared_ptr<MemTrackerLimiter> mem_tracker() const { return 
_mem_tracker; }
 
+    // unified GC entry: count-based + ttl-based, with interval protection
+    void gc_if_needed() {
+        int64_t gc_interval_ms = std::max(1, 
config::dictionary_gc_interval_seconds) * 1000LL;
+        int64_t now = UnixMillis();
+        if (now - _last_gc_time_ms.load(std::memory_order_relaxed) < 
gc_interval_ms) {
+            return;
+        }
+        std::unique_lock<std::shared_mutex> lc(_mutex);
+        // re-check under lock to avoid duplicate GC
+        if (now - _last_gc_time_ms.load(std::memory_order_relaxed) < 
gc_interval_ms) {
+            return;
+        }
+        _last_gc_time_ms.store(now, std::memory_order_relaxed);
+        _gc_all_no_lock(now);
+    }
+
     void get_dictionary_status(std::vector<TDictionaryStatus>& result,
                                std::vector<int64_t> dict_ids);
 
 private:
-    std::map<int64_t, DictionaryPtr> _dict_id_to_dict_map;
-    std::map<int64_t, int64_t> _dict_id_to_version_id_map;
+    // GC all dicts: first count-based, then ttl-based. Always keeps the 
latest version.
+    void _gc_all_no_lock(int64_t now) {
+        int32_t max_versions = std::max(1, config::dictionary_max_versions);
+        int64_t ttl_ms = 
static_cast<int64_t>(config::dictionary_version_ttl_seconds) * 1000;
+        int64_t threshold_ms = ttl_ms > 0 ? now - ttl_ms : 0;
+        for (auto& [dict_id, versioned_map] : _dict_id_to_versioned_map) {
+            if (versioned_map.size() <= 1) {
+                continue;
+            }
+            // count-based: drop oldest while exceeding max_versions
+            while (versioned_map.size() > static_cast<size_t>(max_versions)) {

Review Comment:
   [P1] Do not evict versions still reachable by queued plans
   
   FE embeds version N during translation, but BE does not acquire the 
protecting `shared_ptr` until function `open()`, after workload-group admission 
and fragment dispatch. If N+1 commits and N+2 is the first GC-eligible commit 
after the 60-second interval, an N-stamped plan can still be queued under the 
default 900-second query timeout; with the default cap of two this loop erases 
N and the delayed `get(N)` reproduces `dictionary not found`. A fixed count 
(and likewise TTL) is not a safe lifetime boundary. Please add a lease/pin that 
starts before a plan can wait, or enforce a horizon covering every 
planning/queue/retry timeout without count-GC overriding it, and test 
interval-separated refreshes before `get()`.



##########
be/src/exprs/function/dictionary_factory.h:
##########
@@ -88,65 +110,121 @@ class DictionaryFactory : private boost::noncopyable {
                     "Version ID is not equal to the refreshing version ID. {} 
: {}", version_id,
                     refresh_version_id);
         }
-        {
-            // commit the dictionary
-            if (_dict_id_to_version_id_map.contains(dict_id)) {
-                // check version_id
-                if (version_id <= _dict_id_to_version_id_map[dict_id]) {
-                    LOG_WARNING(
-                            "DictionaryFactory Failed to commit dictionary 
because version ID "
-                            "is not greater than the existing version ID")
-                            .tag("dict_id", dict_id)
-                            .tag("version_id", version_id)
-                            .tag("dict name", dict->dict_name())
-                            .tag("existing version ID", 
_dict_id_to_version_id_map[dict_id]);
-                    return Status::InvalidArgument(
-                            "Version ID is not greater than the existing 
version ID for the "
-                            "dictionary. {} : {}",
-                            version_id, _dict_id_to_version_id_map[dict_id]);
-                }
+        auto& versioned_map = _dict_id_to_versioned_map[dict_id];
+        if (!versioned_map.empty()) {
+            int64_t latest = versioned_map.rbegin()->first;
+            if (version_id <= latest) {
+                LOG_WARNING(
+                        "DictionaryFactory Failed to commit dictionary because 
version ID "
+                        "is not greater than the existing version ID")
+                        .tag("dict_id", dict_id)
+                        .tag("version_id", version_id)
+                        .tag("dict name", dict->dict_name())
+                        .tag("existing version ID", latest);
+                return Status::InvalidArgument(
+                        "Version ID is not greater than the existing version 
ID for the "
+                        "dictionary. {} : {}",
+                        version_id, latest);
             }
-            LOG_INFO("DictionaryFactory Successfully commit dictionary")
-                    .tag("dict_id", dict_id)
-                    .tag("version_id", version_id)
-                    .tag("dict name", dict->dict_name());
-            _dict_id_to_dict_map[dict_id] = dict;
-            _dict_id_to_version_id_map[dict_id] = version_id;
-            _refreshing_dict_map.erase(dict_id);
         }
+        LOG_INFO("DictionaryFactory Successfully commit dictionary")
+                .tag("dict_id", dict_id)
+                .tag("version_id", version_id)
+                .tag("dict name", dict->dict_name());
+        dict->set_commit_time_ms(UnixMillis());
+        versioned_map[version_id] = dict;
+        _refreshing_dict_map.erase(dict_id);
+        lc.unlock();
+        gc_if_needed();
         return Status::OK();
     }
 
     Status delete_dict(int64_t dict_id) {
         VLOG_DEBUG << "DictionaryFactory delete dictionary, dict_id: " << 
dict_id;
         std::unique_lock lc(_mutex);
-        if (!_dict_id_to_dict_map.contains(dict_id)) {
-            LOG_WARNING("DictionaryFactory Failed to delete 
dictionary").tag("dict_id", dict_id);
+        auto it = _dict_id_to_versioned_map.find(dict_id);
+        if (it == _dict_id_to_versioned_map.end()) {
             return Status::OK();
         }
-        auto dict = _dict_id_to_dict_map[dict_id];
-        LOG_INFO("DictionaryFactory Successfully delete dictionary")
-                .tag("dict_id", dict_id)
-                .tag("dict name", dict->dict_name());
-        _dict_id_to_dict_map.erase(dict_id);
-        _dict_id_to_version_id_map.erase(dict_id);
+        if (it->second.empty()) {
+            LOG_WARNING("DictionaryFactory delete dictionary with empty 
version map")
+                    .tag("dict_id", dict_id);
+        } else {
+            auto latest_it = it->second.rbegin();
+            LOG_INFO("DictionaryFactory Successfully delete dictionary")
+                    .tag("dict_id", dict_id)
+                    .tag("dict name", latest_it->second->dict_name())
+                    .tag("latest version_id", latest_it->first);
+        }
+        _dict_id_to_versioned_map.erase(it);
         return Status::OK();
     }
 
     std::shared_ptr<MemTrackerLimiter> mem_tracker() const { return 
_mem_tracker; }
 
+    // unified GC entry: count-based + ttl-based, with interval protection
+    void gc_if_needed() {
+        int64_t gc_interval_ms = std::max(1, 
config::dictionary_gc_interval_seconds) * 1000LL;
+        int64_t now = UnixMillis();
+        if (now - _last_gc_time_ms.load(std::memory_order_relaxed) < 
gc_interval_ms) {
+            return;
+        }
+        std::unique_lock<std::shared_mutex> lc(_mutex);
+        // re-check under lock to avoid duplicate GC
+        if (now - _last_gc_time_ms.load(std::memory_order_relaxed) < 
gc_interval_ms) {
+            return;
+        }
+        _last_gc_time_ms.store(now, std::memory_order_relaxed);
+        _gc_all_no_lock(now);
+    }
+
     void get_dictionary_status(std::vector<TDictionaryStatus>& result,
                                std::vector<int64_t> dict_ids);
 
 private:
-    std::map<int64_t, DictionaryPtr> _dict_id_to_dict_map;
-    std::map<int64_t, int64_t> _dict_id_to_version_id_map;
+    // GC all dicts: first count-based, then ttl-based. Always keeps the 
latest version.
+    void _gc_all_no_lock(int64_t now) {
+        int32_t max_versions = std::max(1, config::dictionary_max_versions);
+        int64_t ttl_ms = 
static_cast<int64_t>(config::dictionary_version_ttl_seconds) * 1000;
+        int64_t threshold_ms = ttl_ms > 0 ? now - ttl_ms : 0;
+        for (auto& [dict_id, versioned_map] : _dict_id_to_versioned_map) {
+            if (versioned_map.size() <= 1) {
+                continue;
+            }
+            // count-based: drop oldest while exceeding max_versions
+            while (versioned_map.size() > static_cast<size_t>(max_versions)) {
+                auto it = versioned_map.begin();
+                LOG_INFO("DictionaryFactory GC old version by count")
+                        .tag("dict_id", dict_id)
+                        .tag("version_id", it->first)
+                        .tag("dict name", it->second->dict_name());
+                versioned_map.erase(it);

Review Comment:
   [P1] Retire dictionary payloads after releasing the factory lock
   
   This erase can drop the last reference and synchronously destroy the 
dictionary while the factory-wide exclusive mutex is held. A pass can retire 
versions from every dictionary, and each payload may be up to the default 2 
GiB, so all `get()`/status/refresh calls are blocked during hash-table and 
column destruction. `commit_refresh_dict()` also waits for this sweep before 
replying to FE's five-second RPC, even though it has already inserted the new 
version, which can turn reclamation latency into a partial-commit rollback. 
Please unlink/move retired pointers under the lock and destroy them after 
unlock (and keep the synchronous commit path bounded); `delete_dict()` needs 
the same treatment for its retained-version map.



##########
regression-test/suites/dictionary_p0/test_dict_version_consistency.groovy:
##########
@@ -0,0 +1,138 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+suite("test_dict_version_consistency", "nonConcurrent") {
+    sql "drop database if exists test_dict_version_consistency"
+    sql "create database test_dict_version_consistency"
+    sql "use test_dict_version_consistency"
+
+    sql """
+        create table src_dict(
+            k varchar(100) not null,
+            v varchar(100) not null
+        )
+        DISTRIBUTED BY HASH(`k`) BUCKETS 1
+        properties("replication_num" = "1");
+    """
+    sql "insert into src_dict values ('1', 'value1'), ('2', 'value2'), ('3', 
'value3')"
+    sql """
+        create dictionary dict1 using src_dict
+        (k KEY, v VALUE) LAYOUT(HASH_MAP)
+        properties('data_lifetime'='600');
+    """
+    waitAllDictionariesReady()
+
+    def queryDict = { String key ->
+        sql "select dict_get('test_dict_version_consistency.dict1', 'v', 
'${key}')"
+    }
+
+    // get current dict version via show dictionaries
+    // columns: DictionaryId(0) DictionaryName(1) BaseTableName(2) Version(3) 
Status(4) ...
+    def getDictVersion = {
+        def rows = sql "show dictionaries"
+        for (def row : rows) {
+            if (row[1] == "dict1") return row[3] as int
+        }
+        return -1
+    }
+
+    // wait until dict version advances past baseline (FE increaseVersion 
done, commit still pending)
+    def waitVersionAdvanced = { int baseline, long timeoutMs ->
+        long deadline = System.currentTimeMillis() + timeoutMs
+        while (System.currentTimeMillis() < deadline) {
+            def v = getDictVersion()
+            if (v > baseline) return v
+            sleep(200)
+        }
+        throw new AssertionError("dict version did not advance past 
${baseline} within ${timeoutMs}ms")
+    }
+
+    // baseline
+    def baselineVersion = getDictVersion()
+    def result = queryDict("1")
+    assertEquals("value1", result[0][0])
+
+    // ============ Test 1: staging fallback ============
+    // Scenario: FE increaseVersion() done (version N+1 visible) but commit 
RPC to BE not yet sent
+    //           (blocked at afterIncJournal debug point).
+    //           Query with version N+1 should succeed via 
_refreshing_dict_map fallback.
+    logger.info("=== Test 1: staging fallback ===")
+    
GetDebugPoint().enableDebugPointForAllFEs("DictionaryManager.afterIncJournal")
+    try {
+        def executor = Executors.newSingleThreadExecutor()
+        def refreshFuture = executor.submit({
+            sql "use test_dict_version_consistency"
+            sql "refresh dictionary dict1"
+        })
+
+        // wait until FE has increased version (blocked at afterIncJournal, 
commit not done yet)
+        def advancedVersion = waitVersionAdvanced(baselineVersion, 10000)
+        logger.info("FE version advanced to ${advancedVersion}, commit still 
pending")
+
+        // query should succeed via staging fallback
+        result = queryDict("1")
+        assertEquals("value1", result[0][0])
+        logger.info("query succeeded via staging fallback")
+
+        
GetDebugPoint().disableDebugPointForAllFEs("DictionaryManager.afterIncJournal")
+        refreshFuture.get(30, TimeUnit.SECONDS)
+        executor.shutdown()
+    } finally {
+        
GetDebugPoint().disableDebugPointForAllFEs("DictionaryManager.afterIncJournal")
+    }
+    logger.info("=== Test 1 passed ===")
+
+    // ============ Test 2: multi-version retention ============
+    // Scenario: query holds old version N (dict_get_delay sleeps 5s at BE 
get()),
+    //           refresh commits new version N+1 during the sleep.
+    //           Verifies _dict_id_to_versioned_map retains old version N 
(max_versions=2).
+    logger.info("=== Test 2: multi-version retention ===")
+    update_all_be_config("dictionary_max_versions", "2")
+    GetDebugPoint().enableDebugPointForAllBEs("dict_get_delay", [sleep_sec: 5])
+    try {
+        def baselineVersion2 = getDictVersion()
+        // submit query in background: FE plan uses baselineVersion2, BE 
sleeps 5s at get()
+        def executor = Executors.newSingleThreadExecutor()
+        def queryResult = new 
java.util.concurrent.atomic.AtomicReference<List>()
+        def queryFuture = executor.submit({
+            sql "use test_dict_version_consistency"
+            queryResult.set(sql "select 
dict_get('test_dict_version_consistency.dict1', 'v', '1')")
+        })
+
+        // wait for BE to enter dict_get_delay (query is now sleeping with old 
version)
+        sleep(2000)
+
+        // refresh commits new version while query holds old version
+        sql "refresh dictionary dict1"
+        def newVersion = waitVersionAdvanced(baselineVersion2, 10000)
+        logger.info("refresh committed version ${newVersion}, query still 
holding ${baselineVersion2}")
+
+        // query should complete successfully using old version (multi-version 
retention)
+        queryFuture.get(30, TimeUnit.SECONDS)
+        executor.shutdown()
+        result = queryResult.get()
+        assertEquals("value1", result[0][0])
+        logger.info("query succeeded with old version (multi-version 
retention)")
+    } finally {
+        GetDebugPoint().disableDebugPointForAllBEs("dict_get_delay")
+        update_all_be_config("dictionary_max_versions", "1")

Review Comment:
   [P2] Restore the prior max-version setting
   
   This suite changes a mutable cluster-wide BE config to 2, then always leaves 
it at 1 even though this PR's new default is 2. Subsequent dictionary suites in 
the same regression cluster therefore run with multi-version retention 
disabled. Please capture and restore the prior value, or at least restore the 
declared default of 2.



##########
regression-test/suites/dictionary_p0/test_dict_version_consistency.groovy:
##########
@@ -0,0 +1,138 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+suite("test_dict_version_consistency", "nonConcurrent") {
+    sql "drop database if exists test_dict_version_consistency"
+    sql "create database test_dict_version_consistency"
+    sql "use test_dict_version_consistency"
+
+    sql """
+        create table src_dict(
+            k varchar(100) not null,
+            v varchar(100) not null
+        )
+        DISTRIBUTED BY HASH(`k`) BUCKETS 1
+        properties("replication_num" = "1");
+    """
+    sql "insert into src_dict values ('1', 'value1'), ('2', 'value2'), ('3', 
'value3')"
+    sql """
+        create dictionary dict1 using src_dict
+        (k KEY, v VALUE) LAYOUT(HASH_MAP)
+        properties('data_lifetime'='600');
+    """
+    waitAllDictionariesReady()
+
+    def queryDict = { String key ->
+        sql "select dict_get('test_dict_version_consistency.dict1', 'v', 
'${key}')"
+    }
+
+    // get current dict version via show dictionaries
+    // columns: DictionaryId(0) DictionaryName(1) BaseTableName(2) Version(3) 
Status(4) ...
+    def getDictVersion = {
+        def rows = sql "show dictionaries"
+        for (def row : rows) {
+            if (row[1] == "dict1") return row[3] as int
+        }
+        return -1
+    }
+
+    // wait until dict version advances past baseline (FE increaseVersion 
done, commit still pending)
+    def waitVersionAdvanced = { int baseline, long timeoutMs ->
+        long deadline = System.currentTimeMillis() + timeoutMs
+        while (System.currentTimeMillis() < deadline) {
+            def v = getDictVersion()
+            if (v > baseline) return v
+            sleep(200)
+        }
+        throw new AssertionError("dict version did not advance past 
${baseline} within ${timeoutMs}ms")
+    }
+
+    // baseline
+    def baselineVersion = getDictVersion()
+    def result = queryDict("1")
+    assertEquals("value1", result[0][0])
+
+    // ============ Test 1: staging fallback ============
+    // Scenario: FE increaseVersion() done (version N+1 visible) but commit 
RPC to BE not yet sent
+    //           (blocked at afterIncJournal debug point).
+    //           Query with version N+1 should succeed via 
_refreshing_dict_map fallback.
+    logger.info("=== Test 1: staging fallback ===")
+    
GetDebugPoint().enableDebugPointForAllFEs("DictionaryManager.afterIncJournal")
+    try {
+        def executor = Executors.newSingleThreadExecutor()
+        def refreshFuture = executor.submit({
+            sql "use test_dict_version_consistency"
+            sql "refresh dictionary dict1"
+        })
+
+        // wait until FE has increased version (blocked at afterIncJournal, 
commit not done yet)
+        def advancedVersion = waitVersionAdvanced(baselineVersion, 10000)
+        logger.info("FE version advanced to ${advancedVersion}, commit still 
pending")
+
+        // query should succeed via staging fallback
+        result = queryDict("1")
+        assertEquals("value1", result[0][0])
+        logger.info("query succeeded via staging fallback")
+
+        
GetDebugPoint().disableDebugPointForAllFEs("DictionaryManager.afterIncJournal")
+        refreshFuture.get(30, TimeUnit.SECONDS)
+        executor.shutdown()
+    } finally {
+        
GetDebugPoint().disableDebugPointForAllFEs("DictionaryManager.afterIncJournal")
+    }
+    logger.info("=== Test 1 passed ===")
+
+    // ============ Test 2: multi-version retention ============
+    // Scenario: query holds old version N (dict_get_delay sleeps 5s at BE 
get()),
+    //           refresh commits new version N+1 during the sleep.
+    //           Verifies _dict_id_to_versioned_map retains old version N 
(max_versions=2).
+    logger.info("=== Test 2: multi-version retention ===")
+    update_all_be_config("dictionary_max_versions", "2")
+    GetDebugPoint().enableDebugPointForAllBEs("dict_get_delay", [sleep_sec: 5])
+    try {
+        def baselineVersion2 = getDictVersion()
+        // submit query in background: FE plan uses baselineVersion2, BE 
sleeps 5s at get()
+        def executor = Executors.newSingleThreadExecutor()
+        def queryResult = new 
java.util.concurrent.atomic.AtomicReference<List>()
+        def queryFuture = executor.submit({
+            sql "use test_dict_version_consistency"
+            queryResult.set(sql "select 
dict_get('test_dict_version_consistency.dict1', 'v', '1')")
+        })
+
+        // wait for BE to enter dict_get_delay (query is now sleeping with old 
version)
+        sleep(2000)

Review Comment:
   [P2] Synchronize before claiming old-version coverage
   
   `sleep(2000)` does not prove the background query reached `dict_get_delay` 
with `baselineVersion2`; if planning/admission takes longer, the refresh wins, 
the query is translated with N+1, and the identical `value1` data lets this 
test pass without exercising retention. Please use a barrier/hit signal before 
refreshing and make N versus N+1 return distinguishable values. Also use the 
suite's `thread` helper (or always cancel and shut down in `finally`) so 
assertion/timeout paths do not leave non-daemon executors and thread-local JDBC 
connections behind; Test 1 has the same cleanup issue.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to