You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@doris.apache.org by yi...@apache.org on 2026/04/01 05:33:41 UTC

(doris) branch branch-4.1 updated: branch-4.1: [pick] Cherry-pick #60520 #60239 #60310 (#61921)

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

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


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new b387fb96de7 branch-4.1: [pick] Cherry-pick #60520 #60239 #60310 (#61921)
b387fb96de7 is described below

commit b387fb96de7e90f565efa6d3759170f636d3d14a
Author: Jimmy <li...@selectdb.com>
AuthorDate: Wed Apr 1 13:33:34 2026 +0800

    branch-4.1: [pick] Cherry-pick #60520 #60239 #60310 (#61921)
    
    ## Proposed changes
    
    Cherry-pick the following PRs to branch-4.1:
    
    - [Enhancement](Recycler) Add bvar metrics for operation logs #60520
    - [Feat](Snapshot) require root privilege for cluster snapshot commands
    #60239
    - [feature](cloud) Support compaction read-write separation for cloud
    mode #60310
---
 be/src/cloud/cloud_cluster_info.cpp                | 201 +++++++++
 be/src/cloud/cloud_cluster_info.h                  |  78 +++-
 be/src/cloud/cloud_meta_mgr.cpp                    |  39 +-
 be/src/cloud/cloud_meta_mgr.h                      |   6 +
 be/src/cloud/cloud_tablet.h                        |  20 +
 be/src/cloud/cloud_tablet_mgr.cpp                  |   9 +-
 be/src/cloud/config.cpp                            |  11 +
 be/src/cloud/config.h                              |  11 +
 be/src/runtime/exec_env_init.cpp                   |   8 +
 be/test/cloud/cloud_cluster_info_test.cpp          | 280 ++++++++++++
 cloud/src/common/bvars.cpp                         |  40 ++
 cloud/src/common/bvars.h                           |  24 +-
 cloud/src/meta-service/meta_service_resource.cpp   |  13 +
 cloud/src/meta-service/meta_service_txn.cpp        |  70 +++
 cloud/src/recycler/recycler.cpp                    |  40 ++
 cloud/src/recycler/recycler.h                      |  25 +-
 cloud/src/recycler/recycler_operation_log.cpp      |  90 +++-
 cloud/test/CMakeLists.txt                          |   1 +
 cloud/test/compaction_rw_separation_test.cpp       | 181 ++++++++
 cloud/test/recycler_operation_log_test.cpp         | 478 +++++++++++++++++++++
 .../main/java/org/apache/doris/common/Config.java  |   8 +
 .../transaction/CloudGlobalTransactionMgr.java     |  16 +
 .../nereids/rules/analysis/UserAuthentication.java |  30 +-
 .../AdminCreateClusterSnapshotCommand.java         |  18 +-
 .../commands/AdminDropClusterSnapshotCommand.java  |  18 +-
 .../AdminSetAutoClusterSnapshotCommand.java        |  18 +-
 ...dminSetClusterSnapshotFeatureSwitchCommand.java |  18 +-
 .../rules/analysis/UserAuthenticationTest.java     | 396 +++++++++++++++++
 .../AdminCreateClusterSnapshotCommandTest.java     | 102 ++++-
 .../AdminDropClusterSnapshotCommandTest.java       |  99 ++++-
 .../AdminSetAutoClusterSnapshotCommandTest.java    | 103 ++++-
 ...SetClusterSnapshotFeatureSwitchCommandTest.java |  99 ++++-
 gensrc/proto/cloud.proto                           |  18 +
 .../test_compaction_cluster_takeover.groovy        | 264 ++++++++++++
 .../test_compaction_rw_separation.groovy           | 158 +++++++
 .../test_compaction_rw_separation_disabled.groovy  | 151 +++++++
 .../test_compaction_rw_separation_force.groovy     | 161 +++++++
 37 files changed, 3243 insertions(+), 59 deletions(-)

diff --git a/be/src/cloud/cloud_cluster_info.cpp b/be/src/cloud/cloud_cluster_info.cpp
new file mode 100644
index 00000000000..cef8040ca3f
--- /dev/null
+++ b/be/src/cloud/cloud_cluster_info.cpp
@@ -0,0 +1,201 @@
+// 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 "cloud/cloud_cluster_info.h"
+
+#include <glog/logging.h>
+
+#include "cloud/cloud_meta_mgr.h"
+#include "cloud/cloud_storage_engine.h"
+#include "cloud/cloud_tablet.h"
+#include "cloud/config.h"
+#include "gen_cpp/cloud.pb.h"
+#include "runtime/exec_env.h"
+#include "util/time.h"
+
+namespace doris {
+
+CloudClusterInfo::~CloudClusterInfo() {
+    stop_bg_worker();
+}
+
+void CloudClusterInfo::start_bg_worker() {
+    bool expected = true;
+    if (!_bg_worker_stopped.compare_exchange_strong(expected, false)) {
+        // Already running
+        return;
+    }
+
+    _bg_worker = std::thread(&CloudClusterInfo::_bg_worker_func, this);
+    LOG(INFO) << "CloudClusterInfo background worker started, "
+              << "refresh_interval=" << config::cluster_status_cache_refresh_interval_sec << "s";
+}
+
+void CloudClusterInfo::stop_bg_worker() {
+    bool expected = false;
+    if (!_bg_worker_stopped.compare_exchange_strong(expected, true)) {
+        // Already stopped
+        return;
+    }
+
+    {
+        std::lock_guard lock(_bg_worker_mutex);
+        _bg_worker_cv.notify_all();
+    }
+
+    if (_bg_worker.joinable()) {
+        _bg_worker.join();
+    }
+
+    LOG(INFO) << "CloudClusterInfo background worker stopped";
+}
+
+void CloudClusterInfo::_bg_worker_func() {
+    LOG(INFO) << "CloudClusterInfo background worker thread running";
+
+    while (!_bg_worker_stopped.load()) {
+        _refresh_cluster_status();
+
+        std::unique_lock lock(_bg_worker_mutex);
+        _bg_worker_cv.wait_for(
+                lock, std::chrono::seconds(config::cluster_status_cache_refresh_interval_sec),
+                [this] { return _bg_worker_stopped.load(); });
+    }
+}
+
+void CloudClusterInfo::_refresh_cluster_status() {
+    if (!config::is_cloud_mode()) {
+        return;
+    }
+    auto* cloud_engine =
+            dynamic_cast<CloudStorageEngine*>(&ExecEnv::GetInstance()->storage_engine());
+    if (!cloud_engine) {
+        return;
+    }
+
+    std::unordered_map<std::string, std::pair<int32_t, int64_t>> cluster_status;
+    std::string resolved_cluster_id;
+    Status st = cloud_engine->meta_mgr().get_cluster_status(
+            &cluster_status, my_cluster_id().empty() ? &resolved_cluster_id : nullptr);
+    if (!st.ok()) {
+        LOG(WARNING) << "Failed to refresh cluster status: " << st;
+        return;
+    }
+
+    // Update cache
+    {
+        std::unique_lock lock(_mutex);
+        _cluster_status_cache.clear();
+        for (const auto& [cluster_id, status_pair] : cluster_status) {
+            _cluster_status_cache[cluster_id] = {status_pair.first, status_pair.second};
+        }
+    }
+
+    VLOG_DEBUG << "Refreshed cluster status cache, " << cluster_status.size() << " clusters";
+
+    // Set our own cluster_id if resolved from the response
+    if (my_cluster_id().empty() && !resolved_cluster_id.empty()) {
+        set_my_cluster_id(resolved_cluster_id);
+        LOG(INFO) << "Resolved my cluster_id: " << resolved_cluster_id;
+    }
+}
+
+bool CloudClusterInfo::should_skip_compaction(CloudTablet* tablet) const {
+    if (!config::enable_compaction_rw_separation) {
+        return false;
+    }
+
+    std::string last_active_cluster = tablet->last_active_cluster_id();
+    std::string my_cluster = my_cluster_id();
+    int64_t tablet_id = tablet->tablet_id();
+
+    // Case 1: No active cluster record, any cluster can compact
+    if (last_active_cluster.empty()) {
+        VLOG_DEBUG << "tablet " << tablet_id << " has no last_active_cluster record, "
+                   << "my_cluster=" << my_cluster << ", allow compaction";
+        return false;
+    }
+
+    // Case 2: This is the active cluster, allow compaction
+    if (last_active_cluster == my_cluster) {
+        VLOG_DEBUG << "tablet " << tablet_id << " last_active_cluster=" << last_active_cluster
+                   << " equals my_cluster=" << my_cluster << ", allow compaction";
+        return false;
+    }
+
+    // Case 3: Check if the last active cluster is available
+    ClusterStatusCache cache;
+    if (!get_cluster_status(last_active_cluster, &cache)) {
+        // Cluster not found in cache, might be deleted, allow takeover
+        LOG(INFO) << "compaction_rw_separation: tablet " << tablet_id
+                  << " last_active_cluster=" << last_active_cluster
+                  << " not found in cache (maybe deleted), my_cluster=" << my_cluster
+                  << ", allow takeover";
+        return false;
+    }
+
+    // Force compaction if tablet has too many rowsets (>80% of max_tablet_version_num),
+    // even on read clusters, to prevent version count from growing unbounded
+    // when the write cluster can't keep up or has compaction disabled.
+    int64_t num_rowsets = tablet->fetch_add_approximate_num_rowsets(0);
+    auto threshold = static_cast<int64_t>(tablet->max_version_config() *
+                                          config::compaction_rw_separation_version_threshold_ratio);
+    if (num_rowsets > threshold) {
+        LOG(INFO) << "compaction_rw_separation: force compaction on tablet " << tablet_id
+                  << ", num_rowsets=" << num_rowsets << " > threshold=" << threshold << " (80% of "
+                  << tablet->max_version_config() << ")"
+                  << ", my_cluster=" << my_cluster;
+        return false;
+    }
+
+    auto status = static_cast<cloud::ClusterStatus>(cache.status);
+    int64_t status_mtime = cache.mtime_ms;
+    int64_t now = UnixMillis();
+    int64_t elapsed = now - status_mtime;
+    int64_t timeout = config::compaction_cluster_takeover_timeout_ms;
+
+    // Case 4: Original cluster is NORMAL (still active), cannot takeover
+    if (status == cloud::ClusterStatus::NORMAL) {
+        LOG_EVERY_N(INFO, 100) << "compaction_rw_separation: skip tablet " << tablet_id
+                               << ", last_active_cluster=" << last_active_cluster
+                               << " is NORMAL (active), my_cluster=" << my_cluster;
+        return true;
+    }
+
+    // Case 5: Original cluster is unavailable (SUSPENDED/MANUAL_SHUTDOWN/deleted)
+    if (elapsed > timeout) {
+        // Takeover successful
+        LOG(INFO) << "compaction_rw_separation: takeover tablet " << tablet_id
+                  << ", last_active_cluster=" << last_active_cluster
+                  << " status=" << cloud::ClusterStatus_Name(status)
+                  << " status_mtime=" << status_mtime << " elapsed=" << elapsed
+                  << "ms > timeout=" << timeout << "ms"
+                  << ", my_cluster=" << my_cluster;
+        return false;
+    } else {
+        // Timeout not reached yet, waiting
+        LOG_EVERY_N(INFO, 100) << "compaction_rw_separation: skip tablet " << tablet_id
+                               << ", last_active_cluster=" << last_active_cluster
+                               << " status=" << cloud::ClusterStatus_Name(status)
+                               << " status_mtime=" << status_mtime << " elapsed=" << elapsed
+                               << "ms <= timeout=" << timeout << "ms"
+                               << ", my_cluster=" << my_cluster << ", waiting for takeover";
+        return true;
+    }
+}
+
+} // namespace doris
diff --git a/be/src/cloud/cloud_cluster_info.h b/be/src/cloud/cloud_cluster_info.h
index e62db51934a..a008ab22ea9 100644
--- a/be/src/cloud/cloud_cluster_info.h
+++ b/be/src/cloud/cloud_cluster_info.h
@@ -15,18 +15,92 @@
 // specific language governing permissions and limitations
 // under the License.
 
+#pragma once
+
+#include <atomic>
+#include <condition_variable>
+#include <mutex>
+#include <shared_mutex>
+#include <string>
+#include <thread>
+#include <unordered_map>
+
 #include "runtime/cluster_info.h"
 
 namespace doris {
 
+class CloudTablet;
+
+// Cached cluster status information
+struct ClusterStatusCache {
+    int32_t status {0};   // ClusterStatus enum value
+    int64_t mtime_ms {0}; // Timestamp when status was last changed
+};
+
 class CloudClusterInfo : public ClusterInfo {
 public:
-    bool is_in_standby() const { return _is_in_standby; }
+    ~CloudClusterInfo();
 
+    bool is_in_standby() const { return _is_in_standby; }
     void set_is_in_standby(bool flag) { _is_in_standby = flag; }
 
+    // Get this BE's cluster ID
+    std::string my_cluster_id() const {
+        std::shared_lock lock(_mutex);
+        return _my_cluster_id;
+    }
+    void set_my_cluster_id(const std::string& id) {
+        std::unique_lock lock(_mutex);
+        _my_cluster_id = id;
+    }
+
+    // Get cached cluster status, returns false if not found
+    bool get_cluster_status(const std::string& id, ClusterStatusCache* cache) const {
+        std::shared_lock lock(_mutex);
+        auto it = _cluster_status_cache.find(id);
+        if (it != _cluster_status_cache.end()) {
+            *cache = it->second;
+            return true;
+        }
+        return false;
+    }
+
+    // Update cluster status cache
+    void set_cluster_status(const std::string& id, int32_t status, int64_t mtime_ms) {
+        std::unique_lock lock(_mutex);
+        _cluster_status_cache[id] = {status, mtime_ms};
+    }
+
+    // Clear all cached cluster status
+    void clear_cluster_status_cache() {
+        std::unique_lock lock(_mutex);
+        _cluster_status_cache.clear();
+    }
+
+    // Start background refresh thread
+    void start_bg_worker();
+    // Stop background refresh thread
+    void stop_bg_worker();
+
+    // Check if this cluster should skip compaction for the given tablet
+    // Returns true if should skip (i.e., another cluster should do the compaction)
+    bool should_skip_compaction(CloudTablet* tablet) const;
+
 private:
+    void _bg_worker_func();
+    void _refresh_cluster_status();
+
     bool _is_in_standby = false;
+
+    mutable std::shared_mutex _mutex;
+    std::string _my_cluster_id;
+    std::unordered_map<std::string, ClusterStatusCache> _cluster_status_cache;
+
+    // Background worker
+    std::thread _bg_worker;
+    std::atomic<bool> _bg_worker_stopped {true};
+    std::mutex _bg_worker_mutex;
+    std::condition_variable _bg_worker_cv;
 };
 
-} // namespace doris
\ No newline at end of file
+} // namespace doris
diff --git a/be/src/cloud/cloud_meta_mgr.cpp b/be/src/cloud/cloud_meta_mgr.cpp
index ca6c6ff3e79..5a73f095e8f 100644
--- a/be/src/cloud/cloud_meta_mgr.cpp
+++ b/be/src/cloud/cloud_meta_mgr.cpp
@@ -352,7 +352,7 @@ static std::string debug_info(const Request& req) {
     } else if constexpr (is_any_v<Request, GetTabletRequest>) {
         return fmt::format(" tablet_id={}", req.tablet_id());
     } else if constexpr (is_any_v<Request, GetObjStoreInfoRequest, ListSnapshotRequest,
-                                  GetInstanceRequest>) {
+                                  GetInstanceRequest, GetClusterStatusRequest>) {
         return "";
     } else if constexpr (is_any_v<Request, CreateRowsetRequest>) {
         return fmt::format(" tablet_id={}", req.rowset_meta().tablet_id());
@@ -789,6 +789,12 @@ Status CloudMetaMgr::sync_tablet_rowsets_unlocked(CloudTablet* tablet,
             tablet->set_cumulative_layer_point(stats.cumulative_point());
             tablet->reset_approximate_stats(stats.num_rowsets(), stats.num_segments(),
                                             stats.num_rows(), stats.data_size());
+
+            // Sync last active cluster info for compaction read-write separation
+            if (config::enable_compaction_rw_separation && stats.has_last_active_cluster_id()) {
+                tablet->set_last_active_cluster_info(stats.last_active_cluster_id(),
+                                                     stats.last_active_time_ms());
+            }
         }
         return Status::OK();
     }
@@ -2304,5 +2310,36 @@ Status CloudMetaMgr::update_packed_file_info(const std::string& packed_file_path
                      &cloud::MetaService_Stub::update_packed_file_info);
 }
 
+Status CloudMetaMgr::get_cluster_status(
+        std::unordered_map<std::string, std::pair<int32_t, int64_t>>* result,
+        std::string* my_cluster_id) {
+    GetClusterStatusRequest req;
+    GetClusterStatusResponse resp;
+    req.add_cloud_unique_ids(config::cloud_unique_id);
+
+    Status s = retry_rpc("get cluster status", req, &resp, &MetaService_Stub::get_cluster_status);
+    if (!s.ok()) {
+        return s;
+    }
+
+    result->clear();
+    for (const auto& detail : resp.details()) {
+        for (const auto& cluster : detail.clusters()) {
+            // Store cluster status and mtime (mtime is in seconds from MS, convert to ms).
+            // If mtime is not set, use current time as a conservative default
+            // to avoid immediate takeover due to elapsed being huge.
+            int64_t mtime_ms = cluster.has_mtime() ? cluster.mtime() * 1000 : UnixMillis();
+            (*result)[cluster.cluster_id()] = {static_cast<int32_t>(cluster.cluster_status()),
+                                               mtime_ms};
+        }
+    }
+
+    if (my_cluster_id && resp.has_requester_cluster_id()) {
+        *my_cluster_id = resp.requester_cluster_id();
+    }
+
+    return Status::OK();
+}
+
 #include "common/compile_check_end.h"
 } // namespace doris::cloud
diff --git a/be/src/cloud/cloud_meta_mgr.h b/be/src/cloud/cloud_meta_mgr.h
index e469a90592e..2713ce12921 100644
--- a/be/src/cloud/cloud_meta_mgr.h
+++ b/be/src/cloud/cloud_meta_mgr.h
@@ -169,6 +169,12 @@ public:
                                    int64_t& max_reserved_snapshots,
                                    int64_t& snapshot_interval_seconds);
 
+    // Get all cluster status for the instance
+    // Returns cluster_id -> (status, mtime_ms)
+    // If my_cluster_id is not null, also returns the requesting node's cluster_id
+    Status get_cluster_status(std::unordered_map<std::string, std::pair<int32_t, int64_t>>* result,
+                              std::string* my_cluster_id = nullptr);
+
 private:
     bool sync_tablet_delete_bitmap_by_cache(CloudTablet* tablet, int64_t old_max_version,
                                             std::ranges::range auto&& rs_metas,
diff --git a/be/src/cloud/cloud_tablet.h b/be/src/cloud/cloud_tablet.h
index 984df0427a4..c872a54c0e5 100644
--- a/be/src/cloud/cloud_tablet.h
+++ b/be/src/cloud/cloud_tablet.h
@@ -251,6 +251,21 @@ public:
     int64_t alter_version() const { return _alter_version; }
     void set_alter_version(int64_t alter_version) { _alter_version = alter_version; }
 
+    // Last active cluster info for compaction read-write separation
+    std::string last_active_cluster_id() const {
+        std::shared_lock lock(_cluster_info_mutex);
+        return _last_active_cluster_id;
+    }
+    int64_t last_active_time_ms() const {
+        std::shared_lock lock(_cluster_info_mutex);
+        return _last_active_time_ms;
+    }
+    void set_last_active_cluster_info(const std::string& cluster_id, int64_t time_ms) {
+        std::unique_lock lock(_cluster_info_mutex);
+        _last_active_cluster_id = cluster_id;
+        _last_active_time_ms = time_ms;
+    }
+
     std::vector<RowsetSharedPtr> pick_candidate_rowsets_to_base_compaction();
 
     inline Version max_version() const {
@@ -487,6 +502,11 @@ private:
 
     mutable std::shared_mutex _warmed_up_rowsets_mutex;
     std::unordered_set<RowsetId> _warmed_up_rowsets;
+
+    // Cluster info for compaction read-write separation
+    mutable std::shared_mutex _cluster_info_mutex;
+    std::string _last_active_cluster_id;
+    int64_t _last_active_time_ms {0};
 };
 
 using CloudTabletSPtr = std::shared_ptr<CloudTablet>;
diff --git a/be/src/cloud/cloud_tablet_mgr.cpp b/be/src/cloud/cloud_tablet_mgr.cpp
index dc09560b870..6161ef484ea 100644
--- a/be/src/cloud/cloud_tablet_mgr.cpp
+++ b/be/src/cloud/cloud_tablet_mgr.cpp
@@ -435,12 +435,19 @@ Status CloudTabletMgr::get_topn_tablets_to_compact(
     auto now = duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
     auto skip = [now, compaction_type](CloudTablet* t) {
         auto* cloud_cluster_info = static_cast<CloudClusterInfo*>(ExecEnv::GetInstance()->cluster_info());
+
         if (config::enable_standby_passive_compaction && cloud_cluster_info->is_in_standby()) {
             if (t->fetch_add_approximate_num_rowsets(0) < config::max_tablet_version_num * config::standby_compaction_version_ratio) {
                 return true;
             }
         }
 
+        // Compaction read-write separation: skip tablets that should be compacted by other clusters.
+        // Placed after standby check so standby invariants (version count threshold) are preserved.
+        if (cloud_cluster_info->should_skip_compaction(t)) {
+            return true;
+        }
+
         int32_t max_version_config = t->max_version_config();
         if (compaction_type == CompactionType::BASE_COMPACTION) {
             bool is_recent_failure = now - t->last_base_compaction_failure_time() < config::min_compaction_failure_interval_ms;
@@ -450,7 +457,7 @@ Status CloudTabletMgr::get_topn_tablets_to_compact(
             g_base_compaction_not_frozen_tablet_num << !is_frozen;
             return is_recent_failure || is_frozen;
         }
-        
+
         // If tablet has too many rowsets but not be compacted for a long time, compaction should be performed
         // regardless of whether there is a load job recently.
         bool is_recent_failure = now - t->last_cumu_compaction_failure_time() < config::min_compaction_failure_interval_ms;
diff --git a/be/src/cloud/config.cpp b/be/src/cloud/config.cpp
index 57d817b9dce..f13af24224a 100644
--- a/be/src/cloud/config.cpp
+++ b/be/src/cloud/config.cpp
@@ -148,6 +148,17 @@ DEFINE_mBool(enable_standby_passive_compaction, "true");
 
 DEFINE_mDouble(standby_compaction_version_ratio, "0.8");
 
+// Compaction read-write separation: only the "last active" cluster (the one that most recently
+// performed load) is allowed to compact a tablet
+DEFINE_mBool(enable_compaction_rw_separation, "false");
+// Timeout in ms for takeover when last active cluster becomes unavailable (default 5 min)
+DEFINE_mInt64(compaction_cluster_takeover_timeout_ms, "300000");
+// Interval in seconds to refresh cluster status cache (default 1 min)
+DEFINE_mInt64(cluster_status_cache_refresh_interval_sec, "60");
+// When version count exceeds this ratio of max_tablet_version_num, force compaction
+// even on read-only clusters (safety valve)
+DEFINE_mDouble(compaction_rw_separation_version_threshold_ratio, "0.8");
+
 DEFINE_mBool(enable_cache_read_from_peer, "true");
 
 // Rate limit for warmup download in bytes per second, default 100MB/s
diff --git a/be/src/cloud/config.h b/be/src/cloud/config.h
index 32d82ea6090..bb7d0a7f73e 100644
--- a/be/src/cloud/config.h
+++ b/be/src/cloud/config.h
@@ -191,6 +191,17 @@ DECLARE_mBool(enable_standby_passive_compaction);
 
 DECLARE_mDouble(standby_compaction_version_ratio);
 
+// Compaction read-write separation: only the "last active" cluster (the one that most recently
+// performed load) is allowed to compact a tablet
+DECLARE_mBool(enable_compaction_rw_separation);
+// Timeout in ms for takeover when last active cluster becomes unavailable (default 5 min)
+DECLARE_mInt64(compaction_cluster_takeover_timeout_ms);
+// Interval in seconds to refresh cluster status cache for compaction read-write separation
+DECLARE_mInt64(cluster_status_cache_refresh_interval_sec);
+// When version count exceeds this ratio of max_tablet_version_num, force compaction
+// even on read-only clusters (safety valve to prevent unbounded version growth)
+DECLARE_mDouble(compaction_rw_separation_version_threshold_ratio);
+
 DECLARE_mBool(enable_cache_read_from_peer);
 
 // Rate limit for warmup download in bytes per second, default 100MB/s
diff --git a/be/src/runtime/exec_env_init.cpp b/be/src/runtime/exec_env_init.cpp
index 0c081a9d9d8..7857762af37 100644
--- a/be/src/runtime/exec_env_init.cpp
+++ b/be/src/runtime/exec_env_init.cpp
@@ -429,6 +429,9 @@ Status ExecEnv::_init(const std::vector<StorePath>& store_paths,
     if (config::is_cloud_mode()) {
         RETURN_IF_ERROR(_packed_file_manager->init());
         _packed_file_manager->start_background_manager();
+
+        // Start cluster info background worker for compaction read-write separation
+        static_cast<CloudClusterInfo*>(_cluster_info)->start_bg_worker();
     }
 
     _index_policy_mgr = new IndexPolicyMgr();
@@ -837,6 +840,11 @@ void ExecEnv::destroy() {
     // _id_manager must be destoried before tablet schema cache
     SAFE_DELETE(_id_manager);
 
+    // Stop cluster info background worker before storage engine is destroyed
+    if (config::is_cloud_mode() && _cluster_info) {
+        static_cast<CloudClusterInfo*>(_cluster_info)->stop_bg_worker();
+    }
+
     // StorageEngine must be destoried before _cache_manager destory
     SAFE_STOP(_storage_engine);
     _storage_engine.reset();
diff --git a/be/test/cloud/cloud_cluster_info_test.cpp b/be/test/cloud/cloud_cluster_info_test.cpp
new file mode 100644
index 00000000000..36e0334a150
--- /dev/null
+++ b/be/test/cloud/cloud_cluster_info_test.cpp
@@ -0,0 +1,280 @@
+// 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 "cloud/cloud_cluster_info.h"
+
+#include <gtest/gtest.h>
+
+#include "cloud/cloud_storage_engine.h"
+#include "cloud/cloud_tablet.h"
+#include "cloud/config.h"
+#include "gen_cpp/cloud.pb.h"
+#include "storage/tablet/tablet_meta.h"
+#include "util/time.h"
+#include "util/uid_util.h"
+
+namespace doris {
+
+class CloudClusterInfoTest : public testing::Test {
+public:
+    CloudClusterInfoTest() : _engine(CloudStorageEngine(EngineOptions {})) {}
+
+    void SetUp() override {
+        _cluster_info = std::make_unique<CloudClusterInfo>();
+
+        _tablet_meta.reset(new TabletMeta(1, 2, 15673, 15674, 4, 5, TTabletSchema(), 6, {{7, 8}},
+                                          UniqueId(9, 10), TTabletType::TABLET_TYPE_DISK,
+                                          TCompressionType::LZ4F));
+        _tablet =
+                std::make_shared<CloudTablet>(_engine, std::make_shared<TabletMeta>(*_tablet_meta));
+    }
+
+    void TearDown() override {
+        _cluster_info.reset();
+        _tablet.reset();
+    }
+
+protected:
+    CloudStorageEngine _engine;
+    std::unique_ptr<CloudClusterInfo> _cluster_info;
+    std::shared_ptr<TabletMeta> _tablet_meta;
+    std::shared_ptr<CloudTablet> _tablet;
+};
+
+// Test my_cluster_id get/set
+TEST_F(CloudClusterInfoTest, MyClusterId) {
+    EXPECT_EQ(_cluster_info->my_cluster_id(), "");
+
+    _cluster_info->set_my_cluster_id("cluster_a");
+    EXPECT_EQ(_cluster_info->my_cluster_id(), "cluster_a");
+
+    _cluster_info->set_my_cluster_id("cluster_b");
+    EXPECT_EQ(_cluster_info->my_cluster_id(), "cluster_b");
+}
+
+// Test cluster status cache get/set/clear
+TEST_F(CloudClusterInfoTest, ClusterStatusCache) {
+    ClusterStatusCache cache;
+
+    // Not found initially
+    EXPECT_FALSE(_cluster_info->get_cluster_status("cluster_a", &cache));
+
+    // Set and get
+    _cluster_info->set_cluster_status("cluster_a", cloud::ClusterStatus::NORMAL, 1000);
+    EXPECT_TRUE(_cluster_info->get_cluster_status("cluster_a", &cache));
+    EXPECT_EQ(cache.status, cloud::ClusterStatus::NORMAL);
+    EXPECT_EQ(cache.mtime_ms, 1000);
+
+    // Set another cluster
+    _cluster_info->set_cluster_status("cluster_b", cloud::ClusterStatus::SUSPENDED, 2000);
+    EXPECT_TRUE(_cluster_info->get_cluster_status("cluster_b", &cache));
+    EXPECT_EQ(cache.status, cloud::ClusterStatus::SUSPENDED);
+    EXPECT_EQ(cache.mtime_ms, 2000);
+
+    // Clear
+    _cluster_info->clear_cluster_status_cache();
+    EXPECT_FALSE(_cluster_info->get_cluster_status("cluster_a", &cache));
+    EXPECT_FALSE(_cluster_info->get_cluster_status("cluster_b", &cache));
+}
+
+// Test is_in_standby
+TEST_F(CloudClusterInfoTest, IsInStandby) {
+    EXPECT_FALSE(_cluster_info->is_in_standby());
+    _cluster_info->set_is_in_standby(true);
+    EXPECT_TRUE(_cluster_info->is_in_standby());
+    _cluster_info->set_is_in_standby(false);
+    EXPECT_FALSE(_cluster_info->is_in_standby());
+}
+
+// Case 1: Feature disabled, should never skip
+TEST_F(CloudClusterInfoTest, ShouldSkipCompactionDisabled) {
+    config::enable_compaction_rw_separation = false;
+
+    _cluster_info->set_my_cluster_id("cluster_a");
+    _tablet->set_last_active_cluster_info("cluster_b", UnixMillis());
+
+    EXPECT_FALSE(_cluster_info->should_skip_compaction(_tablet.get()));
+}
+
+// Case 2: No last_active_cluster, allow compaction
+TEST_F(CloudClusterInfoTest, ShouldSkipCompactionNoActiveCluster) {
+    config::enable_compaction_rw_separation = true;
+
+    _cluster_info->set_my_cluster_id("cluster_a");
+    // tablet has no last_active_cluster set (empty)
+
+    EXPECT_FALSE(_cluster_info->should_skip_compaction(_tablet.get()));
+}
+
+// Case 3: This is the active cluster, allow compaction
+TEST_F(CloudClusterInfoTest, ShouldSkipCompactionSameCluster) {
+    config::enable_compaction_rw_separation = true;
+
+    _cluster_info->set_my_cluster_id("cluster_a");
+    _tablet->set_last_active_cluster_info("cluster_a", UnixMillis());
+
+    EXPECT_FALSE(_cluster_info->should_skip_compaction(_tablet.get()));
+}
+
+// Case 4: Active cluster not in cache (deleted), allow takeover
+TEST_F(CloudClusterInfoTest, ShouldSkipCompactionClusterDeleted) {
+    config::enable_compaction_rw_separation = true;
+
+    _cluster_info->set_my_cluster_id("cluster_a");
+    _tablet->set_last_active_cluster_info("cluster_deleted", UnixMillis());
+    // cluster_deleted is not in cache
+
+    EXPECT_FALSE(_cluster_info->should_skip_compaction(_tablet.get()));
+}
+
+// Case 5: Active cluster is NORMAL, skip compaction
+TEST_F(CloudClusterInfoTest, ShouldSkipCompactionActiveClusterNormal) {
+    config::enable_compaction_rw_separation = true;
+
+    _cluster_info->set_my_cluster_id("cluster_a");
+    _tablet->set_last_active_cluster_info("cluster_b", UnixMillis());
+    _cluster_info->set_cluster_status("cluster_b", cloud::ClusterStatus::NORMAL, UnixMillis());
+
+    EXPECT_TRUE(_cluster_info->should_skip_compaction(_tablet.get()));
+}
+
+// Case 6: Active cluster SUSPENDED, timeout not reached, skip
+TEST_F(CloudClusterInfoTest, ShouldSkipCompactionSuspendedNotTimedOut) {
+    config::enable_compaction_rw_separation = true;
+    config::compaction_cluster_takeover_timeout_ms = 60000; // 60s
+
+    _cluster_info->set_my_cluster_id("cluster_a");
+    _tablet->set_last_active_cluster_info("cluster_b", UnixMillis());
+    // Set suspended just now — timeout not reached
+    _cluster_info->set_cluster_status("cluster_b", cloud::ClusterStatus::SUSPENDED, UnixMillis());
+
+    EXPECT_TRUE(_cluster_info->should_skip_compaction(_tablet.get()));
+}
+
+// Case 7: Active cluster SUSPENDED, timeout exceeded, allow takeover
+TEST_F(CloudClusterInfoTest, ShouldSkipCompactionSuspendedTimedOut) {
+    config::enable_compaction_rw_separation = true;
+    config::compaction_cluster_takeover_timeout_ms = 1000; // 1s
+
+    _cluster_info->set_my_cluster_id("cluster_a");
+    _tablet->set_last_active_cluster_info("cluster_b", UnixMillis());
+    // Set suspended long ago
+    _cluster_info->set_cluster_status("cluster_b", cloud::ClusterStatus::SUSPENDED,
+                                      UnixMillis() - 5000);
+
+    EXPECT_FALSE(_cluster_info->should_skip_compaction(_tablet.get()));
+}
+
+// Case 8: Active cluster MANUAL_SHUTDOWN, timeout exceeded, allow takeover
+TEST_F(CloudClusterInfoTest, ShouldSkipCompactionManualShutdownTimedOut) {
+    config::enable_compaction_rw_separation = true;
+    config::compaction_cluster_takeover_timeout_ms = 1000;
+
+    _cluster_info->set_my_cluster_id("cluster_a");
+    _tablet->set_last_active_cluster_info("cluster_b", UnixMillis());
+    _cluster_info->set_cluster_status("cluster_b", cloud::ClusterStatus::MANUAL_SHUTDOWN,
+                                      UnixMillis() - 5000);
+
+    EXPECT_FALSE(_cluster_info->should_skip_compaction(_tablet.get()));
+}
+
+// Test CloudTablet last_active_cluster_info accessors
+TEST_F(CloudClusterInfoTest, TabletLastActiveClusterInfo) {
+    EXPECT_EQ(_tablet->last_active_cluster_id(), "");
+    EXPECT_EQ(_tablet->last_active_time_ms(), 0);
+
+    int64_t now = UnixMillis();
+    _tablet->set_last_active_cluster_info("cluster_x", now);
+    EXPECT_EQ(_tablet->last_active_cluster_id(), "cluster_x");
+    EXPECT_EQ(_tablet->last_active_time_ms(), now);
+
+    _tablet->set_last_active_cluster_info("cluster_y", now + 1000);
+    EXPECT_EQ(_tablet->last_active_cluster_id(), "cluster_y");
+    EXPECT_EQ(_tablet->last_active_time_ms(), now + 1000);
+}
+
+// Case 9: Active cluster is NORMAL but version count exceeds 80% threshold, force compaction
+TEST_F(CloudClusterInfoTest, ShouldSkipCompactionForceOnHighVersionCount) {
+    config::enable_compaction_rw_separation = true;
+
+    _cluster_info->set_my_cluster_id("cluster_a");
+    _tablet->set_last_active_cluster_info("cluster_b", UnixMillis());
+    _cluster_info->set_cluster_status("cluster_b", cloud::ClusterStatus::NORMAL, UnixMillis());
+
+    // Default max_tablet_version_num is 2000, 80% threshold = 1600
+    // _approximate_num_rowsets starts at -1, so add (1600 - (-1)) = 1601 to reach 1600
+    int64_t cur = _tablet->fetch_add_approximate_num_rowsets(0);
+    _tablet->fetch_add_approximate_num_rowsets(1600 - cur);
+    EXPECT_TRUE(_cluster_info->should_skip_compaction(_tablet.get()));
+
+    // Now bump to 1601 (above threshold) => should NOT skip (force compaction)
+    _tablet->fetch_add_approximate_num_rowsets(1);
+    EXPECT_FALSE(_cluster_info->should_skip_compaction(_tablet.get()));
+}
+
+// Case 10: Active cluster SUSPENDED, not timed out, but version count exceeds threshold
+TEST_F(CloudClusterInfoTest, ShouldSkipCompactionForceOnHighVersionCountSuspended) {
+    config::enable_compaction_rw_separation = true;
+    config::compaction_cluster_takeover_timeout_ms = 60000; // 60s
+
+    _cluster_info->set_my_cluster_id("cluster_a");
+    _tablet->set_last_active_cluster_info("cluster_b", UnixMillis());
+    _cluster_info->set_cluster_status("cluster_b", cloud::ClusterStatus::SUSPENDED, UnixMillis());
+
+    // Set approximate_num_rowsets above 80% threshold => force compaction
+    int64_t cur = _tablet->fetch_add_approximate_num_rowsets(0);
+    _tablet->fetch_add_approximate_num_rowsets(1601 - cur);
+    EXPECT_FALSE(_cluster_info->should_skip_compaction(_tablet.get()));
+}
+
+// Test start/stop bg worker lifecycle — covers start_bg_worker, stop_bg_worker,
+// _bg_worker_func, and _refresh_cluster_status (early return branch since no CloudStorageEngine)
+TEST_F(CloudClusterInfoTest, BgWorkerStartStop) {
+    // Use a very short refresh interval so the bg thread loops quickly
+    auto old_interval = config::cluster_status_cache_refresh_interval_sec;
+    config::cluster_status_cache_refresh_interval_sec = 1;
+
+    // Start the bg worker — this spawns a thread that calls _bg_worker_func,
+    // which calls _refresh_cluster_status (returns early since no CloudStorageEngine)
+    _cluster_info->start_bg_worker();
+
+    // Let it run at least one iteration
+    std::this_thread::sleep_for(std::chrono::milliseconds(1500));
+
+    // Double start should be safe (already running)
+    _cluster_info->start_bg_worker();
+
+    // Stop
+    _cluster_info->stop_bg_worker();
+
+    // Double stop should be safe
+    _cluster_info->stop_bg_worker();
+
+    config::cluster_status_cache_refresh_interval_sec = old_interval;
+}
+
+// Test destructor stops bg worker
+TEST_F(CloudClusterInfoTest, DestructorStopsBgWorker) {
+    auto info = std::make_unique<CloudClusterInfo>();
+    config::cluster_status_cache_refresh_interval_sec = 1;
+    info->start_bg_worker();
+    std::this_thread::sleep_for(std::chrono::milliseconds(1500));
+    // Destructor should call stop_bg_worker
+    info.reset();
+}
+
+} // namespace doris
diff --git a/cloud/src/common/bvars.cpp b/cloud/src/common/bvars.cpp
index 03aa6ad5049..e2f587e151a 100644
--- a/cloud/src/common/bvars.cpp
+++ b/cloud/src/common/bvars.cpp
@@ -189,6 +189,46 @@ BvarStatusWithTag<int64_t> g_bvar_recycler_batch_delete_rowset_plan_count(
 BvarStatusWithTag<int64_t> g_bvar_recycler_batch_delete_failures(
         "recycler", "batch_delete_failures");
 
+// Operation Log Recycler BVars
+mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_total_num(
+        "recycler_oplog_last_round_total_num", {"instance_id"});
+mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_not_recycled_num(
+        "recycler_oplog_last_round_not_recycled_num", {"instance_id"});
+mBvarIntAdder g_bvar_recycler_oplog_recycle_failed_num(
+        "recycler_oplog_recycle_failed_num", {"instance_id"});
+mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_commit_partition_num(
+        "recycler_oplog_last_round_recycled_commit_partition_num", {"instance_id"});
+mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_drop_partition_num(
+        "recycler_oplog_last_round_recycled_drop_partition_num", {"instance_id"});
+mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_commit_index_num(
+        "recycler_oplog_last_round_recycled_commit_index_num", {"instance_id"});
+mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_drop_index_num(
+        "recycler_oplog_last_round_recycled_drop_index_num", {"instance_id"});
+mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_update_tablet_num(
+        "recycler_oplog_last_round_recycled_update_tablet_num", {"instance_id"});
+mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_compaction_num(
+        "recycler_oplog_last_round_recycled_compaction_num", {"instance_id"});
+mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_schema_change_num(
+        "recycler_oplog_last_round_recycled_schema_change_num", {"instance_id"});
+mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_commit_txn_num(
+        "recycler_oplog_last_round_recycled_commit_txn_num", {"instance_id"});
+mBvarIntAdder g_bvar_recycler_oplog_recycled_commit_partition_num(
+        "recycler_oplog_recycled_commit_partition_num", {"instance_id"});
+mBvarIntAdder g_bvar_recycler_oplog_recycled_drop_partition_num(
+        "recycler_oplog_recycled_drop_partition_num", {"instance_id"});
+mBvarIntAdder g_bvar_recycler_oplog_recycled_commit_index_num(
+        "recycler_oplog_recycled_commit_index_num", {"instance_id"});
+mBvarIntAdder g_bvar_recycler_oplog_recycled_drop_index_num(
+        "recycler_oplog_recycled_drop_index_num", {"instance_id"});
+mBvarIntAdder g_bvar_recycler_oplog_recycled_update_tablet_num(
+        "recycler_oplog_recycled_update_tablet_num", {"instance_id"});
+mBvarIntAdder g_bvar_recycler_oplog_recycled_compaction_num(
+        "recycler_oplog_recycled_compaction_num", {"instance_id"});
+mBvarIntAdder g_bvar_recycler_oplog_recycled_schema_change_num(
+        "recycler_oplog_recycled_schema_change_num", {"instance_id"});
+mBvarIntAdder g_bvar_recycler_oplog_recycled_commit_txn_num(
+        "recycler_oplog_recycled_commit_txn_num", {"instance_id"});
+
 // txn_kv's bvars
 bvar::LatencyRecorder g_bvar_txn_kv_get("txn_kv", "get");
 bvar::LatencyRecorder g_bvar_txn_kv_range_get("txn_kv", "range_get");
diff --git a/cloud/src/common/bvars.h b/cloud/src/common/bvars.h
index fc73d8d3149..5497cf6a754 100644
--- a/cloud/src/common/bvars.h
+++ b/cloud/src/common/bvars.h
@@ -676,8 +676,28 @@ extern BvarStatusWithTag<int64_t> g_bvar_recycler_batch_delete_failures;
 extern BvarStatusWithTag<int64_t> g_bvar_recycler_packed_file_bytes_object_deleted;
 extern BvarStatusWithTag<int64_t> g_bvar_recycler_packed_file_rowset_scanned_num;
 
-extern BvarStatusWithTag<int64_t> g_bvar_recycler_batch_delete_rowset_plan_count;
-extern BvarStatusWithTag<int64_t> g_bvar_recycler_batch_delete_failures;
+// Operation Log Recycler BVars
+// Note: generic metrics (last_round_to_recycle_num/bytes, last_round_recycled_num/bytes, etc.)
+// are reported by RecyclerMetricsContext with operation_type = "recycle_operation_logs".
+extern mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_total_num;
+extern mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_not_recycled_num;
+extern mBvarIntAdder g_bvar_recycler_oplog_recycle_failed_num;
+extern mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_commit_partition_num;
+extern mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_drop_partition_num;
+extern mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_commit_index_num;
+extern mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_drop_index_num;
+extern mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_update_tablet_num;
+extern mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_compaction_num;
+extern mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_schema_change_num;
+extern mBvarStatus<int64_t> g_bvar_recycler_oplog_last_round_recycled_commit_txn_num;
+extern mBvarIntAdder g_bvar_recycler_oplog_recycled_commit_partition_num;
+extern mBvarIntAdder g_bvar_recycler_oplog_recycled_drop_partition_num;
+extern mBvarIntAdder g_bvar_recycler_oplog_recycled_commit_index_num;
+extern mBvarIntAdder g_bvar_recycler_oplog_recycled_drop_index_num;
+extern mBvarIntAdder g_bvar_recycler_oplog_recycled_update_tablet_num;
+extern mBvarIntAdder g_bvar_recycler_oplog_recycled_compaction_num;
+extern mBvarIntAdder g_bvar_recycler_oplog_recycled_schema_change_num;
+extern mBvarIntAdder g_bvar_recycler_oplog_recycled_commit_txn_num;
 
 // txn_kv's bvars
 extern bvar::LatencyRecorder g_bvar_txn_kv_get;
diff --git a/cloud/src/meta-service/meta_service_resource.cpp b/cloud/src/meta-service/meta_service_resource.cpp
index 57127e642d0..dad23b45b2c 100644
--- a/cloud/src/meta-service/meta_service_resource.cpp
+++ b/cloud/src/meta-service/meta_service_resource.cpp
@@ -4718,6 +4718,9 @@ void MetaServiceImpl::get_cluster_status(google::protobuf::RpcController* contro
             } else {
                 pb.set_cluster_status(ClusterStatus::NORMAL);
             }
+            if (cluster.has_mtime()) {
+                pb.set_mtime(cluster.mtime());
+            }
             detail.add_clusters()->CopyFrom(pb);
         }
         if (detail.clusters().size() == 0) {
@@ -4728,6 +4731,16 @@ void MetaServiceImpl::get_cluster_status(google::protobuf::RpcController* contro
 
     std::for_each(instance_ids.begin(), instance_ids.end(), get_clusters_info);
 
+    // Resolve the requesting node's cluster_id from cloud_unique_id
+    if (!request->cloud_unique_ids().empty()) {
+        const auto& cloud_unique_id = request->cloud_unique_ids(0);
+        std::vector<NodeInfo> nodes;
+        std::string node_err = resource_mgr_->get_node(cloud_unique_id, &nodes);
+        if (node_err.empty() && !nodes.empty()) {
+            response->set_requester_cluster_id(nodes[0].cluster_id);
+        }
+    }
+
     msg = proto_to_json(*response);
 }
 
diff --git a/cloud/src/meta-service/meta_service_txn.cpp b/cloud/src/meta-service/meta_service_txn.cpp
index 799299d11c3..88fd0b81053 100644
--- a/cloud/src/meta-service/meta_service_txn.cpp
+++ b/cloud/src/meta-service/meta_service_txn.cpp
@@ -40,6 +40,7 @@
 #include "meta-store/txn_kv.h"
 #include "meta-store/txn_kv_error.h"
 #include "meta-store/versioned_value.h"
+#include "resource-manager/resource_manager.h"
 
 using namespace std::chrono;
 
@@ -1182,6 +1183,48 @@ void scan_tmp_rowset(
     return;
 }
 
+// Update the last active cluster info for a tablet
+void update_tablet_last_active_cluster(const StatsTabletKeyInfo& info,
+                                       const std::string& cluster_id,
+                                       std::unique_ptr<Transaction>& txn, MetaServiceCode& code,
+                                       std::string& msg) {
+    if (cluster_id.empty()) {
+        return;
+    }
+
+    std::string key;
+    stats_tablet_key(info, &key);
+    std::string val;
+    TxnErrorCode err = txn->get(key, &val);
+    if (err != TxnErrorCode::TXN_OK) {
+        // If tablet stats not found, skip (will be created later)
+        if (err == TxnErrorCode::TXN_KEY_NOT_FOUND) {
+            return;
+        }
+        code = cast_as<ErrCategory::READ>(err);
+        msg = fmt::format("failed to get tablet stats for cluster update, err={} tablet_id={}", err,
+                          std::get<4>(info));
+        return;
+    }
+
+    TabletStatsPB stats_pb;
+    if (!stats_pb.ParseFromString(val)) {
+        code = MetaServiceCode::PROTOBUF_PARSE_ERR;
+        msg = fmt::format("malformed tablet stats value for cluster update, key={}", hex(key));
+        return;
+    }
+
+    stats_pb.set_last_active_cluster_id(cluster_id);
+    stats_pb.set_last_active_time_ms(::time(nullptr) * 1000);
+    // Clear the mtime when updating cluster to allow dynamic filling on next get_rowset
+    stats_pb.clear_last_active_cluster_status_mtime_ms();
+
+    stats_pb.SerializeToString(&val);
+    txn->put(key, val);
+    LOG(INFO) << "update last_active_cluster, key=" << hex(key) << " cluster_id=" << cluster_id
+              << " tablet_id=" << std::get<4>(info);
+}
+
 void update_tablet_stats(const StatsTabletKeyInfo& info, const TabletStats& stats,
                          std::unique_ptr<Transaction>& txn, MetaServiceCode& code,
                          std::string& msg) {
@@ -1877,6 +1920,14 @@ void MetaServiceImpl::commit_txn_immediately(
                       << " versioned tablet stats, txn_id=" << txn_id;
         }
 
+        // Get cluster_id for updating last active cluster.
+        // Use load_cluster_id stored in TxnInfoPB by prepare_rowset (called by BE),
+        // because commit_txn is called by FE whose cloud_unique_id resolves to SQL server cluster.
+        std::string requester_cluster_id;
+        if (txn_info.has_load_cluster_id()) {
+            requester_cluster_id = txn_info.load_cluster_id();
+        }
+
         // Update stats of affected tablet
         for (auto& [tablet_id, stats] : tablet_stats) {
             DCHECK(tablet_ids.count(tablet_id));
@@ -1886,6 +1937,12 @@ void MetaServiceImpl::commit_txn_immediately(
             update_tablet_stats(info, stats, txn, code, msg);
             if (code != MetaServiceCode::OK) return;
 
+            // Update last active cluster if load has data
+            if (!requester_cluster_id.empty() && stats.num_segs > 0) {
+                update_tablet_last_active_cluster(info, requester_cluster_id, txn, code, msg);
+                if (code != MetaServiceCode::OK) return;
+            }
+
             if (is_versioned_write) {
                 TabletStatsPB stats_pb = existing_versioned_stats[tablet_id];
                 merge_tablet_stats(stats_pb, stats);
@@ -3051,6 +3108,13 @@ void MetaServiceImpl::commit_txn_with_sub_txn(const CommitTxnRequest* request,
                       << " versioned tablet stats, txn_id=" << txn_id;
         }
 
+        // Get cluster_id for updating last active cluster.
+        // Use load_cluster_id stored in TxnInfoPB by prepare_rowset (called by BE).
+        std::string requester_cluster_id_ev;
+        if (txn_info.has_load_cluster_id()) {
+            requester_cluster_id_ev = txn_info.load_cluster_id();
+        }
+
         // Update stats of affected tablet
         for (auto& [tablet_id, stats] : tablet_stats) {
             DCHECK(tablet_ids.count(tablet_id));
@@ -3060,6 +3124,12 @@ void MetaServiceImpl::commit_txn_with_sub_txn(const CommitTxnRequest* request,
             update_tablet_stats(info, stats, txn, code, msg);
             if (code != MetaServiceCode::OK) return;
 
+            // Update last active cluster if load has data
+            if (!requester_cluster_id_ev.empty() && stats.num_segs > 0) {
+                update_tablet_last_active_cluster(info, requester_cluster_id_ev, txn, code, msg);
+                if (code != MetaServiceCode::OK) return;
+            }
+
             if (is_versioned_write) {
                 TabletStatsPB stats_pb = existing_versioned_stats[tablet_id];
                 merge_tablet_stats(stats_pb, stats);
diff --git a/cloud/src/recycler/recycler.cpp b/cloud/src/recycler/recycler.cpp
index acd572e71c7..47958da4f5b 100644
--- a/cloud/src/recycler/recycler.cpp
+++ b/cloud/src/recycler/recycler.cpp
@@ -7298,6 +7298,46 @@ int InstanceRecycler::scan_and_statistics_restore_jobs() {
     return ret;
 }
 
+void InstanceRecycler::scan_and_statistics_operation_logs() {
+    if (!should_recycle_versioned_keys()) {
+        return;
+    }
+
+    RecyclerMetricsContext metrics_context(instance_id_, "recycle_operation_logs");
+
+    OperationLogRecycleChecker recycle_checker(instance_id_, txn_kv_.get(), instance_info_);
+    if (recycle_checker.init() != 0) {
+        return;
+    }
+
+    std::string log_key_prefix = versioned::log_key(instance_id_);
+    std::string begin_key = encode_versioned_key(log_key_prefix, Versionstamp::min());
+    std::string end_key = encode_versioned_key(log_key_prefix, Versionstamp::max());
+
+    std::unique_ptr<BlobIterator> iter = blob_get_range(txn_kv_, begin_key, end_key);
+    for (; iter->valid(); iter->next()) {
+        OperationLogPB operation_log;
+        if (!iter->parse_value(&operation_log)) {
+            continue;
+        }
+
+        std::string_view key = iter->key();
+        Versionstamp log_versionstamp;
+        if (!decode_versioned_key(&key, &log_versionstamp)) {
+            continue;
+        }
+
+        OperationLogReferenceInfo ref_info;
+        if (recycle_checker.can_recycle(log_versionstamp, operation_log.min_timestamp(),
+                                         &ref_info)) {
+            metrics_context.total_need_recycle_num++;
+            metrics_context.total_need_recycle_data_size += operation_log.ByteSizeLong();
+        }
+    }
+
+    metrics_context.report(true);
+}
+
 int InstanceRecycler::classify_rowset_task_by_ref_count(
         RowsetDeleteTask& task, std::vector<RowsetDeleteTask>& batch_delete_tasks) {
     constexpr int MAX_RETRY = 10;
diff --git a/cloud/src/recycler/recycler.h b/cloud/src/recycler/recycler.h
index cf5cd4ea034..1802d7aed40 100644
--- a/cloud/src/recycler/recycler.h
+++ b/cloud/src/recycler/recycler.h
@@ -247,6 +247,8 @@ public:
             : RecyclerMetricsContext("global_recycler", "recycle_segment") {}
 };
 
+struct OplogRecycleStats;
+
 class InstanceRecycler {
 public:
     struct PackedFileRecycleStats {
@@ -401,6 +403,8 @@ public:
 
     int scan_and_statistics_restore_jobs();
 
+    void scan_and_statistics_operation_logs();
+
     /**
      * Decode the key of a packed-file metadata record into the persisted object path.
      *
@@ -489,7 +493,8 @@ private:
     //
     // Both `operation_log` and `raw_keys` will be removed in the same transaction, to ensure atomicity.
     int recycle_operation_log(Versionstamp log_version, const std::vector<std::string>& raw_keys,
-                              OperationLogPB operation_log);
+                              OperationLogPB operation_log,
+                              OplogRecycleStats* oplog_stats = nullptr);
 
     // Recycle rowset meta and data, return 0 for success otherwise error
     //
@@ -613,6 +618,24 @@ struct OperationLogReferenceInfo {
     Versionstamp referenced_snapshot_timestamp;
 };
 
+struct OplogRecycleStats {
+    // Total oplog count scanned per round
+    std::atomic<int64_t> total_num {0};
+    // Oplogs not recycled this round (per round, written to mBvarStatus)
+    std::atomic<int64_t> not_recycled_num {0};
+    // Recycle failures (per round, accumulated to mBvarIntAdder at end)
+    std::atomic<int64_t> failed_num {0};
+    // Per-oplog-type recycled counts (incremented after successful commit)
+    std::atomic<int64_t> recycled_commit_partition {0};
+    std::atomic<int64_t> recycled_drop_partition {0};
+    std::atomic<int64_t> recycled_commit_index {0};
+    std::atomic<int64_t> recycled_drop_index {0};
+    std::atomic<int64_t> recycled_update_tablet {0};
+    std::atomic<int64_t> recycled_compaction {0};
+    std::atomic<int64_t> recycled_schema_change {0};
+    std::atomic<int64_t> recycled_commit_txn {0};
+};
+
 // Helper class to check if operation logs can be recycled based on snapshots and versionstamps
 class OperationLogRecycleChecker {
 public:
diff --git a/cloud/src/recycler/recycler_operation_log.cpp b/cloud/src/recycler/recycler_operation_log.cpp
index 6e4129b23ae..f6743a5dabb 100644
--- a/cloud/src/recycler/recycler_operation_log.cpp
+++ b/cloud/src/recycler/recycler_operation_log.cpp
@@ -31,6 +31,8 @@
 #include <utility>
 #include <vector>
 
+#include "common/bvars.h"
+#include "common/config.h"
 #include "common/defer.h"
 #include "common/encryption_util.h"
 #include "common/logging.h"
@@ -651,6 +653,50 @@ static TxnErrorCode get_txn_info(TxnKv* txn_kv, std::string_view instance_id, in
     return TxnErrorCode::TXN_OK;
 }
 
+static void report_oplog_recycle_stats(const std::string& instance_id,
+                                       const OplogRecycleStats& stats) {
+    g_bvar_recycler_oplog_last_round_total_num.put({instance_id}, stats.total_num.load());
+    g_bvar_recycler_oplog_last_round_not_recycled_num.put({instance_id},
+                                                          stats.not_recycled_num.load());
+    if (stats.failed_num.load() > 0) {
+        g_bvar_recycler_oplog_recycle_failed_num.put({instance_id}, stats.failed_num.load());
+    }
+    // Per-type last round counts (mBvarStatus, overwritten each round)
+    g_bvar_recycler_oplog_last_round_recycled_commit_partition_num.put(
+            {instance_id}, stats.recycled_commit_partition.load());
+    g_bvar_recycler_oplog_last_round_recycled_drop_partition_num.put(
+            {instance_id}, stats.recycled_drop_partition.load());
+    g_bvar_recycler_oplog_last_round_recycled_commit_index_num.put(
+            {instance_id}, stats.recycled_commit_index.load());
+    g_bvar_recycler_oplog_last_round_recycled_drop_index_num.put({instance_id},
+                                                                 stats.recycled_drop_index.load());
+    g_bvar_recycler_oplog_last_round_recycled_update_tablet_num.put(
+            {instance_id}, stats.recycled_update_tablet.load());
+    g_bvar_recycler_oplog_last_round_recycled_compaction_num.put({instance_id},
+                                                                 stats.recycled_compaction.load());
+    g_bvar_recycler_oplog_last_round_recycled_schema_change_num.put(
+            {instance_id}, stats.recycled_schema_change.load());
+    g_bvar_recycler_oplog_last_round_recycled_commit_txn_num.put({instance_id},
+                                                                 stats.recycled_commit_txn.load());
+    // Per-type cumulative counts (mBvarIntAdder, accumulated across rounds)
+    g_bvar_recycler_oplog_recycled_commit_partition_num.put({instance_id},
+                                                            stats.recycled_commit_partition.load());
+    g_bvar_recycler_oplog_recycled_drop_partition_num.put({instance_id},
+                                                          stats.recycled_drop_partition.load());
+    g_bvar_recycler_oplog_recycled_commit_index_num.put({instance_id},
+                                                        stats.recycled_commit_index.load());
+    g_bvar_recycler_oplog_recycled_drop_index_num.put({instance_id},
+                                                      stats.recycled_drop_index.load());
+    g_bvar_recycler_oplog_recycled_update_tablet_num.put({instance_id},
+                                                         stats.recycled_update_tablet.load());
+    g_bvar_recycler_oplog_recycled_compaction_num.put({instance_id},
+                                                      stats.recycled_compaction.load());
+    g_bvar_recycler_oplog_recycled_schema_change_num.put({instance_id},
+                                                         stats.recycled_schema_change.load());
+    g_bvar_recycler_oplog_recycled_commit_txn_num.put({instance_id},
+                                                      stats.recycled_commit_txn.load());
+}
+
 int InstanceRecycler::recycle_operation_logs() {
     if (!should_recycle_versioned_keys()) {
         VLOG_DEBUG << "instance " << instance_id_
@@ -665,6 +711,18 @@ int InstanceRecycler::recycle_operation_logs() {
     AnnotateTag tag("instance_id", instance_id_);
     LOG_WARNING("begin to recycle operation logs");
 
+    const std::string task_name = "recycle_operation_logs";
+    RecyclerMetricsContext metrics_context(instance_id_, task_name);
+    OplogRecycleStats oplog_stats;
+
+    // scan_and_statistics_operation_logs() is expensive (scans lots of KVs),
+    // so it's controlled by enable_recycler_stats_metrics.
+    // The other stats (counting what was actually recycled) are lightweight
+    // and always collected.
+    if (config::enable_recycler_stats_metrics) {
+        scan_and_statistics_operation_logs();
+    }
+
     StopWatch stop_watch;
     size_t total_operation_logs = 0;
     size_t recycled_operation_logs = 0;
@@ -673,6 +731,9 @@ int InstanceRecycler::recycle_operation_logs() {
     size_t recycled_operation_log_data_size = 0;
 
     DORIS_CLOUD_DEFER {
+        metrics_context.finish_report();
+        report_oplog_recycle_stats(instance_id_, oplog_stats);
+
         int64_t cost = stop_watch.elapsed_us() / 1000'000;
         LOG_WARNING("recycle operation logs, cost={}s", cost)
                 .tag("total_operation_logs", total_operation_logs)
@@ -706,16 +767,24 @@ int InstanceRecycler::recycle_operation_logs() {
         OperationLogReferenceInfo reference_info;
         if (recycle_checker.can_recycle(log_versionstamp, operation_log.min_timestamp(),
                                         &reference_info)) {
+            metrics_context.total_need_recycle_num++;
+            metrics_context.total_need_recycle_data_size += value_size;
+
             AnnotateTag tag("log_key", hex(key));
-            int res = recycle_operation_log(log_versionstamp, raw_keys, std::move(operation_log));
+            int res = recycle_operation_log(log_versionstamp, raw_keys, std::move(operation_log),
+                                            &oplog_stats);
             if (res != 0) {
                 LOG_WARNING("failed to recycle operation log").tag("error_code", res);
+                oplog_stats.failed_num.fetch_add(1, std::memory_order_relaxed);
                 return res;
             }
 
             recycled_operation_logs++;
             recycled_operation_log_data_size += value_size;
+            metrics_context.total_recycled_num++;
+            metrics_context.total_recycled_data_size += value_size;
         } else {
+            oplog_stats.not_recycled_num.fetch_add(1, std::memory_order_relaxed);
             int res = calculator.calculate_operation_log_data_size(key, operation_log,
                                                                    reference_info);
             if (res != 0) {
@@ -727,6 +796,8 @@ int InstanceRecycler::recycle_operation_logs() {
         total_operation_logs++;
         operation_log_data_size += value_size;
         max_operation_log_data_size = std::max(max_operation_log_data_size, value_size);
+        oplog_stats.total_num.fetch_add(1, std::memory_order_relaxed);
+        metrics_context.report();
         return 0;
     };
 
@@ -801,8 +872,11 @@ int InstanceRecycler::recycle_operation_logs() {
 
 int InstanceRecycler::recycle_operation_log(Versionstamp log_version,
                                             const std::vector<std::string>& raw_keys,
-                                            OperationLogPB operation_log) {
+                                            OperationLogPB operation_log,
+                                            OplogRecycleStats* oplog_stats) {
     int recycle_log_count = 0;
+    // Track which oplog type was recycled (only one per log entry)
+    std::atomic<int64_t>* recycled_counter = nullptr;
     OperationLogRecycler log_recycler(instance_id_, txn_kv_.get(), log_version,
                                       operation_log.min_timestamp(), raw_keys);
     RETURN_ON_FAILURE(log_recycler.begin());
@@ -818,6 +892,9 @@ int InstanceRecycler::recycle_operation_log(Versionstamp log_version,
                 return res;                                               \
             }                                                             \
             recycle_log_count++;                                          \
+            if (oplog_stats) {                                            \
+                recycled_counter = &oplog_stats->recycled_##log_type;     \
+            }                                                             \
         }                                                                 \
     } while (0)
 
@@ -859,6 +936,9 @@ int InstanceRecycler::recycle_operation_log(Versionstamp log_version,
         }
 
         recycle_log_count++;
+        if (oplog_stats) {
+            recycled_counter = &oplog_stats->recycled_commit_txn;
+        }
     }
 
     if (recycle_log_count > 1) {
@@ -869,7 +949,11 @@ int InstanceRecycler::recycle_operation_log(Versionstamp log_version,
         return -1; // This is an unexpected condition, should not happen
     }
 
-    return log_recycler.commit();
+    int ret = log_recycler.commit();
+    if (ret == 0 && recycled_counter) {
+        recycled_counter->fetch_add(1, std::memory_order_relaxed);
+    }
+    return ret;
 }
 
 } // namespace doris::cloud
diff --git a/cloud/test/CMakeLists.txt b/cloud/test/CMakeLists.txt
index 9edd059321f..f57c9d46282 100644
--- a/cloud/test/CMakeLists.txt
+++ b/cloud/test/CMakeLists.txt
@@ -46,6 +46,7 @@ add_executable(meta_service_test
    meta_service_tablet_stats_test.cpp
    meta_service_versioned_read_test.cpp
    schema_kv_test.cpp
+   compaction_rw_separation_test.cpp
 )
 
 add_executable(meta_server_test meta_server_test.cpp)
diff --git a/cloud/test/compaction_rw_separation_test.cpp b/cloud/test/compaction_rw_separation_test.cpp
new file mode 100644
index 00000000000..adecbba327a
--- /dev/null
+++ b/cloud/test/compaction_rw_separation_test.cpp
@@ -0,0 +1,181 @@
+// 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 <brpc/controller.h>
+#include <gen_cpp/cloud.pb.h>
+#include <gtest/gtest.h>
+
+#include "common/config.h"
+#include "meta-service/meta_service.h"
+#include "meta-store/keys.h"
+#include "meta-store/mem_txn_kv.h"
+#include "mock_resource_manager.h"
+#include "rate-limiter/rate_limiter.h"
+
+namespace doris::cloud {
+
+// Test: commit_txn always updates last_active_cluster_id
+// MS always updates this field, BE controls whether to use it for compaction skipping
+TEST(CompactionRWSeparationTest, CommitTxnUpdatesLastActiveCluster) {
+    auto txn_kv = std::make_shared<MemTxnKv>();
+    ASSERT_EQ(txn_kv->init(), 0);
+
+    // Clear data
+    std::unique_ptr<Transaction> txn;
+    ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+    txn->remove("\x00", "\xfe");
+    ASSERT_EQ(txn->commit(), TxnErrorCode::TXN_OK);
+
+    auto rs = std::make_shared<MockResourceManager>(txn_kv);
+    auto rl = std::make_shared<RateLimiter>();
+    auto snapshot = std::make_shared<SnapshotManager>(txn_kv);
+    MetaServiceImpl meta_service(txn_kv, rs, rl, snapshot);
+
+    int64_t db_id = 1;
+    int64_t table_id = 100;
+    int64_t index_id = 100;
+    int64_t partition_id = 100;
+    int64_t tablet_id = 10001;
+
+    // Create tablet
+    {
+        brpc::Controller cntl;
+        CreateTabletsRequest req;
+        CreateTabletsResponse res;
+        req.set_cloud_unique_id("test_cloud_unique_id");
+        req.set_db_id(db_id);
+
+        auto tablet = req.add_tablet_metas();
+        tablet->set_table_id(table_id);
+        tablet->set_index_id(index_id);
+        tablet->set_partition_id(partition_id);
+        tablet->set_tablet_id(tablet_id);
+        auto schema = tablet->mutable_schema();
+        schema->set_schema_version(0);
+        auto first_rowset = tablet->add_rs_metas();
+        first_rowset->set_rowset_id(0);
+        first_rowset->set_rowset_id_v2("rowset_0");
+        first_rowset->set_start_version(0);
+        first_rowset->set_end_version(1);
+        first_rowset->mutable_tablet_schema()->CopyFrom(*schema);
+
+        meta_service.create_tablets(&cntl, &req, &res, nullptr);
+        ASSERT_EQ(res.status().code(), MetaServiceCode::OK) << res.status().msg();
+    }
+
+    // Begin txn
+    int64_t txn_id = -1;
+    {
+        brpc::Controller cntl;
+        BeginTxnRequest req;
+        BeginTxnResponse res;
+        req.set_cloud_unique_id("test_cloud_unique_id");
+        auto txn_info = req.mutable_txn_info();
+        txn_info->set_db_id(db_id);
+        txn_info->set_label("test_label_1");
+        txn_info->add_table_ids(table_id);
+        txn_info->set_timeout_ms(36000);
+        // FE sets load_cluster_id during begin_txn for compaction RW separation
+        txn_info->set_load_cluster_id(mock_cluster_id);
+
+        meta_service.begin_txn(&cntl, &req, &res, nullptr);
+        ASSERT_EQ(res.status().code(), MetaServiceCode::OK) << res.status().msg();
+        txn_id = res.txn_id();
+    }
+
+    // Prepare rowset
+    {
+        brpc::Controller cntl;
+        CreateRowsetRequest req;
+        CreateRowsetResponse res;
+        req.set_cloud_unique_id("test_cloud_unique_id");
+
+        auto rowset = req.mutable_rowset_meta();
+        rowset->set_rowset_id(0);
+        rowset->set_rowset_id_v2("rowset_txn_1");
+        rowset->set_tablet_id(tablet_id);
+        rowset->set_partition_id(partition_id);
+        rowset->set_txn_id(txn_id);
+        rowset->set_num_segments(1);
+        rowset->set_num_rows(100);
+        rowset->set_data_disk_size(1024);
+        rowset->mutable_tablet_schema()->set_schema_version(0);
+        rowset->set_txn_expiration(::time(nullptr) + 3600);
+
+        meta_service.prepare_rowset(&cntl, &req, &res, nullptr);
+        ASSERT_EQ(res.status().code(), MetaServiceCode::OK) << res.status().msg();
+    }
+
+    // Commit rowset
+    {
+        brpc::Controller cntl;
+        CreateRowsetRequest req;
+        CreateRowsetResponse res;
+        req.set_cloud_unique_id("test_cloud_unique_id");
+
+        auto rowset = req.mutable_rowset_meta();
+        rowset->set_rowset_id(0);
+        rowset->set_rowset_id_v2("rowset_txn_1");
+        rowset->set_tablet_id(tablet_id);
+        rowset->set_partition_id(partition_id);
+        rowset->set_txn_id(txn_id);
+        rowset->set_num_segments(1);
+        rowset->set_num_rows(100);
+        rowset->set_data_disk_size(1024);
+        rowset->mutable_tablet_schema()->set_schema_version(0);
+        rowset->set_txn_expiration(::time(nullptr) + 3600);
+
+        meta_service.commit_rowset(&cntl, &req, &res, nullptr);
+        ASSERT_EQ(res.status().code(), MetaServiceCode::OK) << res.status().msg();
+    }
+
+    // Commit txn
+    {
+        brpc::Controller cntl;
+        CommitTxnRequest req;
+        CommitTxnResponse res;
+        req.set_cloud_unique_id("test_cloud_unique_id");
+        req.set_db_id(db_id);
+        req.set_txn_id(txn_id);
+
+        meta_service.commit_txn(&cntl, &req, &res, nullptr);
+        ASSERT_EQ(res.status().code(), MetaServiceCode::OK) << res.status().msg();
+    }
+
+    // Get rowset and check last_active_cluster_id
+    {
+        brpc::Controller cntl;
+        GetRowsetRequest req;
+        GetRowsetResponse res;
+        req.set_cloud_unique_id("test_cloud_unique_id");
+        req.mutable_idx()->set_tablet_id(tablet_id);
+        req.set_start_version(0);
+        req.set_end_version(-1);
+        req.set_base_compaction_cnt(0);
+        req.set_cumulative_compaction_cnt(0);
+        req.set_cumulative_point(2);
+
+        meta_service.get_rowset(&cntl, &req, &res, nullptr);
+        ASSERT_EQ(res.status().code(), MetaServiceCode::OK) << res.status().msg();
+
+        // Check that last_active_cluster_id is always set (MS always updates it)
+        EXPECT_TRUE(res.stats().has_last_active_cluster_id());
+        EXPECT_EQ(res.stats().last_active_cluster_id(), mock_cluster_id);
+    }
+}
+
+} // namespace doris::cloud
diff --git a/cloud/test/recycler_operation_log_test.cpp b/cloud/test/recycler_operation_log_test.cpp
index 8eb1cb6f4b2..1d3c3e65065 100644
--- a/cloud/test/recycler_operation_log_test.cpp
+++ b/cloud/test/recycler_operation_log_test.cpp
@@ -16,15 +16,18 @@
 // under the License.
 
 #include <butil/strings/string_split.h>
+#include <bvar/variable.h>
 #include <fmt/core.h>
 #include <gen_cpp/cloud.pb.h>
 #include <gen_cpp/olap_file.pb.h>
 #include <gtest/gtest.h>
 
+#include <climits>
 #include <cstdint>
 #include <memory>
 #include <string>
 
+#include "common/bvars.h"
 #include "common/config.h"
 #include "common/util.h"
 #include "cpp/sync_point.h"
@@ -2807,4 +2810,479 @@ TEST(RecycleOperationLogTest, RecycleCompactionLogKeepKeyBoundsWhenDisabled) {
     }
 }
 
+// ============================================================================
+// Tests for OplogRecycleStats and recycle_operation_logs metrics integration
+// ============================================================================
+
+TEST(RecycleOperationLogTest, OplogRecycleStatsAtomicOperations) {
+    OplogRecycleStats stats;
+
+    // Test initial values
+    EXPECT_EQ(stats.total_num.load(), 0);
+    EXPECT_EQ(stats.not_recycled_num.load(), 0);
+    EXPECT_EQ(stats.failed_num.load(), 0);
+    EXPECT_EQ(stats.recycled_commit_partition.load(), 0);
+    EXPECT_EQ(stats.recycled_drop_partition.load(), 0);
+    EXPECT_EQ(stats.recycled_commit_index.load(), 0);
+    EXPECT_EQ(stats.recycled_drop_index.load(), 0);
+    EXPECT_EQ(stats.recycled_update_tablet.load(), 0);
+    EXPECT_EQ(stats.recycled_compaction.load(), 0);
+    EXPECT_EQ(stats.recycled_schema_change.load(), 0);
+    EXPECT_EQ(stats.recycled_commit_txn.load(), 0);
+
+    // Test fetch_add
+    stats.not_recycled_num.fetch_add(3, std::memory_order_relaxed);
+    EXPECT_EQ(stats.not_recycled_num.load(), 3);
+
+    stats.failed_num.fetch_add(1, std::memory_order_relaxed);
+    EXPECT_EQ(stats.failed_num.load(), 1);
+
+    stats.recycled_drop_partition.fetch_add(5, std::memory_order_relaxed);
+    stats.recycled_drop_index.fetch_add(2, std::memory_order_relaxed);
+    stats.recycled_compaction.fetch_add(10, std::memory_order_relaxed);
+    stats.recycled_commit_txn.fetch_add(7, std::memory_order_relaxed);
+    EXPECT_EQ(stats.recycled_drop_partition.load(), 5);
+    EXPECT_EQ(stats.recycled_drop_index.load(), 2);
+    EXPECT_EQ(stats.recycled_compaction.load(), 10);
+    EXPECT_EQ(stats.recycled_commit_txn.load(), 7);
+
+    // Test total_num
+    stats.total_num.fetch_add(10, std::memory_order_relaxed);
+    EXPECT_EQ(stats.total_num.load(), 10);
+}
+
+TEST(RecycleOperationLogTest, OplogRecycleStatsPerTypeCounters) {
+    OplogRecycleStats stats;
+
+    // Simulate multiple oplog recycling events
+    stats.recycled_drop_partition.fetch_add(1, std::memory_order_relaxed);
+    stats.recycled_drop_partition.fetch_add(1, std::memory_order_relaxed);
+    stats.recycled_drop_index.fetch_add(1, std::memory_order_relaxed);
+    stats.recycled_compaction.fetch_add(1, std::memory_order_relaxed);
+    stats.recycled_compaction.fetch_add(1, std::memory_order_relaxed);
+    stats.recycled_compaction.fetch_add(1, std::memory_order_relaxed);
+    stats.recycled_commit_txn.fetch_add(1, std::memory_order_relaxed);
+
+    EXPECT_EQ(stats.recycled_commit_partition.load(), 0);
+    EXPECT_EQ(stats.recycled_drop_partition.load(), 2);
+    EXPECT_EQ(stats.recycled_commit_index.load(), 0);
+    EXPECT_EQ(stats.recycled_drop_index.load(), 1);
+    EXPECT_EQ(stats.recycled_update_tablet.load(), 0);
+    EXPECT_EQ(stats.recycled_compaction.load(), 3);
+    EXPECT_EQ(stats.recycled_schema_change.load(), 0);
+    EXPECT_EQ(stats.recycled_commit_txn.load(), 1);
+}
+
+// Test recycle_operation_logs with stats enabled: drop partition + drop index logs
+// Verifies per-type recycled counts and bvar reporting
+TEST(RecycleOperationLogTest, RecycleOperationLogsWithStatsEnabled) {
+    auto old_flag = config::enable_recycler_stats_metrics;
+    config::enable_recycler_stats_metrics = true;
+    DORIS_CLOUD_DEFER {
+        config::enable_recycler_stats_metrics = old_flag;
+    };
+
+    auto txn_kv = std::make_shared<MemTxnKv>();
+    txn_kv->update_commit_version(1000);
+    ASSERT_EQ(txn_kv->init(), 0);
+
+    InstanceInfoPB instance;
+    instance.set_instance_id(instance_id);
+    instance.set_multi_version_status(MultiVersionStatus::MULTI_VERSION_ENABLED);
+    auto* obj_info = instance.add_obj_info();
+    obj_info->set_id("recycle_oplog_stats");
+    obj_info->set_ak(config::test_s3_ak);
+    obj_info->set_sk(config::test_s3_sk);
+    obj_info->set_endpoint(config::test_s3_endpoint);
+    obj_info->set_region(config::test_s3_region);
+    obj_info->set_bucket(config::test_s3_bucket);
+    obj_info->set_prefix("recycle_oplog_stats");
+    update_instance_info(txn_kv.get(), instance);
+
+    InstanceRecycler recycler(txn_kv, instance, thread_group,
+                              std::make_shared<TxnLazyCommitter>(txn_kv));
+    ASSERT_EQ(recycler.init(), 0);
+
+    uint64_t db_id = 1;
+    uint64_t table_id = 100;
+    uint64_t index_id1 = 201;
+    uint64_t index_id2 = 202;
+    uint64_t partition_id1 = 301;
+    uint64_t partition_id2 = 302;
+    int64_t expiration = ::time(nullptr) + 3600;
+
+    // Create table version so the recycler can remove it
+    {
+        std::string ver_key = versioned::table_version_key({instance_id, table_id});
+        std::unique_ptr<Transaction> txn;
+        ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        versioned_put(txn.get(), ver_key, "");
+        ASSERT_EQ(txn->commit(), TxnErrorCode::TXN_OK);
+    }
+
+    // Put a drop partition log with 2 partitions
+    {
+        std::string log_key = versioned::log_key(instance_id);
+        OperationLogPB operation_log;
+        operation_log.set_min_timestamp(100);
+        auto* drop_partition = operation_log.mutable_drop_partition();
+        drop_partition->set_db_id(db_id);
+        drop_partition->set_table_id(table_id);
+        drop_partition->add_index_ids(index_id1);
+        drop_partition->add_partition_ids(partition_id1);
+        drop_partition->add_partition_ids(partition_id2);
+        drop_partition->set_expired_at_s(expiration);
+        drop_partition->set_update_table_version(true);
+
+        std::unique_ptr<Transaction> txn;
+        ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        versioned::blob_put(txn.get(), log_key, operation_log);
+        ASSERT_EQ(txn->commit(), TxnErrorCode::TXN_OK);
+    }
+
+    // Put a drop index log with 2 indexes
+    {
+        std::string log_key = versioned::log_key(instance_id);
+        OperationLogPB operation_log;
+        operation_log.set_min_timestamp(101);
+        auto* drop_index = operation_log.mutable_drop_index();
+        drop_index->set_db_id(db_id);
+        drop_index->set_table_id(table_id);
+        drop_index->add_index_ids(index_id1);
+        drop_index->add_index_ids(index_id2);
+
+        std::unique_ptr<Transaction> txn;
+        ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        versioned::blob_put(txn.get(), log_key, operation_log);
+        ASSERT_EQ(txn->commit(), TxnErrorCode::TXN_OK);
+    }
+
+    // Save cumulative baselines before recycling (mBvarIntAdder accumulates across tests)
+    int64_t baseline_drop_partition =
+            g_bvar_recycler_oplog_recycled_drop_partition_num.get({instance_id});
+    int64_t baseline_drop_index = g_bvar_recycler_oplog_recycled_drop_index_num.get({instance_id});
+
+    // Recycle the operation logs - stats should be collected
+    ASSERT_EQ(recycler.recycle_operation_logs(), 0);
+
+    // Verify recycle partition records were created
+    {
+        std::string recycle_key1 = recycle_partition_key({instance_id, partition_id1});
+        std::string recycle_key2 = recycle_partition_key({instance_id, partition_id2});
+        std::unique_ptr<Transaction> txn;
+        ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        std::string value;
+        ASSERT_EQ(txn->get(recycle_key1, &value), TxnErrorCode::TXN_OK);
+        ASSERT_EQ(txn->get(recycle_key2, &value), TxnErrorCode::TXN_OK);
+    }
+
+    // Verify recycle index records were created
+    {
+        std::string recycle_key1 = recycle_index_key({instance_id, index_id1});
+        std::string recycle_key2 = recycle_index_key({instance_id, index_id2});
+        std::unique_ptr<Transaction> txn;
+        ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        std::string value;
+        ASSERT_EQ(txn->get(recycle_key1, &value), TxnErrorCode::TXN_OK);
+        ASSERT_EQ(txn->get(recycle_key2, &value), TxnErrorCode::TXN_OK);
+    }
+
+    // Verify total oplog count: 2 logs (1 drop_partition + 1 drop_index)
+    {
+        int64_t total = g_bvar_recycler_oplog_last_round_total_num.get({instance_id});
+        EXPECT_EQ(total, 2) << "Should have scanned 2 operation logs in total";
+    }
+
+    // Verify per-type last round recycled counts: 1 drop_partition log and 1 drop_index log
+    {
+        int last_round_drop_partition =
+                g_bvar_recycler_oplog_last_round_recycled_drop_partition_num.get({instance_id});
+        EXPECT_EQ(last_round_drop_partition, 1) << "Should have recycled 1 drop_partition log";
+    }
+    {
+        int last_round_drop_index =
+                g_bvar_recycler_oplog_last_round_recycled_drop_index_num.get({instance_id});
+        EXPECT_EQ(last_round_drop_index, 1) << "Should have recycled 1 drop_index log";
+    }
+
+    // Verify per-type cumulative recycled counts (check delta from baseline)
+    {
+        int64_t recycled_drop_partition =
+                g_bvar_recycler_oplog_recycled_drop_partition_num.get({instance_id});
+        EXPECT_EQ(recycled_drop_partition - baseline_drop_partition, 1)
+                << "Cumulative drop_partition delta should be 1";
+    }
+    {
+        int64_t recycled_drop_index =
+                g_bvar_recycler_oplog_recycled_drop_index_num.get({instance_id});
+        EXPECT_EQ(recycled_drop_index - baseline_drop_index, 1)
+                << "Cumulative drop_index delta should be 1";
+    }
+}
+
+// Test recycle_operation_logs with snapshot protection: some logs skipped
+TEST(RecycleOperationLogTest, RecycleOperationLogsSkippedBySnapshot) {
+    auto old_flag = config::enable_recycler_stats_metrics;
+    config::enable_recycler_stats_metrics = true;
+    DORIS_CLOUD_DEFER {
+        config::enable_recycler_stats_metrics = old_flag;
+    };
+
+    auto txn_kv = std::make_shared<MemTxnKv>();
+    txn_kv->update_commit_version(1000);
+    ASSERT_EQ(txn_kv->init(), 0);
+
+    std::string test_instance_id = "test_oplog_not_recycled";
+
+    InstanceInfoPB instance;
+    instance.set_instance_id(test_instance_id);
+    instance.set_multi_version_status(MultiVersionStatus::MULTI_VERSION_ENABLED);
+    auto* obj_info = instance.add_obj_info();
+    obj_info->set_id("recycle_snapshot_skip");
+    obj_info->set_ak(config::test_s3_ak);
+    obj_info->set_sk(config::test_s3_sk);
+    obj_info->set_endpoint(config::test_s3_endpoint);
+    obj_info->set_region(config::test_s3_region);
+    obj_info->set_bucket(config::test_s3_bucket);
+    obj_info->set_prefix("recycle_snapshot_skip");
+
+    // Store instance info
+    {
+        std::string key = instance_key({test_instance_id});
+        std::unique_ptr<Transaction> txn;
+        ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        txn->put(key, instance.SerializeAsString());
+        ASSERT_EQ(txn->commit(), TxnErrorCode::TXN_OK);
+    }
+
+    auto insert_empty_value = [&]() {
+        std::unique_ptr<Transaction> txn;
+        EXPECT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        txn->put("dummy_snapshot_test", "");
+        EXPECT_EQ(txn->commit(), TxnErrorCode::TXN_OK);
+    };
+
+    // Create table version
+    uint64_t table_id = 500;
+    {
+        std::string ver_key = versioned::table_version_key({test_instance_id, table_id});
+        std::unique_ptr<Transaction> txn;
+        ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        versioned_put(txn.get(), ver_key, "");
+        ASSERT_EQ(txn->commit(), TxnErrorCode::TXN_OK);
+    }
+
+    // Put an old operation log (will be recyclable)
+    {
+        std::string log_key = versioned::log_key(test_instance_id);
+        OperationLogPB operation_log;
+        operation_log.set_min_timestamp(100);
+
+        std::unique_ptr<Transaction> txn;
+        ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        versioned::blob_put(txn.get(), log_key, operation_log);
+        ASSERT_EQ(txn->commit(), TxnErrorCode::TXN_OK);
+    }
+
+    insert_empty_value();
+
+    // Write a snapshot (this creates a protection boundary)
+    {
+        SnapshotPB snapshot;
+        std::string snapshot_key = versioned::snapshot_full_key(test_instance_id);
+        std::unique_ptr<Transaction> txn;
+        ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        versioned_put(txn.get(), snapshot_key, snapshot.SerializeAsString());
+        ASSERT_EQ(txn->commit(), TxnErrorCode::TXN_OK);
+    }
+
+    insert_empty_value();
+
+    // Put a new operation log after the snapshot (min_timestamp before snapshot)
+    // This log should be skipped by snapshot protection
+    {
+        std::string log_key = versioned::log_key(test_instance_id);
+        OperationLogPB operation_log;
+        // min_timestamp is set to a value before the snapshot, so the snapshot
+        // falls within [min_timestamp, log_version) and the log can't be recycled
+        operation_log.set_min_timestamp(100);
+
+        std::unique_ptr<Transaction> txn;
+        ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        versioned::blob_put(txn.get(), log_key, operation_log);
+        ASSERT_EQ(txn->commit(), TxnErrorCode::TXN_OK);
+    }
+
+    insert_empty_value();
+
+    InstanceRecycler recycler(txn_kv, instance, thread_group,
+                              std::make_shared<TxnLazyCommitter>(txn_kv));
+    ASSERT_EQ(recycler.init(), 0);
+
+    // Recycle - some logs should be recycled, some not recycled due to snapshot
+    ASSERT_EQ(recycler.recycle_operation_logs(), 0);
+
+    // Verify not_recycled bvar is set
+    {
+        int64_t not_recycled =
+                g_bvar_recycler_oplog_last_round_not_recycled_num.get({test_instance_id});
+        EXPECT_GE(not_recycled, 1) << "At least one log should not be recycled";
+    }
+}
+
+// Test recycle_operation_logs with stats disabled (default)
+TEST(RecycleOperationLogTest, RecycleOperationLogsStatsDisabled) {
+    auto old_flag = config::enable_recycler_stats_metrics;
+    config::enable_recycler_stats_metrics = false;
+    DORIS_CLOUD_DEFER {
+        config::enable_recycler_stats_metrics = old_flag;
+    };
+
+    auto txn_kv = std::make_shared<MemTxnKv>();
+    txn_kv->update_commit_version(1000);
+    ASSERT_EQ(txn_kv->init(), 0);
+
+    InstanceInfoPB instance;
+    instance.set_instance_id(instance_id);
+    instance.set_multi_version_status(MultiVersionStatus::MULTI_VERSION_ENABLED);
+    auto* obj_info = instance.add_obj_info();
+    obj_info->set_id("recycle_stats_disabled");
+    obj_info->set_ak(config::test_s3_ak);
+    obj_info->set_sk(config::test_s3_sk);
+    obj_info->set_endpoint(config::test_s3_endpoint);
+    obj_info->set_region(config::test_s3_region);
+    obj_info->set_bucket(config::test_s3_bucket);
+    obj_info->set_prefix("recycle_stats_disabled");
+    update_instance_info(txn_kv.get(), instance);
+
+    InstanceRecycler recycler(txn_kv, instance, thread_group,
+                              std::make_shared<TxnLazyCommitter>(txn_kv));
+    ASSERT_EQ(recycler.init(), 0);
+
+    // Put an empty operation log
+    {
+        std::string log_key = versioned::log_key(instance_id);
+        Versionstamp versionstamp(123, 0);
+        OperationLogPB operation_log;
+        operation_log.set_min_timestamp(versionstamp.version());
+
+        std::unique_ptr<Transaction> txn;
+        ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        versioned::blob_put(txn.get(), log_key, versionstamp, operation_log);
+        ASSERT_EQ(txn->commit(), TxnErrorCode::TXN_OK);
+    }
+
+    // Recycle should succeed without stats collection
+    ASSERT_EQ(recycler.recycle_operation_logs(), 0);
+
+    // Verify all logs were recycled
+    remove_instance_info(txn_kv.get());
+    ASSERT_TRUE(is_empty_range(txn_kv.get())) << dump_range(txn_kv.get());
+}
+
+// Test recycle_operation_logs compaction log with stats: recycled_compaction tracking
+TEST(RecycleOperationLogTest, RecycleCompactionLogWithStats) {
+    auto old_flag = config::enable_recycler_stats_metrics;
+    config::enable_recycler_stats_metrics = true;
+    DORIS_CLOUD_DEFER {
+        config::enable_recycler_stats_metrics = old_flag;
+    };
+
+    auto txn_kv = std::make_shared<MemTxnKv>();
+    txn_kv->update_commit_version(1000);
+    ASSERT_EQ(txn_kv->init(), 0);
+
+    std::string test_instance_id = "test_compaction_log_stats";
+
+    InstanceInfoPB instance;
+    instance.set_instance_id(test_instance_id);
+    instance.set_multi_version_status(MultiVersionStatus::MULTI_VERSION_ENABLED);
+    auto* obj_info = instance.add_obj_info();
+    obj_info->set_id("compaction_stats");
+    obj_info->set_ak(config::test_s3_ak);
+    obj_info->set_sk(config::test_s3_sk);
+    obj_info->set_endpoint(config::test_s3_endpoint);
+    obj_info->set_region(config::test_s3_region);
+    obj_info->set_bucket(config::test_s3_bucket);
+    obj_info->set_prefix("compaction_stats");
+
+    {
+        std::string key = instance_key({test_instance_id});
+        std::unique_ptr<Transaction> txn;
+        ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        txn->put(key, instance.SerializeAsString());
+        ASSERT_EQ(txn->commit(), TxnErrorCode::TXN_OK);
+    }
+
+    InstanceRecycler recycler(txn_kv, instance, thread_group,
+                              std::make_shared<TxnLazyCommitter>(txn_kv));
+    ASSERT_EQ(recycler.init(), 0);
+
+    int64_t table_id = 600;
+    int64_t tablet_id = 603;
+
+    // Create table version
+    {
+        std::string ver_key = versioned::table_version_key({test_instance_id, table_id});
+        std::unique_ptr<Transaction> txn;
+        ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        versioned_put(txn.get(), ver_key, "");
+        ASSERT_EQ(txn->commit(), TxnErrorCode::TXN_OK);
+    }
+
+    // Put a compaction log with 3 recycle rowsets
+    {
+        std::string log_key = versioned::log_key(test_instance_id);
+        OperationLogPB operation_log;
+        operation_log.set_min_timestamp(100);
+        auto* compaction = operation_log.mutable_compaction();
+        compaction->set_tablet_id(tablet_id);
+
+        for (int i = 0; i < 3; i++) {
+            auto* recycle_rowset = compaction->add_recycle_rowsets();
+            auto* rowset_meta = recycle_rowset->mutable_rowset_meta();
+            rowset_meta->set_rowset_id(0);
+            rowset_meta->set_tablet_id(tablet_id);
+            rowset_meta->set_rowset_id_v2(fmt::format("compaction_rowset_{}", i));
+            rowset_meta->set_start_version(i * 10);
+            rowset_meta->set_end_version(i * 10 + 9);
+        }
+
+        std::unique_ptr<Transaction> txn;
+        ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        versioned::blob_put(txn.get(), log_key, operation_log);
+        ASSERT_EQ(txn->commit(), TxnErrorCode::TXN_OK);
+    }
+
+    // Recycle the operation logs
+    ASSERT_EQ(recycler.recycle_operation_logs(), 0);
+
+    // Verify recycle rowset records were created (3 rowsets from compaction log)
+    {
+        std::unique_ptr<Transaction> txn;
+        ASSERT_EQ(txn_kv->create_txn(&txn), TxnErrorCode::TXN_OK);
+        for (int i = 0; i < 3; i++) {
+            std::string rowset_id = fmt::format("compaction_rowset_{}", i);
+            std::string recycle_key = recycle_rowset_key({test_instance_id, tablet_id, rowset_id});
+            std::string value;
+            TxnErrorCode err = txn->get(recycle_key, &value);
+            EXPECT_EQ(err, TxnErrorCode::TXN_OK)
+                    << "Recycle rowset key not found for rowset " << rowset_id;
+        }
+    }
+
+    // Verify last_round recycled_compaction bvar count (1 compaction log recycled)
+    {
+        int last_round_compaction =
+                g_bvar_recycler_oplog_last_round_recycled_compaction_num.get({test_instance_id});
+        EXPECT_EQ(last_round_compaction, 1) << "Last round should have recycled 1 compaction log";
+    }
+    // Verify cumulative recycled_compaction bvar count
+    {
+        int recycled_compaction =
+                g_bvar_recycler_oplog_recycled_compaction_num.get({test_instance_id});
+        EXPECT_EQ(recycled_compaction, 1) << "Cumulative compaction should be 1";
+    }
+}
+
 } // namespace doris::cloud
diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
index 53b73dd2b04..52fea2e8c9b 100644
--- a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
+++ b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
@@ -3787,6 +3787,14 @@ public class Config extends ConfigBase {
     @ConfField(mutable = true)
     public static long cloud_auto_snapshot_min_interval_seconds = 3600;
 
+    @ConfField(mutable = true, description = {
+            "cluster snapshot 相关操作的最低权限要求。可选值:'root'(仅 root 用户可执行)或 'admin'(ADMIN 权限用户可执行)。默认值为 'root'。",
+            "The minimum privilege required for cluster snapshot operations. "
+                    + "Valid values: 'root' (only root user can execute)"
+                    + " or 'admin' (users with ADMIN privilege can execute). "
+                    + "Default is 'root'."})
+    public static String cluster_snapshot_min_privilege = "root";
+
     @ConfField(mutable = true)
     public static long multi_part_upload_part_size_in_bytes = 256 * 1024 * 1024L; // 256MB
     @ConfField(mutable = true)
diff --git a/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgr.java b/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgr.java
index bf7e82072c3..90c95f4c2b4 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgr.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgr.java
@@ -306,6 +306,22 @@ public class CloudGlobalTransactionMgr implements GlobalTransactionMgrIface {
             txnInfoBuilder.setTimeoutMs(timeoutSecond * 1000);
             txnInfoBuilder.setPrecommitTimeoutMs(Config.stream_load_default_precommit_timeout_second * 1000);
 
+            // Set load_cluster_id for compaction read-write separation.
+            // commit_txn uses this to update last_active_cluster_id on tablet stats.
+            if (Config.isCloudMode()) {
+                try {
+                    ConnectContext ctx = ConnectContext.get();
+                    if (ctx != null) {
+                        String clusterId = ctx.getComputeGroup().getId();
+                        if (clusterId != null && !clusterId.isEmpty()) {
+                            txnInfoBuilder.setLoadClusterId(clusterId);
+                        }
+                    }
+                } catch (Exception e) {
+                    LOG.warn("Failed to get compute group id for load_cluster_id, label: {}", label, e);
+                }
+            }
+
             final BeginTxnRequest beginTxnRequest = BeginTxnRequest.newBuilder()
                     .setTxnInfo(txnInfoBuilder.build())
                     .setCloudUniqueId(Config.cloud_unique_id)
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/UserAuthentication.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/UserAuthentication.java
index 0a10f7d9f48..b695254ca83 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/UserAuthentication.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/UserAuthentication.java
@@ -17,8 +17,13 @@
 
 package org.apache.doris.nereids.rules.analysis;
 
+import org.apache.doris.analysis.UserIdentity;
 import org.apache.doris.catalog.DatabaseIf;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.InfoSchemaDb;
 import org.apache.doris.catalog.TableIf;
+import org.apache.doris.cluster.ClusterNamespace;
+import org.apache.doris.common.Config;
 import org.apache.doris.common.ErrorCode;
 import org.apache.doris.common.ErrorReport;
 import org.apache.doris.common.UserException;
@@ -59,7 +64,30 @@ public class UserAuthentication {
         if (db == null) {
             return;
         }
-        String dbName = db.getFullName();
+        String dbName = ClusterNamespace.getNameFromFullName(db.getFullName());
+
+        // Special handling: cluster snapshot related tables in information_schema
+        // require privilege based on configuration
+        if (dbName.equalsIgnoreCase(InfoSchemaDb.DATABASE_NAME)
+                && (tableName.equalsIgnoreCase("cluster_snapshots")
+                    || tableName.equalsIgnoreCase("cluster_snapshot_properties"))) {
+            if ("admin".equalsIgnoreCase(Config.cluster_snapshot_min_privilege)) {
+                // When configured as admin, check ADMIN privilege
+                if (!Env.getCurrentEnv().getAccessManager().checkGlobalPriv(connectContext, PrivPredicate.ADMIN)) {
+                    ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
+                            PrivPredicate.ADMIN.getPrivs().toString());
+                }
+            } else {
+                // Default or configured as root, check if user is root
+                UserIdentity currentUser = connectContext.getCurrentUserIdentity();
+                if (currentUser == null || !currentUser.isRootUser()) {
+                    ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
+                            "root privilege");
+                }
+            }
+            return; // privilege check passed, allow access
+        }
+
         CatalogIf catalog = db.getCatalog();
         if (catalog == null) {
             return;
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminCreateClusterSnapshotCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminCreateClusterSnapshotCommand.java
index ab5c9460bf8..b62a960bede 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminCreateClusterSnapshotCommand.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminCreateClusterSnapshotCommand.java
@@ -18,6 +18,7 @@
 package org.apache.doris.nereids.trees.plans.commands;
 
 import org.apache.doris.analysis.StmtType;
+import org.apache.doris.analysis.UserIdentity;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.cloud.catalog.CloudEnv;
 import org.apache.doris.cloud.snapshot.CloudSnapshotHandler;
@@ -73,9 +74,20 @@ public class AdminCreateClusterSnapshotCommand extends Command implements Forwar
         if (!Config.isCloudMode()) {
             throw new AnalysisException("The sql is illegal in disk mode ");
         }
-        if (!Env.getCurrentEnv().getAccessManager().checkGlobalPriv(ctx, PrivPredicate.ADMIN)) {
-            ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
-                    PrivPredicate.ADMIN.getPrivs().toString());
+        // Check privilege based on configuration
+        if ("admin".equalsIgnoreCase(Config.cluster_snapshot_min_privilege)) {
+            // When configured as admin, check ADMIN privilege
+            if (!Env.getCurrentEnv().getAccessManager().checkGlobalPriv(ctx, PrivPredicate.ADMIN)) {
+                ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
+                        PrivPredicate.ADMIN.getPrivs().toString());
+            }
+        } else {
+            // Default or configured as root, check if user is root
+            UserIdentity currentUser = ctx.getCurrentUserIdentity();
+            if (currentUser == null || !currentUser.isRootUser()) {
+                ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
+                        "root privilege");
+            }
         }
 
         for (Map.Entry<String, String> entry : properties.entrySet()) {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminDropClusterSnapshotCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminDropClusterSnapshotCommand.java
index b131f8a8cc8..a545113321e 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminDropClusterSnapshotCommand.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminDropClusterSnapshotCommand.java
@@ -18,6 +18,7 @@
 package org.apache.doris.nereids.trees.plans.commands;
 
 import org.apache.doris.analysis.StmtType;
+import org.apache.doris.analysis.UserIdentity;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.cloud.proto.Cloud;
 import org.apache.doris.cloud.rpc.MetaServiceProxy;
@@ -70,9 +71,20 @@ public class AdminDropClusterSnapshotCommand extends Command implements ForwardW
         if (!Config.isCloudMode()) {
             throw new AnalysisException("The sql is illegal in disk mode ");
         }
-        if (!Env.getCurrentEnv().getAccessManager().checkGlobalPriv(ctx, PrivPredicate.ADMIN)) {
-            ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
-                    PrivPredicate.ADMIN.getPrivs().toString());
+        // Check privilege based on configuration
+        if ("admin".equalsIgnoreCase(Config.cluster_snapshot_min_privilege)) {
+            // When configured as admin, check ADMIN privilege
+            if (!Env.getCurrentEnv().getAccessManager().checkGlobalPriv(ctx, PrivPredicate.ADMIN)) {
+                ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
+                        PrivPredicate.ADMIN.getPrivs().toString());
+            }
+        } else {
+            // Default or configured as root, check if user is root
+            UserIdentity currentUser = ctx.getCurrentUserIdentity();
+            if (currentUser == null || !currentUser.isRootUser()) {
+                ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
+                        "root privilege");
+            }
         }
 
         if (key == null || !key.equalsIgnoreCase(SNAPSHOT_ID)) {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminSetAutoClusterSnapshotCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminSetAutoClusterSnapshotCommand.java
index b450ada674f..2a873c32790 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminSetAutoClusterSnapshotCommand.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminSetAutoClusterSnapshotCommand.java
@@ -18,6 +18,7 @@
 package org.apache.doris.nereids.trees.plans.commands;
 
 import org.apache.doris.analysis.StmtType;
+import org.apache.doris.analysis.UserIdentity;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.cloud.catalog.CloudEnv;
 import org.apache.doris.cloud.proto.Cloud;
@@ -92,9 +93,20 @@ public class AdminSetAutoClusterSnapshotCommand extends Command implements Forwa
         if (!Config.isCloudMode()) {
             throw new AnalysisException("The sql is illegal in disk mode ");
         }
-        if (!Env.getCurrentEnv().getAccessManager().checkGlobalPriv(ctx, PrivPredicate.ADMIN)) {
-            ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
-                    PrivPredicate.ADMIN.getPrivs().toString());
+        // Check privilege based on configuration
+        if ("admin".equalsIgnoreCase(Config.cluster_snapshot_min_privilege)) {
+            // When configured as admin, check ADMIN privilege
+            if (!Env.getCurrentEnv().getAccessManager().checkGlobalPriv(ctx, PrivPredicate.ADMIN)) {
+                ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
+                        PrivPredicate.ADMIN.getPrivs().toString());
+            }
+        } else {
+            // Default or configured as root, check if user is root
+            UserIdentity currentUser = ctx.getCurrentUserIdentity();
+            if (currentUser == null || !currentUser.isRootUser()) {
+                ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
+                        "root privilege");
+            }
         }
 
         if (properties.isEmpty()) {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminSetClusterSnapshotFeatureSwitchCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminSetClusterSnapshotFeatureSwitchCommand.java
index 7b873ae65dd..9b2d8612315 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminSetClusterSnapshotFeatureSwitchCommand.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AdminSetClusterSnapshotFeatureSwitchCommand.java
@@ -18,6 +18,7 @@
 package org.apache.doris.nereids.trees.plans.commands;
 
 import org.apache.doris.analysis.StmtType;
+import org.apache.doris.analysis.UserIdentity;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.cloud.catalog.CloudEnv;
 import org.apache.doris.cloud.proto.Cloud;
@@ -73,9 +74,20 @@ public class AdminSetClusterSnapshotFeatureSwitchCommand extends Command impleme
         if (!Config.isCloudMode()) {
             throw new AnalysisException("The sql is illegal in disk mode ");
         }
-        if (!Env.getCurrentEnv().getAccessManager().checkGlobalPriv(ctx, PrivPredicate.ADMIN)) {
-            ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
-                    PrivPredicate.ADMIN.getPrivs().toString());
+        // Check privilege based on configuration
+        if ("admin".equalsIgnoreCase(Config.cluster_snapshot_min_privilege)) {
+            // When configured as admin, check ADMIN privilege
+            if (!Env.getCurrentEnv().getAccessManager().checkGlobalPriv(ctx, PrivPredicate.ADMIN)) {
+                ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
+                        PrivPredicate.ADMIN.getPrivs().toString());
+            }
+        } else {
+            // Default or configured as root, check if user is root
+            UserIdentity currentUser = ctx.getCurrentUserIdentity();
+            if (currentUser == null || !currentUser.isRootUser()) {
+                ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
+                        "root privilege");
+            }
         }
     }
 
diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/UserAuthenticationTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/UserAuthenticationTest.java
new file mode 100644
index 00000000000..b026dad95d4
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/UserAuthenticationTest.java
@@ -0,0 +1,396 @@
+// 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.
+
+package org.apache.doris.nereids.rules.analysis;
+
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.catalog.DatabaseIf;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.InfoSchemaDb;
+import org.apache.doris.catalog.TableIf;
+import org.apache.doris.common.AnalysisException;
+import org.apache.doris.common.Config;
+import org.apache.doris.datasource.CatalogIf;
+import org.apache.doris.mysql.privilege.AccessControllerManager;
+import org.apache.doris.mysql.privilege.PrivPredicate;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.SessionVariable;
+
+import mockit.Expectations;
+import mockit.Mocked;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test for UserAuthentication privilege checks.
+ */
+public class UserAuthenticationTest {
+    @Mocked
+    private Env env;
+    @Mocked
+    private ConnectContext connectContext;
+    @Mocked
+    private AccessControllerManager accessControllerManager;
+    @Mocked
+    private SessionVariable sessionVariable;
+    @Mocked
+    private TableIf table;
+    @Mocked
+    private DatabaseIf db;
+    @Mocked
+    private CatalogIf catalog;
+
+    private String originalMinPrivilege;
+
+    @BeforeEach
+    public void setUp() {
+        originalMinPrivilege = Config.cluster_snapshot_min_privilege;
+    }
+
+    @AfterEach
+    public void tearDown() {
+        Config.cluster_snapshot_min_privilege = originalMinPrivilege;
+    }
+
+    /**
+     * Test that a table named "cluster_snapshots" in a normal database (not information_schema)
+     * does NOT trigger the special privilege check for cluster snapshot tables.
+     * This verifies that only information_schema.cluster_snapshots requires special privileges.
+     */
+    @Test
+    public void testSameNameTableInNormalDbNotTriggerSpecialCheck() throws Exception {
+        // Set to root mode - if special check is triggered, non-root user would be denied
+        Config.cluster_snapshot_min_privilege = "root";
+
+        UserIdentity normalUser = new UserIdentity("normal_user", "%");
+        normalUser.setIsAnalyzed();
+
+        new Expectations() {
+            {
+                // Table setup - same name as cluster snapshot table but in a normal database
+                table.getName();
+                minTimes = 0;
+                result = "cluster_snapshots";  // Same name as the special table
+
+                table.getDatabase();
+                minTimes = 0;
+                result = db;
+
+                // Database is NOT information_schema, it's a normal user database
+                db.getFullName();
+                minTimes = 0;
+                result = "mydb";  // Normal database, not information_schema
+
+                db.getCatalog();
+                minTimes = 0;
+                result = catalog;
+
+                catalog.getName();
+                minTimes = 0;
+                result = "internal";
+
+                // ConnectContext setup
+                connectContext.getSessionVariable();
+                minTimes = 0;
+                result = sessionVariable;
+
+                sessionVariable.isPlayNereidsDump();
+                minTimes = 0;
+                result = false;
+
+                connectContext.getEnv();
+                minTimes = 0;
+                result = env;
+
+                env.getAccessManager();
+                minTimes = 0;
+                result = accessControllerManager;
+
+                // Normal SELECT privilege check should be called (not special root check)
+                // and should return true to allow access
+                accessControllerManager.checkTblPriv(connectContext, "internal", "mydb",
+                        "cluster_snapshots", PrivPredicate.SELECT);
+                minTimes = 1;  // This MUST be called, proving we're using normal privilege check
+                result = true;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = normalUser;
+            }
+        };
+
+        // Should NOT throw exception - normal user can access mydb.cluster_snapshots
+        // because it's not the special information_schema table
+        Assertions.assertDoesNotThrow(() ->
+                UserAuthentication.checkPermission(table, connectContext, null));
+    }
+
+    /**
+     * Test that a table named "cluster_snapshot_properties" in a normal database
+     * does NOT trigger the special privilege check.
+     */
+    @Test
+    public void testSameNamePropertiesTableInNormalDbNotTriggerSpecialCheck() throws Exception {
+        Config.cluster_snapshot_min_privilege = "root";
+
+        UserIdentity normalUser = new UserIdentity("normal_user", "%");
+        normalUser.setIsAnalyzed();
+
+        new Expectations() {
+            {
+                table.getName();
+                minTimes = 0;
+                result = "cluster_snapshot_properties";
+
+                table.getDatabase();
+                minTimes = 0;
+                result = db;
+
+                db.getFullName();
+                minTimes = 0;
+                result = "user_database";
+
+                db.getCatalog();
+                minTimes = 0;
+                result = catalog;
+
+                catalog.getName();
+                minTimes = 0;
+                result = "internal";
+
+                connectContext.getSessionVariable();
+                minTimes = 0;
+                result = sessionVariable;
+
+                sessionVariable.isPlayNereidsDump();
+                minTimes = 0;
+                result = false;
+
+                connectContext.getEnv();
+                minTimes = 0;
+                result = env;
+
+                env.getAccessManager();
+                minTimes = 0;
+                result = accessControllerManager;
+
+                accessControllerManager.checkTblPriv(connectContext, "internal", "user_database",
+                        "cluster_snapshot_properties", PrivPredicate.SELECT);
+                minTimes = 1;
+                result = true;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = normalUser;
+            }
+        };
+
+        Assertions.assertDoesNotThrow(() ->
+                UserAuthentication.checkPermission(table, connectContext, null));
+    }
+
+    /**
+     * Test that information_schema.cluster_snapshots DOES trigger special privilege check
+     * and denies non-root user in root mode.
+     */
+    @Test
+    public void testInfoSchemaClusterSnapshotsRequiresRootPrivilege() {
+        Config.cluster_snapshot_min_privilege = "root";
+
+        UserIdentity normalUser = new UserIdentity("normal_user", "%");
+        normalUser.setIsAnalyzed();
+
+        new Expectations() {
+            {
+                table.getName();
+                minTimes = 0;
+                result = "cluster_snapshots";
+
+                table.getDatabase();
+                minTimes = 0;
+                result = db;
+
+                // This IS information_schema
+                db.getFullName();
+                minTimes = 0;
+                result = InfoSchemaDb.DATABASE_NAME;
+
+                connectContext.getSessionVariable();
+                minTimes = 0;
+                result = sessionVariable;
+
+                sessionVariable.isPlayNereidsDump();
+                minTimes = 0;
+                result = false;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = normalUser;
+            }
+        };
+
+        // Should throw AnalysisException because non-root user cannot access
+        // information_schema.cluster_snapshots in root mode
+        Assertions.assertThrows(AnalysisException.class, () ->
+                UserAuthentication.checkPermission(table, connectContext, null));
+    }
+
+    /**
+     * Test that information_schema.cluster_snapshots allows root user.
+     */
+    @Test
+    public void testInfoSchemaClusterSnapshotsAllowsRootUser() {
+        Config.cluster_snapshot_min_privilege = "root";
+
+        new Expectations() {
+            {
+                table.getName();
+                minTimes = 0;
+                result = "cluster_snapshots";
+
+                table.getDatabase();
+                minTimes = 0;
+                result = db;
+
+                db.getFullName();
+                minTimes = 0;
+                result = InfoSchemaDb.DATABASE_NAME;
+
+                connectContext.getSessionVariable();
+                minTimes = 0;
+                result = sessionVariable;
+
+                sessionVariable.isPlayNereidsDump();
+                minTimes = 0;
+                result = false;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = UserIdentity.ROOT;
+            }
+        };
+
+        // Root user should be able to access
+        Assertions.assertDoesNotThrow(() ->
+                UserAuthentication.checkPermission(table, connectContext, null));
+    }
+
+    /**
+     * Test that cluster:information_schema.cluster_snapshots (with cluster prefix)
+     * DOES trigger special privilege check.
+     * This verifies ClusterNamespace.getNameFromFullName correctly strips the cluster prefix.
+     */
+    @Test
+    public void testInfoSchemaWithClusterPrefixTriggersSpecialCheck() {
+        Config.cluster_snapshot_min_privilege = "root";
+
+        UserIdentity normalUser = new UserIdentity("normal_user", "%");
+        normalUser.setIsAnalyzed();
+
+        new Expectations() {
+            {
+                table.getName();
+                minTimes = 0;
+                result = "cluster_snapshots";
+
+                table.getDatabase();
+                minTimes = 0;
+                result = db;
+
+                // Database name with cluster prefix - ClusterNamespace.getNameFromFullName
+                // should strip "default_cluster:" and return "information_schema"
+                db.getFullName();
+                minTimes = 0;
+                result = "default_cluster:information_schema";
+
+                connectContext.getSessionVariable();
+                minTimes = 0;
+                result = sessionVariable;
+
+                sessionVariable.isPlayNereidsDump();
+                minTimes = 0;
+                result = false;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = normalUser;
+            }
+        };
+
+        // Should throw AnalysisException because after stripping cluster prefix,
+        // the db name is "information_schema", which triggers special privilege check
+        Assertions.assertThrows(AnalysisException.class, () ->
+                UserAuthentication.checkPermission(table, connectContext, null));
+    }
+
+    /**
+     * Test that information_schema.cluster_snapshots allows admin user in admin mode.
+     */
+    @Test
+    public void testInfoSchemaClusterSnapshotsAllowsAdminInAdminMode() {
+        Config.cluster_snapshot_min_privilege = "admin";
+
+        UserIdentity adminUser = new UserIdentity("admin", "%");
+        adminUser.setIsAnalyzed();
+
+        new Expectations() {
+            {
+                table.getName();
+                minTimes = 0;
+                result = "cluster_snapshots";
+
+                table.getDatabase();
+                minTimes = 0;
+                result = db;
+
+                db.getFullName();
+                minTimes = 0;
+                result = InfoSchemaDb.DATABASE_NAME;
+
+                connectContext.getSessionVariable();
+                minTimes = 0;
+                result = sessionVariable;
+
+                sessionVariable.isPlayNereidsDump();
+                minTimes = 0;
+                result = false;
+
+                Env.getCurrentEnv();
+                minTimes = 0;
+                result = env;
+
+                env.getAccessManager();
+                minTimes = 0;
+                result = accessControllerManager;
+
+                accessControllerManager.checkGlobalPriv(connectContext, PrivPredicate.ADMIN);
+                minTimes = 0;
+                result = true;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = adminUser;
+            }
+        };
+
+        // Admin user should be able to access in admin mode
+        Assertions.assertDoesNotThrow(() ->
+                UserAuthentication.checkPermission(table, connectContext, null));
+    }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminCreateClusterSnapshotCommandTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminCreateClusterSnapshotCommandTest.java
index 31f63a5bf38..c1a3f2e523e 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminCreateClusterSnapshotCommandTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminCreateClusterSnapshotCommandTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.doris.nereids.trees.plans.commands;
 
+import org.apache.doris.analysis.UserIdentity;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.common.AnalysisException;
 import org.apache.doris.common.Config;
@@ -24,9 +25,12 @@ import org.apache.doris.mysql.privilege.AccessControllerManager;
 import org.apache.doris.mysql.privilege.PrivPredicate;
 import org.apache.doris.qe.ConnectContext;
 
+import com.google.common.collect.ImmutableMap;
 import mockit.Expectations;
 import mockit.Mocked;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 import java.util.HashMap;
@@ -36,9 +40,21 @@ public class AdminCreateClusterSnapshotCommandTest {
     @Mocked
     private Env env;
     @Mocked
-    private AccessControllerManager accessControllerManager;
-    @Mocked
     private ConnectContext connectContext;
+    @Mocked
+    private AccessControllerManager accessControllerManager;
+
+    private String originalMinPrivilege;
+
+    @BeforeEach
+    public void setUp() {
+        originalMinPrivilege = Config.cluster_snapshot_min_privilege;
+    }
+
+    @AfterEach
+    public void tearDown() {
+        Config.cluster_snapshot_min_privilege = originalMinPrivilege;
+    }
 
     private void runBefore() throws Exception {
         new Expectations() {
@@ -47,10 +63,6 @@ public class AdminCreateClusterSnapshotCommandTest {
                 minTimes = 0;
                 result = env;
 
-                env.getAccessManager();
-                minTimes = 0;
-                result = accessControllerManager;
-
                 ConnectContext.get();
                 minTimes = 0;
                 result = connectContext;
@@ -59,9 +71,10 @@ public class AdminCreateClusterSnapshotCommandTest {
                 minTimes = 0;
                 result = true;
 
-                accessControllerManager.checkGlobalPriv(connectContext, PrivPredicate.ADMIN);
+                // Mock root user for privilege check
+                connectContext.getCurrentUserIdentity();
                 minTimes = 0;
-                result = true;
+                result = UserIdentity.ROOT;
             }
         };
     }
@@ -97,7 +110,74 @@ public class AdminCreateClusterSnapshotCommandTest {
     }
 
     @Test
-    public void testValidateNoPriviledge() {
+    public void testValidateNoPrivilegeRootMode() {
+        // Test root mode (default): admin user should be denied
+        Config.cluster_snapshot_min_privilege = "root";
+
+        UserIdentity nonRootUser = new UserIdentity("admin", "%");
+        nonRootUser.setIsAnalyzed();
+
+        new Expectations() {
+            {
+                Env.getCurrentEnv();
+                minTimes = 0;
+                result = env;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = nonRootUser;
+            }
+        };
+        Config.deploy_mode = "cloud";
+
+        Map<String, String> properties = new HashMap<>();
+        AdminCreateClusterSnapshotCommand command = new AdminCreateClusterSnapshotCommand(properties);
+        Assertions.assertThrows(AnalysisException.class, () -> command.validate(connectContext),
+                "Access denied; you need (at least one of) the (root privilege) privilege(s) for this operation");
+    }
+
+    @Test
+    public void testValidateAdminModeWithAdminUser() {
+        // Test admin mode: admin user with ADMIN privilege should be allowed
+        Config.cluster_snapshot_min_privilege = "admin";
+
+        UserIdentity adminUser = new UserIdentity("admin", "%");
+        adminUser.setIsAnalyzed();
+
+        new Expectations() {
+            {
+                Env.getCurrentEnv();
+                minTimes = 0;
+                result = env;
+
+                env.getAccessManager();
+                minTimes = 0;
+                result = accessControllerManager;
+
+                accessControllerManager.checkGlobalPriv(connectContext, PrivPredicate.ADMIN);
+                minTimes = 0;
+                result = true;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = adminUser;
+            }
+        };
+        Config.deploy_mode = "cloud";
+
+        Map<String, String> properties = ImmutableMap.of("ttl", "3600", "label", "test");
+        AdminCreateClusterSnapshotCommand command = new AdminCreateClusterSnapshotCommand(properties);
+        Assertions.assertDoesNotThrow(() -> command.validate(connectContext));
+    }
+
+    @Test
+    public void testValidateAdminModeWithNormalUser() {
+        // Test admin mode: normal user without ADMIN privilege should be denied
+        Config.cluster_snapshot_min_privilege = "admin";
+
+        UserIdentity normalUser = new UserIdentity("normal_user", "%");
+        normalUser.setIsAnalyzed();
+
         new Expectations() {
             {
                 Env.getCurrentEnv();
@@ -111,6 +191,10 @@ public class AdminCreateClusterSnapshotCommandTest {
                 accessControllerManager.checkGlobalPriv(connectContext, PrivPredicate.ADMIN);
                 minTimes = 0;
                 result = false;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = normalUser;
             }
         };
         Config.deploy_mode = "cloud";
diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminDropClusterSnapshotCommandTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminDropClusterSnapshotCommandTest.java
index f9a289e7e90..49a13623b61 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminDropClusterSnapshotCommandTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminDropClusterSnapshotCommandTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.doris.nereids.trees.plans.commands;
 
+import org.apache.doris.analysis.UserIdentity;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.common.AnalysisException;
 import org.apache.doris.common.Config;
@@ -26,16 +27,30 @@ import org.apache.doris.qe.ConnectContext;
 
 import mockit.Expectations;
 import mockit.Mocked;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 public class AdminDropClusterSnapshotCommandTest {
     @Mocked
     private Env env;
     @Mocked
-    private AccessControllerManager accessControllerManager;
-    @Mocked
     private ConnectContext connectContext;
+    @Mocked
+    private AccessControllerManager accessControllerManager;
+
+    private String originalMinPrivilege;
+
+    @BeforeEach
+    public void setUp() {
+        originalMinPrivilege = Config.cluster_snapshot_min_privilege;
+    }
+
+    @AfterEach
+    public void tearDown() {
+        Config.cluster_snapshot_min_privilege = originalMinPrivilege;
+    }
 
     private void runBefore() throws Exception {
         new Expectations() {
@@ -44,10 +59,6 @@ public class AdminDropClusterSnapshotCommandTest {
                 minTimes = 0;
                 result = env;
 
-                env.getAccessManager();
-                minTimes = 0;
-                result = accessControllerManager;
-
                 ConnectContext.get();
                 minTimes = 0;
                 result = connectContext;
@@ -56,9 +67,10 @@ public class AdminDropClusterSnapshotCommandTest {
                 minTimes = 0;
                 result = true;
 
-                accessControllerManager.checkGlobalPriv(connectContext, PrivPredicate.ADMIN);
+                // Mock root user for privilege check
+                connectContext.getCurrentUserIdentity();
                 minTimes = 0;
-                result = true;
+                result = UserIdentity.ROOT;
             }
         };
     }
@@ -94,7 +106,72 @@ public class AdminDropClusterSnapshotCommandTest {
     }
 
     @Test
-    public void testValidateNoPriviledge() {
+    public void testValidateNoPrivilegeRootMode() {
+        // Test root mode (default): admin user should be denied
+        Config.cluster_snapshot_min_privilege = "root";
+
+        UserIdentity nonRootUser = new UserIdentity("admin", "%");
+        nonRootUser.setIsAnalyzed();
+
+        new Expectations() {
+            {
+                Env.getCurrentEnv();
+                minTimes = 0;
+                result = env;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = nonRootUser;
+            }
+        };
+        Config.deploy_mode = "cloud";
+
+        AdminDropClusterSnapshotCommand command = new AdminDropClusterSnapshotCommand("snapshot_id", "323741");
+        Assertions.assertThrows(AnalysisException.class, () -> command.validate(connectContext),
+                "Access denied; you need (at least one of) the (root privilege) privilege(s) for this operation");
+    }
+
+    @Test
+    public void testValidateAdminModeWithAdminUser() {
+        // Test admin mode: admin user with ADMIN privilege should be allowed
+        Config.cluster_snapshot_min_privilege = "admin";
+
+        UserIdentity adminUser = new UserIdentity("admin", "%");
+        adminUser.setIsAnalyzed();
+
+        new Expectations() {
+            {
+                Env.getCurrentEnv();
+                minTimes = 0;
+                result = env;
+
+                env.getAccessManager();
+                minTimes = 0;
+                result = accessControllerManager;
+
+                accessControllerManager.checkGlobalPriv(connectContext, PrivPredicate.ADMIN);
+                minTimes = 0;
+                result = true;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = adminUser;
+            }
+        };
+        Config.deploy_mode = "cloud";
+
+        AdminDropClusterSnapshotCommand command = new AdminDropClusterSnapshotCommand("snapshot_id", "323741");
+        Assertions.assertDoesNotThrow(() -> command.validate(connectContext));
+    }
+
+    @Test
+    public void testValidateAdminModeWithNormalUser() {
+        // Test admin mode: normal user without ADMIN privilege should be denied
+        Config.cluster_snapshot_min_privilege = "admin";
+
+        UserIdentity normalUser = new UserIdentity("normal_user", "%");
+        normalUser.setIsAnalyzed();
+
         new Expectations() {
             {
                 Env.getCurrentEnv();
@@ -108,6 +185,10 @@ public class AdminDropClusterSnapshotCommandTest {
                 accessControllerManager.checkGlobalPriv(connectContext, PrivPredicate.ADMIN);
                 minTimes = 0;
                 result = false;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = normalUser;
             }
         };
         Config.deploy_mode = "cloud";
diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminSetAutoClusterSnapshotCommandTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminSetAutoClusterSnapshotCommandTest.java
index 6779d42fce7..65c8cd8f8f6 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminSetAutoClusterSnapshotCommandTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminSetAutoClusterSnapshotCommandTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.doris.nereids.trees.plans.commands;
 
+import org.apache.doris.analysis.UserIdentity;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.common.AnalysisException;
 import org.apache.doris.common.Config;
@@ -26,7 +27,9 @@ import org.apache.doris.qe.ConnectContext;
 
 import mockit.Expectations;
 import mockit.Mocked;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 import java.util.HashMap;
@@ -36,9 +39,21 @@ public class AdminSetAutoClusterSnapshotCommandTest {
     @Mocked
     private Env env;
     @Mocked
-    private AccessControllerManager accessControllerManager;
-    @Mocked
     private ConnectContext connectContext;
+    @Mocked
+    private AccessControllerManager accessControllerManager;
+
+    private String originalMinPrivilege;
+
+    @BeforeEach
+    public void setUp() {
+        originalMinPrivilege = Config.cluster_snapshot_min_privilege;
+    }
+
+    @AfterEach
+    public void tearDown() {
+        Config.cluster_snapshot_min_privilege = originalMinPrivilege;
+    }
 
     private void runBefore() throws Exception {
         new Expectations() {
@@ -47,10 +62,6 @@ public class AdminSetAutoClusterSnapshotCommandTest {
                 minTimes = 0;
                 result = env;
 
-                env.getAccessManager();
-                minTimes = 0;
-                result = accessControllerManager;
-
                 ConnectContext.get();
                 minTimes = 0;
                 result = connectContext;
@@ -59,9 +70,10 @@ public class AdminSetAutoClusterSnapshotCommandTest {
                 minTimes = 0;
                 result = true;
 
-                accessControllerManager.checkGlobalPriv(connectContext, PrivPredicate.ADMIN);
+                // Mock root user for privilege check
+                connectContext.getCurrentUserIdentity();
                 minTimes = 0;
-                result = true;
+                result = UserIdentity.ROOT;
             }
         };
     }
@@ -117,7 +129,76 @@ public class AdminSetAutoClusterSnapshotCommandTest {
     }
 
     @Test
-    public void testValidateNoPriviledge() {
+    public void testValidateNoPrivilegeRootMode() {
+        // Test root mode (default): admin user should be denied
+        Config.cluster_snapshot_min_privilege = "root";
+
+        UserIdentity nonRootUser = new UserIdentity("admin", "%");
+        nonRootUser.setIsAnalyzed();
+
+        new Expectations() {
+            {
+                Env.getCurrentEnv();
+                minTimes = 0;
+                result = env;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = nonRootUser;
+            }
+        };
+        Config.deploy_mode = "cloud";
+
+        Map<String, String> properties = new HashMap<>();
+        AdminSetAutoClusterSnapshotCommand command = new AdminSetAutoClusterSnapshotCommand(properties);
+        Assertions.assertThrows(AnalysisException.class, () -> command.validate(connectContext),
+                "Access denied; you need (at least one of) the (root privilege) privilege(s) for this operation");
+    }
+
+    @Test
+    public void testValidateAdminModeWithAdminUser() {
+        // Test admin mode: admin user with ADMIN privilege should be allowed
+        Config.cluster_snapshot_min_privilege = "admin";
+
+        UserIdentity adminUser = new UserIdentity("admin", "%");
+        adminUser.setIsAnalyzed();
+
+        new Expectations() {
+            {
+                Env.getCurrentEnv();
+                minTimes = 0;
+                result = env;
+
+                env.getAccessManager();
+                minTimes = 0;
+                result = accessControllerManager;
+
+                accessControllerManager.checkGlobalPriv(connectContext, PrivPredicate.ADMIN);
+                minTimes = 0;
+                result = true;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = adminUser;
+            }
+        };
+        Config.deploy_mode = "cloud";
+
+        Map<String, String> properties = new HashMap<>();
+        properties.put("max_reserved_snapshots", "10");
+        properties.put("snapshot_interval_seconds", "3600");
+        AdminSetAutoClusterSnapshotCommand command = new AdminSetAutoClusterSnapshotCommand(properties);
+        Assertions.assertDoesNotThrow(() -> command.validate(connectContext));
+    }
+
+    @Test
+    public void testValidateAdminModeWithNormalUser() {
+        // Test admin mode: normal user without ADMIN privilege should be denied
+        Config.cluster_snapshot_min_privilege = "admin";
+
+        UserIdentity normalUser = new UserIdentity("normal_user", "%");
+        normalUser.setIsAnalyzed();
+
         new Expectations() {
             {
                 Env.getCurrentEnv();
@@ -131,6 +212,10 @@ public class AdminSetAutoClusterSnapshotCommandTest {
                 accessControllerManager.checkGlobalPriv(connectContext, PrivPredicate.ADMIN);
                 minTimes = 0;
                 result = false;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = normalUser;
             }
         };
         Config.deploy_mode = "cloud";
diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminSetClusterSnapshotFeatureSwitchCommandTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminSetClusterSnapshotFeatureSwitchCommandTest.java
index 63121ebc01b..a36a3493369 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminSetClusterSnapshotFeatureSwitchCommandTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AdminSetClusterSnapshotFeatureSwitchCommandTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.doris.nereids.trees.plans.commands;
 
+import org.apache.doris.analysis.UserIdentity;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.common.AnalysisException;
 import org.apache.doris.common.Config;
@@ -26,16 +27,30 @@ import org.apache.doris.qe.ConnectContext;
 
 import mockit.Expectations;
 import mockit.Mocked;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 public class AdminSetClusterSnapshotFeatureSwitchCommandTest {
     @Mocked
     private Env env;
     @Mocked
-    private AccessControllerManager accessControllerManager;
-    @Mocked
     private ConnectContext connectContext;
+    @Mocked
+    private AccessControllerManager accessControllerManager;
+
+    private String originalMinPrivilege;
+
+    @BeforeEach
+    public void setUp() {
+        originalMinPrivilege = Config.cluster_snapshot_min_privilege;
+    }
+
+    @AfterEach
+    public void tearDown() {
+        Config.cluster_snapshot_min_privilege = originalMinPrivilege;
+    }
 
     private void runBefore() throws Exception {
         new Expectations() {
@@ -44,10 +59,6 @@ public class AdminSetClusterSnapshotFeatureSwitchCommandTest {
                 minTimes = 0;
                 result = env;
 
-                env.getAccessManager();
-                minTimes = 0;
-                result = accessControllerManager;
-
                 ConnectContext.get();
                 minTimes = 0;
                 result = connectContext;
@@ -56,9 +67,10 @@ public class AdminSetClusterSnapshotFeatureSwitchCommandTest {
                 minTimes = 0;
                 result = true;
 
-                accessControllerManager.checkGlobalPriv(connectContext, PrivPredicate.ADMIN);
+                // Mock root user for privilege check
+                connectContext.getCurrentUserIdentity();
                 minTimes = 0;
-                result = true;
+                result = UserIdentity.ROOT;
             }
         };
     }
@@ -81,7 +93,72 @@ public class AdminSetClusterSnapshotFeatureSwitchCommandTest {
     }
 
     @Test
-    public void testValidateNoPriviledge() {
+    public void testValidateNoPrivilegeRootMode() {
+        // Test root mode (default): admin user should be denied
+        Config.cluster_snapshot_min_privilege = "root";
+
+        UserIdentity nonRootUser = new UserIdentity("admin", "%");
+        nonRootUser.setIsAnalyzed();
+
+        new Expectations() {
+            {
+                Env.getCurrentEnv();
+                minTimes = 0;
+                result = env;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = nonRootUser;
+            }
+        };
+        Config.deploy_mode = "cloud";
+
+        AdminSetClusterSnapshotFeatureSwitchCommand command = new AdminSetClusterSnapshotFeatureSwitchCommand(true);
+        Assertions.assertThrows(AnalysisException.class, () -> command.validate(connectContext),
+                "Access denied; you need (at least one of) the (root privilege) privilege(s) for this operation");
+    }
+
+    @Test
+    public void testValidateAdminModeWithAdminUser() {
+        // Test admin mode: admin user with ADMIN privilege should be allowed
+        Config.cluster_snapshot_min_privilege = "admin";
+
+        UserIdentity adminUser = new UserIdentity("admin", "%");
+        adminUser.setIsAnalyzed();
+
+        new Expectations() {
+            {
+                Env.getCurrentEnv();
+                minTimes = 0;
+                result = env;
+
+                env.getAccessManager();
+                minTimes = 0;
+                result = accessControllerManager;
+
+                accessControllerManager.checkGlobalPriv(connectContext, PrivPredicate.ADMIN);
+                minTimes = 0;
+                result = true;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = adminUser;
+            }
+        };
+        Config.deploy_mode = "cloud";
+
+        AdminSetClusterSnapshotFeatureSwitchCommand command = new AdminSetClusterSnapshotFeatureSwitchCommand(true);
+        Assertions.assertDoesNotThrow(() -> command.validate(connectContext));
+    }
+
+    @Test
+    public void testValidateAdminModeWithNormalUser() {
+        // Test admin mode: normal user without ADMIN privilege should be denied
+        Config.cluster_snapshot_min_privilege = "admin";
+
+        UserIdentity normalUser = new UserIdentity("normal_user", "%");
+        normalUser.setIsAnalyzed();
+
         new Expectations() {
             {
                 Env.getCurrentEnv();
@@ -95,6 +172,10 @@ public class AdminSetClusterSnapshotFeatureSwitchCommandTest {
                 accessControllerManager.checkGlobalPriv(connectContext, PrivPredicate.ADMIN);
                 minTimes = 0;
                 result = false;
+
+                connectContext.getCurrentUserIdentity();
+                minTimes = 0;
+                result = normalUser;
             }
         };
         Config.deploy_mode = "cloud";
diff --git a/gensrc/proto/cloud.proto b/gensrc/proto/cloud.proto
index 5f454bbfd42..6e2c46672cd 100644
--- a/gensrc/proto/cloud.proto
+++ b/gensrc/proto/cloud.proto
@@ -517,6 +517,9 @@ message TxnInfoPB {
     // This is used for lazy commit to reduce contention during transaction commit.
     // The delete bitmap locks will be extended and removed during lazy commit phase.
     optional bool defer_deleting_pending_delete_bitmaps = 21;
+    // The cluster_id of the compute cluster that performed the load
+    // Set by FE during begin_txn, used by commit_txn for compaction read-write separation
+    optional string load_cluster_id = 22;
 }
 
 // For check txn conflict and txn timeout
@@ -897,6 +900,17 @@ message TabletStatsPB {
     optional int64 last_full_compaction_time_ms = 13;
     optional int64 index_size = 14;
     optional int64 segment_size = 15;
+    // The cluster ID that most recently performed load or compaction on this tablet
+    optional string last_active_cluster_id = 16;
+    // Timestamp (ms) when the last_active_cluster was updated
+    optional int64 last_active_time_ms = 17;
+    // Current status of the last_active_cluster (filled by MS at get_rowset time)
+    // NORMAL means available, other states (SUSPENDED/MANUAL_SHUTDOWN) or UNKNOWN means unavailable
+    optional ClusterStatus last_active_cluster_status = 18;
+    // Timestamp for timeout judgment
+    // - Normal case: dynamically filled with ClusterPB.mtime (not persisted)
+    // - Deleted case: persisted when first detected as deleted, reused thereafter
+    optional int64 last_active_cluster_status_mtime_ms = 19;
 }
 
 message ObjectFilePB {
@@ -1359,6 +1373,8 @@ message GetRowsetResponse {
     optional SchemaCloudDictionary schema_dict = 4;
     // The current max version of the partition
     optional int64 partition_max_version = 5;
+    // The cluster_id of the requester
+    optional string requester_cluster_id = 6;
 }
 
 message GetSchemaDictRequest {
@@ -1547,6 +1563,8 @@ message GetClusterStatusResponse {
     }
     optional MetaServiceResponseStatus status = 1;
     repeated GetClusterStatusResponseDetail details = 2;
+    // The cluster_id of the requesting node (resolved from cloud_unique_id)
+    optional string requester_cluster_id = 3;
 }
 
 message GetClusterResponse {
diff --git a/regression-test/suites/cloud_p0/compaction_rw_separation/test_compaction_cluster_takeover.groovy b/regression-test/suites/cloud_p0/compaction_rw_separation/test_compaction_cluster_takeover.groovy
new file mode 100644
index 00000000000..6eaf27b9f49
--- /dev/null
+++ b/regression-test/suites/cloud_p0/compaction_rw_separation/test_compaction_cluster_takeover.groovy
@@ -0,0 +1,264 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.apache.doris.regression.suite.ClusterOptions
+import groovy.json.JsonSlurper
+import groovy.json.JsonOutput
+
+/**
+ * Test compaction cluster takeover when the original write cluster becomes unavailable.
+ *
+ * Scenario:
+ * 1. Write cluster performs load, auto compaction is disabled at BE level initially
+ * 2. Write cluster becomes SUSPENDED via MS API
+ * 3. After timeout, enable auto compaction on read cluster BE via HTTP config update
+ * 4. Read cluster should take over compaction via auto compaction
+ *
+ * This test uses auto compaction (not manual trigger) because manual trigger bypasses
+ * the get_topn_tablets_to_compact logic where should_skip_compaction is checked.
+ * Auto compaction is controlled via BE config (not table property) because ALTER TABLE
+ * disable_auto_compaction has a known bug and may not take effect immediately.
+ */
+suite('test_compaction_cluster_takeover', 'docker') {
+    def options = new ClusterOptions()
+    options.feConfigs += [
+        'cloud_cluster_check_interval_second=1',
+        'cloud_tablet_rebalancer_interval_second=1',
+    ]
+    options.beConfigs += [
+        'enable_compaction_rw_separation=true',
+        'compaction_cluster_takeover_timeout_ms=10000',  // 10 seconds for faster testing
+        'cluster_status_cache_refresh_interval_sec=3',   // 3 seconds for faster testing
+        'file_cache_enter_disk_resource_limit_mode_percent=99',
+        'disable_auto_compaction=true',  // Disable auto compaction initially, enable via HTTP later
+    ]
+    options.cloudMode = true
+
+    def getTabletStatus = { ip, port, tablet_id ->
+        def url = "http://${ip}:${port}/api/compaction/show?tablet_id=${tablet_id}"
+        def response = new URL(url).text
+        return new JsonSlurper().parseText(response)
+    }
+
+    def getBeIpAndPort = { cluster_name ->
+        def backends = sql """SHOW BACKENDS"""
+        def cluster_bes = backends.findAll { it[19].contains("""\"compute_group_name\" : \"${cluster_name}\"""") }
+
+        if (cluster_bes.isEmpty()) {
+            throw new RuntimeException("No BE found for cluster: ${cluster_name}")
+        }
+
+        def firstBe = cluster_bes[0]
+        return [ip: firstBe[1], http_port: firstBe[4], brpc_port: firstBe[5], backend_id: firstBe[0]]
+    }
+
+    def getCloudClusterId = { cluster_name ->
+        def backends = sql """SHOW BACKENDS"""
+        def cluster_be = backends.find { it[19].contains("""\"compute_group_name\" : \"${cluster_name}\"""") }
+        if (cluster_be == null) {
+            throw new RuntimeException("No BE found for cluster: ${cluster_name}")
+        }
+        def tag = cluster_be[19]
+        def jsonSlurper = new JsonSlurper()
+        def jsonObject = jsonSlurper.parseText(tag)
+        return [cluster_id: jsonObject.compute_group_id, unique_id: jsonObject.cloud_unique_id]
+    }
+
+    // Helper function to set cluster status via MS HTTP API
+    def setClusterStatus = { String unique_id, String cluster_id, String status, def ms ->
+        def jsonOutput = new JsonOutput()
+        def reqBody = [
+            cloud_unique_id: unique_id,
+            cluster: [
+                cluster_id: cluster_id,
+                cluster_status: status
+            ]
+        ]
+        def js = jsonOutput.toJson(reqBody)
+        logger.info("set_cluster_status request: ${js}")
+
+        httpTest {
+            endpoint ms.host + ':' + ms.httpPort
+            uri "/MetaService/http/set_cluster_status?token=greedisgood9999"
+            body js
+            check { respCode, body ->
+                logger.info("set_cluster_status response: ${body} ${respCode}")
+                def json = parseJson(body)
+                assertTrue(json.code.equalsIgnoreCase("OK"), "Failed to set cluster status: ${body}")
+            }
+        }
+    }
+
+    docker(options) {
+        def writeCluster = "primary_cluster"
+        def readCluster = "secondary_cluster"
+
+        // Add two clusters
+        cluster.addBackend(1, writeCluster)
+        cluster.addBackend(1, readCluster)
+
+        // Get MS instance
+        def ms = cluster.getAllMetaservices().get(0)
+        logger.info("Meta service: ${ms.host}:${ms.httpPort}")
+
+        def writeBe = getBeIpAndPort(writeCluster)
+        def readBe = getBeIpAndPort(readCluster)
+        def writeClusterInfo = getCloudClusterId(writeCluster)
+
+        logger.info("Primary BE: ${writeBe.ip}:${writeBe.http_port}")
+        logger.info("Secondary BE: ${readBe.ip}:${readBe.http_port}")
+        logger.info("Primary cluster ID: ${writeClusterInfo.cluster_id}")
+
+        // Use write cluster to create table and load data
+        // Auto compaction is disabled at BE level, so no compaction will run initially
+        sql """use @${writeCluster}"""
+
+        def tableName = "test_compaction_takeover"
+        sql """ DROP TABLE IF EXISTS ${tableName} """
+        sql """
+            CREATE TABLE ${tableName} (
+                k1 INT NOT NULL,
+                v1 STRING NOT NULL
+            ) UNIQUE KEY(`k1`)
+            DISTRIBUTED BY HASH(k1) BUCKETS 1
+            PROPERTIES (
+                "replication_num" = "1"
+            );
+        """
+
+        // Insert data from primary cluster (this sets last_active_cluster_id)
+        for (int i = 0; i < 10; i++) {
+            sql """INSERT INTO ${tableName} VALUES (${i}, 'value_${i}')"""
+        }
+
+        // Get tablet info
+        def tablets = sql_return_maparray """ SHOW TABLETS FROM ${tableName} """
+        assertEquals(1, tablets.size())
+        def tablet = tablets[0]
+        def tablet_id = tablet.TabletId
+        logger.info("Tablet ID: ${tablet_id}")
+
+        sleep(3000)
+
+        // Sync rowsets to read cluster
+        sql """use @${readCluster}"""
+        def result = sql """SELECT COUNT(*) FROM ${tableName}"""
+        assertEquals(10, result[0][0])
+
+        // Now suspend the primary cluster using MS HTTP API
+        logger.info("Suspending primary cluster to simulate unavailability...")
+        setClusterStatus(writeClusterInfo.unique_id, writeClusterInfo.cluster_id, "SUSPENDED", ms)
+
+        // Wait for cluster status cache to refresh + takeover timeout
+        // cluster_status_cache_refresh_interval_sec=3, compaction_cluster_takeover_timeout_ms=10000
+        logger.info("Waiting for cluster status cache refresh and takeover timeout...")
+        sleep(15000)
+
+        // Enable auto compaction on the read cluster BE via HTTP config update
+        // (Using BE config instead of ALTER TABLE because ALTER TABLE disable_auto_compaction
+        // has a known bug and may not take effect immediately)
+        logger.info("Enabling auto compaction on read cluster BE...")
+        def (code, out, err) = curl("POST",
+            String.format("http://%s:%s/api/update_config?disable_auto_compaction=false",
+                readBe.ip, readBe.http_port))
+        logger.info("Update config response: code=${code}, out=${out}")
+
+        // Wait for auto compaction to run on the read cluster
+        logger.info("Waiting for auto compaction on read cluster after takeover...")
+        sleep(30000)
+
+        // Check that read cluster has compacted
+        def readStatus = getTabletStatus(readBe.ip, readBe.http_port, tablet_id)
+        logger.info("Read cluster tablet status: ${readStatus}")
+        def readLastCumuTime = readStatus["last cumulative success time"]
+        logger.info("Read cluster last cumulative success time: ${readLastCumuTime}")
+
+        assertTrue(readLastCumuTime != "1970-01-01 08:00:00.000",
+            "Read cluster should have taken over compaction after timeout, but last cumulative success time is: ${readLastCumuTime}")
+
+        logger.info("Secondary cluster successfully took over compaction!")
+
+        // ==========================================
+        // Phase 2: Primary resumes, loads data, and takes back compaction control
+        // ==========================================
+        logger.info("=== Phase 2: Testing primary cluster recovery ===")
+
+        // Resume the primary cluster
+        logger.info("Resuming primary cluster...")
+        setClusterStatus(writeClusterInfo.unique_id, writeClusterInfo.cluster_id, "NORMAL", ms)
+
+        // Record secondary's compaction timestamp before primary recovery
+        def readStatusBefore = getTabletStatus(readBe.ip, readBe.http_port, tablet_id)
+        def readCumuTimeBefore = readStatusBefore["last cumulative success time"]
+        logger.info("Secondary last cumulative success time before primary recovery: ${readCumuTimeBefore}")
+
+        // Insert more data from primary cluster — this updates last_active_cluster_id back to primary
+        sql """use @${writeCluster}"""
+        for (int i = 10; i < 20; i++) {
+            sql """INSERT INTO ${tableName} VALUES (${i}, 'value_${i}')"""
+        }
+        logger.info("Inserted more data from primary cluster")
+
+        // Sync to secondary so both clusters have the new rowsets
+        sql """use @${readCluster}"""
+        def result2 = sql """SELECT COUNT(*) FROM ${tableName}"""
+        assertEquals(20, result2[0][0])
+
+        // Wait for cluster status cache to refresh so both clusters see updated last_active_cluster
+        sleep(5000)
+
+        // Enable auto compaction on both clusters
+        logger.info("Enabling auto compaction on both cluster BEs...")
+        def (code3, out3, err3) = curl("POST",
+            String.format("http://%s:%s/api/update_config?disable_auto_compaction=false",
+                writeBe.ip, writeBe.http_port))
+        logger.info("Enable primary auto compaction response: code=${code3}, out=${out3}")
+        def (code4, out4, err4) = curl("POST",
+            String.format("http://%s:%s/api/update_config?disable_auto_compaction=false",
+                readBe.ip, readBe.http_port))
+        logger.info("Enable secondary auto compaction response: code=${code4}, out=${out4}")
+
+        // Wait for auto compaction to run
+        logger.info("Waiting for auto compaction after primary recovery...")
+        sleep(30000)
+
+        // Check that primary cluster has compacted after recovery
+        def writeStatus = getTabletStatus(writeBe.ip, writeBe.http_port, tablet_id)
+        logger.info("Primary cluster tablet status after recovery: ${writeStatus}")
+        def writeLastCumuTime = writeStatus["last cumulative success time"]
+        logger.info("Primary cluster last cumulative success time: ${writeLastCumuTime}")
+
+        assertTrue(writeLastCumuTime != "1970-01-01 08:00:00.000",
+            "Primary cluster should have resumed compaction after recovery, but last cumulative success time is: ${writeLastCumuTime}")
+
+        logger.info("Primary cluster successfully took back compaction control!")
+
+        // Check that secondary cluster did NOT do new compaction after primary took back control
+        def readStatusAfter = getTabletStatus(readBe.ip, readBe.http_port, tablet_id)
+        def readCumuTimeAfter = readStatusAfter["last cumulative success time"]
+        logger.info("Secondary last cumulative success time after primary recovery: ${readCumuTimeAfter}")
+
+        assertEquals(readCumuTimeBefore, readCumuTimeAfter,
+            "Secondary cluster should have stopped compaction after primary recovered, " +
+            "but cumulative time changed from ${readCumuTimeBefore} to ${readCumuTimeAfter}")
+
+        // Clean up
+        sql """ DROP TABLE IF EXISTS ${tableName} """
+
+        logger.info("All takeover tests passed!")
+    }
+}
diff --git a/regression-test/suites/cloud_p0/compaction_rw_separation/test_compaction_rw_separation.groovy b/regression-test/suites/cloud_p0/compaction_rw_separation/test_compaction_rw_separation.groovy
new file mode 100644
index 00000000000..f4755396daf
--- /dev/null
+++ b/regression-test/suites/cloud_p0/compaction_rw_separation/test_compaction_rw_separation.groovy
@@ -0,0 +1,158 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.apache.doris.regression.suite.ClusterOptions
+import groovy.json.JsonSlurper
+
+/**
+ * Test compaction read-write separation feature.
+ *
+ * When enable_compaction_rw_separation is enabled:
+ * - Only the cluster that last performed load (write cluster) should execute compaction
+ * - Read-only clusters should skip compaction to avoid file cache invalidation
+ *
+ * This test uses auto compaction (not manual trigger) because manual trigger bypasses
+ * the get_topn_tablets_to_compact logic where should_skip_compaction is checked.
+ */
+suite('test_compaction_rw_separation', 'docker') {
+    def options = new ClusterOptions()
+    options.feConfigs += [
+        'cloud_cluster_check_interval_second=1',
+        'cloud_tablet_rebalancer_interval_second=1',
+    ]
+    options.beConfigs += [
+        'enable_compaction_rw_separation=true',
+        'compaction_cluster_takeover_timeout_ms=600000',  // 10 min, ensure no takeover in test
+        'cluster_status_cache_refresh_interval_sec=5',
+        'file_cache_enter_disk_resource_limit_mode_percent=99',
+        'disable_auto_compaction=true',  // Disable initially, enable via HTTP after data load
+    ]
+    options.cloudMode = true
+
+    def getTabletStatus = { ip, port, tablet_id ->
+        def url = "http://${ip}:${port}/api/compaction/show?tablet_id=${tablet_id}"
+        def response = new URL(url).text
+        return new JsonSlurper().parseText(response)
+    }
+
+    def getBeIpAndPort = { cluster_name ->
+        def backends = sql """SHOW BACKENDS"""
+        def cluster_bes = backends.findAll { it[19].contains("""\"compute_group_name\" : \"${cluster_name}\"""") }
+
+        if (cluster_bes.isEmpty()) {
+            throw new RuntimeException("No BE found for cluster: ${cluster_name}")
+        }
+
+        def firstBe = cluster_bes[0]
+        return [ip: firstBe[1], http_port: firstBe[4], brpc_port: firstBe[5], backend_id: firstBe[0]]
+    }
+
+    docker(options) {
+        def writeCluster = "write_cluster"
+        def readCluster = "read_cluster"
+
+        // Add two clusters
+        cluster.addBackend(1, writeCluster)
+        cluster.addBackend(1, readCluster)
+
+        logger.info("Created write cluster: ${writeCluster}")
+        logger.info("Created read cluster: ${readCluster}")
+
+        def writeBe = getBeIpAndPort(writeCluster)
+        def readBe = getBeIpAndPort(readCluster)
+
+        logger.info("Write BE: ${writeBe.ip}:${writeBe.http_port}")
+        logger.info("Read BE: ${readBe.ip}:${readBe.http_port}")
+
+        // Use write cluster to create table (auto compaction enabled)
+        sql """use @${writeCluster}"""
+
+        def tableName = "test_compaction_rw_sep"
+        sql """ DROP TABLE IF EXISTS ${tableName} """
+        sql """
+            CREATE TABLE ${tableName} (
+                k1 INT NOT NULL,
+                v1 INT NOT NULL
+            ) UNIQUE KEY(`k1`)
+            DISTRIBUTED BY HASH(k1) BUCKETS 1
+            PROPERTIES (
+                "replication_num" = "1"
+            );
+        """
+
+        // Insert multiple rowsets from write cluster to trigger auto compaction
+        for (int i = 0; i < 10; i++) {
+            sql """INSERT INTO ${tableName} VALUES (${i}, ${i * 10})"""
+        }
+
+        // Get tablet info
+        def tablets = sql_return_maparray """ SHOW TABLETS FROM ${tableName} """
+        assertEquals(1, tablets.size())
+        def tablet = tablets[0]
+        def tablet_id = tablet.TabletId
+        logger.info("Tablet ID: ${tablet_id}")
+
+        // Sync rowsets to read cluster
+        sql """use @${readCluster}"""
+        def result = sql """SELECT COUNT(*) FROM ${tableName}"""
+        assertEquals(10, result[0][0])
+
+        // Enable auto compaction on both BEs via HTTP config update
+        // (Using BE config instead of table property because ALTER TABLE disable_auto_compaction
+        // has a known bug and may not take effect immediately)
+        logger.info("Enabling auto compaction on both BEs...")
+        def (code1, out1, err1) = curl("POST",
+            String.format("http://%s:%s/api/update_config?disable_auto_compaction=false",
+                writeBe.ip, writeBe.http_port))
+        logger.info("Update write BE config response: code=${code1}, out=${out1}")
+        def (code2, out2, err2) = curl("POST",
+            String.format("http://%s:%s/api/update_config?disable_auto_compaction=false",
+                readBe.ip, readBe.http_port))
+        logger.info("Update read BE config response: code=${code2}, out=${out2}")
+
+        // Wait for auto compaction to run on both clusters
+        // Write cluster should compact, read cluster should skip
+        logger.info("Waiting for auto compaction to run...")
+        sleep(30000)
+
+        // Check compaction status on write cluster
+        def writeStatus = getTabletStatus(writeBe.ip, writeBe.http_port, tablet_id)
+        logger.info("Write cluster tablet status: ${writeStatus}")
+        def writeLastCumuTime = writeStatus["last cumulative success time"]
+        logger.info("Write cluster last cumulative success time: ${writeLastCumuTime}")
+
+        // Check compaction status on read cluster
+        def readStatus = getTabletStatus(readBe.ip, readBe.http_port, tablet_id)
+        logger.info("Read cluster tablet status: ${readStatus}")
+        def readLastCumuTime = readStatus["last cumulative success time"]
+        logger.info("Read cluster last cumulative success time: ${readLastCumuTime}")
+
+        // Write cluster should have compacted (last success time is not the initial value)
+        assertTrue(writeLastCumuTime != "1970-01-01 08:00:00.000",
+            "Write cluster should have executed compaction, but last cumulative success time is: ${writeLastCumuTime}")
+
+        // Read cluster should NOT have compacted (last success time should still be initial)
+        assertTrue(readLastCumuTime == "1970-01-01 08:00:00.000",
+            "Read cluster should NOT have executed compaction, but last cumulative success time is: ${readLastCumuTime}")
+
+        logger.info("Test passed: Write cluster compacted, read cluster skipped!")
+
+        // Clean up
+        sql """use @${writeCluster}"""
+        sql """ DROP TABLE IF EXISTS ${tableName} """
+    }
+}
diff --git a/regression-test/suites/cloud_p0/compaction_rw_separation/test_compaction_rw_separation_disabled.groovy b/regression-test/suites/cloud_p0/compaction_rw_separation/test_compaction_rw_separation_disabled.groovy
new file mode 100644
index 00000000000..55ff31ffaec
--- /dev/null
+++ b/regression-test/suites/cloud_p0/compaction_rw_separation/test_compaction_rw_separation_disabled.groovy
@@ -0,0 +1,151 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.apache.doris.regression.suite.ClusterOptions
+import groovy.json.JsonSlurper
+
+/**
+ * Test behavior when compaction read-write separation is DISABLED.
+ *
+ * When enable_compaction_rw_separation is false (default):
+ * - All clusters should be able to execute compaction regardless of which cluster last performed load
+ *
+ * This test uses auto compaction to verify that both clusters can compact.
+ */
+suite('test_compaction_rw_separation_disabled', 'docker') {
+    def options = new ClusterOptions()
+    options.feConfigs += [
+        'cloud_cluster_check_interval_second=1',
+        'cloud_tablet_rebalancer_interval_second=1',
+    ]
+    options.beConfigs += [
+        'enable_compaction_rw_separation=false',  // Feature disabled
+        'file_cache_enter_disk_resource_limit_mode_percent=99',
+        'disable_auto_compaction=true',  // Disable initially, enable via HTTP after data load
+    ]
+    options.cloudMode = true
+
+    def getTabletStatus = { ip, port, tablet_id ->
+        def url = "http://${ip}:${port}/api/compaction/show?tablet_id=${tablet_id}"
+        def response = new URL(url).text
+        return new JsonSlurper().parseText(response)
+    }
+
+    def getBeIpAndPort = { cluster_name ->
+        def backends = sql """SHOW BACKENDS"""
+        def cluster_bes = backends.findAll { it[19].contains("""\"compute_group_name\" : \"${cluster_name}\"""") }
+
+        if (cluster_bes.isEmpty()) {
+            throw new RuntimeException("No BE found for cluster: ${cluster_name}")
+        }
+
+        def firstBe = cluster_bes[0]
+        return [ip: firstBe[1], http_port: firstBe[4], brpc_port: firstBe[5], backend_id: firstBe[0]]
+    }
+
+    docker(options) {
+        def clusterA = "cluster_a"
+        def clusterB = "cluster_b"
+
+        // Add two clusters
+        cluster.addBackend(1, clusterA)
+        cluster.addBackend(1, clusterB)
+
+        logger.info("Created cluster A: ${clusterA}")
+        logger.info("Created cluster B: ${clusterB}")
+
+        def beA = getBeIpAndPort(clusterA)
+        def beB = getBeIpAndPort(clusterB)
+
+        logger.info("Cluster A BE: ${beA.ip}:${beA.http_port}")
+        logger.info("Cluster B BE: ${beB.ip}:${beB.http_port}")
+
+        // Use cluster A to create table and load data (auto compaction enabled)
+        sql """use @${clusterA}"""
+
+        def tableName = "test_compaction_disabled"
+        sql """ DROP TABLE IF EXISTS ${tableName} """
+        sql """
+            CREATE TABLE ${tableName} (
+                k1 INT NOT NULL,
+                v1 INT NOT NULL
+            ) UNIQUE KEY(`k1`)
+            DISTRIBUTED BY HASH(k1) BUCKETS 1
+            PROPERTIES (
+                "replication_num" = "1"
+            );
+        """
+
+        // Insert data from cluster A
+        for (int i = 0; i < 10; i++) {
+            sql """INSERT INTO ${tableName} VALUES (${i}, ${i * 100})"""
+        }
+
+        // Get tablet info
+        def tablets = sql_return_maparray """ SHOW TABLETS FROM ${tableName} """
+        assertEquals(1, tablets.size())
+        def tablet = tablets[0]
+        def tablet_id = tablet.TabletId
+        logger.info("Tablet ID: ${tablet_id}")
+
+        // Sync rowsets to cluster B
+        sql """use @${clusterB}"""
+        def result = sql """SELECT COUNT(*) FROM ${tableName}"""
+        assertEquals(10, result[0][0])
+
+        // Enable auto compaction on both BEs via HTTP config update
+        // (Using BE config instead of table property because ALTER TABLE disable_auto_compaction
+        // has a known bug and may not take effect immediately)
+        logger.info("Enabling auto compaction on both BEs...")
+        def (code1, out1, err1) = curl("POST",
+            String.format("http://%s:%s/api/update_config?disable_auto_compaction=false",
+                beA.ip, beA.http_port))
+        logger.info("Update cluster A BE config response: code=${code1}, out=${out1}")
+        def (code2, out2, err2) = curl("POST",
+            String.format("http://%s:%s/api/update_config?disable_auto_compaction=false",
+                beB.ip, beB.http_port))
+        logger.info("Update cluster B BE config response: code=${code2}, out=${out2}")
+
+        // Wait for auto compaction to run on both clusters
+        logger.info("Waiting for auto compaction to run on both clusters...")
+        sleep(30000)
+
+        // Check compaction status on both clusters
+        def statusA = getTabletStatus(beA.ip, beA.http_port, tablet_id)
+        logger.info("Cluster A tablet status: ${statusA}")
+        def lastCumuTimeA = statusA["last cumulative success time"]
+        logger.info("Cluster A last cumulative success time: ${lastCumuTimeA}")
+
+        def statusB = getTabletStatus(beB.ip, beB.http_port, tablet_id)
+        logger.info("Cluster B tablet status: ${statusB}")
+        def lastCumuTimeB = statusB["last cumulative success time"]
+        logger.info("Cluster B last cumulative success time: ${lastCumuTimeB}")
+
+        // When feature is disabled, both clusters should be able to compact
+        // At least one of them should have compacted within 30 seconds
+        // (Both may not compact if they contend, but at least cluster A which has the tablet should)
+        assertTrue(lastCumuTimeA != "1970-01-01 08:00:00.000" || lastCumuTimeB != "1970-01-01 08:00:00.000",
+            "At least one cluster should have compacted when feature is disabled. " +
+            "Cluster A time: ${lastCumuTimeA}, Cluster B time: ${lastCumuTimeB}")
+
+        logger.info("Feature disabled test passed - compaction ran successfully!")
+
+        // Clean up
+        sql """use @${clusterA}"""
+        sql """ DROP TABLE IF EXISTS ${tableName} """
+    }
+}
diff --git a/regression-test/suites/cloud_p0/compaction_rw_separation/test_compaction_rw_separation_force.groovy b/regression-test/suites/cloud_p0/compaction_rw_separation/test_compaction_rw_separation_force.groovy
new file mode 100644
index 00000000000..eea6addf93e
--- /dev/null
+++ b/regression-test/suites/cloud_p0/compaction_rw_separation/test_compaction_rw_separation_force.groovy
@@ -0,0 +1,161 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.apache.doris.regression.suite.ClusterOptions
+import groovy.json.JsonSlurper
+
+/**
+ * Test force compaction on read cluster when version count exceeds 80% of max_tablet_version_num.
+ *
+ * When the write cluster cannot keep up with compaction (disabled, overloaded, etc.),
+ * version count accumulates. If it exceeds 80% of max_tablet_version_num, read clusters
+ * should do compaction anyway despite RW separation being enabled.
+ *
+ * Setup:
+ * - max_tablet_version_num=20 on both BEs (so threshold = 16)
+ * - Disable auto compaction initially
+ * - Load 18 rowsets from write cluster (exceeds threshold)
+ * - Sync to read cluster
+ * - Keep auto compaction disabled on write cluster
+ * - Enable auto compaction only on read cluster
+ * - Verify read cluster performs compaction despite RW separation
+ */
+suite('test_compaction_rw_separation_force', 'docker') {
+    def options = new ClusterOptions()
+    options.feConfigs += [
+        'cloud_cluster_check_interval_second=1',
+        'cloud_tablet_rebalancer_interval_second=1',
+    ]
+    options.beConfigs += [
+        'enable_compaction_rw_separation=true',
+        'compaction_cluster_takeover_timeout_ms=600000',  // 10 min, ensure no takeover in test
+        'cluster_status_cache_refresh_interval_sec=5',
+        'file_cache_enter_disk_resource_limit_mode_percent=99',
+        'disable_auto_compaction=true',  // Disable initially
+        'max_tablet_version_num=20',     // Low limit so 80% threshold = 16
+    ]
+    options.cloudMode = true
+
+    def getTabletStatus = { ip, port, tablet_id ->
+        def url = "http://${ip}:${port}/api/compaction/show?tablet_id=${tablet_id}"
+        def response = new URL(url).text
+        return new JsonSlurper().parseText(response)
+    }
+
+    def getBeIpAndPort = { cluster_name ->
+        def backends = sql """SHOW BACKENDS"""
+        def cluster_bes = backends.findAll { it[19].contains("""\"compute_group_name\" : \"${cluster_name}\"""") }
+
+        if (cluster_bes.isEmpty()) {
+            throw new RuntimeException("No BE found for cluster: ${cluster_name}")
+        }
+
+        def firstBe = cluster_bes[0]
+        return [ip: firstBe[1], http_port: firstBe[4], brpc_port: firstBe[5], backend_id: firstBe[0]]
+    }
+
+    docker(options) {
+        def writeCluster = "write_cluster"
+        def readCluster = "read_cluster"
+
+        // Add two clusters
+        cluster.addBackend(1, writeCluster)
+        cluster.addBackend(1, readCluster)
+
+        logger.info("Created write cluster: ${writeCluster}")
+        logger.info("Created read cluster: ${readCluster}")
+
+        def writeBe = getBeIpAndPort(writeCluster)
+        def readBe = getBeIpAndPort(readCluster)
+
+        logger.info("Write BE: ${writeBe.ip}:${writeBe.http_port}")
+        logger.info("Read BE: ${readBe.ip}:${readBe.http_port}")
+
+        // Use write cluster to create table
+        sql """use @${writeCluster}"""
+
+        def tableName = "test_compaction_rw_sep_force"
+        sql """ DROP TABLE IF EXISTS ${tableName} """
+        sql """
+            CREATE TABLE ${tableName} (
+                k1 INT NOT NULL,
+                v1 INT NOT NULL
+            ) UNIQUE KEY(`k1`)
+            DISTRIBUTED BY HASH(k1) BUCKETS 1
+            PROPERTIES (
+                "replication_num" = "1"
+            );
+        """
+
+        // Insert 18 rowsets from write cluster to exceed 80% of max_tablet_version_num=20 (threshold=16)
+        for (int i = 0; i < 18; i++) {
+            sql """INSERT INTO ${tableName} VALUES (${i}, ${i * 10})"""
+        }
+
+        // Get tablet info
+        def tablets = sql_return_maparray """ SHOW TABLETS FROM ${tableName} """
+        assertEquals(1, tablets.size())
+        def tablet = tablets[0]
+        def tablet_id = tablet.TabletId
+        logger.info("Tablet ID: ${tablet_id}")
+
+        // Sync rowsets to read cluster
+        sql """use @${readCluster}"""
+        def result = sql """SELECT COUNT(*) FROM ${tableName}"""
+        assertEquals(18, result[0][0])
+
+        // Keep auto compaction DISABLED on write cluster (simulating "can't compact")
+        // Enable auto compaction ONLY on read cluster
+        logger.info("Enabling auto compaction only on read cluster...")
+        def (code2, out2, err2) = curl("POST",
+            String.format("http://%s:%s/api/update_config?disable_auto_compaction=false",
+                readBe.ip, readBe.http_port))
+        logger.info("Update read BE config response: code=${code2}, out=${out2}")
+
+        // Wait for auto compaction to run on read cluster
+        // Since version count (18) > 80% of max_tablet_version_num (20) = 16,
+        // read cluster should force compaction despite RW separation
+        logger.info("Waiting for read cluster to force compaction...")
+        sleep(30000)
+
+        // Check compaction status on write cluster (should NOT have compacted, auto compaction disabled)
+        def writeStatus = getTabletStatus(writeBe.ip, writeBe.http_port, tablet_id)
+        logger.info("Write cluster tablet status: ${writeStatus}")
+        def writeLastCumuTime = writeStatus["last cumulative success time"]
+        logger.info("Write cluster last cumulative success time: ${writeLastCumuTime}")
+
+        // Check compaction status on read cluster (should have compacted due to force)
+        def readStatus = getTabletStatus(readBe.ip, readBe.http_port, tablet_id)
+        logger.info("Read cluster tablet status: ${readStatus}")
+        def readLastCumuTime = readStatus["last cumulative success time"]
+        logger.info("Read cluster last cumulative success time: ${readLastCumuTime}")
+
+        // Write cluster should NOT have compacted (auto compaction is disabled)
+        assertTrue(writeLastCumuTime == "1970-01-01 08:00:00.000",
+            "Write cluster should NOT have executed compaction (disabled), but last cumulative success time is: ${writeLastCumuTime}")
+
+        // Read cluster SHOULD have compacted (forced by high version count)
+        assertTrue(readLastCumuTime != "1970-01-01 08:00:00.000",
+            "Read cluster should have force-compacted due to high version count, but last cumulative success time is: ${readLastCumuTime}")
+
+        logger.info("Test passed: Read cluster force-compacted despite RW separation!")
+
+        // Clean up
+        sql """use @${writeCluster}"""
+        sql """ DROP TABLE IF EXISTS ${tableName} """
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@doris.apache.org
For additional commands, e-mail: commits-help@doris.apache.org