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

deardeng pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 9da67831928 [fix](cloud) Drop CloudReplica route entries of dropped 
compute groups (#66984)
9da67831928 is described below

commit 9da678319280d29bdaf2bc67888f780b887088ad
Author: deardeng <[email protected]>
AuthorDate: Tue Aug 25 17:05:25 2026 +0800

    [fix](cloud) Drop CloudReplica route entries of dropped compute groups 
(#66984)
    
    `CloudReplica.primaryClusterToBackend` (`be`, previously `bes`) is
    persisted per replica, but nothing ever removes an entry:
    `CloudSystemInfoService .dropCluster()` only touches the system-info
    maps, `CloudTabletRebalancer .loopCloudReplica()` only walks the compute
    groups that currently exist, and `unprotectUpdateCloudReplica()` only
    puts. So every created/dropped compute group leaves one key behind on
    every replica, forever.
    
    On an instance that had repeatedly added and removed compute groups this
    grew to 392 route keys per replica against 3 live compute groups: 45.5M
    entries over 167k replicas, 1.78 GB of the 2.07 GB image (85.9%) and
    ~8.6 GB of live heap. Metadata checkpoint keeps the online catalog and
    the checkpoint catalog resident at the same time, which no longer fit in
    the heap, so checkpoint OOMed on every leader in turn and the image
    never advanced.
    
    Drop an entry once its backend id no longer resolves. Such an entry is
    never routed to: `getBackendIdImpl()` already resolves the id, gets null
    and falls back to `hashReplicaToBe()`, so it only costs memory and image
    bytes.
    
    No edit log op is written for the removal, and none is needed:
    `Checkpoint` is a `MasterDaemon`, and its Env cleans the catalog it
    loads before saving, so the image is clean no matter what any serving
    Env holds. The rebalancer sweep is master-only, so a follower keeps its
    own stale entries in heap until it restarts or is promoted, at which
    point one rebalancer round clears them. That is bounded, never reaches
    persisted state, and is the tradeoff for not adding an edit log op that
    would block downgrades.
    
    A dead primary is kept while its compute group still has a live
    secondary. With `enable_immediate_be_assign=false` -- the default --
    primary pointing at an unavailable BE while the secondary holds the
    rehashed live one is the normal failover state, and
    `FrontendServiceImpl.getTabletReplicaInfos()` reaches secondaries
    through `getPrimaryComputeGroupIds()`, so dropping the primary key would
    hide a live secondary BE from the lazy fetch peer cache candidates. The
    secondary map is cleaned first, so a dead pair is still fully removed.
    
    Two call sites:
    - `gsonPostProcess()`, outside the `bes` migration branch, so both
    formats are covered. The backends module is loaded before db/recycleBin,
    and the checkpoint thread resolves `Env.getCurrentEnv()` to its own Env,
    so the backend set read there belongs to the image being loaded. This
    shrinks the load peak and makes existing oversized images converge.
    - The existing per-replica callback in `completeRouteInfo()`, so a
    running leader converges too instead of waiting for a restart. It is
    pinned to one compute group per round because `loopCloudReplica()`
    invokes the callback once per (replica, compute group) while holding
    `table.readLock()`.
    
    `secondaryClusterToBackends` is not persisted, but leaks the same way in
    heap, so it is cleaned by the same predicate.
    
    Guarded by `enable_cloud_replica_stale_route_clean` (default true).
    
    Measured on the metadata of the affected instance. Same build and same
    `image.22568053` (2,096,733,909 B) for both runs, only the config
    flipped; the
     FE was started with `enable_check_compatibility_mode` and
    `checkpoint_after_check_compatibility`, which loads the image, replays
    the journal and dumps a new one.
    
    
    | | clean=false | clean=true |
    | --- | --- | --- |
    | dumped image | 1,961,181,357 B | 272,087,919 B (-86.1%) |
    | route entries | 46,004,776 | 353,422 (-99.2%) |
    | distinct route keys | 395 | 2 |
    | stored format | `bes` x 176,941 | `be` x 176,926 |
    | live set after GC | 8.85 GB | 2.73 GB (-69%) |
    | heap expanded to | 31 GB | stayed at the 8 GB Xms |
    | dump duration | 72.6 s | 6.7 s |
    
    
    The two surviving route keys are exactly the two compute groups whose
    backends
    are present in the image's own backend module -- `cluster_id_1`
    -> 1760610536752 and `cluster_id_2` -> 1765176237562, both alive and
    NORMAL -- so no live route was dropped.
    
    Feeding the 272 MB result back in with `-Xmx8g` loads, replays and
    re-dumps it
    in 20s with a 0.51 GB live set and a stable output size, against the
    19.2 GiB
    heap that could not complete a checkpoint before.
---
 .../main/java/org/apache/doris/common/Config.java  |   7 +
 .../apache/doris/cloud/catalog/CloudReplica.java   |  76 ++++++++
 .../doris/cloud/catalog/CloudTabletRebalancer.java |  62 ++++++-
 .../doris/cloud/catalog/CloudReplicaTest.java      | 206 +++++++++++++++++++++
 .../cloud/catalog/CloudTabletRebalancerTest.java   |  36 ++++
 5 files changed, 386 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 b8c205cf041..c2998f89f34 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
@@ -3352,6 +3352,13 @@ public class Config extends ConfigBase {
                     + "other BEs in cloud mode.")
     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. If false, "
                     + "use the legacy modulo placement. Restart-only.")
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 caec16887ac..b7ae1308ed5 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;
@@ -388,9 +389,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) {
@@ -402,6 +428,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;
@@ -596,6 +623,50 @@ 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.
+        // getBackendByIdWithBoxedId over getBackend: the ids here are already 
boxed, and this loop runs
+        // per replica, so letting getBackend(long) unbox and rebox them would 
allocate a Long per route.
+        int removed = 0;
+        if (!secondaryClusterToBackends.isEmpty()) {
+            for (Map.Entry<String, Pair<Long, Long>> entry : 
secondaryClusterToBackends.entrySet()) {
+                if 
(systemInfo.getBackendByIdWithBoxedId(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.getBackendByIdWithBoxedId(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.
@@ -669,5 +740,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 d4447e987fc..9e92587da5e 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;
@@ -1003,9 +1006,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;
@@ -1017,6 +1058,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
@@ -1085,7 +1136,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;
@@ -1808,6 +1860,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);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudReplicaTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudReplicaTest.java
index ca80fde8e0e..8b04976a490 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudReplicaTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudReplicaTest.java
@@ -24,8 +24,12 @@ 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.persist.gson.GsonUtils;
 import org.apache.doris.system.Backend;
 
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
@@ -433,4 +437,206 @@ public class CloudReplicaTest {
         List<Backend> primaryBes = replica.getAllPrimaryBes();
         Assertions.assertTrue(primaryBes.isEmpty());
     }
+
+    // ---------------------------------------------------------------
+    private void stubBackend(long id, Backend be) {
+        Mockito.when(mockInfoService.getBackend(id)).thenReturn(be);
+        
Mockito.when(mockInfoService.getBackendByIdWithBoxedId(id)).thenReturn(be);
+    }
+
+    // Tests for removeInvalidRoutes: routes of dropped compute groups
+    // ---------------------------------------------------------------
+
+    @Test
+    public void testRemoveInvalidRoutes() {
+        boolean savedClean = Config.enable_cloud_replica_stale_route_clean;
+        boolean savedUnitTest = FeConstants.runningUnitTest;
+        try {
+            Config.enable_cloud_replica_stale_route_clean = true;
+            FeConstants.runningUnitTest = false;
+            Backend liveBe = createBackend(1001L, true, false);
+            stubBackend(1001L, liveBe);
+            // 2001 belongs to a compute group that has been dropped, so it no 
longer resolves
+            stubBackend(2001L, null);
+
+            CloudReplica replica = createReplica();
+            replica.updateClusterToPrimaryBe(CLUSTER_ID_1, 1001L);
+            replica.updateClusterToPrimaryBe(CLUSTER_ID_2, 2001L);
+            replica.updateClusterToSecondaryBe(CLUSTER_ID_2, 2001L);
+
+            // one primary entry plus one secondary entry, both pointing at 
the dropped backend
+            Assertions.assertEquals(2, replica.removeInvalidRoutes());
+
+            Assertions.assertEquals(1, 
replica.getPrimaryComputeGroupIds().size());
+            
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().contains(CLUSTER_ID_1));
+            // if the compute group id is reused later, the stale entry must 
not come back to life
+            Backend revivedBe = createBackend(2001L, true, false);
+            stubBackend(2001L, revivedBe);
+            Assertions.assertNull(replica.getSecondaryBackend(CLUSTER_ID_2));
+        } finally {
+            Config.enable_cloud_replica_stale_route_clean = savedClean;
+            FeConstants.runningUnitTest = savedUnitTest;
+        }
+    }
+
+    @Test
+    public void testRemoveInvalidRoutes_disabled() {
+        boolean savedClean = Config.enable_cloud_replica_stale_route_clean;
+        boolean savedUnitTest = FeConstants.runningUnitTest;
+        try {
+            Config.enable_cloud_replica_stale_route_clean = false;
+            FeConstants.runningUnitTest = false;
+            stubBackend(2001L, null);
+
+            CloudReplica replica = createReplica();
+            replica.updateClusterToPrimaryBe(CLUSTER_ID_2, 2001L);
+            replica.updateClusterToSecondaryBe(CLUSTER_ID_2, 2001L);
+
+            Assertions.assertEquals(0, replica.removeInvalidRoutes());
+
+            
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().contains(CLUSTER_ID_2));
+            Backend revivedBe = createBackend(2001L, true, false);
+            stubBackend(2001L, revivedBe);
+            
Assertions.assertNotNull(replica.getSecondaryBackend(CLUSTER_ID_2));
+        } finally {
+            Config.enable_cloud_replica_stale_route_clean = savedClean;
+            FeConstants.runningUnitTest = savedUnitTest;
+        }
+    }
+
+    @Test
+    public void testRemoveInvalidRoutes_keepsDeadPrimaryWithLiveSecondary() {
+        boolean savedClean = Config.enable_cloud_replica_stale_route_clean;
+        boolean savedUnitTest = FeConstants.runningUnitTest;
+        try {
+            Config.enable_cloud_replica_stale_route_clean = true;
+            FeConstants.runningUnitTest = false;
+            Backend liveBe = createBackend(1001L, true, false);
+            stubBackend(1001L, liveBe);
+            stubBackend(2001L, null);
+
+            // the normal failover state with 
enable_immediate_be_assign=false: primary still points at the
+            // old backend while the secondary holds the rehashed live one
+            CloudReplica replica = createReplica();
+            replica.updateClusterToPrimaryBe(CLUSTER_ID_1, 2001L);
+            replica.updateClusterToSecondaryBe(CLUSTER_ID_1, 1001L);
+
+            Assertions.assertEquals(0, replica.removeInvalidRoutes());
+
+            // FrontendServiceImpl's lazy fetch reaches the secondary through 
the primary key set
+            
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().contains(CLUSTER_ID_1));
+            
Assertions.assertNotNull(replica.getSecondaryBackend(CLUSTER_ID_1));
+        } finally {
+            Config.enable_cloud_replica_stale_route_clean = savedClean;
+            FeConstants.runningUnitTest = savedUnitTest;
+        }
+    }
+
+    private CloudReplica gsonRoundTrip(CloudReplica replica, boolean 
legacyBesFormat) {
+        JsonObject json = GsonUtils.GSON.toJsonTree(replica, 
Replica.class).getAsJsonObject();
+        if (legacyBesFormat) {
+            // rewrite the scalar `be` map back into the pre-#59932 `bes` map 
of single element lists
+            JsonObject be = json.remove("be").getAsJsonObject();
+            JsonObject bes = new JsonObject();
+            for (String cg : be.keySet()) {
+                JsonArray beIds = new JsonArray();
+                beIds.add(be.get(cg).getAsLong());
+                bes.add(cg, beIds);
+            }
+            json.add("bes", bes);
+        }
+        return (CloudReplica) GsonUtils.GSON.fromJson(json, Replica.class);
+    }
+
+    @Test
+    public void testRemoveInvalidRoutes_onImageLoad() {
+        boolean savedClean = Config.enable_cloud_replica_stale_route_clean;
+        boolean savedUnitTest = FeConstants.runningUnitTest;
+        try {
+            Config.enable_cloud_replica_stale_route_clean = true;
+            FeConstants.runningUnitTest = false;
+            Backend liveBe = createBackend(1001L, true, false);
+            stubBackend(1001L, liveBe);
+            stubBackend(2001L, null);
+
+            CloudReplica replica = createReplica();
+            replica.updateClusterToPrimaryBe(CLUSTER_ID_1, 1001L);
+            replica.updateClusterToPrimaryBe(CLUSTER_ID_2, 2001L);
+
+            // both the current `be` format and the legacy `bes` format must 
be cleaned on load,
+            // so the cleanup has to sit outside the bes -> be migration 
branch of gsonPostProcess()
+            for (boolean legacy : new boolean[] {false, true}) {
+                CloudReplica loaded = gsonRoundTrip(replica, legacy);
+                Assertions.assertEquals(1, 
loaded.getPrimaryComputeGroupIds().size(), "legacy=" + legacy);
+                
Assertions.assertTrue(loaded.getPrimaryComputeGroupIds().contains(CLUSTER_ID_1),
 "legacy=" + legacy);
+            }
+        } finally {
+            Config.enable_cloud_replica_stale_route_clean = savedClean;
+            FeConstants.runningUnitTest = savedUnitTest;
+        }
+    }
+
+    @Test
+    public void testRouteDiscardedWhenBackendVanishesDuringAssign() throws 
ComputeGroupException {
+        boolean savedClean = Config.enable_cloud_replica_stale_route_clean;
+        boolean savedUnitTest = FeConstants.runningUnitTest;
+        boolean savedImmediate = Config.enable_immediate_be_assign;
+        try {
+            Config.enable_cloud_replica_stale_route_clean = true;
+            Config.enable_immediate_be_assign = true;
+            FeConstants.runningUnitTest = false;
+            
Mockito.when(mockColocateIndex.isColocateTableNoLock(TABLE_ID)).thenReturn(false);
+
+            // the compute group still lists be 1001, so hashReplicaToBe() 
picks it ...
+            Backend be = createBackend(1001L, true, false);
+            Mockito.when(mockInfoService.getBackendsByClusterId(CLUSTER_ID_1))
+                    .thenReturn(new ArrayList<>(Arrays.asList(be)));
+            
Mockito.when(mockInfoService.getClusterNameByClusterId(CLUSTER_ID_1)).thenReturn(CLUSTER_NAME_1);
+            // ... but by the time the route is published the group has been 
dropped and 1001 is gone
+            stubBackend(1001L, null);
+
+            CloudReplica replica = createReplica();
+            replica.getBackendIdWithClusterId(CLUSTER_ID_1);
+
+            // the write must not survive: nothing else would ever clean it, 
the sweep already ran
+            
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().isEmpty());
+        } finally {
+            Config.enable_cloud_replica_stale_route_clean = savedClean;
+            Config.enable_immediate_be_assign = savedImmediate;
+            FeConstants.runningUnitTest = savedUnitTest;
+        }
+    }
+
+    @Test
+    public void testDiscardRouteReportsWhetherTheRouteSurvived() {
+        boolean savedClean = Config.enable_cloud_replica_stale_route_clean;
+        boolean savedUnitTest = FeConstants.runningUnitTest;
+        try {
+            Config.enable_cloud_replica_stale_route_clean = true;
+            FeConstants.runningUnitTest = false;
+            Backend liveBe = createBackend(1001L, true, false);
+            stubBackend(1001L, liveBe);
+            stubBackend(2001L, null);
+
+            CloudReplica replica = createReplica();
+            replica.updateClusterToPrimaryBe(CLUSTER_ID_1, 1001L);
+            replica.updateClusterToPrimaryBe(CLUSTER_ID_2, 2001L);
+
+            // backend still registered: the drop can only come later, so the 
sweep it triggers covers us
+            
Assertions.assertTrue(replica.discardRouteIfBackendVanished(1001L));
+            // backend already gone: the caller must not persist this route, 
and it is dropped here
+            
Assertions.assertFalse(replica.discardRouteIfBackendVanished(2001L));
+            
Assertions.assertFalse(replica.getPrimaryComputeGroupIds().contains(CLUSTER_ID_2));
+            
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().contains(CLUSTER_ID_1));
+
+            // with the switch off the legacy behaviour is kept: nothing is 
discarded or reported
+            Config.enable_cloud_replica_stale_route_clean = false;
+            replica.updateClusterToPrimaryBe(CLUSTER_ID_2, 2001L);
+            
Assertions.assertTrue(replica.discardRouteIfBackendVanished(2001L));
+            
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().contains(CLUSTER_ID_2));
+        } finally {
+            Config.enable_cloud_replica_stale_route_clean = savedClean;
+            FeConstants.runningUnitTest = savedUnitTest;
+        }
+    }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudTabletRebalancerTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudTabletRebalancerTest.java
index be064ee442a..f2b630947f1 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudTabletRebalancerTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudTabletRebalancerTest.java
@@ -49,6 +49,7 @@ import java.lang.reflect.Modifier;
 import java.util.AbstractList;
 import java.util.AbstractMap;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
@@ -1224,4 +1225,39 @@ public class CloudTabletRebalancerTest {
                     "compute_cluster_b", "cluster-b", 
CloudTabletRebalancer.StatType.PARTITION, 0L));
         }
     }
+
+    @Test
+    public void testStaleRouteSweepGate() {
+        boolean saved = Config.enable_cloud_replica_stale_route_clean;
+        try {
+            Config.enable_cloud_replica_stale_route_clean = true;
+            TestRebalancer rebalancer = new TestRebalancer();
+            Set<Long> bes = new HashSet<>(Arrays.asList(1L, 2L));
+
+            // no baseline yet, so sweep -- twice, to catch route writers in 
flight during the first pass
+            Assertions.assertTrue(rebalancer.staleRouteSweepNeeded(bes));
+            Assertions.assertTrue(rebalancer.staleRouteSweepNeeded(bes));
+            // unchanged topology: nothing can have gone stale
+            Assertions.assertFalse(rebalancer.staleRouteSweepNeeded(bes));
+
+            // a new backend strands nothing, so it does not trigger a sweep 
on its own
+            Set<Long> grown = new HashSet<>(Arrays.asList(1L, 2L, 3L));
+            Assertions.assertFalse(rebalancer.staleRouteSweepNeeded(grown));
+            // ... but it must have entered the baseline, so dropping it again 
is still seen as a removal
+            Assertions.assertTrue(rebalancer.staleRouteSweepNeeded(new 
HashSet<>(Arrays.asList(1L, 2L))));
+            Assertions.assertTrue(rebalancer.staleRouteSweepNeeded(new 
HashSet<>(Arrays.asList(1L, 2L))));
+            Assertions.assertFalse(rebalancer.staleRouteSweepNeeded(new 
HashSet<>(Arrays.asList(1L, 2L))));
+
+            // a backend that disappeared triggers a sweep
+            Assertions.assertTrue(rebalancer.staleRouteSweepNeeded(new 
HashSet<>(Arrays.asList(1L))));
+
+            // turning the switch off drops the baseline so turning it back on 
sweeps again
+            Config.enable_cloud_replica_stale_route_clean = false;
+            Assertions.assertFalse(rebalancer.staleRouteSweepNeeded(bes));
+            Config.enable_cloud_replica_stale_route_clean = true;
+            Assertions.assertTrue(rebalancer.staleRouteSweepNeeded(bes));
+        } finally {
+            Config.enable_cloud_replica_stale_route_clean = saved;
+        }
+    }
 }


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

Reply via email to