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 34b443418ae [fix](cloud) Drop CloudReplica route entries of dropped 
compute groups (#66984) (#67234)
34b443418ae is described below

commit 34b443418ae536849a9bd86a09e36f346060f98b
Author: deardeng <[email protected]>
AuthorDate: Mon Aug 31 16:23:16 2026 +0800

    [fix](cloud) Drop CloudReplica route entries of dropped compute groups 
(#66984) (#67234)
    
    pick from https://github.com/apache/doris/pull/66984
    
    Remove stale CloudReplica routes when compute-group backends are
    dropped.
    
    ### What problem does this PR solve?
    
    Issue Number: close #xxx
    
    Related PR: #xxx
    
    Problem Summary:
    
    ### Release note
    
    None
    
    ### Check List (For Author)
    
    - Test <!-- At least one of them must be included. -->
        - [ ] Regression test
        - [ ] Unit Test
        - [ ] Manual test (add detailed scripts or steps below)
        - [ ] No need to test or manual test. Explain why:
    - [ ] This is a refactor/code format and no logic has been changed.
            - [ ] Previous test can cover this change.
            - [ ] No code files have been changed.
            - [ ] Other reason <!-- Add your reason?  -->
    
    - Behavior changed:
        - [ ] No.
        - [ ] Yes. <!-- Explain the behavior change -->
    
    - Does this need documentation?
        - [ ] No.
    - [ ] Yes. <!-- Add document PR link here. eg:
    https://github.com/apache/doris-website/pull/1214 -->
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label <!-- Add branch pick label that this PR
    should merge into -->
---
 .../main/java/org/apache/doris/common/Config.java  |  7 ++
 .../apache/doris/cloud/catalog/CloudReplica.java   | 74 ++++++++++++++++++++++
 .../doris/cloud/catalog/CloudTabletRebalancer.java | 62 +++++++++++++++++-
 3 files changed, 142 insertions(+), 1 deletion(-)

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 126b29998eb..814a10024fb 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
@@ -3807,6 +3807,13 @@ public class Config extends ConfigBase {
             description = { "存算分离模式下,一个 BE 挂掉多长时间后,它的 tablet 彻底转移到其他 BE 上" })
     public static int rehash_tablet_after_be_dead_seconds = 3600;
 
+    @ConfField(mutable = true, masterOnly = false,
+            description = "Whether to drop the primary/secondary route entries 
of a CloudReplica whose backend no "
+                    + "longer exists, when loading the image and in the tablet 
rebalancer round. Those entries are "
+                    + "already ignored at query time (the replica is 
rehashed), so they only waste FE memory and "
+                    + "image size. Set to false to keep the legacy leaking 
behavior. Default is true.")
+    public static boolean enable_cloud_replica_stale_route_clean = true;
+
     @ConfField(mutable = false, masterOnly = true,
             description = {
                     "Whether to use rendezvous hashing for colocate bucket 
placement in cloud mode. "
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudReplica.java 
b/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudReplica.java
index 40d55aa2255..4ad4f24dc7c 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudReplica.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudReplica.java
@@ -23,6 +23,7 @@ import org.apache.doris.catalog.Replica;
 import org.apache.doris.cloud.qe.ComputeGroupException;
 import org.apache.doris.cloud.system.CloudSystemInfoService;
 import org.apache.doris.common.Config;
+import org.apache.doris.common.FeConstants;
 import org.apache.doris.common.Pair;
 import org.apache.doris.common.util.DebugPointUtil;
 import org.apache.doris.persist.gson.GsonPostProcessable;
@@ -389,9 +390,34 @@ public class CloudReplica extends Replica implements 
GsonPostProcessable {
         } else {
             updateClusterToSecondaryBe(clusterId, pickBeId);
         }
+        discardRouteIfBackendVanished(pickBeId);
         return pickBeId;
     }
 
+    /**
+     * The compute group can be dropped while a publisher is picking a backend 
out of it, in which case the
+     * route just written would outlive the group. Re-checking right after 
publishing closes that window:
+     * either the backend is already gone and the write is undone here, or it 
was still registered, which
+     * means the drop -- and the rebalancer sweep it triggers -- happens after 
this write and will visit
+     * this replica. Ordering, not timing, is what makes the sweep sufficient; 
a publisher may stall for
+     * arbitrarily long between choosing a backend and publishing without 
escaping cleanup.
+     *
+     * Every route publisher that resolves a backend outside the rebalancer 
round must call this, and must
+     * not persist the route when it returns false.
+     *
+     * @return false when the route was discarded because its backend is gone
+     */
+    public boolean discardRouteIfBackendVanished(long beId) {
+        if (!Config.enable_cloud_replica_stale_route_clean) {
+            return true;
+        }
+        if (Env.getCurrentSystemInfo().getBackend(beId) == null) {
+            removeInvalidRoutes();
+            return false;
+        }
+        return true;
+    }
+
     public Backend getPrimaryBackend(String clusterId, boolean setIfAbsent) {
         long beId = getClusterPrimaryBackendId(clusterId);
         if (beId != -1L) {
@@ -403,6 +429,7 @@ public class CloudReplica extends Replica implements 
GsonPostProcessable {
                 try {
                     beId = getBackendIdImpl(clusterId);
                     updateClusterToPrimaryBe(clusterId, beId);
+                    discardRouteIfBackendVanished(beId);
                     return Env.getCurrentSystemInfo().getBackend(beId);
                 } catch (ComputeGroupException e) {
                     return null;
@@ -597,6 +624,48 @@ public class CloudReplica extends Replica implements 
GsonPostProcessable {
         secondaryClusterToBackends.remove(cluster);
     }
 
+    /**
+     * Drop the route entries whose backend has been dropped from the cluster.
+     *
+     * Such an entry is already dead weight: getBackendIdImpl() resolves the 
backend id, gets null and
+     * falls back to hashReplicaToBe(), so removing it does not change 
routing. But nothing ever removes
+     * it either -- dropCluster() only touches CloudSystemInfoService, and the 
rebalancer only walks the
+     * compute groups that currently exist -- so entries of dropped compute 
groups pile up forever, both
+     * in FE heap and in the image (the `bes`/`be` field).
+     *
+     * @return how many entries were dropped
+     */
+    public int removeInvalidRoutes() {
+        if (!Config.enable_cloud_replica_stale_route_clean || 
FeConstants.runningUnitTest) {
+            return 0;
+        }
+        SystemInfoService systemInfo = Env.getCurrentSystemInfo();
+        // Remove conditionally rather than by predicate: route writers run 
concurrently, so comparing map
+        // sizes before and after would mix their insertions into the count 
(and could even report a
+        // negative one), and a key whose value was just rewritten to a live 
backend must not be dropped.
+        int removed = 0;
+        if (!secondaryClusterToBackends.isEmpty()) {
+            for (Map.Entry<String, Pair<Long, Long>> entry : 
secondaryClusterToBackends.entrySet()) {
+                if (systemInfo.getBackend(entry.getValue().key()) == null
+                        && secondaryClusterToBackends.remove(entry.getKey(), 
entry.getValue())) {
+                    removed++;
+                }
+            }
+        }
+        // Keep a dead primary whose compute group still has a live secondary. 
With
+        // enable_immediate_be_assign=false that is the normal failover state, 
and the lazy fetch path in
+        // FrontendServiceImpl.getTabletReplicaInfos() enumerates secondaries 
through the primary key set,
+        // so dropping the key would hide a live secondary from the peer cache 
candidates.
+        for (Map.Entry<String, Long> entry : 
primaryClusterToBackend.entrySet()) {
+            if (systemInfo.getBackend(entry.getValue()) == null
+                    && !secondaryClusterToBackends.containsKey(entry.getKey())
+                    && primaryClusterToBackend.remove(entry.getKey(), 
entry.getValue())) {
+                removed++;
+            }
+        }
+        return removed;
+    }
+
     /**
      * Returns the set of compute group IDs that have primary backends for 
this replica.
      * Used by lazy fetch path to also collect secondary backends per compute 
group.
@@ -670,5 +739,10 @@ public class CloudReplica extends Replica implements 
GsonPostProcessable {
             }
             this.primaryClusterToBackends = null;
         }
+        // outside the `bes` branch on purpose: the new `be` format 
accumulates stale entries just the same.
+        // The backends module is loaded before db/recycleBin 
(PersistMetaModules.MODULE_NAMES), and the
+        // checkpoint thread resolves Env.getCurrentEnv() to its own Env, so 
the backend set read here is
+        // the one belonging to the image being loaded.
+        removeInvalidRoutes();
     }
 }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudTabletRebalancer.java
 
b/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudTabletRebalancer.java
index 11989525c4f..1434577ce32 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudTabletRebalancer.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudTabletRebalancer.java
@@ -105,6 +105,9 @@ public class CloudTabletRebalancer extends MasterDaemon {
     private Map<String, List<Long>> clusterToBes;
 
     private Set<Long> allBes;
+    // backend baseline and remaining sweep rounds, see staleRouteSweepNeeded()
+    private Set<Long> lastSweptBackends = null;
+    private int pendingSweepRounds = 0;
 
     // partitionId -> indexId -> be -> tabletIds
     private ConcurrentHashMap<Long, ConcurrentHashMap<Long, 
ConcurrentHashMap<Long, Set<Long>>>> partitionToTablets;
@@ -966,9 +969,47 @@ public class CloudTabletRebalancer extends MasterDaemon {
         }
     }
 
+    /**
+     * Decides whether this round sweeps stale routes, and advances the 
backend baseline. Call once per
+     * round. Without this gate the sweep would walk every replica's route 
maps once a second under
+     * table.readLock() only to find nothing, which on a large catalog is pure 
allocation.
+     */
+    @VisibleForTesting
+    boolean staleRouteSweepNeeded(Set<Long> currentBes) {
+        if (!Config.enable_cloud_replica_stale_route_clean) {
+            lastSweptBackends = null;
+            pendingSweepRounds = 0;
+            return false;
+        }
+        if (lastSweptBackends == null || 
!currentBes.containsAll(lastSweptBackends)) {
+            // Only a backend that went away can strand a route. Two rounds 
rather than one: a query
+            // thread can pick a backend in hashReplicaToBe() before the drop 
and publish the route in
+            // getBackendIdImpl() after this pass already visited that 
replica, so the extra round
+            // catches writers that were in flight during the first one.
+            pendingSweepRounds = 2;
+        }
+        // An addition strands nothing, so it does not trigger a sweep -- but 
it must still enter the
+        // baseline. Advancing only after a sweep would leave the baseline at 
the pre-addition set, and
+        // dropping that same backend later would compare equal to it and go 
unnoticed.
+        lastSweptBackends = currentBes;
+        if (pendingSweepRounds > 0) {
+            pendingSweepRounds--;
+            return true;
+        }
+        return false;
+    }
+
     private boolean completeRouteInfo() {
         List<UpdateCloudReplicaInfo> updateReplicaInfos = new 
ArrayList<UpdateCloudReplicaInfo>();
         long[] assignedErrNum = {0L};
+        long[] staleRouteNum = {0L};
+        boolean sweepStaleRoutes = staleRouteSweepNeeded(allBes);
+        // loopCloudReplica() has the compute group loop innermost, so it 
hands us every replica once per
+        // live compute group, while removeInvalidRoutes() scans the whole 
route map and does not care
+        // which group we are on. Pin the sweep to one arbitrary group id so a 
sweeping round still makes a
+        // single pass per replica. If clusterToBes is empty the callback 
never runs at all, so the serving
+        // catalog keeps the entries until it reloads the image -- there is 
nothing to route in that state.
+        String sweepTicket = sweepStaleRoutes ? 
clusterToBes.keySet().stream().findFirst().orElse(null) : null;
         long needRehashDeadTime = System.currentTimeMillis() - 
Config.rehash_tablet_after_be_dead_seconds * 1000L;
         loopCloudReplica((Database db, Table table, Partition partition, 
MaterializedIndex index, String cluster) -> {
             boolean assigned = false;
@@ -980,6 +1021,16 @@ public class CloudTabletRebalancer extends MasterDaemon {
             for (Tablet tablet : tablets) {
                 for (Replica r : tablet.getReplicas()) {
                     CloudReplica replica = (CloudReplica) r;
+                    // Drop routes of compute groups that no longer exist; 
gsonPostProcess() only converges
+                    // the catalog on image load, so without this the leader 
keeps them until it restarts.
+                    // No edit log op is written for the removal: the entries 
are already unroutable, and
+                    // the image is written by the master-only checkpoint, 
whose Env cleans the catalog it
+                    // loads, so no leader/follower difference ever reaches 
persisted state. Note this
+                    // daemon is master-only, so a follower keeps its own 
stale entries in heap until it
+                    // restarts or is promoted and runs a round here.
+                    if (cluster.equals(sweepTicket)) {
+                        staleRouteNum[0] += replica.removeInvalidRoutes();
+                    }
                     // clean secondary map
                     replica.checkAndClearSecondaryClusterToBe(cluster, 
needRehashDeadTime);
                     // colocate table no need to update primary backends
@@ -1048,7 +1099,8 @@ public class CloudTabletRebalancer extends MasterDaemon {
             }
         });
 
-        LOG.info("collect to editlog route {} infos, error num {}", 
updateReplicaInfos.size(), assignedErrNum[0]);
+        LOG.info("collect to editlog route {} infos, error num {}, swept stale 
routes {}, entries dropped {}",
+                updateReplicaInfos.size(), assignedErrNum[0], 
sweepStaleRoutes, staleRouteNum[0]);
 
         if (updateReplicaInfos.isEmpty()) {
             return true;
@@ -1773,6 +1825,14 @@ public class CloudTabletRebalancer extends MasterDaemon {
             }
 
             cloudReplica.updateClusterToPrimaryBe(clusterId, destBe);
+            if (!cloudReplica.discardRouteIfBackendVanished(destBe)) {
+                // The warmup checker resolves its destination on its own 
scheduler thread and can stall
+                // there while the compute group is dropped and the sweeps 
triggered by that drop finish.
+                // Journalling the route now would hand every FE an entry 
nothing is left to clean.
+                LOG.info("compute group {} lost backend {} while warming up 
tablet {}, dropping the route",
+                        clusterId, destBe, tabletId);
+                return;
+            }
             UpdateCloudReplicaInfo info = new 
UpdateCloudReplicaInfo(tabletMeta.getDbId(),
                     tabletMeta.getTableId(), tabletMeta.getPartitionId(), 
tabletMeta.getIndexId(),
                     tabletId, cloudReplica.getId(), clusterId, destBe);


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

Reply via email to